feat(linking): re-link on ingest and on card rule changes

- ingestProject now calls relinkProject after every store
- updateCard relinks when scopeScope rule is in the patch
- acceptCard, archiveCard, unlinkCommitArchiveCard relink after their change
- three owner Server Actions for the tray: relinkProjectAction, linkCommitAction, unlinkCommitAction
This commit is contained in:
Dave 2026-09-16 13:24:17 +02:00
parent 25b504c901
commit d7e0b97ff4
3 changed files with 71 additions and 0 deletions

View file

@ -7,6 +7,7 @@ import { requireSession } from "@/lib/auth/session";
import { logActivity } from "@/app/actions/activity";
import { positionBetween } from "@/lib/ordering/fractional";
import { nextCardNumber } from "@/lib/cards/number";
import { relinkProject } from "@/lib/linking/engine";
import type { CardRecord, ColumnRecord } from "@/lib/pb/types";
/**
@ -109,6 +110,10 @@ export async function updateCard(input: {
await pb.collection("cards").update(input.cardId, patch);
if (input.scopeRule !== undefined) {
await relinkProject(card.project);
}
// One row per field, matching how client edits are logged, so the timeline
// reads consistently regardless of who made the change.
for (const field of Object.keys(patch)) {
@ -172,6 +177,8 @@ export async function acceptCard(input: {
position: positionBetween(last ? last.position : null, null),
});
await relinkProject(card.project);
await logActivity({
project: card.project,
card: card.id,
@ -195,6 +202,8 @@ export async function archiveCard(input: {
await pb.collection("cards").update(input.cardId, { archived: true });
await relinkProject(card.project);
await logActivity({
project: card.project,
card: card.id,
@ -217,6 +226,8 @@ export async function unarchiveCard(input: {
await pb.collection("cards").update(input.cardId, { archived: false });
await relinkProject(card.project);
await logActivity({
project: card.project,
card: card.id,

View file

@ -0,0 +1,54 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireSession } from "@/lib/auth/session";
import {
linkCommitManually,
relinkProject,
unlinkCommit,
} from "@/lib/linking/engine";
/** See the security note at the top of src/app/actions/cards.ts. */
async function requireOwner() {
const session = await requireSession();
if (session.role !== "owner") throw new Error("Not found");
return session;
}
export async function relinkProjectAction(input: {
projectId: string;
clientSlug: string;
projectSlug: string;
}): Promise<{ linked: number; unlinked: number; preserved: number }> {
await requireOwner();
const result = await relinkProject(input.projectId);
revalidatePath(`/p/${input.clientSlug}/${input.projectSlug}/unassigned`);
return {
linked: result.linked,
unlinked: result.unlinked,
preserved: result.preserved,
};
}
export async function linkCommitAction(input: {
commitId: string;
cardId: string;
clientSlug: string;
projectSlug: string;
}): Promise<void> {
await requireOwner();
await linkCommitManually(input.commitId, input.cardId);
revalidatePath(`/p/${input.clientSlug}/${input.projectSlug}/unassigned`);
}
export async function unlinkCommitAction(input: {
commitId: string;
clientSlug: string;
projectSlug: string;
}): Promise<void> {
await requireOwner();
await unlinkCommit(input.commitId);
revalidatePath(`/p/${input.clientSlug}/${input.projectSlug}/unassigned`);
}

View file

@ -168,6 +168,12 @@ export async function ingestProject(
.collection("projects")
.update(project.id, { last_ingest_at: new Date().toISOString() });
// New commits mean new links. Doing this here rather than in each caller
// means the webhook, the poll and the backfill all get it, and none of them
// has to remember.
const { relinkProject } = await import("@/lib/linking/engine");
await relinkProject(project.id);
return { created, skipped, pagesRead: page };
}