diff --git a/backend/src/api/public/v1/packages/types.ts b/backend/src/api/public/v1/packages/types.ts index 2762708697..6b4f542af3 100644 --- a/backend/src/api/public/v1/packages/types.ts +++ b/backend/src/api/public/v1/packages/types.ts @@ -24,7 +24,8 @@ export interface OpenVulns { export interface Steward { userId: string - name: string + username: string | null + displayName: string | null role: 'lead' | 'co_steward' assignedAt: string } diff --git a/backend/src/api/public/v1/stewardships/assignSteward.ts b/backend/src/api/public/v1/stewardships/assignSteward.ts index e48ada8a39..e97e984851 100644 --- a/backend/src/api/public/v1/stewardships/assignSteward.ts +++ b/backend/src/api/public/v1/stewardships/assignSteward.ts @@ -12,20 +12,32 @@ const paramsSchema = z.object({ id: z.coerce.number().int().positive(), }) -const bodySchema = z.object({ - userId: z.string().trim().min(1), - role: z.enum(['lead', 'co_steward']), - note: z.string().trim().min(1).optional(), - moveToAssessing: z.boolean().optional().default(false), -}) +const bodySchema = z + .object({ + userId: z.string().trim().min(1), + username: z.string().trim().min(1).optional().nullable(), + displayName: z.string().trim().min(1).optional().nullable(), + role: z.enum(['lead', 'co_steward']), + note: z.string().trim().min(1).optional(), + moveToAssessing: z.boolean().optional().default(false), + }) + .refine((d) => (d.username == null) === (d.displayName == null), { + message: 'username and displayName must both be provided or both be absent', + path: ['displayName'], + }) export async function assignStewardHandler(req: Request, res: Response): Promise { const { id } = validateOrThrow(paramsSchema, req.params) - const { userId, role, note, moveToAssessing } = validateOrThrow(bodySchema, req.body) + const { userId, username, displayName, role, note, moveToAssessing } = validateOrThrow( + bodySchema, + req.body, + ) const qx = await getPackagesQx() const result = await assignSteward(qx, id, { userId, + username, + displayName, role, note, assignedBy: req.actor.id, diff --git a/backend/src/api/public/v1/stewardships/openapi.yaml b/backend/src/api/public/v1/stewardships/openapi.yaml index 08928976d6..bc41892971 100644 --- a/backend/src/api/public/v1/stewardships/openapi.yaml +++ b/backend/src/api/public/v1/stewardships/openapi.yaml @@ -126,12 +126,18 @@ components: type: string description: Auth0 sub of the assigned steward. example: abc123 - name: + username: type: - string - 'null' - description: Display name of the steward. Null if not available. - example: Jonathan R. + description: LFX username of the steward. Null if not yet stored. + example: joanagmaia + displayName: + type: + - string + - 'null' + description: Full display name of the steward. Null if not yet stored. + example: Joana Maia role: type: string enum: [lead, co_steward] @@ -264,6 +270,14 @@ paths: type: string description: Auth0 sub of the user to assign as steward. example: abc123 + username: + type: string + description: LFX username of the steward. When provided together with displayName, stored for display purposes. + example: joanagmaia + displayName: + type: string + description: Full display name of the steward. When provided together with username, stored for display purposes. + example: Joana Maia role: type: string enum: [lead, co_steward] diff --git a/backend/src/osspckgs/migrations/V1781600000__stewards-table.sql b/backend/src/osspckgs/migrations/V1781600000__stewards-table.sql new file mode 100644 index 0000000000..c1af7c1749 --- /dev/null +++ b/backend/src/osspckgs/migrations/V1781600000__stewards-table.sql @@ -0,0 +1,6 @@ +CREATE TABLE stewards ( + user_id TEXT PRIMARY KEY, + username TEXT NOT NULL, + display_name TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/services/libs/data-access-layer/src/osspckgs/api.ts b/services/libs/data-access-layer/src/osspckgs/api.ts index 0d0b588b52..bbe2e85822 100644 --- a/services/libs/data-access-layer/src/osspckgs/api.ts +++ b/services/libs/data-access-layer/src/osspckgs/api.ts @@ -112,6 +112,8 @@ export async function getOsspreyMetrics(qx: QueryExecutor): Promise { + const rows: Array> = await qx.select( + `SELECT ss.id, ss.stewardship_id, ss.user_id, ss.role, ss.assigned_at, ss.assigned_by, + st.username, st.display_name + FROM stewardship_stewards ss + LEFT JOIN stewards st ON st.user_id = ss.user_id + WHERE ss.stewardship_id = $(stewardshipId) + AND ss.deleted_at IS NULL + ORDER BY ss.assigned_at ASC`, + { stewardshipId }, + ) + return rows.map(mapStewardStewardRow) +} + function mapStewardStewardRow(row: Record): StewardshipStewardRecord { return { id: String(row.id), stewardshipId: String(row.stewardship_id), userId: String(row.user_id), - name: null, + username: (row.username as string) ?? null, + displayName: (row.display_name as string) ?? null, role: String(row.role), assignedAt: toIso(row.assigned_at), - assignedBy: row.assigned_by ? String(row.assigned_by) : null, + assignedBy: (row.assigned_by as string) ?? null, } } @@ -190,6 +209,8 @@ export async function assignSteward( stewardshipId: number, data: { userId: string + username?: string | null + displayName?: string | null role: 'lead' | 'co_steward' assignedBy: string note?: string @@ -200,6 +221,18 @@ export async function assignSteward( const stewardship = await getStewardshipById(tx, stewardshipId) if (!stewardship) return null + if (data.username != null && data.displayName != null) { + await tx.result( + `INSERT INTO stewards (user_id, username, display_name, updated_at) + VALUES ($(userId), $(username), $(displayName), NOW()) + ON CONFLICT (user_id) DO UPDATE + SET username = EXCLUDED.username, + display_name = EXCLUDED.display_name, + updated_at = NOW()`, + { userId: data.userId, username: data.username, displayName: data.displayName }, + ) + } + // Soft-delete existing active entry for this user (handles role changes). await tx.result( `UPDATE stewardship_stewards @@ -266,18 +299,9 @@ export async function assignSteward( if (updated) finalStewardship = mapStewardshipRow(updated) } - const stewards: Array> = await tx.select( - `SELECT id, stewardship_id, user_id, role, assigned_at, assigned_by - FROM stewardship_stewards - WHERE stewardship_id = $(stewardshipId) - AND deleted_at IS NULL - ORDER BY assigned_at ASC`, - { stewardshipId }, - ) - return { stewardship: finalStewardship, - stewards: stewards.map(mapStewardStewardRow), + stewards: await fetchActiveStewards(tx, stewardshipId), } }) } @@ -292,14 +316,7 @@ export async function getStewardshipSummary( stewardshipId: number, ): Promise { const [stewards, activityRow] = await Promise.all([ - qx.select( - `SELECT id, stewardship_id, user_id, role, assigned_at, assigned_by - FROM stewardship_stewards - WHERE stewardship_id = $(stewardshipId) - AND deleted_at IS NULL - ORDER BY assigned_at ASC`, - { stewardshipId }, - ) as Promise>>, + fetchActiveStewards(qx, stewardshipId), qx.selectOneOrNone( `SELECT MAX(created_at) AS last_activity_at FROM stewardship_activity @@ -309,7 +326,7 @@ export async function getStewardshipSummary( ]) return { - stewards: stewards.map(mapStewardStewardRow), + stewards, lastActivityAt: activityRow?.last_activity_at ? toIso(activityRow.last_activity_at) : null, } }