fc70344dab
Deploy to route.crispygoat.com / deploy (push) Successful in 5m34s
Per-brand Smartsheet integration that pushes new water_log_entries to a
configured sheet. Config UI lives at /admin/water-log/settings.
Database (0093_water_smartsheet_sync.sql):
- water_smartsheet_config: one row per brand; AES-256-GCM encrypted API
token (BYTEA), sheet id, column mapping JSONB, sync_frequency
(realtime | every_15_minutes | hourly), enable flag, last-sync
metadata. RLS via existing current_brand_id() pattern.
- water_smartsheet_sync_queue: one row per entry awaiting sync;
tracks status / attempts / exponential backoff. UNIQUE(brand_id,
entry_id) for idempotent enqueue.
- water_smartsheet_sync_log: append-only audit of every sync attempt
(success or failure); backs the Recent Activity panel in the UI.
Server:
- src/lib/crypto.ts: AES-256-GCM encrypt/decrypt + token mask helper.
Reads SMARTSHEET_TOKEN_ENC_KEY. Throws on missing/wrong-size key.
- src/lib/smartsheet.ts: typed REST wrapper (no SDK; the official
smartsheet npm has Node 22 compat issues). getSheetMeta, addRow,
findRowByColumn + typed SmartsheetApiError.
- src/services/smartsheet-sync.ts: syncEntryToSmartsheet (one entry),
drainSyncQueue (batch with backoff), runScheduledSync (cron entry).
Sequential processing stays well under Smartsheet's 300 req/min.
Max 5 attempts, then row stays 'failed' permanently.
- src/actions/water-log/smartsheet.ts: 6 server actions. Token never
returned in plaintext — UI gets maskedToken only. Permission check
matches the rest of the water-log module (can_manage_water_log).
- src/app/api/water-log/smartsheet-sync/route.ts: POST cron route,
bearer auth via SMARTSHEET_CRON_SECRET (falls back to CRON_SECRET).
Optional {brandId} body for manual retry.
- vercel.json: added */15 * * * * cron entry. Single entry covers
both 15-min and hourly frequencies (route filters per-brand).
UI:
- src/components/admin/water-log/SmartsheetIntegrationCard.tsx:
self-contained client component with useReducer. Sections: enable
toggle, sheet URL/ID + API token + Test Connection, sync frequency
radio group, column mapping dropdowns (populated after Test),
Recent Activity panel.
- src/app/admin/water-log/settings/page.tsx: mounted the card as new
'§ 05 — Integrations' section after the existing admin-PIN settings.
Card takes brandId as a prop (CLAUDE.md 'Brand ID Threading').
Hooks:
- src/actions/water-log/field.ts::submitWaterEntry: calls
triggerSyncForEntry(brandId, entryId) in a fire-and-forget void
after the entry insert. Sync failures NEVER bubble to the field
worker.
Env vars (.env.example):
- SMARTSHEET_TOKEN_ENC_KEY (REQUIRED, 32 random bytes base64)
- SMARTSHEET_CRON_SECRET (optional bearer)
- SMARTSHEET_SYNC_TIMEOUT_MS (optional, default 8000)
Deploy:
1. openssl rand -base64 32 → SMARTSHEET_TOKEN_ENC_KEY in .env.local
AND Vercel dashboard
2. npm run migrate:one 93 (pushes 0093_water_smartsheet_sync.sql)
3. Push triggers .gitea/workflows/deploy.yml
Rollback: remove cron entry, DROP the 3 tables, revert field.ts +
settings page hooks, git revert the merge commit. See MEMORY.md
for full rollback story + sharp edges (no key rotation, slug URLs).
65 lines
2.0 KiB
JSON
65 lines
2.0 KiB
JSON
{
|
|
"framework": "nextjs",
|
|
"buildCommand": "npm run build",
|
|
"devCommand": "npm run dev",
|
|
"installCommand": "npm install",
|
|
"regions": ["iad1"],
|
|
"crons": [
|
|
{
|
|
"path": "/api/wholesale/webhooks/dispatch",
|
|
"schedule": "*/12 * * * *"
|
|
},
|
|
{
|
|
"path": "/api/wholesale/notifications/send",
|
|
"schedule": "*/5 * * * *"
|
|
},
|
|
{
|
|
"path": "/api/wholesale/notifications/pickup-reminder",
|
|
"schedule": "0 8 * * *"
|
|
},
|
|
{
|
|
"path": "/api/square/process-queue",
|
|
"schedule": "*/2 * * * *"
|
|
},
|
|
{
|
|
"path": "/api/email-automation/abandoned-cart",
|
|
"schedule": "0 */6 * * *"
|
|
},
|
|
{
|
|
"path": "/api/email-automation/welcome-sequence",
|
|
"schedule": "0 */6 * * *"
|
|
},
|
|
{
|
|
"path": "/api/cron/send-scheduled",
|
|
"schedule": "0 9 * * *"
|
|
},
|
|
{
|
|
"path": "/api/water-log/smartsheet-sync",
|
|
"schedule": "*/15 * * * *"
|
|
}
|
|
],
|
|
"headers": [
|
|
{
|
|
"source": "/api/(.*)",
|
|
"headers": [
|
|
{ "key": "X-Content-Type-Options", "value": "nosniff" },
|
|
{ "key": "X-Frame-Options", "value": "DENY" },
|
|
{ "key": "X-XSS-Protection", "value": "1; mode=block" },
|
|
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }
|
|
]
|
|
},
|
|
{
|
|
"source": "/(.*)",
|
|
"headers": [
|
|
{ "key": "X-DNS-Prefetch-Control", "value": "on" },
|
|
{ "key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains; preload" },
|
|
{ "key": "X-Permitted-Cross-Domain-Policies", "value": "none" },
|
|
{ "key": "Content-Security-Policy", "value": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://*.supabase.co https://*.resend.com https://*.stripe.com" }
|
|
]
|
|
}
|
|
],
|
|
"rewrites": [
|
|
{ "source": "/api/stripe/webhook", "destination": "/api/stripe/webhook" },
|
|
{ "source": "/api/resend/webhook", "destination": "/api/resend/webhook" }
|
|
]
|
|
} |