diff --git a/package-lock.json b/package-lock.json index 626545d..f3d5181 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7848,9 +7848,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", "dev": true, "funding": [ { diff --git a/sample-data/competencies-sample.csv b/sample-data/competencies-sample.csv new file mode 100644 index 0000000..0ab134e --- /dev/null +++ b/sample-data/competencies-sample.csv @@ -0,0 +1,12 @@ +title,description +Linear Algebra,Understand vectors and matrices and their applications in computer science. +Probability and Statistics,Apply probability theory and statistical inference to model uncertainty. +Graph Theory,"Represent and analyse graphs, implement BFS and DFS, and solve shortest-path problems." +Lambda Calculus,"Understand alpha-equivalence, beta-reduction, and Church encodings." +Logic and Proof Techniques,"Construct formal proofs using propositional logic, induction, and proof by contradiction." +Compiler Design,"Implement lexers, parsers, and code generators; understand IR and optimisation." +Memory Management,"Manage heap and stack allocation, understand garbage collection and ownership." +Design Patterns,Apply creational and structural design patterns to build maintainable software. +API Design,"Design RESTful and GraphQL APIs with versioning, pagination, and error handling." +Distributed Systems,"Reason about CAP theorem, implement consensus protocols and distributed stores." +Data Structures, diff --git a/sample-data/competencies-sample.json b/sample-data/competencies-sample.json new file mode 100644 index 0000000..320ca93 --- /dev/null +++ b/sample-data/competencies-sample.json @@ -0,0 +1,43 @@ +[ + { + "title": "Linear Algebra", + "description": "Understand vectors, matrices, transformations, eigenvalues, and their applications in computer science." + }, + { + "title": "Probability and Statistics", + "description": "Apply probability theory, distributions, and statistical inference to model uncertainty in computational systems." + }, + { + "title": "Graph Theory", + "description": "Represent and analyse graphs, implement traversal algorithms (BFS, DFS), and solve shortest-path and spanning-tree problems." + }, + { + "title": "Lambda Calculus", + "description": "Understand the foundations of functional computation, alpha-equivalence, beta-reduction, and Church encodings." + }, + { + "title": "Logic and Proof Techniques", + "description": "Construct formal proofs using propositional and predicate logic, induction, and proof by contradiction." + }, + { + "title": "Compiler Design", + "description": "Implement lexers, parsers, semantic analysers, and code generators; understand intermediate representations and optimisation passes." + }, + { + "title": "Memory Management", + "description": "Manage heap and stack allocation, understand garbage collection strategies, and reason about ownership and borrowing." + }, + { + "title": "Design Patterns", + "description": "Apply creational, structural, and behavioural design patterns to build maintainable and extensible software systems." + }, + { + "title": "API Design", + "description": "Design RESTful and GraphQL APIs; apply versioning, pagination, and error-handling conventions." + }, + { + "title": "Distributed Systems", + "description": "Reason about consistency, availability, and partition tolerance; implement consensus protocols and distributed data stores." + }, + { "title": "Duplicate: Data Structures" } +] diff --git a/sample-data/relationships-sample.csv b/sample-data/relationships-sample.csv new file mode 100644 index 0000000..1b5233b --- /dev/null +++ b/sample-data/relationships-sample.csv @@ -0,0 +1,21 @@ +originId,originTitle,destinationId,destinationTitle,relationshipType +comp_ds_001,,comp_fp_001,,ASSUMES +comp_fp_001,,comp_hof_001,,EXTENDS +comp_hof_001,,comp_poly_001,,EXTENDS +comp_poly_001,,comp_mod_001,,ASSUMES +comp_fp_001,,comp_mod_001,,ASSUMES +,Data Structures,comp_alg_001,,ASSUMES +comp_alg_001,,,Complexity Theory,EXTENDS +,Complexity Theory,comp_alg_001,,UNRELATED +,Functional Programming,,Type Checking and Inference,ASSUMES +,Higher-Order Functions,,Functional Programming,MATCHES +comp_alg_001,,,Parsing and Grammars,ASSUMES +,Evaluation Strategies,comp_fp_001,,EXTENDS +comp_fp_001,,comp_err_001,,ASSUMES +comp_alg_001,,comp_test_001,,EXTENDS +comp_db_001,,comp_sec_001,,ASSUMES +comp_os_001,,comp_conc_001,,ASSUMES +comp_net_001,,comp_sec_001,,ASSUMES +,Version Control with Git,,Software Security,UNRELATED +,Testing and Verification,,Error Handling,EXTENDS +,Object-Oriented Design,,Module System and Abstraction,MATCHES diff --git a/sample-data/relationships-sample.json b/sample-data/relationships-sample.json new file mode 100644 index 0000000..2e4af1a --- /dev/null +++ b/sample-data/relationships-sample.json @@ -0,0 +1,103 @@ +[ + { + "originId": "comp_ds_001", + "destinationId": "comp_fp_001", + "relationshipType": "ASSUMES" + }, + { + "originId": "comp_fp_001", + "destinationId": "comp_hof_001", + "relationshipType": "EXTENDS" + }, + { + "originId": "comp_hof_001", + "destinationId": "comp_poly_001", + "relationshipType": "EXTENDS" + }, + { + "originId": "comp_poly_001", + "destinationId": "comp_mod_001", + "relationshipType": "ASSUMES" + }, + { + "originId": "comp_fp_001", + "destinationId": "comp_mod_001", + "relationshipType": "ASSUMES" + }, + + { + "originTitle": "Data Structures", + "destinationId": "comp_alg_001", + "relationshipType": "ASSUMES" + }, + { + "originId": "comp_alg_001", + "destinationTitle": "Complexity Theory", + "relationshipType": "EXTENDS" + }, + { + "originTitle": "Functional Programming", + "destinationTitle": "Type Checking and Inference", + "relationshipType": "ASSUMES" + }, + { + "originTitle": "Higher-Order Functions", + "destinationTitle": "Functional Programming", + "relationshipType": "MATCHES" + }, + { + "originTitle": "Testing and Verification", + "destinationTitle": "Error Handling", + "relationshipType": "EXTENDS" + }, + + { + "originId": "comp_alg_001", + "destinationTitle": "Parsing and Grammars", + "relationshipType": "ASSUMES" + }, + { + "originTitle": "Evaluation Strategies", + "destinationId": "comp_fp_001", + "relationshipType": "EXTENDS" + }, + { + "originId": "comp_os_001", + "destinationTitle": "Concurrency and Parallelism", + "relationshipType": "ASSUMES" + }, + + { + "originId": "comp_db_001", + "destinationId": "comp_sec_001", + "relationshipType": "ASSUMES" + }, + { + "originId": "comp_net_001", + "destinationId": "comp_sec_001", + "relationshipType": "ASSUMES" + }, + { + "originId": "comp_alg_001", + "destinationId": "comp_test_001", + "relationshipType": "EXTENDS" + }, + { + "originId": "comp_fp_001", + "destinationId": "comp_err_001", + "relationshipType": "ASSUMES" + }, + + { + "originTitle": "Version Control with Git", + "destinationTitle": "Software Security", + "relationshipType": "UNRELATED" + }, + { + "originTitle": "Object-Oriented Design", + "destinationTitle": "Module System and Abstraction", + "relationshipType": "MATCHES" + }, + + { "originId": "comp_ds_001", "destinationId": "comp_alg_001" } +] diff --git a/server/src/main/java/de/tum/cit/memo/controller/AdminController.java b/server/src/main/java/de/tum/cit/memo/controller/AdminController.java index ecd3f67..fa0dcae 100644 --- a/server/src/main/java/de/tum/cit/memo/controller/AdminController.java +++ b/server/src/main/java/de/tum/cit/memo/controller/AdminController.java @@ -3,6 +3,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import de.tum.cit.memo.dto.CompetencyImportRow; import de.tum.cit.memo.dto.ImportResult; +import de.tum.cit.memo.dto.RelationshipImportRow; +import de.tum.cit.memo.service.CompetencyRelationshipService; import de.tum.cit.memo.service.CompetencyService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirement; @@ -13,9 +15,9 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; +import lombok.RequiredArgsConstructor; import org.apache.commons.csv.CSVFormat; import org.apache.commons.csv.CSVRecord; -import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -33,6 +35,7 @@ public class AdminController { private final CompetencyService competencyService; + private final CompetencyRelationshipService competencyRelationshipService; private final ObjectMapper objectMapper; @PostMapping("/competencies/import") @@ -58,6 +61,67 @@ public ResponseEntity importFile( return ResponseEntity.ok(competencyService.bulkImportCompetencies(rows)); } + @PostMapping("/relationships/import") + @Operation(summary = "Bulk import competency relationships from JSON array") + public ResponseEntity importRelationshipsJson( + @RequestBody List rows + ) { + return ResponseEntity.ok(competencyRelationshipService.bulkImportRelationships(rows)); + } + + @PostMapping(value = "/relationships/import/file", consumes = "multipart/form-data") + @Operation(summary = "Bulk import competency relationships from CSV or JSON file") + public ResponseEntity importRelationshipsFile( + @RequestParam("file") MultipartFile file + ) throws IOException { + String originalFilename = file.getOriginalFilename(); + List rows; + if (originalFilename != null && originalFilename.endsWith(".csv")) { + rows = parseCsvRelationships(file); + } else { + rows = objectMapper.readValue( + file.getInputStream(), + objectMapper.getTypeFactory().constructCollectionType(List.class, RelationshipImportRow.class) + ); + } + return ResponseEntity.ok(competencyRelationshipService.bulkImportRelationships(rows)); + } + + private List parseCsvRelationships(MultipartFile file) throws IOException { + List rows = new ArrayList<>(); + CSVFormat format = CSVFormat.DEFAULT.builder() + .setHeader() + .setSkipHeaderRecord(true) + .setTrim(true) + .setIgnoreHeaderCase(true) + .build(); + try (var reader = new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8); + var parser = format.parse(reader)) { + for (CSVRecord record : parser) { + RelationshipImportRow row = new RelationshipImportRow(); + row.setOriginId(getMappedValue(record, "originId")); + row.setOriginTitle(getMappedValue(record, "originTitle")); + row.setDestinationId(getMappedValue(record, "destinationId")); + row.setDestinationTitle(getMappedValue(record, "destinationTitle")); + row.setRelationshipType(getMappedValue(record, "relationshipType")); + rows.add(row); + } + } + return rows; + } + + private String getMappedValue(CSVRecord record, String header) { + if (!record.isMapped(header)) { + return null; + } + String val = record.get(header); + if (val.isEmpty()) { + return null; + } else { + return val; + } + } + private List parseCsv(MultipartFile file) throws IOException { List rows = new ArrayList<>(); CSVFormat format = CSVFormat.DEFAULT.builder() @@ -68,18 +132,18 @@ private List parseCsv(MultipartFile file) throws IOExceptio try (var reader = new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8); var parser = format.parse(reader)) { for (CSVRecord record : parser) { - String title; - if (record.size() > 0) { - title = record.get(0); - } else { - title = ""; - } - String description; - if (record.size() > 1) { - description = record.get(1); - } else { - description = null; - } + String title; + if (record.size() > 0) { + title = record.get(0); + } else { + title = ""; + } + String description; + if (record.size() > 1) { + description = record.get(1); + } else { + description = null; + } rows.add(new CompetencyImportRow(title, description)); } } diff --git a/server/src/main/java/de/tum/cit/memo/dto/RelationshipImportRow.java b/server/src/main/java/de/tum/cit/memo/dto/RelationshipImportRow.java new file mode 100644 index 0000000..2d293b3 --- /dev/null +++ b/server/src/main/java/de/tum/cit/memo/dto/RelationshipImportRow.java @@ -0,0 +1,17 @@ +package de.tum.cit.memo.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class RelationshipImportRow { + + private String originId; + private String originTitle; + private String destinationId; + private String destinationTitle; + + /** One of: ASSUMES / EXTENDS / MATCHES / UNRELATED */ + private String relationshipType; +} diff --git a/server/src/main/java/de/tum/cit/memo/repository/CompetencyRepository.java b/server/src/main/java/de/tum/cit/memo/repository/CompetencyRepository.java index f655c94..cc76790 100644 --- a/server/src/main/java/de/tum/cit/memo/repository/CompetencyRepository.java +++ b/server/src/main/java/de/tum/cit/memo/repository/CompetencyRepository.java @@ -7,6 +7,7 @@ import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; @Repository public interface CompetencyRepository extends JpaRepository { @@ -29,4 +30,6 @@ public interface CompetencyRepository extends JpaRepository void decrementDegree(@Param("ids") List ids); boolean existsByTitle(String title); + + Optional findByTitle(String title); } diff --git a/server/src/main/java/de/tum/cit/memo/security/DbRoleJwtAuthenticationConverter.java b/server/src/main/java/de/tum/cit/memo/security/DbRoleJwtAuthenticationConverter.java index 005447b..2d7e5c4 100644 --- a/server/src/main/java/de/tum/cit/memo/security/DbRoleJwtAuthenticationConverter.java +++ b/server/src/main/java/de/tum/cit/memo/security/DbRoleJwtAuthenticationConverter.java @@ -4,6 +4,7 @@ import de.tum.cit.memo.enums.UserRole; import de.tum.cit.memo.repository.UserRepository; import java.util.List; +import java.util.Map; import java.util.Objects; import lombok.RequiredArgsConstructor; import org.springframework.core.convert.converter.Converter; @@ -26,8 +27,19 @@ public AbstractAuthenticationToken convert(@NonNull Jwt jwt) { String subject = Objects.requireNonNullElse(jwt.getSubject(), ""); UserRole role = userRepository.findById(subject) .map(User::getRole) - .orElse(UserRole.USER); + .orElseGet(() -> extractRoleFromJwt(jwt)); return new JwtAuthenticationToken(jwt, List.of(new SimpleGrantedAuthority("ROLE_" + role.name()))); } + + private UserRole extractRoleFromJwt(Jwt jwt) { + Map realmAccess = jwt.getClaim("realm_access"); + if (realmAccess != null) { + Object roles = realmAccess.get("roles"); + if (roles instanceof List roleList && roleList.contains("ADMIN")) { + return UserRole.ADMIN; + } + } + return UserRole.USER; + } } diff --git a/server/src/main/java/de/tum/cit/memo/service/CompetencyRelationshipService.java b/server/src/main/java/de/tum/cit/memo/service/CompetencyRelationshipService.java index 4c7f022..6986c43 100644 --- a/server/src/main/java/de/tum/cit/memo/service/CompetencyRelationshipService.java +++ b/server/src/main/java/de/tum/cit/memo/service/CompetencyRelationshipService.java @@ -1,6 +1,10 @@ package de.tum.cit.memo.service; +import de.tum.cit.memo.dto.ImportResult; +import de.tum.cit.memo.dto.RelationshipImportRow; +import de.tum.cit.memo.entity.Competency; import de.tum.cit.memo.entity.CompetencyRelationship; +import de.tum.cit.memo.enums.RelationshipType; import de.tum.cit.memo.exception.InvalidOperationException; import de.tum.cit.memo.exception.ResourceNotFoundException; import de.tum.cit.memo.repository.CompetencyRelationshipRepository; @@ -10,7 +14,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; import java.util.List; +import java.util.Optional; /** * CRUD for competency relationships (voting logic lives in SchedulingService). @@ -54,6 +60,102 @@ public void deleteRelationship(String id) { relationshipRepository.deleteById(id); } + @Transactional + public ImportResult bulkImportRelationships(List rows) { + List errors = new ArrayList<>(); + int skipped = 0; + int imported = 0; + + for (int i = 1; i <= rows.size(); i++) { + RelationshipImportRow row = rows.get(i - 1); + String label = "Row " + i; + + String originId = resolveCompetencyId( + row.getOriginId(), row.getOriginTitle(), + label + ": originId or originTitle is required", label, errors); + if (originId == null) continue; + + String destinationId = resolveCompetencyId( + row.getDestinationId(), row.getDestinationTitle(), + label + ": destinationId or destinationTitle is required", label, errors); + if (destinationId == null) continue; + + if (originId.equals(destinationId)) { + errors.add(label + ": origin and destination must be different"); + continue; + } + + RelationshipType type = null; + if (row.getRelationshipType() != null && !row.getRelationshipType().isBlank()) { + type = parseRelationshipType(row.getRelationshipType()); + if (type == null) { + errors.add(label + ": unknown relationshipType '" + row.getRelationshipType() + "'"); + continue; + } + } + + if (relationshipRepository.existsByOriginIdAndDestinationId(originId, destinationId)) { + skipped++; + continue; + } + + CompetencyRelationship relationship = buildAndSave(originId, destinationId); + + if (type != null) { + seedVote(relationship, type); + relationshipRepository.save(relationship); + } + + imported++; + } + + return new ImportResult(imported, skipped, errors); + } + + private String resolveCompetencyId(String id, String title, String missingMessage, String label, List errors) { + boolean idBlank = (id == null || id.isBlank()); + boolean titleBlank = (title == null || title.isBlank()); + + if (idBlank && titleBlank) { + errors.add(missingMessage); + return null; + } + + if (!idBlank) { + Optional found = competencyRepository.findById(id); + if (found.isEmpty()) { + errors.add(label + ": competency not found for id '" + id + "'"); + return null; + } + return id; + } + + Optional found = competencyRepository.findByTitle(title); + if (found.isEmpty()) { + errors.add(label + ": competency not found for title '" + title + "'"); + return null; + } + return found.get().getId(); + } + + private RelationshipType parseRelationshipType(String raw) { + try { + return RelationshipType.valueOf(raw.toUpperCase().trim()); + } catch (IllegalArgumentException e) { + return null; + } + } + + private void seedVote(CompetencyRelationship rel, RelationshipType type) { + switch (type) { + case ASSUMES -> rel.setVoteAssumes(rel.getVoteAssumes() + 1); + case EXTENDS -> rel.setVoteExtends(rel.getVoteExtends() + 1); + case MATCHES -> rel.setVoteMatches(rel.getVoteMatches() + 1); + case UNRELATED -> rel.setVoteUnrelated(rel.getVoteUnrelated() + 1); + } + rel.recalculateEntropy(); + } + private CompetencyRelationship buildAndSave(String originId, String destinationId) { CompetencyRelationship relationship = CompetencyRelationship.builder() .id(IdGenerator.generateCuid()) diff --git a/server/src/main/resources/db/migration/V10__add_consent_ttl_tracking.sql b/server/src/main/resources/db/migration/V11__add_consent_ttl_tracking.sql similarity index 100% rename from server/src/main/resources/db/migration/V10__add_consent_ttl_tracking.sql rename to server/src/main/resources/db/migration/V11__add_consent_ttl_tracking.sql diff --git a/src/App.tsx b/src/App.tsx index 2e3a8f5..d271689 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,7 @@ import { AdminRoute } from './components/AdminRoute'; import { AdminPage } from './pages/AdminPage'; import { ModerationPage } from './pages/ModerationPage'; import { ImportPage } from './pages/ImportPage'; +import { RelationshipImportPage } from './pages/RelationshipImportPage'; import { RolesPage } from './pages/RolesPage'; import { ExportPage } from './pages/ExportPage'; import { AuthProvider } from './contexts/AuthContext'; @@ -55,6 +56,10 @@ export function App() { element={} /> } /> + } + /> } /> } /> diff --git a/src/lib/api/admin.ts b/src/lib/api/admin.ts index 147680d..56b358e 100644 --- a/src/lib/api/admin.ts +++ b/src/lib/api/admin.ts @@ -1,5 +1,5 @@ import { apiClient } from './client'; -import type { ImportResult } from './types'; +import type { ImportResult, RelationshipImportRow } from './types'; export interface CompetencyImportRow { title: string; @@ -27,4 +27,25 @@ export const adminApi = { ); return response.data; }, + + importRelationshipsJson: async ( + rows: RelationshipImportRow[] + ): Promise => { + const response = await apiClient.post( + '/api/admin/relationships/import', + rows + ); + return response.data; + }, + + importRelationshipsFile: async (file: File): Promise => { + const form = new FormData(); + form.append('file', file); + const response = await apiClient.post( + '/api/admin/relationships/import/file', + form, + { headers: { 'Content-Type': 'multipart/form-data' } } + ); + return response.data; + }, }; diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index d98be14..bff7194 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -30,7 +30,7 @@ export interface LearningResource { export interface CompetencyRelationship { id: string; - relationshipType: 'ASSUMES' | 'EXTENDS' | 'MATCHES'; + relationshipType: 'ASSUMES' | 'EXTENDS' | 'MATCHES' | 'UNRELATED'; originId: string; destinationId: string; userId: string; @@ -79,8 +79,16 @@ export interface UpdateLearningResourceRequest { url?: string; } +export interface RelationshipImportRow { + originId?: string; + originTitle?: string; + destinationId?: string; + destinationTitle?: string; + relationshipType?: 'ASSUMES' | 'EXTENDS' | 'MATCHES' | 'UNRELATED'; +} + export interface CreateCompetencyRelationshipRequest { - relationshipType: 'ASSUMES' | 'EXTENDS' | 'MATCHES'; + relationshipType: 'ASSUMES' | 'EXTENDS' | 'MATCHES' | 'UNRELATED'; originId: string; destinationId: string; userId: string; diff --git a/src/pages/AdminPage.tsx b/src/pages/AdminPage.tsx index e14bd8b..293a7fb 100644 --- a/src/pages/AdminPage.tsx +++ b/src/pages/AdminPage.tsx @@ -1,5 +1,12 @@ import { Link } from 'react-router-dom'; -import { Shield, SquarePen, Upload, Users, Download } from 'lucide-react'; +import { + GitMerge, + Shield, + SquarePen, + Upload, + Users, + Download, +} from 'lucide-react'; export function AdminPage() { return ( @@ -52,7 +59,7 @@ export function AdminPage() {

- Import + Import Competencies

Bulk-import competencies from a JSON or CSV file. @@ -60,6 +67,24 @@ export function AdminPage() { + +

+
+
+ +
+

+ Import Relationships +

+

+ Bulk-import competency relationships from a JSON or CSV file. +

+
+ + ('file'); + const [status, setStatus] = useState('idle'); + const [result, setResult] = useState(null); + const [errorMessage, setErrorMessage] = useState(''); + + const [selectedFile, setSelectedFile] = useState(null); + const [jsonText, setJsonText] = useState(''); + const [jsonError, setJsonError] = useState(''); + + const fileInputRef = useRef(null); + + const reset = () => { + setStatus('idle'); + setResult(null); + setErrorMessage(''); + setSelectedFile(null); + setJsonText(''); + setJsonError(''); + }; + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] ?? null; + setSelectedFile(file); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + const file = e.dataTransfer.files[0]; + if (file) setSelectedFile(file); + }; + + const handleJsonChange = (e: React.ChangeEvent) => { + setJsonText(e.target.value); + setJsonError(''); + }; + + const handleSubmit = async () => { + setStatus('uploading'); + setResult(null); + setErrorMessage(''); + + try { + let importResult: ImportResult; + + if (mode === 'file') { + if (!selectedFile) { + setErrorMessage('Please select a file.'); + setStatus('error'); + return; + } + importResult = await adminApi.importRelationshipsFile(selectedFile); + } else { + let rows: RelationshipImportRow[]; + try { + rows = JSON.parse(jsonText) as RelationshipImportRow[]; + } catch { + setJsonError('Invalid JSON. Please check the format.'); + setStatus('idle'); + return; + } + importResult = await adminApi.importRelationshipsJson(rows); + } + + setResult(importResult); + setStatus('success'); + } catch { + setErrorMessage('Import failed. Please check your file and try again.'); + setStatus('error'); + } + }; + + return ( +
+
+
+
+
+
+ +
+
+ + + Admin + + / + + Import Relationships + +
+ +
+

+ Import Relationships +

+

+ Bulk-import competency relationships from a JSON or CSV file. +

+
+ +
+ {status === 'success' && result ? ( +
+
+
+ + Import complete +
+
    +
  • + ✓ Imported: {result.imported} +
  • + {result.skipped > 0 && ( +
  • + ⚠ Skipped (duplicates): {result.skipped} +
  • + )} +
+ {result.errors.length > 0 && ( +
+

Errors:

+
    + {result.errors.map((e, i) => ( +
  • • {e}
  • + ))} +
+
+ )} +
+ +
+ ) : ( + <> +
+ {(['file', 'json'] as Mode[]).map(m => ( + + ))} +
+ + {mode === 'file' ? ( +
+
e.preventDefault()} + onClick={() => fileInputRef.current?.click()} + className="flex cursor-pointer flex-col items-center justify-center gap-3 rounded-xl border-2 border-dashed border-slate-200 bg-slate-50/50 p-10 transition hover:border-[#0a4da2]/50 hover:bg-slate-50" + > + {selectedFile ? ( + <> + +

+ {selectedFile.name} +

+

+ {(selectedFile.size / 1024).toFixed(1)} KB +

+ + ) : ( + <> + +

+ Drop a file here or click to browse +

+

+ Accepts .json or .csv (max 5 MB) +

+ + )} + +
+
+

+ CSV format +

+
+                      {`originId,originTitle,destinationId,destinationTitle,relationshipType\n"uuid-1",,"uuid-2",,"ASSUMES"\n,"Big-O notation",,"Sorting algorithms",`}
+                    
+
+
+ ) : ( +
+