diff --git a/apps/dashboard/src/components/maintenances/maintenance-properties.tsx b/apps/dashboard/src/components/maintenances/maintenance-properties.tsx index 2a757753..79fc2c25 100644 --- a/apps/dashboard/src/components/maintenances/maintenance-properties.tsx +++ b/apps/dashboard/src/components/maintenances/maintenance-properties.tsx @@ -7,6 +7,7 @@ import { useState } from "react"; import { toast } from "sonner"; import { StatusDot } from "@/components/common/status-dot"; +import { UserAvatar } from "@/components/common/user-avatar"; import { Property, PropertyInput, @@ -15,6 +16,7 @@ import { PropertyList, PropertyValue, } from "@/components/content/property-list"; +import { distinctEditor } from "@/data/attribution.client"; import { type MaintenanceStatus, maintenanceStatusConfig, @@ -26,6 +28,24 @@ import { useUpdateMaintenance } from "./use-update-maintenance"; type Maintenance = NonNullable; +function UserProperty({ + label, + user, +}: { + label: string; + user: { name: string; photoUrl: string | null }; +}) { + return ( + + {label} + + + {user.name} + + + ); +} + export function MaintenanceProperties({ maintenance, page, @@ -55,6 +75,8 @@ export function MaintenanceProperties({ const dirty = draft !== null && (from !== serverFrom || to !== serverTo); const invalid = !from || !to || new Date(to) <= new Date(from); + const author = maintenance.createdByUser; + const editor = distinctEditor(maintenance); return ( @@ -138,6 +160,8 @@ export function MaintenanceProperties({ ) : null} + {author ? : null} + {editor ? : null} Timezone diff --git a/apps/dashboard/src/components/status-reports/status-report-timeline.tsx b/apps/dashboard/src/components/status-reports/status-report-timeline.tsx index 6daedc8c..4ef95cc1 100644 --- a/apps/dashboard/src/components/status-reports/status-report-timeline.tsx +++ b/apps/dashboard/src/components/status-reports/status-report-timeline.tsx @@ -19,16 +19,19 @@ import { } from "@/components/content/component-list"; import { ProcessMessage } from "@/components/content/process-message"; import { + TimelineActor, TimelineBody, TimelineContent, TimelineHeader, TimelineIndicator, TimelineItem, + TimelineMeta, TimelineTime, TimelineTitle, } from "@/components/content/timeline"; import { QuickActions } from "@/components/dropdowns/quick-actions"; import { FormSheetStatusReportUpdate } from "@/components/forms/status-report-update/sheet"; +import { distinctEditor } from "@/data/attribution.client"; import { icons } from "@/data/icons"; import { getActions, @@ -107,6 +110,10 @@ export function StatusReportTimelineItem({ name: c.name, })); const Icon = icons.status[update.status]; + // Every update is a status change, so the colored indicator keeps the rail + // and the author sits inline, as on incident state-change rows. + const author = update.createdByUser; + const editor = distinctEditor(update); const impacts = update.componentImpacts.flatMap((ci) => { const component = components.find((c) => c.id === ci.pageComponentId); return component @@ -131,6 +138,12 @@ export function StatusReportTimelineItem({ {update.status} {impacts.length ? : null} + {author ? : null} + {editor ? ( + + edited by + + ) : null} diff --git a/apps/dashboard/src/data/attribution.client.ts b/apps/dashboard/src/data/attribution.client.ts new file mode 100644 index 00000000..1c106de2 --- /dev/null +++ b/apps/dashboard/src/data/attribution.client.ts @@ -0,0 +1,14 @@ +type Attributed = { + createdByUser: U | null; + updatedByUser: U | null; +}; + +/** The last editor, only when it is someone other than the author. */ +export function distinctEditor( + row: Attributed, +): U | null { + const { createdByUser, updatedByUser } = row; + return updatedByUser && updatedByUser.id !== createdByUser?.id + ? updatedByUser + : null; +} diff --git a/packages/services/src/__tests__/attribution.test.ts b/packages/services/src/__tests__/attribution.test.ts new file mode 100644 index 00000000..4a9d4206 --- /dev/null +++ b/packages/services/src/__tests__/attribution.test.ts @@ -0,0 +1,43 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { toAgentUser, toAttributedUserDetail } from "../attribution"; + +const row = { + id: 7, + name: null, + firstName: "Ada", + lastName: "Lovelace", + email: "ada@example.com", + photoUrl: "", +}; + +describe("attribution projections", () => { + test("detail carries email and normalizes an empty photo to null", () => { + expect(toAttributedUserDetail(row)).toEqual({ + id: 7, + name: "Ada Lovelace", + email: "ada@example.com", + photoUrl: null, + }); + }); + + test("soft-deleted user exposes no contact details", () => { + expect(toAttributedUserDetail({ ...row, deletedAt: new Date() })).toEqual({ + id: 7, + name: "Deleted user", + email: null, + photoUrl: null, + }); + }); + + test("agent projection drops email and photo", () => { + const detail = toAttributedUserDetail({ + ...row, + photoUrl: "https://example.com/ada.png", + }); + expect(detail?.photoUrl).toBe("https://example.com/ada.png"); + expect(toAgentUser(detail)).toEqual({ id: 7, name: "Ada Lovelace" }); + expect(toAgentUser(null)).toBeNull(); + }); +}); diff --git a/packages/services/src/agent-tools/maintenance.ts b/packages/services/src/agent-tools/maintenance.ts index 95542ebb..467e9858 100644 --- a/packages/services/src/agent-tools/maintenance.ts +++ b/packages/services/src/agent-tools/maintenance.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { attributedUserSchema } from "../attribution"; +import { attributedUserSchema, toAgentUser } from "../attribution"; import { createMaintenance, listMaintenances, @@ -101,8 +101,8 @@ export const listMaintenancesTool: AgentTool< to: m.to.toISOString(), pageId: m.pageId, pageComponentIds: m.pageComponentIds, - createdBy: m.createdByUser, - updatedBy: m.updatedByUser, + createdBy: toAgentUser(m.createdByUser), + updatedBy: toAgentUser(m.updatedByUser), })), pagination: { page, diff --git a/packages/services/src/agent-tools/status-report.ts b/packages/services/src/agent-tools/status-report.ts index a80faf8f..59e39661 100644 --- a/packages/services/src/agent-tools/status-report.ts +++ b/packages/services/src/agent-tools/status-report.ts @@ -4,7 +4,7 @@ import { } from "@openstatus/db/src/schema"; import { z } from "zod"; -import { attributedUserSchema } from "../attribution"; +import { attributedUserSchema, toAgentUser } from "../attribution"; import type { ServiceContext } from "../context"; import { addStatusReportUpdate, @@ -166,14 +166,14 @@ export const listStatusReportsTool: AgentTool< pageId: r.pageId, createdAt: r.createdAt?.toISOString() ?? null, updatedAt: r.updatedAt?.toISOString() ?? null, - createdBy: r.createdByUser, - updatedBy: r.updatedByUser, + createdBy: toAgentUser(r.createdByUser), + updatedBy: toAgentUser(r.updatedByUser), latestUpdate: latestUpdate ? { message: latestUpdate.message, status: latestUpdate.status, date: latestUpdate.date?.toISOString() ?? null, - createdBy: latestUpdate.createdByUser, + createdBy: toAgentUser(latestUpdate.createdByUser), } : null, }; diff --git a/packages/services/src/attribution.ts b/packages/services/src/attribution.ts index acb4777f..c9b9dbc2 100644 --- a/packages/services/src/attribution.ts +++ b/packages/services/src/attribution.ts @@ -9,15 +9,25 @@ export const attributedUserSchema = z.object({ name: z.string(), }); -/** The user behind a `created_by` / `updated_by` column, reduced to what callers may show. */ +/** The user behind a `created_by` / `updated_by` column, reduced to what agent tools may show. */ export type AttributedUser = z.infer; +/** + * `AttributedUser` plus what the dashboard needs to render an avatar. Carries + * the email: pass through `toAgentUser` before any other transport. + */ +export type AttributedUserDetail = AttributedUser & { + email: string | null; + photoUrl: string | null; +}; + type UserRow = { id: number; name: string | null; firstName: string | null; lastName: string | null; email: string | null; + photoUrl?: string | null; deletedAt?: Date | null; }; @@ -43,15 +53,34 @@ export function toAttributedUser( }; } +export function toAttributedUserDetail( + row: UserRow | null | undefined, +): AttributedUserDetail | null { + const base = toAttributedUser(row); + if (!base || !row) return null; + return { + ...base, + email: row.deletedAt ? null : row.email || null, + photoUrl: row.deletedAt ? null : row.photoUrl || null, + }; +} + +/** Agent tools expose the name only; `email` and `photoUrl` stay in the dashboard. */ +export function toAgentUser( + detail: AttributedUserDetail | null, +): AttributedUser | null { + return detail ? { id: detail.id, name: detail.name } : null; +} + /** One query for every distinct id; ids with no user row are absent from the map. */ export async function loadAttributedUsers( db: DB, ids: Iterable, -): Promise> { +): Promise> { const distinct = [ ...new Set([...ids].filter((id): id is number => id != null)), ]; - const map = new Map(); + const map = new Map(); if (distinct.length === 0) return map; const rows = await db .select({ @@ -60,13 +89,14 @@ export async function loadAttributedUsers( firstName: user.firstName, lastName: user.lastName, email: user.email, + photoUrl: user.photoUrl, deletedAt: user.deletedAt, }) .from(user) .where(inArray(user.id, distinct)) .all(); for (const row of rows) { - const attributed = toAttributedUser(row); + const attributed = toAttributedUserDetail(row); if (attributed) map.set(row.id, attributed); } return map; diff --git a/packages/services/src/maintenance/__tests__/attribution.test.ts b/packages/services/src/maintenance/__tests__/attribution.test.ts index 4fa6840b..b128a9bd 100644 --- a/packages/services/src/maintenance/__tests__/attribution.test.ts +++ b/packages/services/src/maintenance/__tests__/attribution.test.ts @@ -11,6 +11,7 @@ import { makeUserCtx, withTestTransaction, } from "../../../test/helpers"; +import { listMaintenancesTool } from "../../agent-tools/maintenance"; import type { ServiceContext } from "../../context"; import { createMaintenance } from "../create"; import { getMaintenance } from "../list"; @@ -67,8 +68,30 @@ describe("maintenance attribution", () => { expect(record.updatedBy).toBe(ownerId); const full = await getMaintenance({ ctx, input: { id: record.id } }); - expect(full.createdByUser).toEqual({ id: ownerId, name: "Test User" }); - expect(full.updatedByUser).toEqual({ id: ownerId, name: "Test User" }); + const owner = { + id: ownerId, + name: "Test User", + email: expect.stringContaining("@openstatus.dev"), + // the factory stores "", which the projection normalizes to null + photoUrl: null, + }; + expect(full.createdByUser).toEqual(owner); + expect(full.updatedByUser).toEqual(owner); + }); + }); + + test("agent tool output carries the name only", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const record = await create(ctx, "agent"); + const output = await listMaintenancesTool.run({ + ctx, + input: { pageId, page: 1, perPage: 50 }, + }); + const item = output.items.find((i) => i.id === record.id); + const agentUser = { id: ownerId, name: "Test User" }; + expect(item?.createdBy).toEqual(agentUser); + expect(item?.updatedBy).toEqual(agentUser); }); }); diff --git a/packages/services/src/maintenance/list.ts b/packages/services/src/maintenance/list.ts index 70780d8e..5d6a587e 100644 --- a/packages/services/src/maintenance/list.ts +++ b/packages/services/src/maintenance/list.ts @@ -16,7 +16,7 @@ import { selectPageComponentSchema, } from "@openstatus/db/src/schema"; -import { type AttributedUser, loadAttributedUsers } from "../attribution"; +import { type AttributedUserDetail, loadAttributedUsers } from "../attribution"; import type { DB, ServiceContext } from "../context"; import type { Maintenance, PageComponent } from "../types"; import { getMaintenanceInWorkspace } from "./internal"; @@ -42,8 +42,8 @@ function periodToSince(period: MaintenanceListPeriod): Date { export type MaintenanceWithRelations = Maintenance & { pageComponents: PageComponent[]; pageComponentIds: number[]; - createdByUser: AttributedUser | null; - updatedByUser: AttributedUser | null; + createdByUser: AttributedUserDetail | null; + updatedByUser: AttributedUserDetail | null; }; export type ListMaintenancesResult = { diff --git a/packages/services/src/status-report/__tests__/attribution.test.ts b/packages/services/src/status-report/__tests__/attribution.test.ts index ac17ef49..da91cd0a 100644 --- a/packages/services/src/status-report/__tests__/attribution.test.ts +++ b/packages/services/src/status-report/__tests__/attribution.test.ts @@ -15,6 +15,7 @@ import { makeUserCtx, withTestTransaction, } from "../../../test/helpers"; +import { listStatusReportsTool } from "../../agent-tools/status-report"; import type { DB, ServiceContext } from "../../context"; import { addStatusReportUpdate } from "../add-update"; import { createStatusReport } from "../create"; @@ -92,12 +93,32 @@ describe("status report attribution", () => { expect(initialUpdate.updatedBy).toBe(ownerId); const full = await getStatusReport({ ctx, input: { id: report.id } }); - expect(full.createdByUser).toEqual({ id: ownerId, name: "Test User" }); - expect(full.updatedByUser).toEqual({ id: ownerId, name: "Test User" }); - expect(full.updates[0].createdByUser).toEqual({ + const owner = { id: ownerId, name: "Test User", + email: expect.stringContaining("@openstatus.dev"), + // the factory stores "", which the projection normalizes to null + photoUrl: null, + }; + expect(full.createdByUser).toEqual(owner); + expect(full.updatedByUser).toEqual(owner); + expect(full.updates[0].createdByUser).toEqual(owner); + }); + }); + + test("agent tool output carries the name only", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const { statusReport: report } = await create(ctx, "agent"); + const output = await listStatusReportsTool.run({ + ctx, + input: { filter: "all", pageId, page: 1, perPage: 50 }, }); + const item = output.items.find((i) => i.id === report.id); + const agentUser = { id: ownerId, name: "Test User" }; + expect(item?.createdBy).toEqual(agentUser); + expect(item?.updatedBy).toEqual(agentUser); + expect(item?.latestUpdate?.createdBy).toEqual(agentUser); }); }); @@ -246,7 +267,12 @@ describe("status report attribution", () => { }; const { statusReport: report } = await create(ctx, "deleted"); const full = await getStatusReport({ ctx, input: { id: report.id } }); - expect(full.createdByUser).toEqual({ id: gone.id, name: "Deleted user" }); + expect(full.createdByUser).toEqual({ + id: gone.id, + name: "Deleted user", + email: null, + photoUrl: null, + }); }); }); }); diff --git a/packages/services/src/status-report/list.ts b/packages/services/src/status-report/list.ts index 83a29031..a333eff5 100644 --- a/packages/services/src/status-report/list.ts +++ b/packages/services/src/status-report/list.ts @@ -21,7 +21,7 @@ import { statusReportUpdateToPageComponents, } from "@openstatus/db/src/schema"; -import { type AttributedUser, loadAttributedUsers } from "../attribution"; +import { type AttributedUserDetail, loadAttributedUsers } from "../attribution"; import { batchReads, type DB, type ServiceContext } from "../context"; import type { Page, @@ -50,8 +50,8 @@ function periodToSince(period: StatusReportListPeriod): Date { } type Attributed = { - createdByUser: AttributedUser | null; - updatedByUser: AttributedUser | null; + createdByUser: AttributedUserDetail | null; + updatedByUser: AttributedUserDetail | null; }; export type StatusReportUpdateWithImpacts = StatusReportUpdate &