From 8865044c9e2838e7cfcf5f961a7988803161ae8b Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Tue, 23 Jun 2026 15:30:00 +0200 Subject: [PATCH 1/3] feat: add actor interface Signed-off-by: Umberto Sgueglia --- .../src/api/public/v1/ossprey/activityFeed.ts | 3 +- .../api/public/v1/stewardships/actorSchema.ts | 8 + .../public/v1/stewardships/assignSteward.ts | 14 +- .../api/public/v1/stewardships/escalate.ts | 14 +- .../public/v1/stewardships/getMyActivity.ts | 1 + .../public/v1/stewardships/getMyPackages.ts | 12 +- .../public/v1/stewardships/openStewardship.ts | 20 +- .../api/public/v1/stewardships/openapi.yaml | 92 +++++++++ .../public/v1/stewardships/updateStatus.ts | 14 +- ...00__stewardship-activity-actor-display.sql | 4 + .../data-access-layer/src/osspckgs/api.ts | 7 + .../src/osspckgs/stewardships.ts | 184 ++++++++++++------ 12 files changed, 293 insertions(+), 80 deletions(-) create mode 100644 backend/src/api/public/v1/stewardships/actorSchema.ts create mode 100644 backend/src/osspckgs/migrations/V1781700000__stewardship-activity-actor-display.sql diff --git a/backend/src/api/public/v1/ossprey/activityFeed.ts b/backend/src/api/public/v1/ossprey/activityFeed.ts index d87185227e..ea00e46a58 100644 --- a/backend/src/api/public/v1/ossprey/activityFeed.ts +++ b/backend/src/api/public/v1/ossprey/activityFeed.ts @@ -25,8 +25,7 @@ export async function activityFeedHandler(req: Request, res: Response): Promise< packagePurl: r.packagePurl, packageName: r.packageName, packageEcosystem: r.packageEcosystem, - actorUserId: r.actorUserId, - actorName: r.actorUserId, // TODO: resolve display name from crowd.dev users/members table by actorUserId + actor: r.actor, actorType: r.actorType, activityType: r.activityType, content: r.content, diff --git a/backend/src/api/public/v1/stewardships/actorSchema.ts b/backend/src/api/public/v1/stewardships/actorSchema.ts new file mode 100644 index 0000000000..1ad728ac0b --- /dev/null +++ b/backend/src/api/public/v1/stewardships/actorSchema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod' + +export const actorInputSchema = z.object({ + userId: z.string().trim().min(1, { message: 'actor.userId is required and must not be empty' }), + username: z.string().trim().min(1).optional().nullable(), + displayName: z.string().trim().min(1).optional().nullable(), + avatarUrl: z.string().url().optional().nullable(), +}) diff --git a/backend/src/api/public/v1/stewardships/assignSteward.ts b/backend/src/api/public/v1/stewardships/assignSteward.ts index e97e984851..54e81a58e4 100644 --- a/backend/src/api/public/v1/stewardships/assignSteward.ts +++ b/backend/src/api/public/v1/stewardships/assignSteward.ts @@ -1,13 +1,15 @@ import type { Request, Response } from 'express' import { z } from 'zod' -import { NotFoundError } from '@crowd/common' +import { BadRequestError, NotFoundError } from '@crowd/common' import { assignSteward } from '@crowd/data-access-layer' import { getPackagesQx } from '@/db/packagesDb' import { ok } from '@/utils/api' import { validateOrThrow } from '@/utils/validation' +import { actorInputSchema } from './actorSchema' + const paramsSchema = z.object({ id: z.coerce.number().int().positive(), }) @@ -20,6 +22,7 @@ const bodySchema = z role: z.enum(['lead', 'co_steward']), note: z.string().trim().min(1).optional(), moveToAssessing: z.boolean().optional().default(false), + actor: actorInputSchema, }) .refine((d) => (d.username == null) === (d.displayName == null), { message: 'username and displayName must both be provided or both be absent', @@ -28,11 +31,15 @@ const bodySchema = z export async function assignStewardHandler(req: Request, res: Response): Promise { const { id } = validateOrThrow(paramsSchema, req.params) - const { userId, username, displayName, role, note, moveToAssessing } = validateOrThrow( + const { userId, username, displayName, role, note, moveToAssessing, actor } = validateOrThrow( bodySchema, req.body, ) + if (actor.userId !== req.actor.id) { + throw new BadRequestError('actor.userId must match the authenticated user id') + } + const qx = await getPackagesQx() const result = await assignSteward(qx, id, { userId, @@ -41,6 +48,9 @@ export async function assignStewardHandler(req: Request, res: Response): Promise role, note, assignedBy: req.actor.id, + actorUsername: actor.username ?? null, + actorDisplayName: actor.displayName ?? null, + actorAvatarUrl: actor.avatarUrl ?? null, moveToAssessing, }) diff --git a/backend/src/api/public/v1/stewardships/escalate.ts b/backend/src/api/public/v1/stewardships/escalate.ts index 0481a8c414..b6f70e5f44 100644 --- a/backend/src/api/public/v1/stewardships/escalate.ts +++ b/backend/src/api/public/v1/stewardships/escalate.ts @@ -1,13 +1,15 @@ import type { Request, Response } from 'express' import { z } from 'zod' -import { NotFoundError } from '@crowd/common' +import { BadRequestError, NotFoundError } from '@crowd/common' import { ESCALATION_RESOLUTION_PATHS, escalateStewardship } from '@crowd/data-access-layer' import { getPackagesQx } from '@/db/packagesDb' import { ok } from '@/utils/api' import { validateOrThrow } from '@/utils/validation' +import { actorInputSchema } from './actorSchema' + const paramsSchema = z.object({ id: z.coerce.number().int().positive(), }) @@ -15,17 +17,25 @@ const paramsSchema = z.object({ const bodySchema = z.object({ resolutionPath: z.enum(ESCALATION_RESOLUTION_PATHS), notes: z.string().trim().min(1).optional(), + actor: actorInputSchema, }) export async function escalateHandler(req: Request, res: Response): Promise { const { id } = validateOrThrow(paramsSchema, req.params) - const { resolutionPath, notes } = validateOrThrow(bodySchema, req.body) + const { resolutionPath, notes, actor } = validateOrThrow(bodySchema, req.body) + + if (actor.userId !== req.actor.id) { + throw new BadRequestError('actor.userId must match the authenticated user id') + } const qx = await getPackagesQx() const stewardship = await escalateStewardship(qx, id, { resolutionPath, notes, actorUserId: req.actor.id, + actorUsername: actor.username ?? null, + actorDisplayName: actor.displayName ?? null, + actorAvatarUrl: actor.avatarUrl ?? null, }) if (!stewardship) { diff --git a/backend/src/api/public/v1/stewardships/getMyActivity.ts b/backend/src/api/public/v1/stewardships/getMyActivity.ts index 723165b23a..c6f2430d30 100644 --- a/backend/src/api/public/v1/stewardships/getMyActivity.ts +++ b/backend/src/api/public/v1/stewardships/getMyActivity.ts @@ -63,6 +63,7 @@ export async function getMyActivityHandler(req: Request, res: Response): Promise stewardshipStatus: r.stewardshipStatus, activityType: r.activityType, description: r.content, + actor: r.actor, createdAt: r.createdAt, suggestedAction: SUGGESTED_ACTIONS[r.stewardshipStatus] ?? null, })), diff --git a/backend/src/api/public/v1/stewardships/getMyPackages.ts b/backend/src/api/public/v1/stewardships/getMyPackages.ts index e0512f8cfe..69ade91770 100644 --- a/backend/src/api/public/v1/stewardships/getMyPackages.ts +++ b/backend/src/api/public/v1/stewardships/getMyPackages.ts @@ -1,11 +1,7 @@ import type { Request, Response } from 'express' import { z } from 'zod' -import { - computeHealthBand, - listMyPackages, - translateActivityContent, -} from '@crowd/data-access-layer' +import { computeHealthBand, listMyPackages } from '@crowd/data-access-layer' import { getPackagesQx } from '@/db/packagesDb' import { ok } from '@/utils/api' @@ -42,11 +38,7 @@ export async function getMyPackagesHandler(req: Request, res: Response): Promise healthBand: computeHealthBand(r.scorecardScore), openVulns: r.openVulns, vulnSeverity: r.maxVulnSeverity, - lastActivityDescription: translateActivityContent( - r.lastActivityContent, - r.lastActivityType, - r.lastActivityMetadata, - ), + lastActivityDescription: r.lastActivityDescription, lastActivityAt: r.lastActivityAt ? r.lastActivityAt.toISOString() : null, stewardshipId: r.stewardshipId, stewardshipStatus: r.stewardshipStatus, diff --git a/backend/src/api/public/v1/stewardships/openStewardship.ts b/backend/src/api/public/v1/stewardships/openStewardship.ts index 18a9828df2..31acaac88e 100644 --- a/backend/src/api/public/v1/stewardships/openStewardship.ts +++ b/backend/src/api/public/v1/stewardships/openStewardship.ts @@ -1,7 +1,7 @@ import type { Request, Response } from 'express' import { z } from 'zod' -import { NotFoundError } from '@crowd/common' +import { BadRequestError, NotFoundError } from '@crowd/common' import { openStewardshipByPurl } from '@crowd/data-access-layer' import { getPackagesQx } from '@/db/packagesDb' @@ -10,15 +10,29 @@ import { validateOrThrow } from '@/utils/validation' import { purlFieldSchema } from '../packages/purl' +import { actorInputSchema } from './actorSchema' + const bodySchema = z.object({ purl: purlFieldSchema, + actor: actorInputSchema, }) export async function openStewardship(req: Request, res: Response): Promise { - const { purl } = validateOrThrow(bodySchema, req.body) + const { purl, actor } = validateOrThrow(bodySchema, req.body) + + if (actor.userId !== req.actor.id) { + throw new BadRequestError('actor.userId must match the authenticated user id') + } const qx = await getPackagesQx() - const stewardship = await openStewardshipByPurl(qx, purl, req.actor.id) + const stewardship = await openStewardshipByPurl( + qx, + purl, + req.actor.id, + actor.username ?? null, + actor.displayName ?? null, + actor.avatarUrl ?? null, + ) if (!stewardship) { throw new NotFoundError(`Package not found: ${purl}`) diff --git a/backend/src/api/public/v1/stewardships/openapi.yaml b/backend/src/api/public/v1/stewardships/openapi.yaml index bc41892971..d78a78833e 100644 --- a/backend/src/api/public/v1/stewardships/openapi.yaml +++ b/backend/src/api/public/v1/stewardships/openapi.yaml @@ -45,6 +45,66 @@ components: type: string example: Stewardship not found. + ActivityActor: + type: object + required: [userId] + description: Profile of the actor who performed an activity. Stored as a snapshot on the activity log. + properties: + userId: + type: string + description: Auth0 sub of the actor. + example: auth0|abc123 + username: + type: + - string + - 'null' + description: LFX username of the actor. + example: gaspergrom + displayName: + type: + - string + - 'null' + description: Full display name of the actor. + example: Gašper Grom + avatarUrl: + type: + - string + - 'null' + format: uri + description: Avatar URL of the actor. + example: 'https://avatars.githubusercontent.com/u/12345' + + ActorInput: + type: object + required: [userId] + description: > + Profile of the actor performing this action. Stored as a snapshot on the activity log. + `userId` is required. All other fields are optional and can be null. + properties: + userId: + type: string + description: Auth0 sub of the actor. Must match the authenticated user's token sub. + example: auth0|abc123 + username: + type: + - string + - 'null' + description: LFX username of the actor. + example: gaspergrom + displayName: + type: + - string + - 'null' + description: Full display name of the actor. + example: Gašper Grom + avatarUrl: + type: + - string + - 'null' + format: uri + description: Avatar URL of the actor. + example: 'https://avatars.githubusercontent.com/u/12345' + StewardshipStatus: type: string enum: @@ -187,8 +247,14 @@ paths: type: string description: Package URL (must start with `pkg:`). example: pkg:npm/lodash + actor: + $ref: '#/components/schemas/ActorInput' example: purl: pkg:npm/lodash + actor: + username: gaspergrom + displayName: Gašper Grom + avatarUrl: 'https://avatars.githubusercontent.com/u/12345' responses: '200': description: Stewardship opened (or already open). @@ -288,10 +354,16 @@ paths: If true, atomically transitions the stewardship status to `assessing` in the same transaction as the assignment. Use for the "Assign & move to Assessing" action to avoid a second round-trip. + actor: + $ref: '#/components/schemas/ActorInput' example: userId: abc123 role: lead moveToAssessing: true + actor: + username: gaspergrom + displayName: Gašper Grom + avatarUrl: 'https://avatars.githubusercontent.com/u/12345' responses: '200': description: Steward assigned. @@ -386,9 +458,15 @@ paths: minLength: 1 description: Optional free-text notes for the activity log. example: Contacted maintainer, no response after 30 days. + actor: + $ref: '#/components/schemas/ActorInput' example: resolutionPath: right_of_first_refusal notes: Contacted maintainer, no response after 30 days. + actor: + username: gaspergrom + displayName: Gašper Grom + avatarUrl: 'https://avatars.githubusercontent.com/u/12345' responses: '200': description: Stewardship escalated. @@ -485,22 +563,36 @@ paths: type: string minLength: 1 description: Optional free-text notes for the activity log. + actor: + $ref: '#/components/schemas/ActorInput' examples: set_active: summary: Transition to active value: status: active + actor: + username: gaspergrom + displayName: Gašper Grom + avatarUrl: 'https://avatars.githubusercontent.com/u/12345' set_inactive: summary: Transition to inactive (inactiveReason required) value: status: inactive inactiveReason: stepped_down notes: Steward stepped down voluntarily. + actor: + username: gaspergrom + displayName: Gašper Grom + avatarUrl: 'https://avatars.githubusercontent.com/u/12345' set_blocked: summary: Transition to blocked value: status: blocked notes: Waiting on upstream maintainer response. + actor: + username: gaspergrom + displayName: Gašper Grom + avatarUrl: 'https://avatars.githubusercontent.com/u/12345' responses: '200': description: Status updated. diff --git a/backend/src/api/public/v1/stewardships/updateStatus.ts b/backend/src/api/public/v1/stewardships/updateStatus.ts index 6ed8a6c08a..c42ae207b1 100644 --- a/backend/src/api/public/v1/stewardships/updateStatus.ts +++ b/backend/src/api/public/v1/stewardships/updateStatus.ts @@ -1,7 +1,7 @@ import type { Request, Response } from 'express' import { z } from 'zod' -import { NotFoundError } from '@crowd/common' +import { BadRequestError, NotFoundError } from '@crowd/common' import { INACTIVE_REASONS, STEWARDSHIP_UPDATABLE_STATUSES, @@ -12,6 +12,8 @@ import { getPackagesQx } from '@/db/packagesDb' import { ok } from '@/utils/api' import { validateOrThrow } from '@/utils/validation' +import { actorInputSchema } from './actorSchema' + const paramsSchema = z.object({ id: z.coerce.number().int().positive(), }) @@ -21,6 +23,7 @@ const bodySchema = z status: z.enum(STEWARDSHIP_UPDATABLE_STATUSES), inactiveReason: z.enum(INACTIVE_REASONS).optional(), notes: z.string().trim().min(1).optional(), + actor: actorInputSchema, }) .refine((d) => d.status !== 'inactive' || !!d.inactiveReason, { message: 'inactiveReason is required when status is inactive', @@ -29,7 +32,11 @@ const bodySchema = z export async function updateStatusHandler(req: Request, res: Response): Promise { const { id } = validateOrThrow(paramsSchema, req.params) - const { status, inactiveReason, notes } = validateOrThrow(bodySchema, req.body) + const { status, inactiveReason, notes, actor } = validateOrThrow(bodySchema, req.body) + + if (actor.userId !== req.actor.id) { + throw new BadRequestError('actor.userId must match the authenticated user id') + } const qx = await getPackagesQx() const stewardship = await updateStewardshipStatus(qx, id, { @@ -37,6 +44,9 @@ export async function updateStatusHandler(req: Request, res: Response): Promise< inactiveReason, notes, actorUserId: req.actor.id, + actorUsername: actor.username ?? null, + actorDisplayName: actor.displayName ?? null, + actorAvatarUrl: actor.avatarUrl ?? null, }) if (!stewardship) { diff --git a/backend/src/osspckgs/migrations/V1781700000__stewardship-activity-actor-display.sql b/backend/src/osspckgs/migrations/V1781700000__stewardship-activity-actor-display.sql new file mode 100644 index 0000000000..2dca278f6f --- /dev/null +++ b/backend/src/osspckgs/migrations/V1781700000__stewardship-activity-actor-display.sql @@ -0,0 +1,4 @@ +ALTER TABLE stewardship_activity + ADD COLUMN IF NOT EXISTS actor_username TEXT, + ADD COLUMN IF NOT EXISTS actor_display_name TEXT, + ADD COLUMN IF NOT EXISTS actor_avatar_url TEXT; diff --git a/services/libs/data-access-layer/src/osspckgs/api.ts b/services/libs/data-access-layer/src/osspckgs/api.ts index a42d6998d0..901a6a47ce 100644 --- a/services/libs/data-access-layer/src/osspckgs/api.ts +++ b/services/libs/data-access-layer/src/osspckgs/api.ts @@ -154,6 +154,13 @@ export function computeHealthBand(scorecardScore: number | null): HealthBand { return 'healthy' } +export function buildHealthBandCondition(scoreColumn: string, band: HealthBand): string { + if (band === 'healthy') return `${scoreColumn} >= 7.0` + if (band === 'fair') return `${scoreColumn} >= 5.0 AND ${scoreColumn} < 7.0` + if (band === 'concerning') return `${scoreColumn} >= 3.0 AND ${scoreColumn} < 5.0` + return `(${scoreColumn} IS NULL OR ${scoreColumn} < 3.0)` +} + export interface ListPackagesOptions { page: number pageSize: number diff --git a/services/libs/data-access-layer/src/osspckgs/stewardships.ts b/services/libs/data-access-layer/src/osspckgs/stewardships.ts index 50b2f82b2e..46314f33cd 100644 --- a/services/libs/data-access-layer/src/osspckgs/stewardships.ts +++ b/services/libs/data-access-layer/src/osspckgs/stewardships.ts @@ -1,11 +1,18 @@ import { QueryExecutor } from '../queryExecutor' +import { buildHealthBandCondition } from './api' import { SEVERITY_RANK_EXPR, STEWARD_DISPLAY_NAME_METADATA, STEWARD_MENTIONED_JOIN, } from './sqlFragments' +export interface ActivityActor { + userId: string | null + username: string | null + displayName: string | null + avatarUrl: string | null +} export interface StewardshipRecord { id: string packageId: string @@ -165,6 +172,9 @@ export async function openStewardshipByPurl( qx: QueryExecutor, purl: string, actorUserId: string, + actorUsername?: string | null, + actorDisplayName?: string | null, + actorAvatarUrl?: string | null, ): Promise { const row: Record | null = await qx.selectOneOrNone( ` @@ -192,14 +202,22 @@ export async function openStewardshipByPurl( last_status_at, inactive_reason, resolution_path, status_note, created_at, updated_at ), _log AS ( - INSERT INTO stewardship_activity (stewardship_id, actor_user_id, actor_type, activity_type, content) - SELECT upserted.id, $(actorUserId), 'user', 'state_changed', 'Opened for stewardship' + INSERT INTO stewardship_activity + (stewardship_id, actor_user_id, actor_type, activity_type, content, actor_username, actor_display_name, actor_avatar_url) + SELECT upserted.id, $(actorUserId), 'user', 'state_changed', 'Opened for stewardship', + $(actorUsername), $(actorDisplayName), $(actorAvatarUrl) FROM upserted WHERE NOT EXISTS (SELECT 1 FROM prev WHERE prev.old_status = 'open') ) SELECT * FROM upserted `, - { purl, actorUserId }, + { + purl, + actorUserId, + actorUsername: actorUsername ?? null, + actorDisplayName: actorDisplayName ?? null, + actorAvatarUrl: actorAvatarUrl ?? null, + }, ) return row ? mapStewardshipRow(row) : null } @@ -219,6 +237,9 @@ export async function assignSteward( displayName?: string | null role: 'lead' | 'co_steward' assignedBy: string + actorUsername?: string | null + actorDisplayName?: string | null + actorAvatarUrl?: string | null note?: string moveToAssessing?: boolean }, @@ -256,11 +277,16 @@ export async function assignSteward( ) await tx.result( - `INSERT INTO stewardship_activity (stewardship_id, actor_user_id, actor_type, activity_type, content, metadata) - VALUES ($(stewardshipId), $(actorUserId), 'user', 'steward_added', $(content), $(metadata)::jsonb)`, + `INSERT INTO stewardship_activity + (stewardship_id, actor_user_id, actor_type, activity_type, content, metadata, actor_username, actor_display_name, actor_avatar_url) + VALUES ($(stewardshipId), $(actorUserId), 'user', 'steward_added', $(content), $(metadata)::jsonb, + $(actorUsername), $(actorDisplayName), $(actorAvatarUrl))`, { stewardshipId, actorUserId: data.assignedBy, + actorUsername: data.actorUsername ?? null, + actorDisplayName: data.actorDisplayName ?? null, + actorAvatarUrl: data.actorAvatarUrl ?? null, content: `Assigned steward ${data.userId} as ${data.role}`, metadata: JSON.stringify({ userId: data.userId, @@ -290,9 +316,10 @@ export async function assignSteward( ), _log AS ( INSERT INTO stewardship_activity - (stewardship_id, actor_user_id, actor_type, activity_type, content, metadata) + (stewardship_id, actor_user_id, actor_type, activity_type, content, metadata, actor_username, actor_display_name, actor_avatar_url) SELECT id, $(actorUserId), 'user', 'state_changed', - 'Status updated to assessing', $(metadata)::jsonb + 'Status updated to assessing', $(metadata)::jsonb, + $(actorUsername), $(actorDisplayName), $(actorAvatarUrl) FROM upd ) SELECT * FROM upd @@ -300,6 +327,9 @@ export async function assignSteward( { stewardshipId, actorUserId: data.assignedBy, + actorUsername: data.actorUsername ?? null, + actorDisplayName: data.actorDisplayName ?? null, + actorAvatarUrl: data.actorAvatarUrl ?? null, metadata: JSON.stringify({ status: 'assessing' }), }, ) @@ -344,8 +374,7 @@ export interface ActivityFeedRow { packagePurl: string packageName: string packageEcosystem: string - actorUserId: string | null - // TODO: join actor display name from crowd.dev users/members table (actor_user_id is an Auth0 ID stored in packages DB) + actor: ActivityActor actorType: string activityType: string content: string | null @@ -361,7 +390,7 @@ export async function listStewardshipActivity( qx: QueryExecutor, opts: { page: number; pageSize: number }, ): Promise<{ rows: Omit[]; total: number }> { - const rows: ActivityFeedRow[] = await qx.select( + const rows: Array> = await qx.select( ` SELECT sa.id::text AS id, @@ -370,6 +399,9 @@ export async function listStewardshipActivity( p.name AS "packageName", p.ecosystem AS "packageEcosystem", sa.actor_user_id AS "actorUserId", + sa.actor_username AS "actorUsername", + sa.actor_display_name AS "actorDisplayName", + sa.actor_avatar_url AS "actorAvatarUrl", sa.actor_type AS "actorType", sa.activity_type AS "activityType", sa.content AS content, @@ -389,7 +421,7 @@ export async function listStewardshipActivity( let total: number if (rows.length > 0) { - total = parseInt(rows[0].total, 10) + total = parseInt(rows[0].total as string, 10) } else { const countRow: { count: string } = await qx.selectOne( `SELECT COUNT(*)::text AS count @@ -402,21 +434,26 @@ export async function listStewardshipActivity( return { rows: rows.map((row) => ({ - id: row.id, - stewardshipId: row.stewardshipId, - packagePurl: row.packagePurl, - packageName: row.packageName, - packageEcosystem: row.packageEcosystem, - actorUserId: row.actorUserId, - actorType: row.actorType, - activityType: row.activityType, + id: row.id as string, + stewardshipId: row.stewardshipId as string, + packagePurl: row.packagePurl as string, + packageName: row.packageName as string, + packageEcosystem: row.packageEcosystem as string, + actor: { + userId: row.actorUserId ? String(row.actorUserId) : null, + username: row.actorUsername ? String(row.actorUsername) : null, + displayName: row.actorDisplayName ? String(row.actorDisplayName) : null, + avatarUrl: row.actorAvatarUrl ? String(row.actorAvatarUrl) : null, + }, + actorType: row.actorType as string, + activityType: row.activityType as string, content: translateActivityContent( - row.content, - row.activityType, + row.content ? String(row.content) : null, + row.activityType ? String(row.activityType) : null, row.metadata as Record | null, ), metadata: row.metadata as Record | null, - stewardshipStatus: row.stewardshipStatus, + stewardshipStatus: row.stewardshipStatus as string, createdAt: toIso(row.createdAt), })), total, @@ -425,7 +462,7 @@ export async function listStewardshipActivity( export interface PackageHistoryEvent { id: string - actorUserId: string | null + actor: ActivityActor actorType: string activityType: string content: string | null @@ -440,6 +477,9 @@ export async function listPackageHistory( const rows: Array> = await qx.select( `SELECT sa.id::text AS id, sa.actor_user_id AS "actorUserId", + sa.actor_username AS "actorUsername", + sa.actor_display_name AS "actorDisplayName", + sa.actor_avatar_url AS "actorAvatarUrl", sa.actor_type AS "actorType", sa.activity_type AS "activityType", sa.content, @@ -453,7 +493,12 @@ export async function listPackageHistory( ) return rows.map((r) => ({ id: r.id as string, - actorUserId: r.actorUserId ? String(r.actorUserId) : null, + actor: { + userId: r.actorUserId ? String(r.actorUserId) : null, + username: r.actorUsername ? String(r.actorUsername) : null, + displayName: r.actorDisplayName ? String(r.actorDisplayName) : null, + avatarUrl: r.actorAvatarUrl ? String(r.actorAvatarUrl) : null, + }, actorType: String(r.actorType), activityType: String(r.activityType), content: translateActivityContent( @@ -477,6 +522,7 @@ export interface MyPackageRow { lastActivityContent: string | null lastActivityType: string | null lastActivityMetadata: Record | null + lastActivityDescription: string | null lastActivityAt: Date | null stewardshipId: string stewardshipStatus: string @@ -536,15 +582,7 @@ export async function listMyPackages( } if (opts.healthBand) { - if (opts.healthBand === 'healthy') { - conditions.push('r_sc.scorecard_score >= 7.0') - } else if (opts.healthBand === 'fair') { - conditions.push('r_sc.scorecard_score >= 5.0 AND r_sc.scorecard_score < 7.0') - } else if (opts.healthBand === 'concerning') { - conditions.push('r_sc.scorecard_score >= 3.0 AND r_sc.scorecard_score < 5.0') - } else { - conditions.push('(r_sc.scorecard_score IS NULL OR r_sc.scorecard_score < 3.0)') - } + conditions.push(buildHealthBandCondition('r_sc.scorecard_score', opts.healthBand)) } if (opts.vulnSeverity) { @@ -699,13 +737,14 @@ export async function listMyPackages( scorecardScore: row.scorecardScore != null ? Number(row.scorecardScore) : null, openVulns: Number(row.openVulns), maxVulnSeverity: row.maxVulnSeverity ?? null, - lastActivityContent: translateActivityContent( - row.lastActivityContent ?? null, - row.lastActivityType, - row.lastActivityMetadata as Record | null, - ), + lastActivityContent: row.lastActivityContent ?? null, lastActivityType: row.lastActivityType ?? null, lastActivityMetadata: row.lastActivityMetadata ?? null, + lastActivityDescription: translateActivityContent( + row.lastActivityContent ?? null, + row.lastActivityType ?? null, + row.lastActivityMetadata ?? null, + ), lastActivityAt: row.lastActivityAt ?? null, stewardshipId: row.stewardshipId, stewardshipStatus: row.stewardshipStatus, @@ -733,15 +772,15 @@ export async function listMyActivity( offset: (opts.page - 1) * opts.pageSize, } - const statusFilter = - opts.status && opts.status.length > 0 ? 'AND s.status = ANY($(statusFilter))' : '' - if (opts.status && opts.status.length > 0) { + const hasStatusFilter = opts.status && opts.status.length > 0 + const statusFilter = hasStatusFilter ? 'AND s.status = ANY($(statusFilter))' : '' + if (hasStatusFilter) { params.statusFilter = opts.status } // DISTINCT ON keeps the most recent event per stewardship; the outer query // re-sorts newest-first after deduplication and applies pagination. - const rows: ActivityFeedRow[] = await qx.select( + const rows: Array> = await qx.select( ` SELECT sub.id, @@ -750,6 +789,9 @@ export async function listMyActivity( sub."packageName", sub."packageEcosystem", sub."actorUserId", + sub."actorUsername", + sub."actorDisplayName", + sub."actorAvatarUrl", sub."actorType", sub."activityType", sub.content, @@ -765,6 +807,9 @@ export async function listMyActivity( p.name AS "packageName", p.ecosystem AS "packageEcosystem", sa.actor_user_id AS "actorUserId", + sa.actor_username AS "actorUsername", + sa.actor_display_name AS "actorDisplayName", + sa.actor_avatar_url AS "actorAvatarUrl", sa.actor_type AS "actorType", sa.activity_type AS "activityType", sa.content AS content, @@ -790,7 +835,7 @@ export async function listMyActivity( let total: number if (rows.length > 0) { - total = parseInt(rows[0].total, 10) + total = parseInt(rows[0].total as string, 10) } else { const countParams: Record = { userId: opts.userId } if (opts.status && opts.status.length > 0) { @@ -814,21 +859,26 @@ export async function listMyActivity( return { rows: rows.map((row) => ({ - id: row.id, - stewardshipId: row.stewardshipId, - packagePurl: row.packagePurl, - packageName: row.packageName, - packageEcosystem: row.packageEcosystem, - actorUserId: row.actorUserId, - actorType: row.actorType, - activityType: row.activityType, + id: row.id as string, + stewardshipId: row.stewardshipId as string, + packagePurl: row.packagePurl as string, + packageName: row.packageName as string, + packageEcosystem: row.packageEcosystem as string, + actor: { + userId: row.actorUserId ? String(row.actorUserId) : null, + username: row.actorUsername ? String(row.actorUsername) : null, + displayName: row.actorDisplayName ? String(row.actorDisplayName) : null, + avatarUrl: row.actorAvatarUrl ? String(row.actorAvatarUrl) : null, + }, + actorType: row.actorType as string, + activityType: row.activityType as string, content: translateActivityContent( - row.content, - row.activityType, + row.content ? String(row.content) : null, + row.activityType ? String(row.activityType) : null, row.metadata as Record | null, ), metadata: row.metadata as Record | null, - stewardshipStatus: row.stewardshipStatus, + stewardshipStatus: row.stewardshipStatus as string, createdAt: toIso(row.createdAt), })), total, @@ -886,7 +936,14 @@ export function translateActivityContent( export async function escalateStewardship( qx: QueryExecutor, stewardshipId: number, - data: { resolutionPath: EscalationResolutionPath; notes?: string; actorUserId: string }, + data: { + resolutionPath: EscalationResolutionPath + notes?: string + actorUserId: string + actorUsername?: string | null + actorDisplayName?: string | null + actorAvatarUrl?: string | null + }, ): Promise { const row: Record | null = await qx.selectOneOrNone( ` @@ -904,9 +961,9 @@ export async function escalateStewardship( ), _log AS ( INSERT INTO stewardship_activity - (stewardship_id, actor_user_id, actor_type, activity_type, content, metadata) + (stewardship_id, actor_user_id, actor_type, activity_type, content, metadata, actor_username, actor_display_name, actor_avatar_url) SELECT id, $(actorUserId), 'user', 'escalation', - $(content), $(metadata)::jsonb + $(content), $(metadata)::jsonb, $(actorUsername), $(actorDisplayName), $(actorAvatarUrl) FROM upd ) SELECT * FROM upd @@ -916,6 +973,9 @@ export async function escalateStewardship( resolutionPath: data.resolutionPath, statusNote: data.notes ?? null, actorUserId: data.actorUserId, + actorUsername: data.actorUsername ?? null, + actorDisplayName: data.actorDisplayName ?? null, + actorAvatarUrl: data.actorAvatarUrl ?? null, content: `Escalated with resolution path: ${data.resolutionPath}`, metadata: JSON.stringify({ resolutionPath: data.resolutionPath, @@ -956,6 +1016,9 @@ export async function updateStewardshipStatus( inactiveReason?: InactiveReason notes?: string actorUserId: string + actorUsername?: string | null + actorDisplayName?: string | null + actorAvatarUrl?: string | null }, ): Promise { const row: Record | null = await qx.selectOneOrNone( @@ -974,9 +1037,9 @@ export async function updateStewardshipStatus( ), _log AS ( INSERT INTO stewardship_activity - (stewardship_id, actor_user_id, actor_type, activity_type, content, metadata) + (stewardship_id, actor_user_id, actor_type, activity_type, content, metadata, actor_username, actor_display_name, actor_avatar_url) SELECT id, $(actorUserId), 'user', 'state_changed', - $(content), $(metadata)::jsonb + $(content), $(metadata)::jsonb, $(actorUsername), $(actorDisplayName), $(actorAvatarUrl) FROM upd ) SELECT * FROM upd @@ -987,6 +1050,9 @@ export async function updateStewardshipStatus( inactiveReason: data.inactiveReason ?? null, statusNote: data.notes ?? null, actorUserId: data.actorUserId, + actorUsername: data.actorUsername ?? null, + actorDisplayName: data.actorDisplayName ?? null, + actorAvatarUrl: data.actorAvatarUrl ?? null, content: `Status updated to ${data.status}`, metadata: JSON.stringify({ status: data.status, From 7df6c9b01a027939cef0587ee21de5ec3345a255 Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Tue, 23 Jun 2026 16:06:26 +0200 Subject: [PATCH 2/3] fix: add stewards param Signed-off-by: Umberto Sgueglia --- .../public/v1/stewardships/assignSteward.ts | 43 +++++----- .../api/public/v1/stewardships/openapi.yaml | 80 ++++++++++++------- 2 files changed, 74 insertions(+), 49 deletions(-) diff --git a/backend/src/api/public/v1/stewardships/assignSteward.ts b/backend/src/api/public/v1/stewardships/assignSteward.ts index 54e81a58e4..20afd2d9a6 100644 --- a/backend/src/api/public/v1/stewardships/assignSteward.ts +++ b/backend/src/api/public/v1/stewardships/assignSteward.ts @@ -14,27 +14,26 @@ const paramsSchema = z.object({ id: z.coerce.number().int().positive(), }) -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), - actor: actorInputSchema, - }) - .refine((d) => (d.username == null) === (d.displayName == null), { - message: 'username and displayName must both be provided or both be absent', - path: ['displayName'], - }) +const bodySchema = z.object({ + steward: 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']), + }) + .refine((d) => (d.username == null) === (d.displayName == null), { + message: 'username and displayName must both be provided or both be absent', + path: ['displayName'], + }), + note: z.string().trim().min(1).optional(), + moveToAssessing: z.boolean().optional().default(false), + actor: actorInputSchema, +}) export async function assignStewardHandler(req: Request, res: Response): Promise { const { id } = validateOrThrow(paramsSchema, req.params) - const { userId, username, displayName, role, note, moveToAssessing, actor } = validateOrThrow( - bodySchema, - req.body, - ) + const { steward, note, moveToAssessing, actor } = validateOrThrow(bodySchema, req.body) if (actor.userId !== req.actor.id) { throw new BadRequestError('actor.userId must match the authenticated user id') @@ -42,10 +41,10 @@ export async function assignStewardHandler(req: Request, res: Response): Promise const qx = await getPackagesQx() const result = await assignSteward(qx, id, { - userId, - username, - displayName, - role, + userId: steward.userId, + username: steward.username, + displayName: steward.displayName, + role: steward.role, note, assignedBy: req.actor.id, actorUsername: actor.username ?? null, diff --git a/backend/src/api/public/v1/stewardships/openapi.yaml b/backend/src/api/public/v1/stewardships/openapi.yaml index d78a78833e..6028ae4855 100644 --- a/backend/src/api/public/v1/stewardships/openapi.yaml +++ b/backend/src/api/public/v1/stewardships/openapi.yaml @@ -13,9 +13,9 @@ info: scope enforcement is temporarily disabled. See CM-1235. servers: - - url: https://cm.lfx.dev/api/v1 + - url: https://cm.lfx.dev/api/v1/akrites description: Production - - url: https://lf-staging.crowd.dev/api/v1 + - url: https://lf-staging.crowd.dev/api/v1/akrites description: Staging tags: @@ -83,18 +83,21 @@ components: properties: userId: type: string + minLength: 1 description: Auth0 sub of the actor. Must match the authenticated user's token sub. example: auth0|abc123 username: type: - string - 'null' + minLength: 1 description: LFX username of the actor. example: gaspergrom displayName: type: - string - 'null' + minLength: 1 description: Full display name of the actor. example: Gašper Grom avatarUrl: @@ -222,7 +225,7 @@ components: - namespace_takeover paths: - /stewardships: + /stewardships/open: post: operationId: openStewardship summary: Open a package for stewardship @@ -241,7 +244,7 @@ paths: application/json: schema: type: object - required: [purl] + required: [purl, actor] properties: purl: type: string @@ -252,6 +255,7 @@ paths: example: purl: pkg:npm/lodash actor: + userId: auth0|abc123 username: gaspergrom displayName: Gašper Grom avatarUrl: 'https://avatars.githubusercontent.com/u/12345' @@ -303,8 +307,8 @@ paths: schema: $ref: '#/components/schemas/Error' - /stewardships/{id}/steward: - put: + /stewardships/{id}/assign: + post: operationId: assignSteward summary: Assign a steward to a stewardship description: > @@ -330,23 +334,39 @@ paths: application/json: schema: type: object - required: [userId, role] + required: [steward, actor] properties: - userId: - 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: + steward: + type: object + required: [userId, role] + description: > + The user to assign as steward. + `username` and `displayName` must be provided together or both omitted — sending one without the other returns 400. + properties: + userId: + type: string + minLength: 1 + description: Auth0 sub of the user to assign as steward. + example: abc123 + username: + type: + - string + - 'null' + description: LFX username of the steward. Must be provided together with `displayName`. + example: joanagmaia + displayName: + type: + - string + - 'null' + description: Full display name of the steward. Must be provided together with `username`. + example: Joana Maia + role: + type: string + enum: [lead, co_steward] + note: type: string - enum: [lead, co_steward] + minLength: 1 + description: Optional free-text note for the activity log. moveToAssessing: type: boolean default: false @@ -357,10 +377,12 @@ paths: actor: $ref: '#/components/schemas/ActorInput' example: - userId: abc123 - role: lead + steward: + userId: abc123 + role: lead moveToAssessing: true actor: + userId: auth0|xyz username: gaspergrom displayName: Gašper Grom avatarUrl: 'https://avatars.githubusercontent.com/u/12345' @@ -424,7 +446,7 @@ paths: $ref: '#/components/schemas/Error' /stewardships/{id}/escalate: - put: + post: operationId: escalateStewardship summary: Escalate a stewardship description: > @@ -449,7 +471,7 @@ paths: application/json: schema: type: object - required: [resolutionPath] + required: [resolutionPath, actor] properties: resolutionPath: $ref: '#/components/schemas/EscalationResolutionPath' @@ -464,6 +486,7 @@ paths: resolutionPath: right_of_first_refusal notes: Contacted maintainer, no response after 30 days. actor: + userId: auth0|abc123 username: gaspergrom displayName: Gašper Grom avatarUrl: 'https://avatars.githubusercontent.com/u/12345' @@ -516,7 +539,7 @@ paths: $ref: '#/components/schemas/Error' /stewardships/{id}/status: - put: + patch: operationId: updateStewardshipStatus summary: Update stewardship status description: > @@ -542,7 +565,7 @@ paths: application/json: schema: type: object - required: [status] + required: [status, actor] properties: status: type: string @@ -571,6 +594,7 @@ paths: value: status: active actor: + userId: auth0|abc123 username: gaspergrom displayName: Gašper Grom avatarUrl: 'https://avatars.githubusercontent.com/u/12345' @@ -581,6 +605,7 @@ paths: inactiveReason: stepped_down notes: Steward stepped down voluntarily. actor: + userId: auth0|abc123 username: gaspergrom displayName: Gašper Grom avatarUrl: 'https://avatars.githubusercontent.com/u/12345' @@ -590,6 +615,7 @@ paths: status: blocked notes: Waiting on upstream maintainer response. actor: + userId: auth0|abc123 username: gaspergrom displayName: Gašper Grom avatarUrl: 'https://avatars.githubusercontent.com/u/12345' From 938ad46d621de0e05e8a0fa3816b3986a9fead1e Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Tue, 23 Jun 2026 18:02:15 +0200 Subject: [PATCH 3/3] fix: remove actorid validation Signed-off-by: Umberto Sgueglia --- backend/src/api/public/v1/stewardships/actorSchema.ts | 1 - backend/src/api/public/v1/stewardships/assignSteward.ts | 6 +----- backend/src/api/public/v1/stewardships/escalate.ts | 6 +----- backend/src/api/public/v1/stewardships/openStewardship.ts | 6 +----- backend/src/api/public/v1/stewardships/updateStatus.ts | 6 +----- 5 files changed, 4 insertions(+), 21 deletions(-) diff --git a/backend/src/api/public/v1/stewardships/actorSchema.ts b/backend/src/api/public/v1/stewardships/actorSchema.ts index 1ad728ac0b..fe616686fe 100644 --- a/backend/src/api/public/v1/stewardships/actorSchema.ts +++ b/backend/src/api/public/v1/stewardships/actorSchema.ts @@ -1,7 +1,6 @@ import { z } from 'zod' export const actorInputSchema = z.object({ - userId: z.string().trim().min(1, { message: 'actor.userId is required and must not be empty' }), username: z.string().trim().min(1).optional().nullable(), displayName: z.string().trim().min(1).optional().nullable(), avatarUrl: z.string().url().optional().nullable(), diff --git a/backend/src/api/public/v1/stewardships/assignSteward.ts b/backend/src/api/public/v1/stewardships/assignSteward.ts index 20afd2d9a6..4428302226 100644 --- a/backend/src/api/public/v1/stewardships/assignSteward.ts +++ b/backend/src/api/public/v1/stewardships/assignSteward.ts @@ -1,7 +1,7 @@ import type { Request, Response } from 'express' import { z } from 'zod' -import { BadRequestError, NotFoundError } from '@crowd/common' +import { NotFoundError } from '@crowd/common' import { assignSteward } from '@crowd/data-access-layer' import { getPackagesQx } from '@/db/packagesDb' @@ -35,10 +35,6 @@ export async function assignStewardHandler(req: Request, res: Response): Promise const { id } = validateOrThrow(paramsSchema, req.params) const { steward, note, moveToAssessing, actor } = validateOrThrow(bodySchema, req.body) - if (actor.userId !== req.actor.id) { - throw new BadRequestError('actor.userId must match the authenticated user id') - } - const qx = await getPackagesQx() const result = await assignSteward(qx, id, { userId: steward.userId, diff --git a/backend/src/api/public/v1/stewardships/escalate.ts b/backend/src/api/public/v1/stewardships/escalate.ts index b6f70e5f44..2bf475a854 100644 --- a/backend/src/api/public/v1/stewardships/escalate.ts +++ b/backend/src/api/public/v1/stewardships/escalate.ts @@ -1,7 +1,7 @@ import type { Request, Response } from 'express' import { z } from 'zod' -import { BadRequestError, NotFoundError } from '@crowd/common' +import { NotFoundError } from '@crowd/common' import { ESCALATION_RESOLUTION_PATHS, escalateStewardship } from '@crowd/data-access-layer' import { getPackagesQx } from '@/db/packagesDb' @@ -24,10 +24,6 @@ export async function escalateHandler(req: Request, res: Response): Promise { const { purl, actor } = validateOrThrow(bodySchema, req.body) - if (actor.userId !== req.actor.id) { - throw new BadRequestError('actor.userId must match the authenticated user id') - } - const qx = await getPackagesQx() const stewardship = await openStewardshipByPurl( qx, diff --git a/backend/src/api/public/v1/stewardships/updateStatus.ts b/backend/src/api/public/v1/stewardships/updateStatus.ts index c42ae207b1..2bb3d058ff 100644 --- a/backend/src/api/public/v1/stewardships/updateStatus.ts +++ b/backend/src/api/public/v1/stewardships/updateStatus.ts @@ -1,7 +1,7 @@ import type { Request, Response } from 'express' import { z } from 'zod' -import { BadRequestError, NotFoundError } from '@crowd/common' +import { NotFoundError } from '@crowd/common' import { INACTIVE_REASONS, STEWARDSHIP_UPDATABLE_STATUSES, @@ -34,10 +34,6 @@ export async function updateStatusHandler(req: Request, res: Response): Promise< const { id } = validateOrThrow(paramsSchema, req.params) const { status, inactiveReason, notes, actor } = validateOrThrow(bodySchema, req.body) - if (actor.userId !== req.actor.id) { - throw new BadRequestError('actor.userId must match the authenticated user id') - } - const qx = await getPackagesQx() const stewardship = await updateStewardshipStatus(qx, id, { status,