"use server"; /** * Smartsheet Workspace — `/admin/water-log/settings` Smartsheet hub. * * Cycle 7. Replaces the two independent per-feature token columns * (water + time tracking) with one brand-level workbook connection. * * Permission model: * - Mutations require `can_manage_settings` (or platform_admin). * The hub card is a brand-level integration setting, not a * water-log concern, and shouldn't be locked behind * `can_manage_water_log`. * - Reads are open to any authenticated admin. * * Token handling: * - The plaintext token NEVER crosses the server-action boundary. * - Save actions accept a token via `input.token` only when the user * types a new one. An empty token means "keep the saved one". * - Read actions return `maskedToken` ("••••abcd") and never the * plaintext. */ import { eq } from "drizzle-orm"; import { withBrand } from "@/db/client"; import { smartsheetWorkspace } from "@/db/schema/smartsheet-workspace"; import { decryptToken, encryptToken, maskToken } from "@/lib/crypto"; import { getSheetMeta, SmartsheetApiError } from "@/lib/smartsheet"; import { getAdminUser } from "@/lib/admin-permissions"; // ── Public types ─────────────────────────────────────────────────────────── export type SmartsheetWorkspaceResponse = { /** True iff a workspace row exists for this brand. */ configured: boolean; /** Default sheet ID (the "home" tab). Null = use the first feature sheet. */ defaultSheetId: string | null; /** Master switch — pauses all per-feature syncs without losing config. */ connectionEnabled: boolean; /** Masked preview of the saved token ("••••abcd") or null. */ maskedToken: string | null; /** True iff a token is saved and decrypts cleanly. */ hasToken: boolean; /** Last successful test-connection timestamp. */ lastTestAt: string | null; /** Most recent test-connection error (sanitized). */ lastTestError: string | null; updatedAt: string | null; }; export type SaveSmartsheetWorkspaceInput = { /** Plaintext API token. Empty string = keep the saved token. */ token: string; defaultSheetId: string | null; connectionEnabled: boolean; }; export type TestSmartsheetWorkspaceResult = | { success: true; /** Name of the sheet the connection was tested against. */ sheetName: string; /** Number of columns in the sheet header. */ columnCount: number; columns: { id: string; title: string; type: string }[]; } | { success: false; error: string }; // ── Auth ─────────────────────────────────────────────────────────────────── async function requireManageSettings(): Promise< { ok: true; adminUser: NonNullable>> } | { ok: false; error: string } > { const adminUser = await getAdminUser(); if (!adminUser) return { ok: false, error: "Not authenticated" }; if ( !adminUser.can_manage_settings && adminUser.role !== "platform_admin" ) { return { ok: false, error: "Not authorized" }; } return { ok: true, adminUser }; } // ── Read ─────────────────────────────────────────────────────────────────── export async function getSmartsheetWorkspace( brandId: string, ): Promise { const auth = await requireManageSettings(); if (!auth.ok) { return { configured: false, defaultSheetId: null, connectionEnabled: true, maskedToken: null, hasToken: false, lastTestAt: null, lastTestError: null, updatedAt: null, }; } return withBrand(brandId, async (db) => { const rows = await db .select() .from(smartsheetWorkspace) .where(eq(smartsheetWorkspace.brandId, brandId)) .limit(1); const ws = rows[0]; if (!ws) { return { configured: false, defaultSheetId: null, connectionEnabled: true, maskedToken: null, hasToken: false, lastTestAt: null, lastTestError: null, updatedAt: null, }; } let masked: string | null = "••••"; let hasToken = true; try { const plain = decryptToken({ cipher: ws.encryptedApiToken, iv: ws.tokenIv, authTag: ws.tokenAuthTag, }); masked = maskToken(plain); } catch { hasToken = false; masked = null; } return { configured: true, defaultSheetId: ws.defaultSheetId, connectionEnabled: ws.connectionEnabled, maskedToken: masked, hasToken, lastTestAt: ws.lastTestAt?.toISOString() ?? null, lastTestError: ws.lastTestError, updatedAt: ws.updatedAt.toISOString(), }; }); } // ── Write: save (upsert) ─────────────────────────────────────────────────── export async function saveSmartsheetWorkspace( brandId: string, input: SaveSmartsheetWorkspaceInput, ): Promise< | { success: true; workspace: SmartsheetWorkspaceResponse } | { success: false; error: string } > { const auth = await requireManageSettings(); if (!auth.ok) return { success: false, error: auth.error }; const tokenTrimmed = input.token?.trim() ?? ""; return withBrand(brandId, async (db) => { const existingRows = await db .select() .from(smartsheetWorkspace) .where(eq(smartsheetWorkspace.brandId, brandId)) .limit(1); const existing = existingRows[0]; // First-time setup requires a token; subsequent saves can omit it // to keep the saved one. if (!existing && tokenTrimmed.length === 0) { return { success: false, error: "API token is required for first-time setup", }; } const updateValues: Partial = { defaultSheetId: input.defaultSheetId, connectionEnabled: input.connectionEnabled, updatedBy: auth.adminUser.email ?? auth.adminUser.id ?? null, }; if (tokenTrimmed.length > 0) { const enc = encryptToken(tokenTrimmed); updateValues.encryptedApiToken = enc.cipher; updateValues.tokenIv = enc.iv; updateValues.tokenAuthTag = enc.authTag; } if (existing) { await db .update(smartsheetWorkspace) .set(updateValues) .where(eq(smartsheetWorkspace.brandId, brandId)); } else { await db.insert(smartsheetWorkspace).values({ brandId, encryptedApiToken: updateValues.encryptedApiToken ?? Buffer.from([]), tokenIv: updateValues.tokenIv ?? Buffer.from([]), tokenAuthTag: updateValues.tokenAuthTag ?? Buffer.from([]), defaultSheetId: input.defaultSheetId, connectionEnabled: input.connectionEnabled, createdBy: auth.adminUser.email ?? auth.adminUser.id ?? null, updatedBy: auth.adminUser.email ?? auth.adminUser.id ?? null, }); } return { success: true, workspace: await getSmartsheetWorkspace(brandId), }; }); } // ── Test connection ──────────────────────────────────────────────────────── export async function testSmartsheetWorkspaceConnection( brandId: string, sheetIdInput: string, tokenInput: string, ): Promise { const auth = await requireManageSettings(); if (!auth.ok) return { success: false, error: auth.error }; if (!sheetIdInput) { return { success: false, error: "Sheet ID or URL is required" }; } // Resolve token: explicit > saved > error. let token = tokenInput?.trim() ?? ""; let lastTestError: string | null = null; if (!token) { const saved = await withBrand(brandId, async (db) => { const rows = await db .select() .from(smartsheetWorkspace) .where(eq(smartsheetWorkspace.brandId, brandId)) .limit(1); return rows[0] ?? null; }); if (!saved) { return { success: false, error: "No token saved yet — paste one above to test." }; } try { token = decryptToken({ cipher: saved.encryptedApiToken, iv: saved.tokenIv, authTag: saved.tokenAuthTag, }); } catch { return { success: false, error: "Saved token cannot be decrypted (key was rotated). Paste a fresh token to test." }; } } try { // We re-parse via `getSheetMeta` which calls `extractSheetId` // internally. Surface any share-link-slug error directly. const meta = await getSheetMeta(sheetIdInput, token); lastTestError = null; await withBrand(brandId, async (db) => { await db .update(smartsheetWorkspace) .set({ lastTestAt: new Date(), lastTestError: null }) .where(eq(smartsheetWorkspace.brandId, brandId)); }); return { success: true, sheetName: meta.name, columnCount: meta.columns.length, columns: meta.columns.map((c) => ({ id: c.id, title: c.title, type: c.type, })), }; } catch (err) { lastTestError = err instanceof Error ? err.message : String(err); // Sanitize: strip any token echoes. const sanitized = lastTestError.split(token).join("[redacted-token]"); await withBrand(brandId, async (db) => { await db .update(smartsheetWorkspace) .set({ lastTestAt: new Date(), lastTestError: sanitized }) .where(eq(smartsheetWorkspace.brandId, brandId)); }); if (err instanceof SmartsheetApiError) { if (err.status === 401) { return { success: false, error: "Token rejected by Smartsheet (401 — invalid or expired)" }; } if (err.status === 403) { return { success: false, error: "Forbidden (403) — token doesn't have access to this sheet" }; } if (err.status === 404) { return { success: false, error: "Sheet not found (404) — check the ID/URL. If you pasted a share URL with a slug, try the numeric sheet ID instead." }; } return { success: false, error: `Smartsheet ${err.status}: ${err.body.slice(0, 200)}` }; } return { success: false, error: sanitized }; } } // ── Disable (preserves token, turns off connection) ──────────────────────── export async function disableSmartsheetWorkspace( brandId: string, ): Promise<{ success: true } | { success: false; error: string }> { const auth = await requireManageSettings(); if (!auth.ok) return { success: false, error: auth.error }; return withBrand(brandId, async (db) => { await db .update(smartsheetWorkspace) .set({ connectionEnabled: false }) .where(eq(smartsheetWorkspace.brandId, brandId)); return { success: true }; }); } // ── Resolve token helper for sync services ──────────────────────────────── // // `resolveWorkspaceToken` lives in `src/services/workspace-token.ts` // (server-only, NOT a server action). Per-feature action files import // it from there directly — re-exporting through a "use server" file // would re-expose the plaintext-token helper as a callable RPC.