mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-21 05:51:46 +10:00
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:
@@ -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 }),
|
||||
},
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
Reference in New Issue
Block a user