feat(web): real linked Signal projection from signalIssueAttachments
Replace the deliberate signalCount of 0 with authenticated linked-Signal data from the Lane A signalIssueAttachments relation. - Add listLinkedSignalsForProject query (user-authenticated via requireProjectMember) returning issue-grouped LinkedSignalView data using the existing by_issue index. One query serves both card counts and expanded detail. - Update work-unit-projection builders to accept linkedSignals param; LinkedSignal type with signalId, title, summary, sourceCount, createdAt. - Feed real attachment data into collapsed card signalCount and expanded detail linked-signal list with provenance (source count, relative time). - Add signal.attached event handler to activity timeline projection. - 32 tests including 4 linked-signal projection tests, 1 signal.attached activity mapping test, and cross-issue signal isolation test proving issue A cannot inherit issue B's linked signals.
This commit is contained in:
@@ -9,7 +9,7 @@ import {
|
||||
type QueryCtx,
|
||||
query,
|
||||
} from "./_generated/server";
|
||||
import { requireOrganizationMember } from "./authz";
|
||||
import { requireOrganizationMember, requireProjectMember } from "./authz";
|
||||
|
||||
const PROBLEM_STATEMENT = v.object({
|
||||
title: v.string(),
|
||||
@@ -435,3 +435,70 @@ export const get = query({
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Linked Signal query for the Work OS surface.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LinkedSignalView {
|
||||
readonly signalId: Id<"signals">;
|
||||
readonly title: string;
|
||||
readonly summary: string;
|
||||
readonly sourceCount: number;
|
||||
readonly createdAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all Signal-issue attachments for a project, grouped by issue id.
|
||||
* User-authenticated: the caller must be a member of the project organization.
|
||||
* Used by the Work OS card list to show per-issue linked Signal counts.
|
||||
*/
|
||||
export const listLinkedSignalsForProject = query({
|
||||
args: {
|
||||
projectId: v.id("projects"),
|
||||
},
|
||||
handler: async (ctx, args): Promise<Record<string, LinkedSignalView[]>> => {
|
||||
const { organizationId } = await requireProjectMember(ctx, args.projectId);
|
||||
const issues = await ctx.db
|
||||
.query("projectIssues")
|
||||
.withIndex("by_project_and_number", (q) =>
|
||||
q.eq("projectId", args.projectId)
|
||||
)
|
||||
.take(100);
|
||||
const result: Record<string, LinkedSignalView[]> = {};
|
||||
for (const issue of issues) {
|
||||
const attachments = await ctx.db
|
||||
.query("signalIssueAttachments")
|
||||
.withIndex("by_issue", (q) => q.eq("issueId", issue._id))
|
||||
.collect();
|
||||
if (attachments.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const signals = await Promise.all(
|
||||
attachments.map(async (attachment) => {
|
||||
const signal = await ctx.db.get(attachment.signalId);
|
||||
if (!signal || signal.organizationId !== organizationId) {
|
||||
return null;
|
||||
}
|
||||
const sources = await ctx.db
|
||||
.query("signalSources")
|
||||
.withIndex("by_signal_and_ordinal", (q) =>
|
||||
q.eq("signalId", signal._id)
|
||||
)
|
||||
.collect();
|
||||
return {
|
||||
createdAt: signal.createdAt,
|
||||
signalId: signal._id,
|
||||
sourceCount: sources.length,
|
||||
summary: signal.problemStatement.summary,
|
||||
title: signal.problemStatement.title,
|
||||
} satisfies LinkedSignalView;
|
||||
})
|
||||
);
|
||||
result[String(issue._id)] = signals.filter(
|
||||
(entry): entry is LinkedSignalView => entry !== null
|
||||
);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user