Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion backend/src/api/public/v1/packages/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
26 changes: 19 additions & 7 deletions backend/src/api/public/v1/stewardships/assignSteward.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
})
Comment thread
ulemons marked this conversation as resolved.

export async function assignStewardHandler(req: Request, res: Response): Promise<void> {
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,
Expand Down
20 changes: 17 additions & 3 deletions backend/src/api/public/v1/stewardships/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
);
11 changes: 10 additions & 1 deletion services/libs/data-access-layer/src/osspckgs/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ export async function getOsspreyMetrics(qx: QueryExecutor): Promise<OsspreyMetri

export interface StewardEntry {
userId: string
username: string | null
displayName: string | null
role: string
assignedAt: string
}
Expand Down Expand Up @@ -460,12 +462,19 @@ export async function listPackagesForApi(
LEFT JOIN LATERAL (
SELECT COALESCE(
json_agg(
json_build_object('userId', ss.user_id, 'role', ss.role, 'assignedAt', ss.assigned_at)
json_build_object(
'userId', ss.user_id,
'username', st.username,
'displayName', st.display_name,
'role', ss.role,
'assignedAt', ss.assigned_at
)
ORDER BY ss.assigned_at ASC
) FILTER (WHERE ss.id IS NOT NULL),
'[]'::json
) AS stewards
FROM stewardship_stewards ss
LEFT JOIN stewards st ON st.user_id = ss.user_id
WHERE ss.stewardship_id = s.id
AND ss.deleted_at IS NULL
) ss_agg ON true`
Expand Down
61 changes: 39 additions & 22 deletions services/libs/data-access-layer/src/osspckgs/stewardships.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ export interface StewardshipStewardRecord {
id: string
stewardshipId: string
userId: string
name: string | null
username: string | null
displayName: string | null
role: string
assignedAt: string
assignedBy: string | null
Expand Down Expand Up @@ -87,15 +88,33 @@ function toIso(v: unknown): string {
return v instanceof Date ? v.toISOString() : String(v)
}

async function fetchActiveStewards(
qx: QueryExecutor,
stewardshipId: number,
): Promise<StewardshipStewardRecord[]> {
const rows: Array<Record<string, unknown>> = 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<string, unknown>): 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,
Comment thread
ulemons marked this conversation as resolved.
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -266,18 +299,9 @@ export async function assignSteward(
if (updated) finalStewardship = mapStewardshipRow(updated)
}

const stewards: Array<Record<string, unknown>> = 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),
}
})
}
Expand All @@ -292,14 +316,7 @@ export async function getStewardshipSummary(
stewardshipId: number,
): Promise<StewardshipSummary> {
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<Array<Record<string, unknown>>>,
fetchActiveStewards(qx, stewardshipId),
qx.selectOneOrNone(
`SELECT MAX(created_at) AS last_activity_at
FROM stewardship_activity
Expand All @@ -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,
}
}
Expand Down
Loading