feat: add application timeline history (#3237)

* feat: add application timeline history

* fix: address application timeline review

* fix: keep application tracker e2e stable

* fix: use stable timeline e2e selector

* fix: target timeline note input in e2e
This commit is contained in:
Amruth Pillai
2026-07-09 00:36:45 +02:00
committed by GitHub
parent 1124d3dfda
commit 18d0c14aa1
22 changed files with 6651 additions and 115 deletions
+22 -3
View File
@@ -2,9 +2,9 @@ import { createSelectSchema } from "drizzle-zod";
import z from "zod";
import * as schema from "@reactive-resume/db/schema";
import {
activityEventSchema,
aiMetadataSchema,
applicationStatusSchema,
applicationTimelineEntrySchema,
contactSchema,
} from "@reactive-resume/schema/applications/data";
@@ -12,6 +12,7 @@ const MAX_APPLICATION_JOB_DESCRIPTION_CHARS = 20_000;
const MAX_APPLICATION_DOCUMENT_BYTES = 10 * 1024 * 1024;
const applicationDocumentKindSchema = z.enum(["resume", "cover-letter"]);
const timelineDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must use YYYY-MM-DD format.");
const applicationDocumentFileSchema = z
.file()
@@ -61,7 +62,7 @@ const applicationSchema = createSelectSchema(schema.application, {
followUpNote: z.string().trim().nullable(),
tags: z.array(z.string()),
contacts: z.array(contactSchema),
activity: z.array(activityEventSchema),
activity: z.array(applicationTimelineEntrySchema),
appliedAt: z.date(),
createdAt: z.date(),
updatedAt: z.date(),
@@ -94,6 +95,7 @@ const createInputSchema = editableSchema.partial().extend({
company: applicationSchema.shape.company,
role: applicationSchema.shape.role,
status: applicationStatusSchema.optional(),
stageEnteredAt: timelineDateSchema.optional(),
});
export const applicationDto = {
@@ -150,7 +152,24 @@ export const applicationDto = {
},
addNote: {
input: z.object({ id: z.string(), text: z.string().trim().min(1) }),
input: z.object({ id: z.string(), text: z.string().trim().min(1), date: timelineDateSchema.optional() }),
output: applicationSchema.omit({ userId: true }),
},
updateTimelineEntry: {
input: z
.object({
id: z.string(),
entryId: z.string(),
date: timelineDateSchema.optional(),
text: z.string().trim().min(1).optional(),
})
.refine((value) => value.date !== undefined || value.text !== undefined, "Provide date or text to update."),
output: applicationSchema.omit({ userId: true }),
},
deleteTimelineEntry: {
input: z.object({ id: z.string(), entryId: z.string() }),
output: applicationSchema.omit({ userId: true }),
},
+36 -1
View File
@@ -171,7 +171,42 @@ export const crudRouter = {
.use(resumeMutationRateLimit)
.output(applicationDto.addNote.output)
.handler(async ({ input, context }) => {
return applicationService.addNote({ id: input.id, userId: context.user.id, text: input.text });
return applicationService.addNote({ id: input.id, userId: context.user.id, text: input.text, date: input.date });
}),
updateTimelineEntry: protectedProcedure
.route({
method: "PUT",
path: "/applications/{id}/timeline/{entryId}",
tags: ["Applications"],
operationId: "updateApplicationTimelineEntry",
summary: "Update a timeline entry",
description: "Updates a timeline entry date, or note text for note entries. Requires authentication.",
successDescription: "The updated application.",
})
.input(applicationDto.updateTimelineEntry.input)
.use(resumeMutationRateLimit)
.output(applicationDto.updateTimelineEntry.output)
.handler(async ({ input, context }) => {
return applicationService.updateTimelineEntry({ ...input, userId: context.user.id });
}),
deleteTimelineEntry: protectedProcedure
.route({
method: "DELETE",
path: "/applications/{id}/timeline/{entryId}",
tags: ["Applications"],
operationId: "deleteApplicationTimelineEntry",
summary: "Delete a timeline entry",
description:
"Deletes a note or older stage entry. The current stage entry cannot be deleted. Requires authentication.",
successDescription: "The updated application.",
})
.input(applicationDto.deleteTimelineEntry.input)
.use(resumeMutationRateLimit)
.output(applicationDto.deleteTimelineEntry.output)
.handler(async ({ input, context }) => {
return applicationService.deleteTimelineEntry({ ...input, userId: context.user.id });
}),
delete: protectedProcedure
@@ -10,6 +10,8 @@ export const applicationsRouter = {
attachDocument: crudRouter.attachDocument,
removeDocument: crudRouter.removeDocument,
addNote: crudRouter.addNote,
updateTimelineEntry: crudRouter.updateTimelineEntry,
deleteTimelineEntry: crudRouter.deleteTimelineEntry,
delete: crudRouter.delete,
bulkUpdate: crudRouter.bulkUpdate,
bulkDelete: crudRouter.bulkDelete,
@@ -6,6 +6,8 @@ const dbMock = vi.hoisted(() => ({
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
execute: vi.fn(),
transaction: vi.fn(),
}));
const resumeGetByIdMock = vi.hoisted(() => vi.fn());
const storageDeleteMock = vi.hoisted(() => vi.fn());
@@ -17,6 +19,8 @@ vi.mock("@reactive-resume/db/schema", () => ({
id: "id",
userId: "user_id",
status: "status",
activity: "activity",
appliedAt: "applied_at",
updatedAt: "updated_at",
resumeFileUrl: "resume_file_url",
coverLetterUrl: "cover_letter_url",
@@ -48,7 +52,9 @@ const existing = {
company: "Stripe",
role: "Engineer",
status: "saved" as const,
activity: [{ id: "e0", type: "created" as const, text: "Added to Saved", at: new Date() }],
activity: [{ id: "e0", type: "stage" as const, stage: "saved" as const, at: new Date("2026-07-01T12:00:00.000Z") }],
appliedAt: new Date("2026-07-01T12:00:00.000Z"),
createdAt: new Date("2026-07-01T12:00:00.000Z"),
resumeFileUrl: "http://localhost:3000/api/uploads/user-1/pictures/resume.pdf",
coverLetterUrl: "/api/uploads/user-1/pictures/cover.pdf",
};
@@ -71,6 +77,9 @@ beforeEach(() => {
dbMock.insert.mockReset();
dbMock.update.mockReset();
dbMock.delete.mockReset();
dbMock.execute.mockReset();
dbMock.transaction.mockReset();
dbMock.transaction.mockImplementation((callback) => callback(dbMock));
resumeGetByIdMock.mockReset();
storageDeleteMock.mockReset();
uploadFileMock.mockReset();
@@ -84,15 +93,22 @@ beforeEach(() => {
});
describe("applicationService.create", () => {
it("seeds a 'created' activity event", async () => {
it("seeds an initial stage timeline entry with the chosen date", async () => {
const values = vi.fn(() => Promise.resolve());
dbMock.insert.mockReturnValue({ values });
await applicationService.create({ userId: "user-1", company: "Stripe", role: "Engineer", status: "applied" });
await applicationService.create({
userId: "user-1",
company: "Stripe",
role: "Engineer",
status: "applied",
stageEnteredAt: "2026-07-10",
} as never);
const [[inserted]] = values.mock.calls as unknown as [[{ activity: { type: string }[] }]];
const [[inserted]] = values.mock.calls as unknown as [[{ activity: { type: string; stage: string; at: Date }[] }]];
expect(inserted.activity).toHaveLength(1);
expect(inserted.activity.at(0)?.type).toBe("created");
expect(inserted.activity.at(0)).toMatchObject({ type: "stage", stage: "applied" });
expect(inserted.activity.at(0)?.at.toISOString()).toBe("2026-07-10T12:00:00.000Z");
});
it("checks linked resume ownership before inserting", async () => {
@@ -118,12 +134,17 @@ describe("applicationService.update", () => {
return set;
};
it("appends a 'stage' event when the status changes", async () => {
const appendedEvent = (activity: { values: unknown[] }) => {
const value = activity.values.find((item) => typeof item === "string" && item.includes('"type":"stage"'));
return JSON.parse(String(value))[0] as { type: string; stage: string };
};
it("appends a typed stage timeline entry when the status changes", async () => {
const set = captureSet();
await applicationService.update({ id: "app-1", userId: "user-1", status: "applied" });
const [[arg]] = set.mock.calls as unknown as [[{ activity: unknown }]];
expect(arg.activity).toBeDefined();
const [[arg]] = set.mock.calls as unknown as [[{ activity: { values: unknown[] } }]];
expect(appendedEvent(arg.activity)).toMatchObject({ type: "stage", stage: "applied" });
});
it("does not rewrite activity when the status is unchanged", async () => {
@@ -142,6 +163,221 @@ describe("applicationService.update", () => {
});
});
describe("applicationService timeline entries", () => {
const captureSet = (returning = [{ ...existing }]) => {
const set = vi.fn(() => ({ where: () => ({ returning: () => Promise.resolve(returning) }) }));
dbMock.update.mockReturnValue({ set });
return set;
};
it("adds dated note timeline entries", async () => {
const set = captureSet();
await applicationService.addNote({
id: "app-1",
userId: "user-1",
text: "Recruiter replied",
date: "2026-07-12",
} as never);
const [[arg]] = set.mock.calls as unknown as [[{ activity: { values: unknown[] } }]];
const value = arg.activity.values.find((item) => typeof item === "string" && item.includes('"type":"note"'));
const [entry] = JSON.parse(String(value)) as [{ type: string; text: string; at: string }];
expect(entry).toMatchObject({ type: "note", text: "Recruiter replied" });
expect(new Date(entry.at).toISOString()).toBe("2026-07-12T12:00:00.000Z");
});
it("rejects invalid calendar dates instead of letting Date overflow", async () => {
await expect(
applicationService.addNote({
id: "app-1",
userId: "user-1",
text: "Impossible date",
date: "2026-99-99",
} as never),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
it("updates note text and timeline dates", async () => {
const activity = [
{ id: "stage-1", type: "stage" as const, stage: "saved" as const, at: new Date("2026-07-01T09:30:00.000Z") },
{ id: "note-1", type: "note" as const, text: "Old note", at: new Date("2026-07-02T15:45:00.000Z") },
];
setSelectResults([{ ...existing, activity }]);
const set = captureSet();
await (
applicationService as unknown as {
updateTimelineEntry: (input: {
id: string;
userId: string;
entryId: string;
date?: string;
text?: string;
}) => Promise<unknown>;
}
).updateTimelineEntry({
id: "app-1",
userId: "user-1",
entryId: "note-1",
date: "2026-07-10",
text: "Updated note",
});
const [[arg]] = set.mock.calls as unknown as [[{ activity: typeof activity }]];
expect(arg.activity.find((entry) => entry.id === "note-1")).toMatchObject({
text: "Updated note",
at: new Date("2026-07-10T15:45:00.000Z"),
});
expect(dbMock.transaction).toHaveBeenCalled();
expect(dbMock.execute).toHaveBeenCalled();
});
it("normalizes JSONB date strings when editing timeline dates", async () => {
const activity = [
{ id: "stage-1", type: "stage" as const, stage: "saved" as const, at: "2026-07-01T09:30:00.000Z" },
];
setSelectResults([{ ...existing, activity }]);
const set = captureSet();
await (
applicationService as unknown as {
updateTimelineEntry: (input: { id: string; userId: string; entryId: string; date: string }) => Promise<unknown>;
}
).updateTimelineEntry({
id: "app-1",
userId: "user-1",
entryId: "stage-1",
date: "2026-07-05",
});
const [[arg]] = set.mock.calls as unknown as [[{ activity: { id: string; at: Date }[] }]];
expect(arg.activity.find((entry) => entry.id === "stage-1")?.at.toISOString()).toBe("2026-07-05T09:30:00.000Z");
});
it("allows notes to be newer than the current-stage anchor", async () => {
const activity = [
{ id: "stage-1", type: "stage" as const, stage: "saved" as const, at: new Date("2026-07-01T12:00:00.000Z") },
{ id: "note-1", type: "note" as const, text: "Followed up", at: new Date("2026-07-10T12:00:00.000Z") },
];
setSelectResults([{ ...existing, activity }]);
const set = captureSet();
await (
applicationService as unknown as {
updateTimelineEntry: (input: { id: string; userId: string; entryId: string; date: string }) => Promise<unknown>;
}
).updateTimelineEntry({ id: "app-1", userId: "user-1", entryId: "stage-1", date: "2026-07-02" });
const [[arg]] = set.mock.calls as unknown as [[{ activity: { id: string; at: Date }[] }]];
expect(arg.activity.find((entry) => entry.id === "stage-1")?.at.toISOString()).toBe("2026-07-02T12:00:00.000Z");
});
it("blocks moving the current-stage anchor older than another stage", async () => {
setSelectResults([
{
...existing,
status: "screening",
activity: [
{
id: "stage-1",
type: "stage" as const,
stage: "applied" as const,
at: new Date("2026-07-03T12:00:00.000Z"),
},
{
id: "stage-2",
type: "stage" as const,
stage: "screening" as const,
at: new Date("2026-07-04T12:00:00.000Z"),
},
],
},
]);
await expect(
(
applicationService as unknown as {
updateTimelineEntry: (input: {
id: string;
userId: string;
entryId: string;
date: string;
}) => Promise<unknown>;
}
).updateTimelineEntry({ id: "app-1", userId: "user-1", entryId: "stage-2", date: "2026-07-01" }),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
it("blocks deleting the current-stage anchor", async () => {
setSelectResults([
{
...existing,
status: "screening",
activity: [
{
id: "stage-1",
type: "stage" as const,
stage: "applied" as const,
at: new Date("2026-07-01T12:00:00.000Z"),
},
{
id: "stage-2",
type: "stage" as const,
stage: "screening" as const,
at: new Date("2026-07-03T12:00:00.000Z"),
},
],
},
]);
await expect(
(
applicationService as unknown as {
deleteTimelineEntry: (input: { id: string; userId: string; entryId: string }) => Promise<unknown>;
}
).deleteTimelineEntry({ id: "app-1", userId: "user-1", entryId: "stage-2" }),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(dbMock.transaction).toHaveBeenCalled();
expect(dbMock.execute).toHaveBeenCalled();
});
it("deletes older stage entries", async () => {
setSelectResults([
{
...existing,
status: "screening",
activity: [
{
id: "stage-1",
type: "stage" as const,
stage: "applied" as const,
at: new Date("2026-07-01T12:00:00.000Z"),
},
{
id: "stage-2",
type: "stage" as const,
stage: "screening" as const,
at: new Date("2026-07-03T12:00:00.000Z"),
},
],
},
]);
const set = captureSet();
await (
applicationService as unknown as {
deleteTimelineEntry: (input: { id: string; userId: string; entryId: string }) => Promise<unknown>;
}
).deleteTimelineEntry({ id: "app-1", userId: "user-1", entryId: "stage-1" });
const [[arg]] = set.mock.calls as unknown as [[{ activity: { id: string }[] }]];
expect(arg.activity).toEqual([
{ id: "stage-2", type: "stage", stage: "screening", at: new Date("2026-07-03T12:00:00.000Z") },
]);
});
});
describe("applicationService.delete", () => {
it("deletes owned uploaded attachments after deleting the application", async () => {
dbMock.delete.mockReturnValue({
+221 -27
View File
@@ -1,18 +1,87 @@
import type { ActivityEvent, AiMetadata, ApplicationStatus, Contact } from "@reactive-resume/schema/applications/data";
import type {
AiMetadata,
ApplicationStatus,
ApplicationTimelineEntry,
Contact,
} from "@reactive-resume/schema/applications/data";
import type { ApplicationDocumentKind } from "../../dto/application";
import { ORPCError } from "@orpc/client";
import { and, arrayContains, desc, eq, inArray, sql } from "drizzle-orm";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
import { STAGES } from "@reactive-resume/schema/applications/data";
import { generateId } from "@reactive-resume/utils/string";
import { resumeService } from "../resume/service";
import { getStorageService, uploadFile } from "../storage/service";
const stageLabel = (status: ApplicationStatus) => STAGES.find((s) => s.value === status)?.label ?? status;
function timelineDate(value: Date | string): Date {
return value instanceof Date ? value : new Date(value);
}
function activityEvent(type: ActivityEvent["type"], text: string): ActivityEvent {
return { id: generateId(), type, text, at: new Date() };
function atFromDateString(date: string, existing?: Date | string): Date {
const [year, month, day] = date.split("-").map(Number);
if (!year || !month || !day) throw new ORPCError("BAD_REQUEST", { message: "Date must use YYYY-MM-DD format." });
const existingDate = existing ? timelineDate(existing) : undefined;
const parsed = new Date(
Date.UTC(
year,
month - 1,
day,
existingDate?.getUTCHours() ?? 12,
existingDate?.getUTCMinutes() ?? 0,
existingDate?.getUTCSeconds() ?? 0,
existingDate?.getUTCMilliseconds() ?? 0,
),
);
if (timelineDay(parsed) !== date) throw new ORPCError("BAD_REQUEST", { message: "Date must use YYYY-MM-DD format." });
return parsed;
}
function stageEntry(stage: ApplicationStatus, date?: string): ApplicationTimelineEntry {
return { id: generateId(), type: "stage", stage, at: date ? atFromDateString(date) : new Date() };
}
function noteEntry(text: string, date?: string): ApplicationTimelineEntry {
return { id: generateId(), type: "note", text, at: date ? atFromDateString(date) : new Date() };
}
function byNewest(a: ApplicationTimelineEntry, b: ApplicationTimelineEntry) {
return new Date(b.at).getTime() - new Date(a.at).getTime();
}
function timelineDay(value: Date | string) {
return timelineDate(value).toISOString().slice(0, 10);
}
function sortTimeline(activity: ApplicationTimelineEntry[]): ApplicationTimelineEntry[] {
return [...activity].sort(byNewest);
}
function currentStageAnchor(activity: ApplicationTimelineEntry[], status: ApplicationStatus) {
return sortTimeline(activity).find((entry) => entry.type === "stage" && entry.stage === status);
}
function assertCurrentStageAnchorLatest(activity: ApplicationTimelineEntry[], status: ApplicationStatus) {
const anchor = currentStageAnchor(activity, status);
if (!anchor)
throw new ORPCError("BAD_REQUEST", { message: "Application timeline is missing its current stage entry." });
const anchorDay = timelineDay(anchor.at);
const newerStage = activity.some((entry) => entry.type === "stage" && timelineDay(entry.at) > anchorDay);
if (newerStage) {
throw new ORPCError("BAD_REQUEST", {
message: "Current stage date cannot be older than another stage entry.",
});
}
}
function appliedAtFromTimeline(activity: ApplicationTimelineEntry[], fallback: Date): Date {
const sorted = sortTimeline(activity);
const applied = sorted.find((entry) => entry.type === "stage" && entry.stage === "applied");
const fallbackEntry = sorted.at(-1);
return applied ? timelineDate(applied.at) : fallbackEntry ? timelineDate(fallbackEntry.at) : fallback;
}
// Editable fields shared by create/update. Kept explicit so Drizzle's typed insert/update
@@ -124,9 +193,9 @@ function documentFields(kind: ApplicationDocumentKind) {
} as const);
}
const stripUserId = <T extends { userId: string }>(row: T) => {
const stripUserId = <T extends { userId: string; activity?: ApplicationTimelineEntry[] }>(row: T) => {
const { userId: _userId, ...rest } = row;
return rest;
return rest.activity ? { ...rest, activity: sortTimeline(rest.activity) } : rest;
};
export const applicationService = {
@@ -151,18 +220,27 @@ export const applicationService = {
},
create: async (
input: EditableFields & { userId: string; company: string; role: string; status?: ApplicationStatus | undefined },
input: EditableFields & {
userId: string;
company: string;
role: string;
status?: ApplicationStatus | undefined;
stageEnteredAt?: string | undefined;
},
) => {
const { userId, status, ...fields } = input;
const { userId, status, stageEnteredAt, ...fields } = input;
const id = generateId();
const initialStatus = status ?? "saved";
const activity = [stageEntry(initialStatus, stageEnteredAt)];
await assertOwnedResume(userId, fields.resumeId);
await db.insert(schema.application).values({
id,
userId,
status: status ?? "saved",
activity: [activityEvent("created", `Added to ${stageLabel(status ?? "saved")}`)],
status: initialStatus,
activity,
appliedAt: appliedAtFromTimeline(activity, new Date()),
...fields,
});
@@ -171,7 +249,12 @@ export const applicationService = {
importMany: async (input: {
userId: string;
items: (EditableFields & { company: string; role: string; status?: ApplicationStatus | undefined })[];
items: (EditableFields & {
company: string;
role: string;
status?: ApplicationStatus | undefined;
stageEnteredAt?: string | undefined;
})[];
}) => {
if (input.items.length === 0) return { imported: 0 };
@@ -180,13 +263,18 @@ export const applicationService = {
input.items.map((item) => item.resumeId),
);
const values = input.items.map(({ status, ...fields }) => ({
id: generateId(),
userId: input.userId,
status: status ?? ("saved" as ApplicationStatus),
activity: [activityEvent("created", `Added to ${stageLabel(status ?? "saved")}`)],
...fields,
}));
const values = input.items.map(({ status, stageEnteredAt, ...fields }) => {
const initialStatus = status ?? ("saved" as ApplicationStatus);
const activity = [stageEntry(initialStatus, stageEnteredAt)];
return {
id: generateId(),
userId: input.userId,
status: initialStatus,
activity,
appliedAt: appliedAtFromTimeline(activity, new Date()),
...fields,
};
});
const rows = await db.insert(schema.application).values(values).returning({ id: schema.application.id });
return { imported: rows.length };
@@ -205,12 +293,19 @@ export const applicationService = {
const { id, userId, status, archived, ...fields } = input;
await assertOwnedResume(userId, fields.resumeId);
const statusEntry = status !== undefined ? stageEntry(status) : undefined;
// Append in SQL so concurrent notes/stage events are not overwritten by a stale array.
const activityExpr =
status !== undefined
statusEntry !== undefined
? sql`case when ${schema.application.status} <> ${status}
then ${schema.application.activity} || ${JSON.stringify([activityEvent("stage", `Moved to ${stageLabel(status)}`)])}::jsonb
else ${schema.application.activity} end`
then ${schema.application.activity} || ${JSON.stringify([statusEntry])}::jsonb
else ${schema.application.activity} end`
: undefined;
const appliedAtExpr =
statusEntry !== undefined && status === "applied"
? sql`case when ${schema.application.status} <> ${status}
then ${statusEntry.at}
else ${schema.application.appliedAt} end`
: undefined;
const [updated] = await db
@@ -218,6 +313,7 @@ export const applicationService = {
.set({
...fields,
...(status !== undefined ? { status } : {}),
...(appliedAtExpr ? { appliedAt: appliedAtExpr } : {}),
...(archived !== undefined ? { archived } : {}),
...(activityExpr ? { activity: activityExpr } : {}),
})
@@ -313,10 +409,10 @@ export const applicationService = {
return stripUserId(updated);
},
addNote: async (input: { id: string; userId: string; text: string }) => {
addNote: async (input: { id: string; userId: string; text: string; date?: string | undefined }) => {
// Append in a single statement (activity || [event]) so concurrent notes can't drop each
// other via read-then-write; ownership is enforced by the WHERE clause.
const event = activityEvent("note", input.text);
const event = noteEntry(input.text, input.date);
const [updated] = await db
.update(schema.application)
.set({ activity: sql`${schema.application.activity} || ${JSON.stringify([event])}::jsonb` })
@@ -327,6 +423,96 @@ export const applicationService = {
return stripUserId(updated);
},
updateTimelineEntry: async (input: {
id: string;
userId: string;
entryId: string;
date?: string | undefined;
text?: string | undefined;
}) => {
return db.transaction(async (tx) => {
await tx.execute(sql`
select 1 from ${schema.application}
where ${schema.application.id} = ${input.id} and ${schema.application.userId} = ${input.userId}
for update
`);
const [existing] = await tx
.select()
.from(schema.application)
.where(and(eq(schema.application.id, input.id), eq(schema.application.userId, input.userId)));
if (!existing) throw new ORPCError("NOT_FOUND");
const activity = existing.activity.map((entry) => {
if (entry.id !== input.entryId) return entry;
if (entry.type === "stage" && input.text !== undefined) {
throw new ORPCError("BAD_REQUEST", { message: "Stage timeline text is derived and cannot be edited." });
}
return {
...entry,
...(input.date !== undefined ? { at: atFromDateString(input.date, entry.at) } : {}),
...(entry.type === "note" && input.text !== undefined ? { text: input.text } : {}),
};
});
if (!activity.some((entry) => entry.id === input.entryId)) throw new ORPCError("NOT_FOUND");
assertCurrentStageAnchorLatest(activity, existing.status);
const [updated] = await tx
.update(schema.application)
.set({
activity,
appliedAt: appliedAtFromTimeline(activity, existing.appliedAt),
})
.where(and(eq(schema.application.id, input.id), eq(schema.application.userId, input.userId)))
.returning();
if (!updated) throw new ORPCError("NOT_FOUND");
return stripUserId(updated);
});
},
deleteTimelineEntry: async (input: { id: string; userId: string; entryId: string }) => {
return db.transaction(async (tx) => {
await tx.execute(sql`
select 1 from ${schema.application}
where ${schema.application.id} = ${input.id} and ${schema.application.userId} = ${input.userId}
for update
`);
const [existing] = await tx
.select()
.from(schema.application)
.where(and(eq(schema.application.id, input.id), eq(schema.application.userId, input.userId)));
if (!existing) throw new ORPCError("NOT_FOUND");
const entry = existing.activity.find((item) => item.id === input.entryId);
if (!entry) throw new ORPCError("NOT_FOUND");
const anchor = currentStageAnchor(existing.activity, existing.status);
if (entry.type === "stage" && anchor?.id === entry.id) {
throw new ORPCError("BAD_REQUEST", { message: "The current stage timeline entry cannot be deleted." });
}
const activity = existing.activity.filter((item) => item.id !== input.entryId);
assertCurrentStageAnchorLatest(activity, existing.status);
const [updated] = await tx
.update(schema.application)
.set({
activity,
appliedAt: appliedAtFromTimeline(activity, existing.appliedAt),
})
.where(and(eq(schema.application.id, input.id), eq(schema.application.userId, input.userId)))
.returning();
if (!updated) throw new ORPCError("NOT_FOUND");
return stripUserId(updated);
});
},
delete: async (input: { id: string; userId: string }) => {
const existing = await requireOwned(input.id, input.userId);
const result = await db
@@ -359,17 +545,25 @@ export const applicationService = {
// Stage moves must log a timeline event on every row that actually changed — mirror the
// single-item update path. Append the event only where the current status differs.
const statusEntry = input.status !== undefined ? stageEntry(input.status) : undefined;
const activityExpr =
input.status !== undefined
statusEntry !== undefined
? sql`case when ${schema.application.status} <> ${input.status}
then ${schema.application.activity} || ${JSON.stringify([activityEvent("stage", `Moved to ${stageLabel(input.status)}`)])}::jsonb
else ${schema.application.activity} end`
then ${schema.application.activity} || ${JSON.stringify([statusEntry])}::jsonb
else ${schema.application.activity} end`
: undefined;
const appliedAtExpr =
statusEntry !== undefined && input.status === "applied"
? sql`case when ${schema.application.status} <> ${input.status}
then ${statusEntry.at}
else ${schema.application.appliedAt} end`
: undefined;
const rows = await db
.update(schema.application)
.set({
...(input.status !== undefined ? { status: input.status } : {}),
...(appliedAtExpr ? { appliedAt: appliedAtExpr } : {}),
...(activityExpr ? { activity: activityExpr } : {}),
...(input.archived !== undefined ? { archived: input.archived } : {}),
...(tagsExpr ? { tags: tagsExpr } : {}),