@@ -22,24 +22,93 @@ import { ApiResponse } from '../types/dctap.js';
2222
2323const router = Router ( ) ;
2424
25+ // Normalize a workspace name into a URL-safe slug
26+ function slugifyName ( name : string ) : string {
27+ return name
28+ . toLowerCase ( )
29+ . trim ( )
30+ . replace ( / [ ^ a - z 0 - 9 ] + / g, '-' ) // Replace non-alphanumeric runs with a single hyphen
31+ . replace ( / ^ - + | - + $ / g, '' ) ; // Trim leading/trailing hyphens
32+ }
33+
34+ // Build a map of slug -> workspace ID, using only the first workspace for duplicate slugs.
35+ // Returns the map and a set of slugs that have duplicates.
36+ function buildSlugMap ( workspaces : Array < { id : string ; name : string } > ) {
37+ const slugToId = new Map < string , string > ( ) ;
38+ const duplicateSlugs = new Set < string > ( ) ;
39+
40+ for ( const ws of workspaces ) {
41+ const slug = slugifyName ( ws . name ) ;
42+ if ( ! slug ) continue ; // skip if name normalizes to empty
43+ if ( slugToId . has ( slug ) ) {
44+ duplicateSlugs . add ( slug ) ;
45+ } else {
46+ slugToId . set ( slug , ws . id ) ;
47+ }
48+ }
49+
50+ return { slugToId, duplicateSlugs } ;
51+ }
52+
2553// List all workspaces available for serving
2654router . get ( '/workspaces' , ( _req : Request , res : Response ) => {
2755 const workspaces = workspaceService . list ( ) ;
56+ const { duplicateSlugs } = buildSlugMap ( workspaces ) ;
57+
58+ const workspaceList = workspaces . map ( ws => {
59+ const slug = slugifyName ( ws . name ) ;
60+ const entry : Record < string , any > = {
61+ id : ws . id ,
62+ name : ws . name ,
63+ updatedAt : ws . updatedAt ,
64+ profileUrl : `/api/serve/${ ws . id } /profile` ,
65+ startingPointsUrl : `/api/serve/${ ws . id } /starting-points` ,
66+ csvUrl : `/api/serve/${ ws . id } /csv` ,
67+ tsvUrl : `/api/serve/${ ws . id } /tsv`
68+ } ;
69+
70+ if ( slug ) {
71+ entry . profileUrlAlias = `/api/serve/${ slug } /profile` ;
72+ entry . startingPointsUrlAlias = `/api/serve/${ slug } /starting-points` ;
73+ }
74+
75+ if ( duplicateSlugs . has ( slug ) ) {
76+ entry . warning = `Multiple workspaces share the normalized name "${ slug } ". The alias URLs resolve to the first workspace encountered.` ;
77+ }
2878
29- const workspaceList = workspaces . map ( ws => ( {
30- id : ws . id ,
31- name : ws . name ,
32- updatedAt : ws . updatedAt ,
33- profileUrl : `/api/serve/${ ws . id } /profile` ,
34- startingPointsUrl : `/api/serve/${ ws . id } /starting-points` ,
35- csvUrl : `/api/serve/${ ws . id } /csv` ,
36- tsvUrl : `/api/serve/${ ws . id } /tsv`
37- } ) ) ;
79+ return entry ;
80+ } ) ;
3881
3982 const response : ApiResponse = { success : true , data : workspaceList } ;
4083 res . json ( response ) ;
4184} ) ;
4285
86+ // Middleware: resolve name-based slugs to workspace IDs.
87+ // If the :workspaceId param is not a UUID, treat it as a slug and look up the matching workspace.
88+ const UUID_RE = / ^ [ 0 - 9 a - f ] { 8 } - [ 0 - 9 a - f ] { 4 } - [ 0 - 9 a - f ] { 4 } - [ 0 - 9 a - f ] { 4 } - [ 0 - 9 a - f ] { 12 } $ / i;
89+
90+ function resolveWorkspaceSlug ( req : Request , _res : Response , next : NextFunction ) {
91+ const param = req . params . workspaceId ;
92+ if ( UUID_RE . test ( param ) ) {
93+ return next ( ) ; // Already a UUID, nothing to resolve
94+ }
95+
96+ // Treat param as a slug — find the first workspace whose name matches
97+ const workspaces = workspaceService . list ( ) ;
98+ const { slugToId } = buildSlugMap ( workspaces ) ;
99+ const workspaceId = slugToId . get ( param ) ;
100+
101+ if ( ! workspaceId ) {
102+ return next ( new AppError ( 404 , 'Workspace not found' , 'WORKSPACE_NOT_FOUND' ) ) ;
103+ }
104+
105+ req . params . workspaceId = workspaceId ;
106+ next ( ) ;
107+ }
108+
109+ router . use ( '/:workspaceId/profile' , resolveWorkspaceSlug ) ;
110+ router . use ( '/:workspaceId/starting-points' , resolveWorkspaceSlug ) ;
111+
43112// Serve Marva profile JSON for a workspace
44113router . get ( '/:workspaceId/profile' , ( req : Request , res : Response , next : NextFunction ) => {
45114 try {
0 commit comments