mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-24 00:43:29 +10:00
feat(ai): implement an AI chat window for agentic resume building (#3022)
This commit is contained in:
@@ -32,9 +32,7 @@ export const resumeDto = {
|
||||
|
||||
getById: {
|
||||
input: resumeSchema.pick({ id: true }),
|
||||
output: resumeSchema
|
||||
.omit({ password: true, userId: true, createdAt: true, updatedAt: true })
|
||||
.extend({ hasPassword: z.boolean() }),
|
||||
output: resumeSchema.omit({ password: true, userId: true, createdAt: true }).extend({ hasPassword: z.boolean() }),
|
||||
},
|
||||
|
||||
getBySlug: {
|
||||
@@ -65,9 +63,7 @@ export const resumeDto = {
|
||||
.pick({ name: true, slug: true, tags: true, data: true, isPublic: true })
|
||||
.partial()
|
||||
.extend({ id: z.string() }),
|
||||
output: resumeSchema
|
||||
.omit({ password: true, userId: true, createdAt: true, updatedAt: true })
|
||||
.extend({ hasPassword: z.boolean() }),
|
||||
output: resumeSchema.omit({ password: true, userId: true, createdAt: true }).extend({ hasPassword: z.boolean() }),
|
||||
},
|
||||
|
||||
setLocked: {
|
||||
@@ -88,14 +84,16 @@ export const resumeDto = {
|
||||
patch: {
|
||||
input: z.object({
|
||||
id: z.string().describe("The ID of the resume to patch."),
|
||||
expectedUpdatedAt: z.coerce
|
||||
.date()
|
||||
.optional()
|
||||
.describe("If provided, the patch only applies when the resume version still matches this timestamp."),
|
||||
operations: z
|
||||
.array(jsonPatchOperationSchema)
|
||||
.min(1)
|
||||
.describe("An array of JSON Patch (RFC 6902) operations to apply to the resume data."),
|
||||
}),
|
||||
output: resumeSchema
|
||||
.omit({ password: true, userId: true, createdAt: true, updatedAt: true })
|
||||
.extend({ hasPassword: z.boolean() }),
|
||||
output: resumeSchema.omit({ password: true, userId: true, createdAt: true }).extend({ hasPassword: z.boolean() }),
|
||||
},
|
||||
|
||||
duplicate: {
|
||||
|
||||
@@ -58,6 +58,7 @@ export const aiRouter = {
|
||||
try {
|
||||
return await aiService.testConnection(input);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
|
||||
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
|
||||
throw error;
|
||||
@@ -137,7 +138,7 @@ export const aiRouter = {
|
||||
operationId: "aiChat",
|
||||
summary: "Chat with AI to modify resume",
|
||||
description:
|
||||
"Streams a chat response from the configured AI provider. The LLM can call the patch_resume tool to generate JSON Patch operations that modify the resume. Requires authentication and AI provider credentials.",
|
||||
"Streams a chat response from the configured AI provider. The LLM can call the propose_resume_patches tool to generate JSON Patch proposals for explicit user approval. Requires authentication and AI provider credentials.",
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
@@ -147,6 +148,7 @@ export const aiRouter = {
|
||||
baseURL: string;
|
||||
messages: UIMessage[];
|
||||
resumeData: ResumeData;
|
||||
resumeUpdatedAt: Date;
|
||||
}>(),
|
||||
)
|
||||
.use(aiRequestRateLimit)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { protectedProcedure, publicProcedure } from "../context";
|
||||
import { resumeDto } from "../dto/resume";
|
||||
import { resumeMutationRateLimit, resumePasswordRateLimit } from "../middleware/rate-limit";
|
||||
import { resumeService } from "../services/resume";
|
||||
import { subscribeResumeUpdated } from "../services/resume-events";
|
||||
|
||||
const tagsRouter = {
|
||||
list: protectedProcedure
|
||||
@@ -71,10 +72,43 @@ const analysisRouter = {
|
||||
}),
|
||||
};
|
||||
|
||||
const updatesRouter = {
|
||||
subscribe: protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/resumes/{id}/updates",
|
||||
tags: ["Resumes"],
|
||||
operationId: "subscribeResumeUpdates",
|
||||
summary: "Subscribe to resume updates",
|
||||
description:
|
||||
"Streams lightweight invalidation events when the specified resume changes. The event payload contains metadata only; clients should refetch the resume for canonical data.",
|
||||
successDescription: "A stream of resume update invalidation events.",
|
||||
})
|
||||
.input(z.object({ id: z.string().describe("The unique identifier of the resume.") }))
|
||||
.handler(async function* ({ context, input, signal }) {
|
||||
const resume = await resumeService.getById({ id: input.id, userId: context.user.id });
|
||||
|
||||
yield {
|
||||
type: "resume.updated" as const,
|
||||
resumeId: input.id,
|
||||
userId: context.user.id,
|
||||
updatedAt: resume.updatedAt.toISOString(),
|
||||
mutation: "sync" as const,
|
||||
};
|
||||
|
||||
yield* subscribeResumeUpdated({
|
||||
resumeId: input.id,
|
||||
userId: context.user.id,
|
||||
...(signal ? { signal } : {}),
|
||||
});
|
||||
}),
|
||||
};
|
||||
|
||||
export const resumeRouter = {
|
||||
tags: tagsRouter,
|
||||
statistics: statisticsRouter,
|
||||
analysis: analysisRouter,
|
||||
updates: updatesRouter,
|
||||
|
||||
list: protectedProcedure
|
||||
.route({
|
||||
@@ -250,12 +284,17 @@ export const resumeRouter = {
|
||||
message: "The patch operations are invalid or produced an invalid resume.",
|
||||
status: 400,
|
||||
},
|
||||
RESUME_VERSION_CONFLICT: {
|
||||
message: "The resume changed after this patch was generated.",
|
||||
status: 409,
|
||||
},
|
||||
})
|
||||
.handler(async ({ context, input }) => {
|
||||
return resumeService.patch({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
operations: input.operations,
|
||||
...(input.expectedUpdatedAt ? { expectedUpdatedAt: input.expectedUpdatedAt } : {}),
|
||||
});
|
||||
}),
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { UIMessage } from "ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { convertToModelMessages, modelMessageSchema } from "ai";
|
||||
|
||||
describe("AI chat service", () => {
|
||||
it("keeps proposal tool history valid for follow-up chat messages", async () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "user-1",
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: "Add draft references." }],
|
||||
},
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
parts: [
|
||||
{
|
||||
type: "tool-propose_resume_patches",
|
||||
toolCallId: "call-1",
|
||||
state: "output-available",
|
||||
input: {
|
||||
proposals: [
|
||||
{
|
||||
title: "Add draft references",
|
||||
operations: [
|
||||
{
|
||||
op: "replace",
|
||||
path: "/sections/references/items",
|
||||
value: [
|
||||
{ id: "reference-1", name: "Jane Mitchell" },
|
||||
{ id: "reference-2", name: "Marcus Chen" },
|
||||
{ id: "reference-3", name: "Olivia Ramirez" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
output: {
|
||||
proposals: [
|
||||
{
|
||||
id: "proposal-1",
|
||||
title: "Add draft references",
|
||||
baseUpdatedAt: "2026-05-10T06:38:27.093Z",
|
||||
operations: [
|
||||
{
|
||||
op: "replace",
|
||||
path: "/sections/references/items",
|
||||
value: [
|
||||
{ id: "reference-1", name: "Jane Mitchell" },
|
||||
{ id: "reference-2", name: "Marcus Chen" },
|
||||
{ id: "reference-3", name: "Olivia Ramirez" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "assistant-2",
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: "I prepared draft reference changes for review." }],
|
||||
},
|
||||
{
|
||||
id: "user-2",
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: "Reduce it down to the first two." }],
|
||||
},
|
||||
];
|
||||
|
||||
const modelMessages = await convertToModelMessages(messages);
|
||||
|
||||
expect(modelMessages.map((message) => message.role)).toEqual(["user", "assistant", "tool", "assistant", "user"]);
|
||||
expect(JSON.stringify(modelMessages)).toContain("proposal-1");
|
||||
expect(JSON.stringify(modelMessages)).toContain("/sections/references/items");
|
||||
expect(JSON.stringify(modelMessages)).toContain("tool-result");
|
||||
|
||||
for (const message of modelMessages) {
|
||||
expect(modelMessageSchema.safeParse(message).success).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -22,12 +22,13 @@ import {
|
||||
import { buildAiExtractionTemplate } from "@reactive-resume/ai/resume/extraction-template";
|
||||
import { sanitizeAndParseResumeJson } from "@reactive-resume/ai/resume/sanitize";
|
||||
import {
|
||||
executePatchResume,
|
||||
patchResumeDescription,
|
||||
patchResumeInputSchema,
|
||||
} from "@reactive-resume/ai/tools/patch-resume";
|
||||
normalizeResumePatchProposals,
|
||||
resumePatchProposalToolInputSchema,
|
||||
resumePatchProposalToolOutputSchema,
|
||||
} from "@reactive-resume/ai/tools/patch-proposal";
|
||||
import { AI_PROVIDER_DEFAULT_BASE_URLS, aiProviderSchema } from "@reactive-resume/ai/types";
|
||||
import { resumeAnalysisOutputSchema, resumeAnalysisSchema } from "@reactive-resume/schema/resume/analysis";
|
||||
import { applyResumePatches } from "@reactive-resume/utils/resume/patch";
|
||||
import { isPrivateOrLoopbackHost, parseUrl } from "@reactive-resume/utils/url-security.node";
|
||||
|
||||
const aiExtractionTemplate = buildAiExtractionTemplate();
|
||||
@@ -209,6 +210,7 @@ function buildChatSystemPrompt(resumeData: ResumeData): string {
|
||||
type ChatInput = z.infer<typeof aiCredentialsSchema> & {
|
||||
messages: UIMessage[];
|
||||
resumeData: ResumeData;
|
||||
resumeUpdatedAt: Date;
|
||||
};
|
||||
|
||||
async function chat(input: ChatInput) {
|
||||
@@ -220,10 +222,20 @@ async function chat(input: ChatInput) {
|
||||
system: systemPrompt,
|
||||
messages: await convertToModelMessages(input.messages),
|
||||
tools: {
|
||||
patch_resume: tool({
|
||||
description: patchResumeDescription,
|
||||
inputSchema: patchResumeInputSchema,
|
||||
execute: async ({ operations }) => executePatchResume(input.resumeData, operations),
|
||||
propose_resume_patches: tool({
|
||||
description:
|
||||
"Return one or more cohesive resume change proposals. Each proposal must include a title, optional summary, and valid JSON Patch operations against the current resume data. The tool validates but does not apply changes.",
|
||||
inputSchema: resumePatchProposalToolInputSchema,
|
||||
outputSchema: resumePatchProposalToolOutputSchema,
|
||||
execute: async (toolInput) => {
|
||||
const proposals = normalizeResumePatchProposals(toolInput, input.resumeUpdatedAt);
|
||||
|
||||
for (const proposal of proposals) {
|
||||
applyResumePatches(input.resumeData, proposal.operations);
|
||||
}
|
||||
|
||||
return { proposals };
|
||||
},
|
||||
}),
|
||||
},
|
||||
stopWhen: stepCountIs(3),
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { getPool } from "@reactive-resume/db/client";
|
||||
|
||||
const RESUME_UPDATED_CHANNEL = "resume_updated";
|
||||
|
||||
type PgNotification = {
|
||||
channel?: string | undefined;
|
||||
payload?: string | undefined;
|
||||
};
|
||||
|
||||
export type ResumeUpdatedEvent = {
|
||||
type: "resume.updated";
|
||||
resumeId: string;
|
||||
userId: string;
|
||||
updatedAt: string;
|
||||
mutation: "sync" | "create" | "update" | "patch" | "lock" | "password" | "delete";
|
||||
};
|
||||
|
||||
type SubscribeResumeUpdatedInput = {
|
||||
resumeId: string;
|
||||
userId: string;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
function isResumeUpdatedEvent(value: unknown): value is ResumeUpdatedEvent {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
|
||||
const event = value as Partial<ResumeUpdatedEvent>;
|
||||
return (
|
||||
event.type === "resume.updated" &&
|
||||
typeof event.resumeId === "string" &&
|
||||
typeof event.userId === "string" &&
|
||||
typeof event.updatedAt === "string" &&
|
||||
typeof event.mutation === "string"
|
||||
);
|
||||
}
|
||||
|
||||
export async function publishResumeUpdated(event: ResumeUpdatedEvent) {
|
||||
await getPool().query("SELECT pg_notify($1, $2)", [RESUME_UPDATED_CHANNEL, JSON.stringify(event)]);
|
||||
}
|
||||
|
||||
export async function* subscribeResumeUpdated({ resumeId, userId, signal }: SubscribeResumeUpdatedInput) {
|
||||
const client = await getPool().connect();
|
||||
const queue: ResumeUpdatedEvent[] = [];
|
||||
let done = signal?.aborted ?? false;
|
||||
let wake: (() => void) | undefined;
|
||||
|
||||
const resolveWake = () => {
|
||||
wake?.();
|
||||
wake = undefined;
|
||||
};
|
||||
|
||||
const onAbort = () => {
|
||||
done = true;
|
||||
resolveWake();
|
||||
};
|
||||
|
||||
const onNotification = (notification: PgNotification) => {
|
||||
if (notification.channel !== RESUME_UPDATED_CHANNEL || !notification.payload) return;
|
||||
|
||||
try {
|
||||
const event = JSON.parse(notification.payload) as unknown;
|
||||
if (!isResumeUpdatedEvent(event)) return;
|
||||
if (event.resumeId !== resumeId || event.userId !== userId) return;
|
||||
|
||||
queue.push(event);
|
||||
resolveWake();
|
||||
} catch {
|
||||
// Ignore malformed notifications; the refetch path is invalidation-only.
|
||||
}
|
||||
};
|
||||
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
client.on("notification", onNotification);
|
||||
|
||||
try {
|
||||
await client.query(`LISTEN ${RESUME_UPDATED_CHANNEL}`);
|
||||
|
||||
while (!done) {
|
||||
const event = queue.shift();
|
||||
if (event) {
|
||||
yield event;
|
||||
continue;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
wake = resolve;
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
client.off("notification", onNotification);
|
||||
|
||||
try {
|
||||
await client.query(`UNLISTEN ${RESUME_UPDATED_CHANNEL}`);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { StoredResumeAnalysis } from "@reactive-resume/schema/resume/analysis";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { Locale } from "@reactive-resume/utils/locale";
|
||||
import type { Operation } from "fast-json-patch";
|
||||
import type { JsonPatchOperation } from "@reactive-resume/utils/resume/patch";
|
||||
import type { ResumeUpdatedEvent } from "./resume-events";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { compare, hash } from "bcrypt";
|
||||
import { and, arrayContains, asc, desc, eq, isNotNull, sql } from "drizzle-orm";
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
redactResumeForViewer,
|
||||
shouldCountForStatistics,
|
||||
} from "../helpers/resume-access-policy";
|
||||
import { publishResumeUpdated } from "./resume-events";
|
||||
import { getStorageService } from "./storage";
|
||||
|
||||
const tags = {
|
||||
@@ -149,6 +151,14 @@ function toSharedResumeResponse<TPassword extends boolean>(
|
||||
};
|
||||
}
|
||||
|
||||
async function notifyResumeUpdated(event: ResumeUpdatedEvent) {
|
||||
try {
|
||||
await publishResumeUpdated(event);
|
||||
} catch (error) {
|
||||
console.warn("Failed to publish resume.updated event:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export const resumeService = {
|
||||
tags,
|
||||
statistics,
|
||||
@@ -194,6 +204,7 @@ export const resumeService = {
|
||||
data: schema.resume.data,
|
||||
isPublic: schema.resume.isPublic,
|
||||
isLocked: schema.resume.isLocked,
|
||||
updatedAt: schema.resume.updatedAt,
|
||||
hasPassword: sql<boolean>`${schema.resume.password} IS NOT NULL`,
|
||||
})
|
||||
.from(schema.resume)
|
||||
@@ -263,6 +274,14 @@ export const resumeService = {
|
||||
data,
|
||||
});
|
||||
|
||||
await notifyResumeUpdated({
|
||||
type: "resume.updated",
|
||||
resumeId: id,
|
||||
userId: input.userId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
mutation: "create",
|
||||
});
|
||||
|
||||
return id;
|
||||
} catch (error) {
|
||||
const constraint = get(error, "cause.constraint") as string | undefined;
|
||||
@@ -319,13 +338,24 @@ export const resumeService = {
|
||||
data: schema.resume.data,
|
||||
isPublic: schema.resume.isPublic,
|
||||
isLocked: schema.resume.isLocked,
|
||||
updatedAt: schema.resume.updatedAt,
|
||||
hasPassword: sql<boolean>`${schema.resume.password} IS NOT NULL`,
|
||||
});
|
||||
|
||||
if (!resume) throw new ORPCError("NOT_FOUND");
|
||||
|
||||
await notifyResumeUpdated({
|
||||
type: "resume.updated",
|
||||
resumeId: resume.id,
|
||||
userId: input.userId,
|
||||
updatedAt: resume.updatedAt.toISOString(),
|
||||
mutation: "update",
|
||||
});
|
||||
|
||||
return resume;
|
||||
} catch (error) {
|
||||
if (error instanceof ORPCError) throw error;
|
||||
|
||||
if (get(error, "cause.constraint") === "resume_slug_user_id_unique") {
|
||||
throw new ORPCError("RESUME_SLUG_ALREADY_EXISTS", { status: 400 });
|
||||
}
|
||||
@@ -335,14 +365,21 @@ export const resumeService = {
|
||||
}
|
||||
},
|
||||
|
||||
patch: async (input: { id: string; userId: string; operations: Operation[] }) => {
|
||||
patch: async (input: { id: string; userId: string; operations: JsonPatchOperation[]; expectedUpdatedAt?: Date }) => {
|
||||
const [existing] = await db
|
||||
.select({ data: schema.resume.data, isLocked: schema.resume.isLocked })
|
||||
.select({ data: schema.resume.data, isLocked: schema.resume.isLocked, updatedAt: schema.resume.updatedAt })
|
||||
.from(schema.resume)
|
||||
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
|
||||
|
||||
if (!existing) throw new ORPCError("NOT_FOUND");
|
||||
if (existing.isLocked) throw new ORPCError("RESUME_LOCKED");
|
||||
if (input.expectedUpdatedAt && existing.updatedAt.getTime() !== input.expectedUpdatedAt.getTime()) {
|
||||
throw new ORPCError("RESUME_VERSION_CONFLICT", {
|
||||
status: 409,
|
||||
message: "The resume changed after this patch was generated.",
|
||||
data: { updatedAt: existing.updatedAt.toISOString() },
|
||||
});
|
||||
}
|
||||
|
||||
let patchedData: ResumeData;
|
||||
|
||||
@@ -377,28 +414,59 @@ export const resumeService = {
|
||||
data: schema.resume.data,
|
||||
isPublic: schema.resume.isPublic,
|
||||
isLocked: schema.resume.isLocked,
|
||||
updatedAt: schema.resume.updatedAt,
|
||||
hasPassword: sql<boolean>`${schema.resume.password} IS NOT NULL`,
|
||||
});
|
||||
|
||||
if (!resume) throw new ORPCError("NOT_FOUND");
|
||||
|
||||
await notifyResumeUpdated({
|
||||
type: "resume.updated",
|
||||
resumeId: resume.id,
|
||||
userId: input.userId,
|
||||
updatedAt: resume.updatedAt.toISOString(),
|
||||
mutation: "patch",
|
||||
});
|
||||
|
||||
return resume;
|
||||
},
|
||||
|
||||
setLocked: async (input: { id: string; userId: string; isLocked: boolean }) => {
|
||||
await db
|
||||
const [resume] = await db
|
||||
.update(schema.resume)
|
||||
.set({ isLocked: input.isLocked })
|
||||
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
|
||||
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)))
|
||||
.returning({ id: schema.resume.id, updatedAt: schema.resume.updatedAt });
|
||||
|
||||
if (!resume) return;
|
||||
|
||||
await notifyResumeUpdated({
|
||||
type: "resume.updated",
|
||||
resumeId: resume.id,
|
||||
userId: input.userId,
|
||||
updatedAt: resume.updatedAt.toISOString(),
|
||||
mutation: "lock",
|
||||
});
|
||||
},
|
||||
|
||||
setPassword: async (input: { id: string; userId: string; password: string }) => {
|
||||
const hashedPassword = await hash(input.password, 10);
|
||||
|
||||
await db
|
||||
const [resume] = await db
|
||||
.update(schema.resume)
|
||||
.set({ password: hashedPassword })
|
||||
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
|
||||
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)))
|
||||
.returning({ id: schema.resume.id, updatedAt: schema.resume.updatedAt });
|
||||
|
||||
if (!resume) return;
|
||||
|
||||
await notifyResumeUpdated({
|
||||
type: "resume.updated",
|
||||
resumeId: resume.id,
|
||||
userId: input.userId,
|
||||
updatedAt: resume.updatedAt.toISOString(),
|
||||
mutation: "password",
|
||||
});
|
||||
},
|
||||
|
||||
verifyPassword: async (input: { slug: string; username: string; password: string }) => {
|
||||
@@ -427,10 +495,21 @@ export const resumeService = {
|
||||
},
|
||||
|
||||
removePassword: async (input: { id: string; userId: string }) => {
|
||||
await db
|
||||
const [resume] = await db
|
||||
.update(schema.resume)
|
||||
.set({ password: null })
|
||||
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
|
||||
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)))
|
||||
.returning({ id: schema.resume.id, updatedAt: schema.resume.updatedAt });
|
||||
|
||||
if (!resume) return;
|
||||
|
||||
await notifyResumeUpdated({
|
||||
type: "resume.updated",
|
||||
resumeId: resume.id,
|
||||
userId: input.userId,
|
||||
updatedAt: resume.updatedAt.toISOString(),
|
||||
mutation: "password",
|
||||
});
|
||||
},
|
||||
|
||||
delete: async (input: { id: string; userId: string }) => {
|
||||
@@ -452,5 +531,13 @@ export const resumeService = {
|
||||
storageService.delete(`uploads/${input.userId}/screenshots/${input.id}`),
|
||||
storageService.delete(`uploads/${input.userId}/pdfs/${input.id}`),
|
||||
]);
|
||||
|
||||
await notifyResumeUpdated({
|
||||
type: "resume.updated",
|
||||
resumeId: input.id,
|
||||
userId: input.userId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
mutation: "delete",
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user