src/server/actions.ts
Rama main · réplica de lectura del repositorio original.
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import type { ActivityType, McpScope, NoteKind, Stage, TagKind, UserRole } from "@prisma/client";
import * as me from "@/lib/data/me";
import * as revisions from "@/lib/data/revisions";
import * as resumes from "@/lib/data/resumes";
import * as pipeline from "@/lib/data/pipeline";
import * as offers from "@/lib/data/offers";
import * as interviews from "@/lib/data/interviews";
import * as letters from "@/lib/data/letters";
import * as stageTemplates from "@/lib/data/stage-templates";
import * as proposals from "@/lib/data/proposals";
import * as digest from "@/lib/data/digest";
import * as tags from "@/lib/data/tags";
import { STAGE_LABEL } from "@/lib/data/pipeline";
import * as views from "@/lib/data/views";
import * as pipelineShare from "@/lib/data/pipeline-share";
import * as users from "@/lib/data/users";
import * as waitlist from "@/lib/data/waitlist";
import * as connections from "@/lib/data/connections";
import * as accounts from "@/lib/data/accounts";
import * as watch from "@/lib/data/watch";
import * as mailSweep from "@/lib/data/mail-sweep";
import * as captureLink from "@/lib/data/capture-link";
import * as outbound from "@/lib/data/outbound";
import * as onboarding from "@/lib/data/onboarding";
import * as assistant from "@/lib/data/assistant";
import { pendingApproval } from "@/lib/assistant/run";
import { StaleWriteError } from "@/lib/data/revision-store";
import * as transferables from "@/lib/data/transferables";
import {
authenticate,
claimInstance,
endSession,
instanceNeedsSetup,
requireAdmin,
requireUser,
requireUserPendingPasswordChange,
setupKeyMatches,
startSession,
PLACEHOLDER_OWNER_EMAIL,
} from "@/lib/auth";
import { deleteVariable, getSettings, setVariables } from "@/lib/settings";
import { unlinkGoogleFromUser } from "@/lib/google";
import { renderEmailTemplate, sendEmail } from "@/lib/email";
import { syncAllBilling } from "@/lib/billing";
import { loadPosting } from "@/lib/posting";
import { dateRange } from "@/lib/utils";
import {
checkLoginAllowed,
clearLoginFailures,
clientIp,
recordLoginFailure,
sweepThrottles,
} from "@/lib/login-throttle";
import { listAudit, recordAudit } from "@/lib/data/audit";
import { recordSystemEvent, sweepSystemEvents } from "@/lib/data/system";
import * as archive from "@/lib/data/archive";
import type { PipelineView } from "@/lib/pipeline-fields";
import type { ColumnList } from "@/lib/column-widths";
import { sweepArchive } from "@/lib/data/archive";
import { applicationDetail, tagOption } from "@/server/application-detail";
/**
* Every action resolves the caller from their session cookie. No action ever
* accepts a userId from the client, so a crafted request cannot act as somebody
* else no matter what it sends.
*/
// ---------------------------------------------------------------------------
// Auth
// ---------------------------------------------------------------------------
export async function setupAction(_prev: { error?: string } | undefined, formData: FormData) {
if (!(await instanceNeedsSetup())) return { error: "This instance has already been set up." };
const setupKey = String(formData.get("setupKey") ?? "");
const email = String(formData.get("email") ?? "").trim();
const name = String(formData.get("name") ?? "").trim();
const password = String(formData.get("password") ?? "");
if (!setupKeyMatches(setupKey)) return { error: "That setup key doesn't match APP_PASSWORD." };
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return { error: "Enter a valid email address." };
if (password.length < 10) return { error: "Use a password of at least 10 characters." };
const user = await claimInstance({ email, name, password });
await startSession(user.id);
redirect("/");
}
export async function loginAction(_prev: { error?: string } | undefined, formData: FormData) {
const email = String(formData.get("email") ?? "");
const password = String(formData.get("password") ?? "");
const ip = clientIp(await headers());
// Checked before the password is even looked at, so a locked account costs an
// attacker a database read rather than a scrypt verification.
const verdict = await checkLoginAllowed(email, ip);
if (!verdict.allowed) {
const minutes = Math.ceil(verdict.retryAfterSeconds / 60);
return {
error: `Too many attempts. Try again in ${minutes} minute${minutes === 1 ? "" : "s"}.`,
};
}
const user = await authenticate(email, password);
if (!user) {
await recordLoginFailure(email, ip);
// The same sentence whether the address exists, the password is wrong or
// the account is suspended. Three different messages is a tool for
// discovering which addresses are real.
return { error: "That email and password don't match." };
}
await clearLoginFailures(email, ip);
// Absent means the box was unticked: a few hours, and gone when the browser
// closes. The checkbox ships ticked, so the default stays a month.
await startSession(user.id, { remember: formData.get("remember") !== null });
// Cheap, and it keeps the tables from growing on a busy instance. Deliberately
// not awaited-and-blocking on failure: a failed sweep must not fail a login.
void sweepThrottles().catch(() => {});
void sweepSystemEvents().catch(() => {});
void sweepArchive().catch(() => {});
redirect("/");
}
export async function logoutAction() {
await endSession();
redirect("/login");
}
export async function acceptInviteAction(
_prev: { error?: string } | undefined,
formData: FormData,
) {
const token = String(formData.get("token") ?? "");
const name = String(formData.get("name") ?? "").trim();
// Blank when the invitation carries a password the inviter set — that form
// has no password field at all. acceptInvite decides which it is by reading
// the invite, so an empty string here is a question for it, not an error.
const password = String(formData.get("password") ?? "");
try {
const user = await users.acceptInvite({ token, name, password });
await startSession(user.id);
} catch (error) {
return { error: error instanceof Error ? error.message : "Could not accept that invitation." };
}
redirect("/");
}
/**
* Set a password for an account that was handed one by an admin.
*
* The difference from `changeOwnPasswordAction` is the missing current-password
* check, and it is missing on purpose: the password this person holds is one
* somebody else chose, so proving they have it proves nothing about them. What
* authorises this is the session cookie plus the flag on the account — hence
* `requireUserPendingPasswordChange`, and hence the refusal when the flag is
* not set, which stops this being a no-questions-asked password change for
* anyone who is simply signed in.
*/
export async function setNewPasswordAction(
_prev: { error?: string } | undefined,
formData: FormData,
) {
const user = await requireUserPendingPasswordChange();
if (!user.mustChangePassword) redirect("/settings");
const next = String(formData.get("newPassword") ?? "");
// The owner made at first boot has a placeholder address, and nothing sent
// to it — a waitlist notice, a digest — reaches anybody. This is the one
// moment they are guaranteed to be looking, so it is asked for here.
const placeholder = user.email === PLACEHOLDER_OWNER_EMAIL;
const email = String(formData.get("email") ?? "").trim().toLowerCase();
if (placeholder && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return { error: "Enter the email address you want to sign in with." };
}
try {
if (placeholder) await users.updateOwnAccount(user.id, { email });
await users.changePassword(user.id, next);
} catch (error) {
return { error: error instanceof Error ? error.message : "Could not set that password." };
}
// changePassword ends every session, this one included; start a fresh one so
// they land in the app rather than back at sign-in having just proved it.
await startSession(user.id);
redirect("/");
}
export async function changeOwnPasswordAction(
_prev: { error?: string; ok?: boolean; cut?: boolean } | undefined,
formData: FormData,
) {
const user = await requireUser();
const current = String(formData.get("currentPassword") ?? "");
const next = String(formData.get("newPassword") ?? "");
const cut = formData.get("cutConnections") === "on";
if (next.length < 10) return { error: "Use a password of at least 10 characters." };
if (!(await authenticate(user.email, current))) return { error: "Your current password is wrong." };
await users.changePassword(user.id, next, { cutConnections: cut });
await startSession(user.id); // keep this device signed in
revalidatePath("/settings");
return { ok: true, cut };
}
export async function updateOwnAccountAction(patch: { name?: string; email?: string }) {
const user = await requireUser();
await users.updateOwnAccount(user.id, patch);
revalidatePath("/settings");
}
// ---------------------------------------------------------------------------
// Sharing a resume
// ---------------------------------------------------------------------------
async function currentBaseUrl() {
const headerList = await headers();
const host = headerList.get("x-forwarded-host") ?? headerList.get("host") ?? "localhost:3000";
const proto =
headerList.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
return `${proto}://${host}`;
}
export async function publishResumeAction(id: string) {
const user = await requireUser();
const resume = await resumes.publishResume(user.id, id);
revalidatePath(`/resumes/${id}`);
revalidatePath("/me");
return { url: `${await currentBaseUrl()}/r/${resume.slug}` };
}
export async function unpublishResumeAction(id: string) {
const user = await requireUser();
await resumes.unpublishResume(user.id, id);
revalidatePath(`/resumes/${id}`);
revalidatePath("/me");
}
// ---------------------------------------------------------------------------
// MCP connections
// ---------------------------------------------------------------------------
export async function createConnectionAction(input: { name?: string; client?: string }) {
const user = await requireUser();
const connection = await connections.createConnection(user.id, input);
revalidatePath("/settings");
return { id: connection.id, token: connection.token };
}
export async function renameConnectionAction(id: string, name: string) {
const user = await requireUser();
await connections.renameConnection(user.id, id, name);
revalidatePath("/settings");
}
/**
* Narrow or widen one connection. Not a permission — see the data layer — so
* this needs no confirmation beyond the select itself.
*/
export async function setConnectionScopeAction(id: string, scope: McpScope) {
const user = await requireUser();
await connections.setConnectionScope(user.id, id, scope);
revalidatePath("/settings");
}
export async function rotateConnectionAction(id: string) {
const user = await requireUser();
const token = await connections.rotateConnection(user.id, id);
revalidatePath("/settings");
return token;
}
export async function deleteConnectionAction(id: string) {
const user = await requireUser();
await connections.deleteConnection(user.id, id);
revalidatePath("/settings");
}
/**
* Calls our own MCP endpoint the way a client would, over real HTTP, and
* reports what came back. Proves the whole path — routing, host headers, token
* lookup — rather than just asserting the token exists in the database.
*/
export async function testConnectionAction(id: string) {
const user = await requireUser();
const all = await connections.listConnections(user.id);
const connection = all.find((item) => item.id === id);
if (!connection) return { ok: false as const, error: "No connection with that id." };
const headerList = await headers();
const host = headerList.get("x-forwarded-host") ?? headerList.get("host") ?? "localhost:3000";
const proto =
headerList.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
try {
const response = await fetch(`${proto}://${host}/api/mcp/${connection.token}`, {
method: "POST",
headers: { "Content-Type": "application/json", "user-agent": "hired-selftest" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
cache: "no-store",
});
if (!response.ok) {
return { ok: false as const, error: `Server answered ${response.status}.` };
}
const payload = (await response.json()) as { result?: { tools?: unknown[] } };
const toolCount = payload.result?.tools?.length ?? 0;
if (!toolCount) return { ok: false as const, error: "Connected, but no tools came back." };
return { ok: true as const, toolCount };
} catch (error) {
return {
ok: false as const,
error: error instanceof Error ? error.message : "Could not reach the endpoint.",
};
}
}
// ---------------------------------------------------------------------------
// Admin
// ---------------------------------------------------------------------------
export async function inviteUserAction(input: {
email: string;
role: UserRole;
password?: string;
mustChangePassword?: boolean;
}) {
const actor = await requireAdmin();
const headerList = await headers();
const host = headerList.get("x-forwarded-host") ?? headerList.get("host") ?? "localhost:3000";
const proto = headerList.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
try {
const result = await users.createInvite({
actor,
email: input.email,
role: input.role,
baseUrl: `${proto}://${host}`,
password: input.password,
mustChangePassword: input.mustChangePassword,
});
revalidatePath("/settings/admin");
return {
ok: true as const,
acceptUrl: result.acceptUrl,
emailSent: result.emailSent,
emailError: result.emailError,
passwordSet: result.passwordSet,
mustChangePassword: result.mustChangePassword,
};
} catch (error) {
return { ok: false as const, error: error instanceof Error ? error.message : "Could not invite." };
}
}
/**
* Add or remove a password on an invitation that is already out there, keeping
* its link alive. Separate from `inviteUserAction` because re-inviting mints a
* new token and invalidates the link the person may already be holding.
*/
export async function setInvitePasswordAction(
id: string,
options: { password?: string; mustChange?: boolean } = {},
) {
const actor = await requireAdmin();
try {
const result = await users.setInvitePassword(actor, id, options);
revalidatePath("/settings/admin");
return { ok: true as const, ...result };
} catch (error) {
return {
ok: false as const,
error: error instanceof Error ? error.message : "Could not update that invitation.",
};
}
}
export async function revokeInviteAction(id: string) {
const actor = await requireAdmin();
await users.revokeInvite(actor, id);
revalidatePath("/settings/admin");
}
/**
* Invite someone off the waitlist. Same shape as inviteUserAction because it
* is the same job with the address already chosen — the request row supplies
* the email, so this can't be used to invite an arbitrary person.
*/
export async function inviteFromWaitlistAction(input: { id: string; role: UserRole }) {
const actor = await requireAdmin();
const headerList = await headers();
const host = headerList.get("x-forwarded-host") ?? headerList.get("host") ?? "localhost:3000";
const proto = headerList.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
try {
const result = await waitlist.inviteFromWaitlist({
actor,
id: input.id,
role: input.role,
baseUrl: `${proto}://${host}`,
});
revalidatePath("/settings/admin");
return {
ok: true as const,
acceptUrl: result.acceptUrl,
emailSent: result.emailSent,
emailError: result.emailError,
};
} catch (error) {
return { ok: false as const, error: error instanceof Error ? error.message : "Could not invite." };
}
}
export async function removeWaitlistSignupAction(id: string) {
await requireAdmin();
await waitlist.removeWaitlistSignup(id);
revalidatePath("/settings/admin");
}
export async function setUserRoleAction(userId: string, role: UserRole) {
const actor = await requireAdmin();
try {
await users.setUserRole(actor, userId, role);
revalidatePath("/settings/admin");
return { ok: true as const };
} catch (error) {
return { ok: false as const, error: error instanceof Error ? error.message : "Could not update." };
}
}
export async function setUserActiveAction(userId: string, isActive: boolean) {
const actor = await requireAdmin();
try {
await users.setUserActive(actor, userId, isActive);
revalidatePath("/settings/admin");
return { ok: true as const };
} catch (error) {
return { ok: false as const, error: error instanceof Error ? error.message : "Could not update." };
}
}
/**
* Reset a member's password — to one the admin typed, or to a generated one —
* and hand it back once.
*
* The password is returned to the admin who asked rather than emailed, because
* on a self-hosted instance email may not be configured at all — and an admin
* reading it off the screen to a customer they are already on a call with is
* the actual support flow.
*/
export async function adminResetPasswordAction(
userId: string,
options: { password?: string; mustChange?: boolean } = {},
) {
const actor = await requireAdmin();
try {
const result = await users.adminResetPassword(actor, userId, options);
revalidatePath("/settings/admin");
return { ok: true as const, ...result };
} catch (error) {
return { ok: false as const, error: error instanceof Error ? error.message : "Could not reset." };
}
}
export async function listAuditAction(limit = 100) {
await requireAdmin();
const rows = await listAudit({ limit });
return rows.map((row) => ({
id: row.id,
actorEmail: row.actorEmail,
action: row.action,
targetEmail: row.targetEmail,
detail: row.detail,
createdAt: row.createdAt.toISOString(),
}));
}
export async function deleteUserAction(userId: string) {
const actor = await requireAdmin();
try {
await users.deleteUser(actor, userId);
revalidatePath("/settings/admin");
return { ok: true as const };
} catch (error) {
return { ok: false as const, error: error instanceof Error ? error.message : "Could not delete." };
}
}
/**
* Every settings write from the browser, by key. Configuration is one screen
* of editable rows now rather than three typed forms, so this is the only
* shape it needs — and an unknown key is created, which is how a setting
* exists before it has a section of its own.
*/
/**
* Detach Google from your own account. Linking is a redirect through Google
* (see /api/auth/google?link=1), so only the undo needs an action.
*/
export async function unlinkGoogleAction() {
const user = await requireUser();
const result = await unlinkGoogleFromUser(user.id);
revalidatePath("/settings");
return result;
}
// ---------------------------------------------------------------------------
// Mail and calendar accounts
// ---------------------------------------------------------------------------
/**
* Google and Microsoft connect by a redirect through their consent screens
* (/api/auth/google?data=1, /api/auth/microsoft), not an action. IMAP is a
* form, so it is one. Disconnecting revokes where the provider allows it and
* deletes the row.
*/
export async function connectImapAccountAction(input: accounts.ImapConnectInput) {
const user = await requireUser();
try {
const account = await accounts.connectImapAccount(user.id, input);
revalidatePath("/settings");
return { ok: true as const, account: serialiseAccount(account) };
} catch (error) {
return { ok: false as const, error: error instanceof Error ? error.message : String(error) };
}
}
export async function testAccountAction(accountId: string) {
const user = await requireUser();
try {
const result = await accounts.testAccount(user.id, accountId);
revalidatePath("/settings");
return { ok: true as const, mail: result.mail, calendar: result.calendar };
} catch (error) {
return { ok: false as const, error: error instanceof Error ? error.message : String(error) };
}
}
export async function renameAccountAction(accountId: string, label: string) {
const user = await requireUser();
await accounts.renameLinkedAccount(user.id, accountId, label);
revalidatePath("/settings");
}
export async function disconnectAccountAction(accountId: string) {
const user = await requireUser();
await accounts.disconnectAccount(user.id, accountId);
revalidatePath("/settings");
return { ok: true as const };
}
function serialiseAccount(account: accounts.LinkedAccountView) {
return {
...account,
connectedAt: account.connectedAt.toISOString(),
lastUsedAt: account.lastUsedAt?.toISOString() ?? null,
lastErrorAt: account.lastErrorAt?.toISOString() ?? null,
};
}
/**
* The panels on a contact, company, application or resume. Fetched after
* the page renders, because it is a round trip to Google and the page should
* not wait on it. Dates go out as ISO strings so the client can format them.
*/
export async function correspondenceAction(
subject: accounts.CorrespondenceSubject,
): Promise<
| { ok: true; correspondence: ReturnType<typeof serialiseCorrespondence> }
| { ok: false; error: string; notConnected: boolean }
> {
const user = await requireUser();
try {
const result = await accounts.listCorrespondence(user.id, subject);
return { ok: true, correspondence: serialiseCorrespondence(result) };
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : String(error),
notConnected: error instanceof accounts.AccountNotConnectedError,
};
}
}
function serialiseCorrespondence(result: accounts.Correspondence) {
return {
subject: result.subject,
terms: result.terms,
notes: result.notes,
warnings: result.warnings,
mail:
result.mail?.map((thread) => ({
...thread,
firstMessageAt: thread.firstMessageAt.toISOString(),
lastMessageAt: thread.lastMessageAt.toISOString(),
})) ?? null,
calendar:
result.calendar?.map((event) => ({
...event,
start: event.start.toISOString(),
end: event.end.toISOString(),
})) ?? null,
};
}
export async function emailThreadAction(threadId: string) {
const user = await requireUser();
try {
const thread = await accounts.getEmailThread(user.id, threadId);
return {
ok: true as const,
thread: {
...thread,
messages: thread.messages.map((message) => ({ ...message, date: message.date.toISOString() })),
},
};
} catch (error) {
return { ok: false as const, error: error instanceof Error ? error.message : String(error) };
}
}
export async function saveVariablesAction(patch: Record<string, string>) {
const actor = await requireAdmin();
try {
const changed = await setVariables(actor, patch);
revalidateSettings();
return { ok: true as const, changed };
} catch (error) {
return { ok: false as const, error: error instanceof Error ? error.message : "Could not save." };
}
}
/** Reset a declared variable to its default, or remove a custom one. */
export async function deleteVariableAction(key: string) {
const actor = await requireAdmin();
try {
const result = await deleteVariable(actor, key);
revalidateSettings();
return { ok: true as const, ...result };
} catch (error) {
return { ok: false as const, error: error instanceof Error ? error.message : "Could not clear." };
}
}
/**
* A settings change can reach anywhere — the instance name is on the sign-in
* page, the logo switch is on every pipeline screen — so revalidate the whole
* tree rather than guessing which routes read what.
*/
function revalidateSettings() {
revalidatePath("/", "layout");
}
/**
* Fetch a posting URL and hand back what the page says, for the new-application
* dialog to prefill. Deliberately creates nothing: in the UI a person reviews
* the fields before tracking the job, so the parse and the create stay
* separate. (The capture_job_posting tool is the one-move version for
* assistants.)
*/
export async function parsePostingAction(url: string) {
await requireUser();
try {
return { ok: true as const, parsed: await loadPosting(url) };
} catch (error) {
return { ok: false as const, error: error instanceof Error ? error.message : "Could not fetch that." };
}
}
export async function syncBillingAction(email?: string) {
await requireAdmin();
const headerList = await headers();
const host = headerList.get("x-forwarded-host") ?? headerList.get("host") ?? "localhost:3000";
const proto = headerList.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
try {
const results = await syncAllBilling(`${proto}://${host}`, email?.trim() || undefined);
revalidatePath("/settings/admin");
return { ok: true as const, results };
} catch (error) {
return { ok: false as const, error: error instanceof Error ? error.message : "Sync failed." };
}
}
export async function sendTestEmailAction(to?: string, template?: string) {
const actor = await requireAdmin();
const settings = await getSettings();
const result = await sendEmail({
to: to?.trim() || actor.email,
...renderEmailTemplate(template, settings),
settings,
});
return result.ok
? { ok: true as const, to: to?.trim() || actor.email }
: { ok: false as const, error: result.error };
}
// ---------------------------------------------------------------------------
// Me
// ---------------------------------------------------------------------------
export async function saveProfileAction(patch: me.ProfilePatch) {
const user = await requireUser();
await me.updateProfile(user.id, patch);
revalidatePath("/me");
revalidatePath("/");
}
/**
* Set the calendar this person's dates are read against, or clear it back to
* the server's own with an empty string.
*
* `seeded` marks the browser filling it in for somebody who has never set one,
* which must not overwrite a zone a person actually chose — the effect that
* calls it runs on a machine that may be in a different place from the one
* they are searching for work in.
*/
export async function setTimeZoneAction(timeZone: string, options?: { seeded?: boolean }) {
const user = await requireUser();
if (options?.seeded && (await me.timeZoneOf(user.id))) return { timeZone: "", skipped: true };
const result = await me.setTimeZone(user.id, timeZone);
// Every screen shows a date, so every screen is stale.
revalidatePath("/", "layout");
return { ...result, skipped: false };
}
/**
* Store a headshot, or clear it with an empty string.
*
* The browser has already cropped and downscaled by the time this runs, so what
* arrives is a small data URI; the size and type rules still live in the data
* layer, because `set_profile_photo` posts here through the same function and
* neither door should be the lenient one.
*/
export async function setProfilePhotoAction(input: string) {
const user = await requireUser();
const result = await me.setProfilePhoto(user.id, input);
revalidatePath("/settings");
revalidatePath("/me");
revalidatePath("/me");
revalidatePath("/");
return result;
}
export async function createRoleAction(input: me.RoleInput) {
const user = await requireUser();
const role = await me.createRole(user.id, input);
revalidatePath("/me");
return role.id;
}
/**
* `expectedUpdatedAt` is the version the editor last saw. When the role has
* moved since — an assistant wrote it — nothing is saved and the answer is
* `{ conflict: true }`, which the autosave hook turns into "changed elsewhere".
*/
export async function updateRoleAction(
id: string,
patch: Partial<me.RoleInput>,
expectedUpdatedAt?: string,
): Promise<{ updatedAt: string } | { conflict: true }> {
const user = await requireUser();
try {
const role = await me.updateRole(user.id, id, patch, undefined, { expectedUpdatedAt });
revalidatePath("/me");
revalidatePath(`/me/${id}`);
return { updatedAt: role.updatedAt.toISOString() };
} catch (error) {
if (error instanceof StaleWriteError) return { conflict: true };
throw error;
}
}
export async function deleteRoleAction(id: string) {
const user = await requireUser();
await me.deleteRole(user.id, id);
revalidatePath("/me");
redirect("/me");
}
export async function createHighlightAction(input: me.HighlightInput) {
const user = await requireUser();
const highlight = await me.createHighlight(user.id, input);
revalidatePath("/me");
if (input.roleId) revalidatePath(`/me/${input.roleId}`);
return highlight;
}
export async function updateHighlightAction(
id: string,
patch: Partial<me.HighlightInput> & { archived?: boolean },
) {
const user = await requireUser();
await me.updateHighlight(user.id, id, patch);
revalidatePath("/me");
}
export async function deleteHighlightAction(id: string) {
const user = await requireUser();
await me.deleteHighlight(user.id, id);
revalidatePath("/me");
}
export async function createNoteAction(input: {
title: string;
body?: string;
tags?: string[];
roleId?: string | null;
}) {
const user = await requireUser();
const note = await me.createNote(user.id, input);
revalidatePath("/me");
return note.id;
}
export async function updateNoteAction(
id: string,
patch: Partial<{
title: string;
body: string;
tags: string[];
pinned: boolean;
kind: NoteKind;
roleId: string | null;
}>,
) {
const user = await requireUser();
const note = await me.updateNote(user.id, id, patch);
revalidatePath("/me");
if (note.roleId) revalidatePath(`/me/${note.roleId}`);
}
/** Answer one open question, or confirm an assumed date. Null role = profile. */
export async function resolveOpenQuestionAction(
roleId: string | null,
question: string,
answer?: string,
) {
const user = await requireUser();
const result = await me.resolveOpenQuestion(user.id, roleId, question, answer);
revalidatePath("/me");
if (roleId) revalidatePath(`/me/${roleId}`);
return result;
}
export async function roleHistoryAction(roleId: string) {
const user = await requireUser();
return revisions.roleHistory(user.id, roleId);
}
export async function restoreRoleRevisionAction(roleId: string, revisionId: string) {
const user = await requireUser();
// The revision must be this role's: restoreRevision finds it by id and user,
// and this check stops a stale panel restoring a different record.
const revision = await revisions.getRevision(user.id, revisionId);
if (!revision || revision.recordId !== roleId) throw new Error("That version is not one of this role's.");
const result = await revisions.restoreRevision(user.id, revisionId, { writtenBy: "app" });
revalidatePath("/me");
revalidatePath(`/me/${roleId}`);
return result;
}
export async function deleteNoteAction(id: string) {
const user = await requireUser();
await me.deleteNote(user.id, id);
revalidatePath("/me");
}
export async function createEducationAction(input: { school: string }) {
const user = await requireUser();
await me.createEducation(user.id, input);
revalidatePath("/me");
}
export async function updateEducationAction(id: string, patch: Record<string, unknown>) {
const user = await requireUser();
await me.updateEducation(user.id, id, patch);
revalidatePath("/me");
}
export async function deleteEducationAction(id: string) {
const user = await requireUser();
await me.deleteEducation(user.id, id);
revalidatePath("/me");
}
export async function createProjectAction(input: { name: string }) {
const user = await requireUser();
await me.createProject(user.id, input);
revalidatePath("/me");
}
export async function updateProjectAction(id: string, patch: Record<string, unknown>) {
const user = await requireUser();
await me.updateProject(user.id, id, patch);
revalidatePath("/me");
}
export async function deleteProjectAction(id: string) {
const user = await requireUser();
await me.deleteProject(user.id, id);
revalidatePath("/me");
}
export async function createSkillGroupAction(input: { name: string; skills?: string[] }) {
const user = await requireUser();
await me.createSkillGroup(user.id, input);
revalidatePath("/me");
}
export async function updateSkillGroupAction(id: string, patch: { name?: string; skills?: string[] }) {
const user = await requireUser();
await me.updateSkillGroup(user.id, id, patch);
revalidatePath("/me");
}
export async function deleteSkillGroupAction(id: string) {
const user = await requireUser();
await me.deleteSkillGroup(user.id, id);
revalidatePath("/me");
}
export async function createCertificationAction(input: {
name: string;
issuer?: string;
date?: string;
url?: string;
}) {
const user = await requireUser();
await me.createCertification(user.id, input);
revalidatePath("/me");
}
export async function deleteCertificationAction(id: string) {
const user = await requireUser();
await me.deleteCertification(user.id, id);
revalidatePath("/me");
}
// ---------------------------------------------------------------------------
// Resumes
// ---------------------------------------------------------------------------
export async function createResumeAction(input: resumes.ResumeMeta & { seedFromMe?: boolean }) {
const user = await requireUser();
const resume = await resumes.createResume(user.id, input);
revalidatePath("/me");
return resume.id;
}
/** See updateRoleAction for `expectedUpdatedAt` and the conflict answer. */
export async function updateResumeAction(
id: string,
patch: resumes.ResumeMeta & { data?: unknown },
expectedUpdatedAt?: string,
): Promise<{ updatedAt: string } | { conflict: true }> {
const user = await requireUser();
try {
const resume = await resumes.updateResume(user.id, id, patch, undefined, { expectedUpdatedAt });
revalidatePath("/me");
revalidatePath(`/resumes/${id}`);
return { updatedAt: resume.updatedAt.toISOString() };
} catch (error) {
if (error instanceof StaleWriteError) return { conflict: true };
throw error;
}
}
/**
* Everything the command palette can jump to or act on.
*
* Fetched when the palette opens rather than shipped with every page. It used
* to be assembled in src/app/(app)/layout.tsx, which meant three content
* queries on every single navigation — and, worse, three hand-written
* `where: { userId }` clauses outside src/lib/data/, the one place in the app
* that bypassed the compile-time tenant guarantee.
*/
export async function paletteIndexAction() {
const user = await requireUser();
// Labels only, and all of them. The index is fetched when the palette opens
// and cmdk filters it in the browser, so a slice here was a record the
// search could never find: the first sixty companies by name, the sixty most
// recently touched applications. The cap is a safety net, not a page.
const CAP = 2000;
const [roles, resumeList, applications, companies, contacts] = await Promise.all([
me.listRoles(user.id),
resumes.listResumeNames(user.id),
pipeline.listApplicationNames(user.id),
pipeline.listCompanyNames(user.id),
pipeline.listContactNames(user.id),
]);
return {
roles: roles.slice(0, CAP).map((role) => ({
id: role.id,
label: `${role.title} · ${role.company}`,
sub: dateRange(role.startDate, role.endDate, role.isCurrent),
})),
resumes: resumeList.slice(0, CAP).map((resume) => ({
id: resume.id,
label: resume.name,
sub: resume.targetCompany || resume.targetRole || "",
})),
applications: applications.slice(0, CAP).map((application) => ({
id: application.id,
label: `${application.company.name} · ${application.roleTitle}`,
// STAGE_LABEL, not stage.toLowerCase(): the palette was the one
// surface that said "screen" where everything else says "Screening".
sub: STAGE_LABEL[application.stage],
stage: application.stage,
company: application.company.name,
roleTitle: application.roleTitle,
jobUrl: application.jobUrl,
})),
companies: companies.slice(0, CAP).map((company) => ({
id: company.id,
label: company.name,
// Industry and location are tags now, so the subtitle reads whichever
// of them the company actually wears rather than two named columns.
sub: company.tagNames.slice(0, 2).join(" · "),
})),
contacts: contacts.slice(0, CAP).map((contact) => ({
id: contact.id,
label: contact.name,
sub: [contact.title, contact.company].filter(Boolean).join(" · "),
})),
};
}
/**
* Bring a career in from a pasted document.
*
* The draft arrives from the browser because the parse happens there: it is
* pure, and the person corrects it on screen before anything is written. The
* filing itself is importResume — the same call an assistant makes — so the
* two doors cannot drift.
*
* The raw text is filed as a note here rather than inside importResume: the
* assistant reading a document keeps its own copy of what it read, and this is
* the browser path making sure the paste is not the only place it existed.
*/
export async function importResumeAction(draft: me.ResumeImport, sourceText?: string) {
const user = await requireUser();
const report = await me.importResume(user.id, draft);
const raw = sourceText?.trim();
if (raw) {
const existing = await me.listNotes(user.id);
if (!existing.some((note) => note.body.trim() === raw)) {
await me.createNote(user.id, {
title: `Imported resume — ${new Date().toISOString().slice(0, 10)}`,
body: raw,
tags: ["imported"],
});
}
}
revalidatePath("/me");
revalidatePath("/");
return report;
}
/**
* What backs each claim in a document, and where the document went.
*
* Read on demand rather than with the page: the editor flushes its autosave
* before asking, because evidence for a document you are still typing is
* evidence for a document that does not exist yet. The diff is not here — the
* compare-to-base view computes that live in the browser.
*/
export async function resumeEvidenceAction(id: string) {
const user = await requireUser();
return resumes.traceResumeEvidence(user.id, id);
}
/**
* The button on an application: a copy of the base, named for this job and
* attached to it, with the editor open on the other side.
*/
export async function tailorResumeForApplicationAction(applicationId: string) {
const user = await requireUser();
const result = await resumes.createResumeForApplication(user.id, applicationId);
revalidatePath("/me");
revalidatePath("/applications");
revalidatePath(`/applications/${applicationId}`);
return {
id: result.resume.id,
name: result.resume.name,
basedOn: result.basedOn?.name ?? null,
seededFromMe: result.seededFromMe,
};
}
export async function setResumeBaseAction(id: string, baseId: string | null) {
const user = await requireUser();
await resumes.setResumeBase(user.id, id, baseId);
revalidatePath("/me");
revalidatePath(`/resumes/${id}`);
}
export async function deleteResumeAction(id: string, redirectAfter = true) {
const user = await requireUser();
await resumes.deleteResume(user.id, id);
revalidatePath("/me");
// The editor needs somewhere to go after its document is gone; the grid is
// already standing where it wants to be, search and sort included.
if (redirectAfter) redirect("/me?tab=resumes");
}
/**
* Pull one job from Me into an open resume.
*
* The editor holds the document in state and autosaves it, so this returns the
* document it wrote rather than only a receipt: the caller replaces its state
* with what actually landed, and the two cannot drift apart. Everything else in
* the editor goes through commit() the same way, undo included.
*/
/**
* The draft you get once your history is in — made, or the one you already had.
*
* The same call `import_resume`'s create_base_resume makes, so the button at the
* end of the import dialog and the assistant doing it in one shot cannot end up
* with different documents.
*/
export async function buildBaseResumeAction() {
const user = await requireUser();
const base = await resumes.ensureBaseResume(user.id);
revalidatePath("/me");
return base;
}
export async function addRoleToResumeAction(
resumeId: string,
role: string,
options?: { position?: number; bullets?: number },
) {
const user = await requireUser();
const result = await resumes.addRoleToResume(user.id, resumeId, { role, ...options });
const resume = await resumes.getResume(user.id, resumeId);
// The version this write made, so the editor's next save is measured from
// it rather than refused as stale.
return { added: result.added, doc: resume!.doc, updatedAt: resume!.updatedAt.toISOString() };
}
export async function duplicateResumeAction(id: string, name?: string) {
const user = await requireUser();
const copy = await resumes.duplicateResume(user.id, id, name);
revalidatePath("/me");
return copy.id;
}
// ---------------------------------------------------------------------------
// Pipeline
// ---------------------------------------------------------------------------
// --- tags -------------------------------------------------------------------
/**
* The four screens a tag can show on. A tag is a label, and a label the person
* just renamed should not still read the old way on the page they came from.
*/
function revalidateTags() {
revalidatePath("/");
revalidatePath("/applications");
revalidatePath("/crm/companies");
revalidatePath("/crm/contacts");
}
const asOption = tagOption;
export async function listTagsAction(kind: TagKind) {
const user = await requireUser();
return (await tags.listTags(user.id, kind)).map(asOption);
}
export async function seedTagsAction(kind: TagKind) {
const user = await requireUser();
const seeded = await tags.seedTags(user.id, kind);
revalidateTags();
return seeded.map(asOption);
}
export async function createTagAction(input: { kind: TagKind; name: string; color?: string }) {
const user = await requireUser();
const tag = await tags.createTag(user.id, input);
revalidateTags();
return asOption(tag);
}
export async function updateTagAction(id: string, patch: { name?: string; color?: string }) {
const user = await requireUser();
const tag = await tags.updateTag(user.id, id, patch);
revalidateTags();
return asOption(tag);
}
export async function deleteTagAction(id: string) {
const user = await requireUser();
const result = await tags.deleteTag(user.id, id);
revalidateTags();
return result;
}
export async function createApplicationAction(input: pipeline.ApplicationInput) {
const user = await requireUser();
const application = await pipeline.createApplication(user.id, input);
revalidatePath("/applications");
revalidatePath("/");
return application.id;
}
export async function updateApplicationAction(
id: string,
patch: Partial<pipeline.ApplicationInput>,
) {
const user = await requireUser();
await pipeline.updateApplication(user.id, id, patch);
revalidatePath("/applications");
revalidatePath(`/applications/${id}`);
revalidatePath("/");
}
/**
* The two emails. Both off until somebody turns them on here or over a
* connection; there is no path that subscribes a person to anything.
*/
export async function setDigestSettingsAction(patch: {
weeklyDigest?: boolean;
dailyNudge?: boolean;
digestHour?: number;
}) {
const user = await requireUser();
const next = await digest.setDigestPreferences(user.id, patch);
revalidatePath("/settings");
return next;
}
export async function sendDigestNowAction(kind: digest.DigestKind) {
const user = await requireUser();
const outcome = await digest.sendDigest(user.id, kind, { force: true });
revalidatePath("/settings");
return outcome;
}
/**
* The mail sweep. Off until somebody turns it on here or over a connection —
* the same promise the digest switches make, and for a stronger reason: this
* one reads their inbox.
*/
export async function setMailSweepAction(on: boolean) {
const user = await requireUser();
const next = await mailSweep.setMailSweep(user.id, on);
revalidatePath("/settings");
return next;
}
/**
* Outbound mail. Both of these are the app's own buttons, and the second is the
* ONLY way a draft becomes APPROVED — there is deliberately no MCP tool for it,
* because "approve each one" would mean nothing if a model could click.
*/
export async function setOutboundSettingsAction(patch: {
dailyLimit?: number;
approval?: "each" | "trusted";
}) {
const user = await requireUser();
const next = await outbound.setOutboundSettings(user.id, patch);
revalidatePath("/settings");
return next;
}
export async function approveOutboundAction(id: string) {
const user = await requireUser();
const row = await outbound.approveOutbound(user.id, id);
revalidateEverywhere();
return row;
}
export async function cancelOutboundAction(id: string) {
const user = await requireUser();
const row = await outbound.cancelOutbound(user.id, id);
revalidateEverywhere();
return row;
}
/**
* Sending from the app, after the click.
*
* The same data function the tool calls, so there is one implementation of
* every refusal — including the approval gate, which this path has just
* satisfied honestly rather than bypassed.
*/
export async function sendOutboundAction(id: string) {
const user = await requireUser();
const result = await outbound.sendOutbound(user.id, id, {
writtenBy: "app",
connectionId: "",
connectionName: "the app",
tool: "sendOutboundAction",
});
revalidateEverywhere();
return result;
}
/**
* The capture link. Minting when one exists ROTATES it, which kills the old
* URL — the panel says so beside the button, because a bookmark built on it
* stops working the moment this runs.
*/
export async function mintCaptureLinkAction() {
const user = await requireUser();
const next = await captureLink.mintCaptureLink(user.id, await requestBaseUrl());
revalidatePath("/settings");
return next;
}
export async function revokeCaptureLinkAction() {
const user = await requireUser();
const done = await captureLink.revokeCaptureLink(user.id);
revalidatePath("/settings");
return done;
}
/**
* Stopping a board watch. There is no action to START one: creating a watch
* needs a board URL and two filter lists, which is a sentence in a conversation
* and a form nobody wants. Stopping one is a button because something making
* noise must never need an assistant to switch off.
*/
export async function stopWatchingCompanyBoardAction(id: string) {
const user = await requireUser();
const done = await watch.unwatchCompanyBoard(user.id, id);
revalidatePath("/crm");
revalidatePath("/settings");
return done;
}
/** The instance's own address, from the request when no public URL is set. */
async function requestBaseUrl() {
const settings = await getSettings();
if (settings.publicUrl.trim()) return settings.publicUrl.trim();
const headerList = await headers();
const host = headerList.get("x-forwarded-host") ?? headerList.get("host") ?? "localhost:3000";
const proto = headerList.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
return `${proto}://${host}`;
}
/**
* The review queue. Accepting writes through the same data layer the tool does,
* so there is exactly one implementation of what "accept" means.
*/
export async function acceptProposalAction(id: string) {
const user = await requireUser();
const done = await proposals.acceptProposal(user.id, id);
revalidateEverywhere();
return done.outcome;
}
export async function dismissProposalAction(id: string) {
const user = await requireUser();
await proposals.dismissProposal(user.id, id);
revalidatePath("/");
}
export async function dismissAllProposalsAction() {
const user = await requireUser();
const { dismissed } = await proposals.dismissAllProposals(user.id);
revalidatePath("/");
return dismissed;
}
/**
* Stage checklists. These are settings, so they revalidate the settings screen
* and nothing else — the tasks they make are created by a stage move, which
* revalidates its own screens.
*/
export async function createStageTemplateAction(input: stageTemplates.StageTemplateInput) {
const user = await requireUser();
const row = await stageTemplates.createStageTemplate(user.id, input);
revalidatePath("/settings");
return row.id;
}
export async function updateStageTemplateAction(
id: string,
patch: stageTemplates.StageTemplateInput,
) {
const user = await requireUser();
await stageTemplates.updateStageTemplate(user.id, id, patch);
revalidatePath("/settings");
}
export async function deleteStageTemplateAction(id: string) {
const user = await requireUser();
await stageTemplates.deleteStageTemplate(user.id, id);
revalidatePath("/settings");
}
export async function seedStageTemplatesAction() {
const user = await requireUser();
const result = await stageTemplates.seedStageTemplates(user.id);
revalidatePath("/settings");
return result.created;
}
/**
* Letters. Prose, so the body autosaves like everything else in this app —
* `update` is a patch and only rewrites what it is handed.
*/
export async function createLetterAction(input: letters.LetterInput) {
const user = await requireUser();
const letter = await letters.createLetter(user.id, input);
revalidateLetters(letter.applicationId);
return letter.id;
}
/** See updateRoleAction for `expectedUpdatedAt` and the conflict answer. */
export async function updateLetterAction(
id: string,
patch: letters.LetterInput,
expectedUpdatedAt?: string,
): Promise<{ updatedAt: string } | { conflict: true }> {
const user = await requireUser();
try {
const letter = await letters.updateLetter(user.id, id, patch, { expectedUpdatedAt });
revalidateLetters(letter.applicationId);
return { updatedAt: letter.updatedAt.toISOString() };
} catch (error) {
if (error instanceof StaleWriteError) return { conflict: true };
throw error;
}
}
export async function deleteLetterAction(id: string) {
const user = await requireUser();
const existing = await letters.getLetter(user.id, id);
await letters.deleteLetter(user.id, id);
revalidateLetters(existing?.applicationId ?? null);
}
function revalidateLetters(applicationId: string | null) {
revalidatePath("/me");
if (applicationId) revalidatePath(`/applications/${applicationId}`);
}
/**
* Offers are their own actions rather than fields on the application's autosave
* bag, and that is on purpose: the bag REPLACES what it is given, and an offer
* is a versioned record. Folding it in would mean every keystroke in the notes
* field rewriting a number somebody negotiated for.
*/
export async function recordOfferAction(applicationId: string, input: offers.OfferInput) {
const user = await requireUser();
const result = await offers.recordOffer(user.id, applicationId, input);
revalidateApplication(applicationId);
return offers.offerForUi(result.offer);
}
export async function updateOfferAction(id: string, patch: offers.OfferInput) {
const user = await requireUser();
const offer = await offers.updateOffer(user.id, id, patch);
revalidateApplication(offer.applicationId);
return offers.offerForUi(offer);
}
export async function deleteOfferAction(id: string) {
const user = await requireUser();
const offer = await offers.getOffer(user.id, id);
await offers.deleteOffer(user.id, id);
if (offer) revalidateApplication(offer.applicationId);
}
/**
* Interview rounds, for the tab on an opened application.
*
* No `pick` here, unlike the profile and resume actions: interviews.ts builds
* its Prisma `data` object field by field rather than spreading a patch, so the
* allow-list already exists one level down where patch.ts says it belongs. A
* second one here would be a second place to forget a column.
*/
export async function scheduleInterviewAction(
applicationId: string,
input: interviews.InterviewInput,
) {
const user = await requireUser();
const result = await interviews.scheduleInterview(user.id, applicationId, input);
revalidateApplication(applicationId);
return result.interview.id;
}
export async function updateInterviewAction(id: string, patch: interviews.InterviewInput) {
const user = await requireUser();
const interview = await interviews.updateInterview(user.id, id, patch);
revalidateApplication(interview.application.id);
}
export async function deleteInterviewAction(id: string) {
const user = await requireUser();
// Read it first so the right screen is revalidated after the row is gone.
const interview = await interviews.getInterview(user.id, id);
await interviews.deleteInterview(user.id, id);
if (interview) revalidateApplication(interview.application.id);
}
export async function addInterviewQuestionAction(
interviewId: string,
input: interviews.QuestionInput,
) {
const user = await requireUser();
await interviews.addQuestions(user.id, interviewId, [input]);
const interview = await interviews.getInterview(user.id, interviewId);
if (interview) revalidateApplication(interview.application.id);
}
export async function updateInterviewQuestionAction(
id: string,
patch: Partial<interviews.QuestionInput>,
) {
const user = await requireUser();
const question = await interviews.updateQuestion(user.id, id, patch);
const interview = await interviews.getInterview(user.id, question.interviewId);
if (interview) revalidateApplication(interview.application.id);
}
export async function deleteInterviewQuestionAction(id: string) {
const user = await requireUser();
await interviews.deleteQuestion(user.id, id);
revalidatePath("/applications");
}
/** The three screens an offer shows on. */
function revalidateApplication(id: string) {
revalidatePath("/applications");
revalidatePath(`/applications/${id}`);
revalidatePath("/");
}
export async function moveApplicationsStageAction(ids: string[], stage: Stage) {
const user = await requireUser();
const result = await pipeline.moveApplicationsStage(user.id, ids, stage);
revalidatePath("/applications");
revalidatePath("/");
return result;
}
export async function moveStageAction(id: string, stage: Stage) {
const user = await requireUser();
const moved = await pipeline.moveApplicationStage(user.id, id, stage);
revalidatePath("/applications");
revalidatePath(`/applications/${id}`);
revalidatePath("/");
// The titles, not a count: the toast has to say what landed on the list, or
// the tasks appear out of nowhere and the checklist reads as a bug.
return { addedTasks: moved.addedTasks.map((task) => task.title) };
}
/** Archives, now. The name stays because the button still says Delete. */
export async function deleteApplicationAction(id: string) {
const user = await requireUser();
const result = await pipeline.deleteApplication(user.id, id);
void archive.purgeExpiredFor(user.id).catch(() => {});
revalidateEverywhere();
return result;
}
export async function addActivityAction(input: {
applicationId?: string;
contactId?: string;
type?: ActivityType;
body: string;
}) {
const user = await requireUser();
await pipeline.addActivity(user.id, input);
if (input.applicationId) revalidatePath(`/applications/${input.applicationId}`);
if (input.contactId) revalidatePath(`/crm/contacts/${input.contactId}`);
revalidatePath("/");
}
export async function createTaskAction(
input: {
title: string;
detail?: string;
dueAt?: string | null;
} & pipeline.TaskSubjectInput,
) {
const user = await requireUser();
await pipeline.createTask(user.id, input);
revalidatePath("/");
// The subject's own screen shows its tasks, so it has to be refreshed too.
revalidateSubject(input);
}
export async function updateTaskAction(
id: string,
patch: { title?: string; detail?: string; dueAt?: string | null } & pipeline.TaskSubjectInput,
) {
const user = await requireUser();
await pipeline.updateTask(user.id, id, patch);
revalidatePath("/");
revalidateSubject(patch);
}
/** The detail page of whatever a task was just hung on, if it has one. */
function revalidateSubject(input: pipeline.TaskSubjectInput) {
if (input.applicationId) revalidatePath(`/applications/${input.applicationId}`);
if (input.companyId) revalidatePath(`/crm/companies/${input.companyId}`);
if (input.contactId) revalidatePath(`/crm/contacts/${input.contactId}`);
if (input.resumeId) revalidatePath(`/resumes/${input.resumeId}`);
if (input.roleId) revalidatePath(`/me/${input.roleId}`);
if (input.noteId) revalidatePath("/me");
}
export async function toggleTaskAction(id: string, done: boolean) {
const user = await requireUser();
await pipeline.setTaskDone(user.id, id, done);
revalidatePath("/");
revalidatePath("/applications");
}
export async function deleteTaskAction(id: string) {
const user = await requireUser();
await pipeline.deleteTask(user.id, id);
revalidatePath("/");
}
/**
* When to next get in touch with someone.
*
* Lives here rather than on the contact record, which is where it used to be:
* a date box halfway down a page you opened to read is not where you schedule
* anything. Empty string clears it.
*/
export async function scheduleContactPingAction(id: string, date: string) {
const user = await requireUser();
await pipeline.updateContact(user.id, id, { nextFollowUpAt: date || null });
revalidatePath("/");
revalidatePath("/crm/contacts");
revalidatePath(`/crm/contacts/${id}`);
}
// ---------------------------------------------------------------------------
// The archive
// ---------------------------------------------------------------------------
/** Everywhere a record could have been showing before it moved. */
function revalidateEverywhere() {
revalidatePath("/");
revalidatePath("/archive");
revalidatePath("/applications");
revalidatePath("/crm/companies");
revalidatePath("/crm/contacts");
}
export async function archiveRecordsAction(kind: archive.ArchiveKind, ids: string[]) {
const user = await requireUser();
const result = await archive.archiveRecords(user.id, kind, ids);
// The act that fills the bin is the act that should trim it, which is what
// bounds it on an instance nobody restarts and nobody signs out of.
void archive.purgeExpiredFor(user.id).catch(() => {});
revalidateEverywhere();
return result;
}
export async function restoreRecordsAction(kind: archive.ArchiveKind, ids: string[]) {
const user = await requireUser();
const result = await archive.restoreRecords(user.id, kind, ids);
revalidateEverywhere();
return result;
}
export async function deleteArchivedAction(kind: archive.ArchiveKind, ids: string[]) {
const user = await requireUser();
const result = await archive.deleteArchived(user.id, kind, ids);
revalidateEverywhere();
return result;
}
/**
* No expectCount here, unlike the tool.
*
* The dialog states the number and the click IS the confirmation. The count
* guard exists for the assistant, which has no dialog and can only be stopped
* by being made to say back what it read.
*/
export async function emptyArchiveAction(kind?: archive.ArchiveKind) {
const user = await requireUser();
const result = await archive.emptyArchive(user.id, kind ? { kind } : undefined);
revalidateEverywhere();
return result;
}
// ---------------------------------------------------------------------------
// Bulk changes from the CRM lists
// ---------------------------------------------------------------------------
export async function tagCompaniesAction(
ids: string[],
change: { add?: string[]; remove?: string[] },
) {
const user = await requireUser();
const result = await pipeline.tagCompanies(user.id, ids, change);
revalidatePath("/crm/companies");
revalidatePath("/applications");
return result;
}
export async function tagContactsAction(
ids: string[],
change: { add?: string[]; remove?: string[] },
) {
const user = await requireUser();
const result = await pipeline.tagContacts(user.id, ids, change);
revalidatePath("/crm/contacts");
return result;
}
export async function scheduleContactPingsAction(ids: string[], date: string) {
const user = await requireUser();
const result = await pipeline.scheduleContactPings(user.id, ids, date);
revalidatePath("/crm/contacts");
revalidatePath("/");
return result;
}
/**
* Which optional fields a pipeline view draws.
*
* The view is positional and typed, not a key in a patch bag: a mistyped one
* would otherwise be silently dropped and reported as saved.
*/
export async function setPipelineFieldsAction(view: PipelineView, fields: string[]) {
const user = await requireUser();
const result = await me.setPipelineFields(user.id, view, fields);
revalidatePath("/applications");
return result;
}
/**
* Save a column width after a drag.
*
* No revalidatePath: the table already has the new width on screen — it has
* been rendering it since the pointer moved — so refreshing the route would
* repaint the whole list to arrive at the layout it is already showing.
*/
export async function setColumnWidthsAction(
list: ColumnList,
widths: Record<string, number>,
options?: { reset?: boolean },
) {
const user = await requireUser();
return me.setColumnWidths(user.id, list, widths, options);
}
export async function createContactAction(input: {
name: string;
title?: string;
email?: string;
relationship?: string;
company?: string;
applicationId?: string | null;
}) {
const user = await requireUser();
await pipeline.createContact(user.id, input);
if (input.applicationId) revalidatePath(`/applications/${input.applicationId}`);
revalidatePath("/applications");
}
export async function deleteContactAction(id: string, applicationId?: string) {
const user = await requireUser();
await pipeline.deleteContact(user.id, id);
if (applicationId) revalidatePath(`/applications/${applicationId}`);
}
/**
* Attach a person already on file to an application, or detach them (null).
* Detaching keeps the person — removing someone from an application must never
* delete them from the CRM.
*/
export async function setContactApplicationAction(id: string, applicationId: string | null) {
const user = await requireUser();
await pipeline.updateContact(user.id, id, { applicationId });
if (applicationId) revalidatePath(`/applications/${applicationId}`);
revalidatePath("/applications");
revalidatePath("/crm/contacts");
}
/**
* Candidates for "attach someone you already know" on an application: every
* contact except those already on it, with enough context to pick the right
* Sarah. Someone attached to a different application is offered too — people
* move between threads — but says where they currently are.
*/
export async function listContactsForAttachAction(applicationId: string) {
const user = await requireUser();
const contacts = await pipeline.listContacts(user.id);
return contacts
.filter((contact) => contact.applicationId !== applicationId)
.map((contact) => ({
id: contact.id,
name: contact.name,
title: contact.title,
company: contact.companies.map((company) => company.name).join(", "),
attachedTo: contact.application ? contact.application.roleTitle : null,
}));
}
export async function snoozeFollowUpAction(id: string, days: number) {
const user = await requireUser();
await pipeline.snoozeFollowUp(user.id, id, days);
revalidatePath("/");
revalidatePath("/applications");
}
export async function snoozeContactFollowUpAction(id: string, days: number) {
const user = await requireUser();
await pipeline.snoozeContactFollowUp(user.id, id, days);
revalidatePath("/");
revalidatePath("/crm/contacts");
}
/**
* Chased it — the other half of the chase list, and the honest one.
* Writes the touch and moves the date; snoozing only moves the date.
*/
export async function logFollowUpAction(id: string, body?: string, days?: number) {
const user = await requireUser();
await pipeline.logFollowUp(user.id, { applicationId: id, body, days });
revalidatePath("/");
revalidatePath("/applications");
revalidatePath(`/applications/${id}`);
}
export async function logContactFollowUpAction(id: string, body?: string, days?: number) {
const user = await requireUser();
await pipeline.logFollowUp(user.id, { contactId: id, body, days });
revalidatePath("/");
revalidatePath("/crm/contacts");
revalidatePath(`/crm/contacts/${id}`);
}
/**
* Read one typed line against the open pipeline. Writes nothing: the person
* confirms what it found, because a wrong guess written silently is worse
* than no guess at all.
*/
export async function readQuickLogAction(text: string) {
const user = await requireUser();
return pipeline.readQuickLogAgainstPipeline(user.id, text);
}
/**
* Commit what they confirmed: one activity, and the stage move if they took
* the suggestion. Two writes, because they are two facts — what happened, and
* where it leaves the application.
*/
export async function commitQuickLogAction(input: {
applicationId: string;
body: string;
type: ActivityType;
stage?: Stage | null;
}) {
const user = await requireUser();
if (input.stage) {
await pipeline.moveApplicationStage(user.id, input.applicationId, input.stage, input.body);
} else {
await pipeline.addActivity(user.id, {
applicationId: input.applicationId,
type: input.type,
body: input.body,
});
}
revalidatePath("/");
revalidatePath("/applications");
revalidatePath(`/applications/${input.applicationId}`);
}
// ---------------------------------------------------------------------------
// CRM — companies and the people at them
// ---------------------------------------------------------------------------
export async function saveCompanyAction(
id: string,
patch: {
name?: string;
website?: string;
notes?: string;
industry?: string[];
industryIds?: string[];
size?: string[];
sizeIds?: string[];
location?: string[];
locationIds?: string[];
tags?: string[];
tagIds?: string[];
},
) {
const user = await requireUser();
const company = await pipeline.updateCompany(user.id, id, patch);
revalidatePath(`/crm/companies/${id}`);
revalidatePath("/crm/companies");
// The website is where the logo comes from, so the pipeline changes too.
revalidatePath("/applications");
return { name: company.name };
}
export async function createCompanyAction(input: { name: string; website?: string }) {
const user = await requireUser();
const company = await pipeline.createCompany(user.id, input);
revalidatePath("/crm/companies");
return { id: company.id };
}
/** What a merge would do. Writes nothing, so nothing is revalidated. */
export async function previewCompanyMergeAction(keepId: string, mergeId: string) {
const user = await requireUser();
return pipeline.previewCompanyMerge(user.id, keepId, mergeId);
}
export async function mergeCompaniesAction(keepId: string, mergeId: string) {
const user = await requireUser();
const survivor = await pipeline.mergeCompanies(user.id, keepId, mergeId);
revalidatePath("/crm/companies");
revalidatePath(`/crm/companies/${keepId}`);
revalidatePath("/crm/contacts");
// The board carries the company's name and logo on every card it moved.
revalidatePath("/applications");
revalidatePath("/");
return { id: survivor.id, name: survivor.name, merged: survivor.merged };
}
/** Archives, now. The name stays because the button still says Delete. */
export async function deleteCompanyAction(id: string) {
const user = await requireUser();
const result = await pipeline.deleteCompany(user.id, id);
void archive.purgeExpiredFor(user.id).catch(() => {});
revalidateEverywhere();
return result;
}
export async function saveContactAction(
id: string,
patch: {
name?: string;
title?: string;
email?: string;
phone?: string;
linkedin?: string;
twitter?: string;
instagram?: string;
github?: string;
website?: string;
otherLinks?: string[];
relationship?: string;
notes?: string;
/** Replaces the whole set. Ids, because the picker has already made them. */
tagIds?: string[];
/** "yyyy-mm-dd" from a date input; empty string clears the date. */
nextFollowUpAt?: string;
},
) {
const user = await requireUser();
const { nextFollowUpAt, ...rest } = patch;
await pipeline.updateContact(user.id, id, {
...rest,
...(nextFollowUpAt !== undefined ? { nextFollowUpAt: nextFollowUpAt || null } : {}),
});
revalidatePath(`/crm/contacts/${id}`);
revalidatePath("/crm/contacts");
}
/**
* Everywhere this person represents, as a set.
*
* Replaces rather than adds, because that is what the data layer does and one
* rule is easier to hold than two — the chips send the list they end up with.
* `create` is the picker's "Create X" row: a name gets a company before it can
* get an id, and doing it here keeps the client from needing a second call.
* Returns the new set so the chips can settle without a round trip.
*/
export async function setContactCompaniesAction(id: string, companyIds: string[], create?: string) {
const user = await requireUser();
const ids = [...companyIds];
if (create?.trim()) {
const company = await pipeline.upsertCompanyByName(user.id, create);
if (!ids.includes(company.id)) ids.push(company.id);
}
const contact = await pipeline.updateContact(user.id, id, { companyIds: ids });
revalidatePath(`/crm/contacts/${id}`);
revalidatePath("/crm/contacts");
revalidatePath("/crm/companies");
return contact.companies.map((company) => ({
id: company.id,
name: company.name,
website: company.website,
}));
}
/** Archives, now. The name stays because the button still says Delete. */
export async function deleteCrmContactAction(id: string) {
const user = await requireUser();
const result = await pipeline.deleteContact(user.id, id);
void archive.purgeExpiredFor(user.id).catch(() => {});
revalidateEverywhere();
return result;
}
/**
* The application behind a card, for the side panel.
*
* The panel opens over the board rather than navigating, so it has to fetch
* what the full page would have been given at render time. Same data function,
* same ownership check — the id is all that crosses from the client.
*/
export async function getApplicationForPanelAction(id: string) {
const user = await requireUser();
const detail = await applicationDetail(user.id, id);
if (!detail) throw new Error("That application is gone.");
return detail;
}
// --- sharing the pipeline read-only -----------------------------------------
export async function sharePipelineAction(includeClosed?: boolean, savedViewId?: string | null) {
const user = await requireUser();
const share = await pipelineShare.sharePipeline(user.id, { includeClosed, savedViewId });
revalidatePath("/applications");
return {
slug: share.slug,
includeClosed: share.includeClosed,
savedViewId: share.savedViewId,
url: `${await currentBaseUrl()}/p/${share.slug}`,
};
}
export async function unsharePipelineAction(savedViewId?: string | null) {
const user = await requireUser();
await pipelineShare.unsharePipeline(user.id, { savedViewId });
revalidatePath("/applications");
}
// --- saved pipeline views ---------------------------------------------------
export async function saveViewAction(name: string, query: string) {
const user = await requireUser();
const view = await views.saveView(user.id, name, query);
revalidatePath("/applications");
return view;
}
export async function deleteSavedViewAction(id: string) {
const user = await requireUser();
await views.deleteSavedView(user.id, id);
revalidatePath("/applications");
}
// --- the audit log ----------------------------------------------------------
/**
* A page of the audit log, filtered.
*
* The Log tab pages through the server rather than filtering rows already in
* the browser, because the log outlives everything else on an instance: cutting
* a page and then filtering it shows you the wrong hundred rows.
*/
export async function loadAuditAction(input: {
group?: string;
search?: string;
offset?: number;
limit?: number;
}) {
await requireAdmin();
const limit = Math.min(input.limit ?? 100, 200);
const rows = await listAudit({
group: input.group,
search: input.search,
offset: input.offset ?? 0,
limit,
});
return {
rows: rows.map((row) => ({
id: row.id,
actorEmail: row.actorEmail,
action: row.action,
targetEmail: row.targetEmail,
detail: row.detail,
createdAt: row.createdAt.toISOString(),
})),
// Fewer than asked for means this was the last page. One boolean beats a
// count query the tab would run on every keystroke.
more: rows.length === limit,
};
}
// --- errors -----------------------------------------------------------------
/**
* Called by the error boundary when a screen throws.
*
* Next logs the real error to stdout and hands the browser only a `digest`, so
* without this an admin has no way to see that anything happened. Requires a
* session, so it is not an open write endpoint, and takes nothing from the
* client but the digest and the path — never the message, which the browser
* cannot be trusted to have not made up.
*/
export async function reportRenderErrorAction(input: { digest?: string; path?: string }) {
const user = await requireUser();
await recordSystemEvent({
source: "app",
message: `A screen failed to render${input.path ? `: ${input.path}` : "."}`,
detail: input.digest ? `digest ${input.digest}` : "",
userEmail: user.email,
});
}
// --- the welcome tour --------------------------------------------------------
/**
* Put the tour away, or ask for it back.
*
* Two names for one write, because they are two different intentions and a
* boolean at the call site reads as neither. Finishing and skipping both count
* as seen — see setTourSeen for why.
*
* `setTourSeen(userId, true)` has no tool beside it, and that is the direct
* manipulation exception rather than a gap: it is a person closing a dialog in
* their own browser, and there is nothing for an assistant to do with it.
* Asking for the tour BACK is a real request somebody makes out loud, so that
* half is `restart_tour`.
*/
export async function completeTourAction() {
const user = await requireUser();
await onboarding.setTourSeen(user.id, true);
}
export async function restartTourAction() {
const user = await requireUser();
await onboarding.setTourSeen(user.id, false);
// The tour mounts from the layout, which every screen renders.
revalidatePath("/", "layout");
}
// --- the built-in assistant --------------------------------------------------
/**
* The drawer's reads and its housekeeping. The CONVERSATION itself does not go
* through here — it streams from /api/assistant, because a server action
* returns once and this one has to render as it arrives.
*
* There are no MCP tools beside these four, deliberately: a transcript of a
* client talking to this app is not career content, and Claude Desktop's
* conversations are not in Hired either. Everything the assistant can DO was
* already callable from a conversation, because it calls those exact tools.
*/
export async function assistantThreadsAction() {
const user = await requireUser();
return assistant.listThreads(user.id);
}
export async function assistantThreadAction(id: string) {
const user = await requireUser();
const thread = await assistant.getThread(user.id, id);
if (!thread) return null;
const messages = thread.messages.map((message) => ({
id: message.id,
role: message.role,
content: message.content as unknown[],
}));
return { id: thread.id, title: thread.title, messages, pending: pendingApproval(messages) };
}
export async function deleteAssistantThreadAction(id: string) {
const user = await requireUser();
await assistant.deleteThread(user.id, id);
}
// --- transferable skills, and the keyword policy ------------------------------
/**
* What they have used, and what it transfers to.
*
* The policy itself rides on updateProfileAction, because it is a profile
* field — one fewer action, and it saves through the same autosave every other
* field on that screen uses.
*/
export async function listTransferablesAction() {
const user = await requireUser();
return transferables.listTransferables(user.id);
}
export async function createTransferableAction(input: {
have: string;
covers?: string[];
note?: string;
}) {
const user = await requireUser();
const row = await transferables.createTransferable(user.id, input);
revalidatePath("/me");
return row;
}
export async function updateTransferableAction(
id: string,
patch: { have?: string; covers?: string[]; note?: string },
) {
const user = await requireUser();
await transferables.updateTransferable(user.id, id, patch);
revalidatePath("/me");
}
export async function deleteTransferableAction(id: string) {
const user = await requireUser();
await transferables.deleteTransferable(user.id, id);
revalidatePath("/me");
}