Files
route-commerce/src/actions/smartsheet/workspace.ts
T
RouteComm Dev ad4d3c9976 feat(smartsheet): cycle 7 — workbook hub
Replaces two independent Smartsheet configs (water log + time tracking)
with one brand-level workbook connection that owns the encrypted API
token. Per-feature configs keep their sheet_id + column_mapping +
frequency + sync_enabled.

Schema (migration 0096):
- NEW smartsheet_workspace: one row per brand. Holds encrypted_api_token
  + token_iv + token_auth_tag (AES-256-GCM, same key), default_sheet_id,
  connection_enabled (master switch), last_test_*, audit columns. RLS
  brand-scoped.
- Backfill: water_smartsheet_config rows copy into workspace first;
  time_tracking_smartsheet_config fills in only brands with no workspace
  row yet. Idempotent (NOT EXISTS + ON CONFLICT DO NOTHING).
- DROP encrypted_api_token + token_iv + token_auth_tag from both feature
  configs (destructive — bytes are copied to workspace first).
- Drop unused bytea customType from both schemas (no longer needed).

Actions:
- NEW src/actions/smartsheet/workspace.ts: getSmartsheetWorkspace,
  saveSmartsheetWorkspace, testSmartsheetWorkspaceConnection,
  disableSmartsheetWorkspace. Permission: can_manage_settings +
  platform_admin.
- NEW src/services/workspace-token.ts (server-only): resolveWorkspaceToken
  helper. Lives outside the 'use server' file so the plaintext token
  is not exposed as an RPC surface — only callable from server-side
  sync engines.
- MODIFIED water-log + time-tracking smartsheet actions: refactored to
  read token via resolveWorkspaceToken; save* no longer accepts a
  token; test* falls through to the workspace token if no token given.
- MODIFIED both sync services: load token from resolveWorkspaceToken
  instead of decrypting the per-feature config. 'connection_disabled'
  now skips cleanly (no retry, no failed log row) — pausing the
  workspace at the hub level no longer floods Recent Activity with
  'disabled' failures.

UI:
- NEW src/components/admin/water-log/SmartsheetHub.tsx: top-level hub
  card. Owns the token + Test Connection + connection enabled toggle +
  default sheet ID + last-verified badge. Permission gated.
- MODIFIED SmartsheetIntegrationCard.tsx (water): rewritten without
  token UI. Reads masked token from workspace. Sheet ID + column
  mapping + frequency + enabled + backfill + recent activity.
- MODIFIED TimeTrackingSmartsheetCard.tsx: same treatment.
- MODIFIED /admin/water-log/settings/page: collapses §05 + §06 into a
  single Smartsheet section with the hub on top + both feature cards
  as siblings underneath.

Tuxedo-only throughout (TUXEDO_BRAND_ID hardcoded in the settings
page; matches existing single-tenant /water convention).

Security fixes from pr-reviewer:
- resolveWorkspaceToken extracted from 'use server' file to a
  server-only service module — was reachable as an RPC, would have
  returned the plaintext token to any authenticated admin.
- getSmartsheetWorkspace now actually checks the auth result instead
  of calling requireManageSettings() and discarding the return value.

Diff: -1742 / +597 lines (net -1100 from the simpler per-feature card).
2026-07-03 19:18:49 -06:00

324 lines
11 KiB
TypeScript

"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<Awaited<ReturnType<typeof getAdminUser>>> } | { 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<SmartsheetWorkspaceResponse> {
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<typeof smartsheetWorkspace.$inferInsert> = {
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<TestSmartsheetWorkspaceResult> {
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.