Ctrl K
ComunidadGuías…

src/server/actions.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"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");
}