diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 43ce4e0..b54a1eb 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -36,6 +36,8 @@ import { bondAnnotationsRouter } from './routes/bond-annotations.js'; import { slaRouter } from './routes/sla.js'; import { developerRouter } from './routes/developer.js'; import { onboardingRouter } from './routes/onboarding.js'; +import { npsRouter } from './routes/nps.js'; +import { reportTemplatesRouter } from './routes/report-templates.js'; import { apiKeyUsageMeter } from './services/api-key-usage.js'; import { startApiKeyUsagePruneScheduler } from './jobs/prune-api-key-usage.js'; import { startOnboardingDripScheduler } from './services/onboarding-drip.js'; @@ -336,6 +338,8 @@ app.use('/bond-annotations', bondAnnotationsRouter); app.use('/sla', slaRouter); app.use('/developer', developerRouter); app.use('/onboarding', onboardingRouter); +app.use('/nps', npsRouter); +app.use('/report-templates', reportTemplatesRouter); app.use('/api/v1/regulatory', regulatoryRouter); app.use('/bonds', bondWebhookRouter); // unauthenticated DocuSign webhook app.use('/api', bondSignaturesRouter); // authenticated bond signature routes diff --git a/apps/api/src/migrations/0010_nps_survey_and_report_templates.ts b/apps/api/src/migrations/0010_nps_survey_and_report_templates.ts new file mode 100644 index 0000000..e04906f --- /dev/null +++ b/apps/api/src/migrations/0010_nps_survey_and_report_templates.ts @@ -0,0 +1,44 @@ +import type { PoolClient } from 'pg'; + +export const up = async (client: PoolClient): Promise => { + // ── #1035: In-App NPS/Feedback Survey ───────────────────────────────────── + await client.query(` + CREATE TABLE IF NOT EXISTS nps_survey_prompts ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + last_shown_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_dismissed_at TIMESTAMPTZ, + last_responded_at TIMESTAMPTZ + ); + + CREATE TABLE IF NOT EXISTS nps_survey_responses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + score SMALLINT NOT NULL CHECK (score BETWEEN 0 AND 10), + comment TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + CREATE INDEX IF NOT EXISTS idx_nps_survey_responses_created_at + ON nps_survey_responses(created_at DESC); + `); + + // ── #1032: Customizable Branded PDF Export Templates ────────────────────── + await client.query(` + CREATE TABLE IF NOT EXISTS report_templates ( + surety_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + logo_url TEXT, + header_text TEXT, + footer_text TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + `); +}; + +export const down = async (client: PoolClient): Promise => { + await client.query(` + DROP TABLE IF EXISTS nps_survey_responses CASCADE; + DROP TABLE IF EXISTS nps_survey_prompts CASCADE; + DROP TABLE IF EXISTS report_templates CASCADE; + `); +}; diff --git a/apps/api/src/routes/compliance.ts b/apps/api/src/routes/compliance.ts index d3722fd..27c4e22 100644 --- a/apps/api/src/routes/compliance.ts +++ b/apps/api/src/routes/compliance.ts @@ -8,6 +8,7 @@ import { tosReacceptanceGate, type AuthedRequest, } from '../auth.js'; +import { getReportTemplate } from './report-templates.js'; export const complianceRouter = Router(); complianceRouter.use(authMiddleware); @@ -301,5 +302,9 @@ complianceRouter.get('/reports/:id/download', async (req: Request, res: Response const key: string = reportRow.pdf_s3_key; // In production, generate a pre-signed S3 GetObject URL here. const url = `/dev/reports/${key}`; - res.json({ url, expiresInSeconds: 900 }); + // #1032: the tenant's branded export template — the PDF renderer applies + // logo/header/footer from this when generating the file at `key`; it does + // not change the underlying report data the PDF is built from. + const reportTemplate = await getReportTemplate(user.id); + res.json({ url, expiresInSeconds: 900, reportTemplate }); }); diff --git a/apps/api/src/routes/nps.ts b/apps/api/src/routes/nps.ts new file mode 100644 index 0000000..fff87cf --- /dev/null +++ b/apps/api/src/routes/nps.ts @@ -0,0 +1,123 @@ +import { Router, type Request, type Response } from 'express'; +import { z } from 'zod'; +import { pool } from '../db.js'; +import { + authMiddleware, + requireRole, + privacyReacceptanceGate, + tosReacceptanceGate, + type AuthedRequest, +} from '../auth.js'; + +// Issue #1035 — in-app NPS/feedback survey: prompt cadence, response +// collection, and the admin aggregate trend. +export const npsRouter = Router(); +npsRouter.use(authMiddleware); +npsRouter.use(privacyReacceptanceGate); +npsRouter.use(tosReacceptanceGate); + +// Minimum days between survey prompts for a given user. Configurable so the +// cadence can be tuned without a code change. +const PROMPT_CADENCE_DAYS = Math.max(1, Number(process.env.NPS_SURVEY_CADENCE_DAYS ?? '30')); + +npsRouter.get('/prompt-status', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + + const row = await pool.query<{ last_shown_at: string }>( + `SELECT last_shown_at FROM nps_survey_prompts WHERE user_id = $1`, + [user.id] + ); + + if (row.rowCount === 0) { + res.json({ shouldShow: true, cadenceDays: PROMPT_CADENCE_DAYS, lastShownAt: null }); + return; + } + + const lastShownAt = new Date(row.rows[0]!.last_shown_at); + const dueAt = new Date(lastShownAt.getTime() + PROMPT_CADENCE_DAYS * 24 * 60 * 60 * 1000); + res.json({ + shouldShow: dueAt.getTime() <= Date.now(), + cadenceDays: PROMPT_CADENCE_DAYS, + lastShownAt: lastShownAt.toISOString(), + }); +}); + +npsRouter.post('/dismiss', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + await pool.query( + `INSERT INTO nps_survey_prompts (user_id, last_shown_at, last_dismissed_at) + VALUES ($1, now(), now()) + ON CONFLICT (user_id) DO UPDATE SET last_shown_at = now(), last_dismissed_at = now()`, + [user.id] + ); + res.json({ success: true }); +}); + +const RespondSchema = z.object({ + score: z.number().int().min(0).max(10), + comment: z.string().max(2000).optional(), +}); + +npsRouter.post('/respond', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + + const parse = RespondSchema.safeParse(req.body); + if (!parse.success) { + res.status(400).json({ error: 'score (0-10) is required, comment is optional' }); + return; + } + + const { score, comment } = parse.data; + + await pool.query( + `INSERT INTO nps_survey_responses (user_id, score, comment) VALUES ($1, $2, $3)`, + [user.id, score, comment ?? null] + ); + await pool.query( + `INSERT INTO nps_survey_prompts (user_id, last_shown_at, last_responded_at) + VALUES ($1, now(), now()) + ON CONFLICT (user_id) DO UPDATE SET last_shown_at = now(), last_responded_at = now()`, + [user.id] + ); + + res.status(201).json({ success: true }); +}); + +// GET /nps/admin/trend — weekly NPS trend (promoters% - detractors%) for the admin dashboard. +npsRouter.get('/admin/trend', requireRole('surety_admin'), async (_req: Request, res: Response) => { + const rows = await pool.query<{ + week_start: string; + promoters: string; + passives: string; + detractors: string; + total: string; + }>( + `SELECT + date_trunc('week', created_at)::date AS week_start, + COUNT(*) FILTER (WHERE score >= 9)::text AS promoters, + COUNT(*) FILTER (WHERE score BETWEEN 7 AND 8)::text AS passives, + COUNT(*) FILTER (WHERE score <= 6)::text AS detractors, + COUNT(*)::text AS total + FROM nps_survey_responses + WHERE created_at >= now() - INTERVAL '26 weeks' + GROUP BY 1 + ORDER BY 1 ASC` + ); + + const trend = rows.rows.map((r) => { + const total = parseInt(r.total, 10); + const promoters = parseInt(r.promoters, 10); + const detractors = parseInt(r.detractors, 10); + const nps = total === 0 ? 0 : Math.round(((promoters - detractors) / total) * 100); + return { + weekStart: r.week_start, + promoters, + passives: parseInt(r.passives, 10), + detractors, + total, + nps, + }; + }); + + res.json({ trend }); +}); diff --git a/apps/api/src/routes/regulatory.ts b/apps/api/src/routes/regulatory.ts index 25a9bd7..7aaba6f 100644 --- a/apps/api/src/routes/regulatory.ts +++ b/apps/api/src/routes/regulatory.ts @@ -9,6 +9,7 @@ import { tosReacceptanceGate, type AuthedRequest, } from '../auth.js'; +import { getReportTemplate, type ReportTemplate } from './report-templates.js'; const logger = pino({ name: 'regulatory-report' }); @@ -96,6 +97,12 @@ regulatoryRouter.get('/state-report/:state_code', async (req: Request, res: Resp return; } + // #1032: branded export template (logo/header/footer) for this surety, applied + // to the underlying report data below — fetched fresh on every request (not + // cached alongside the report data) so a template edit is reflected + // immediately even while the report cache entry is still warm. + const template = await getReportTemplate(user.id); + // 3. Cache lookup const cacheKey = `${stateCode}:${user.id}:${startDate.getTime()}:${endDate.getTime()}:${format}`; const cachedData = getCached(cacheKey); @@ -108,9 +115,9 @@ regulatoryRouter.get('/state-report/:state_code', async (req: Request, res: Resp 'Content-Disposition', `attachment; filename="regulatory_report_${stateCode}.csv"` ); - res.send(cachedData); + res.send(applyCsvTemplate(cachedData, template)); } else { - res.json(cachedData); + res.json({ ...cachedData, reportTemplate: template }); } return; } @@ -230,7 +237,7 @@ regulatoryRouter.get('/state-report/:state_code', async (req: Request, res: Resp 'Compliance report generated for regulator' ); - // 7. Write to cache and send response + // 7. Write to cache (raw, un-templated) and send response setCache(cacheKey, responseData); res.setHeader('X-Cache', 'MISS'); @@ -240,8 +247,21 @@ regulatoryRouter.get('/state-report/:state_code', async (req: Request, res: Resp 'Content-Disposition', `attachment; filename="regulatory_report_${stateCode}.csv"` ); - res.send(responseData); + res.send(applyCsvTemplate(responseData, template)); } else { - res.json(responseData); + res.json({ ...responseData, reportTemplate: template }); } }); + +// #1032: wraps the CSV data rows with the tenant's configured header/footer +// text as leading/trailing comment lines. The data rows themselves — and +// their column order — are untouched, so this is safe for automated CSV +// ingestion that skips '#'-prefixed lines and purely cosmetic for a human +// opening the file directly. +function applyCsvTemplate(csvData: string, template: ReportTemplate): string { + const lines: string[] = []; + if (template.headerText) lines.push(`# ${template.headerText}`); + lines.push(csvData.trimEnd()); + if (template.footerText) lines.push(`# ${template.footerText}`); + return lines.join('\r\n') + '\r\n'; +} diff --git a/apps/api/src/routes/report-templates.ts b/apps/api/src/routes/report-templates.ts new file mode 100644 index 0000000..69ceffe --- /dev/null +++ b/apps/api/src/routes/report-templates.ts @@ -0,0 +1,92 @@ +import { Router, type Request, type Response } from 'express'; +import { z } from 'zod'; +import { pool } from '../db.js'; +import { + authMiddleware, + requireRole, + privacyReacceptanceGate, + tosReacceptanceGate, + type AuthedRequest, +} from '../auth.js'; + +// Issue #1032 — per-tenant branded report export templates, applied to the +// regulatory state-report and compliance report downloads. Template changes +// only affect presentation metadata (logo/header/footer) attached to an +// export — the underlying report data is computed identically either way. +export const reportTemplatesRouter = Router(); +reportTemplatesRouter.use(authMiddleware); +reportTemplatesRouter.use(privacyReacceptanceGate); +reportTemplatesRouter.use(tosReacceptanceGate); +reportTemplatesRouter.use(requireRole('surety_admin')); + +export interface ReportTemplate { + logoUrl: string | null; + headerText: string | null; + footerText: string | null; +} + +export const DEFAULT_REPORT_TEMPLATE: ReportTemplate = { + logoUrl: null, + headerText: 'TariffShield Compliance Report', + footerText: 'Generated by TariffShield — confidential, for regulatory use only.', +}; + +interface ReportTemplateRow { + logo_url: string | null; + header_text: string | null; + footer_text: string | null; +} + +export async function getReportTemplate(suretyId: string): Promise { + const row = await pool.query( + `SELECT logo_url, header_text, footer_text FROM report_templates WHERE surety_id = $1`, + [suretyId] + ); + if (row.rowCount === 0) return DEFAULT_REPORT_TEMPLATE; + const r = row.rows[0]!; + return { + logoUrl: r.logo_url ?? DEFAULT_REPORT_TEMPLATE.logoUrl, + headerText: r.header_text ?? DEFAULT_REPORT_TEMPLATE.headerText, + footerText: r.footer_text ?? DEFAULT_REPORT_TEMPLATE.footerText, + }; +} + +const TemplateSchema = z.object({ + logoUrl: z.string().url().max(2000).nullable().optional(), + headerText: z.string().max(200).nullable().optional(), + footerText: z.string().max(400).nullable().optional(), +}); + +// GET /report-templates — current tenant's saved template, falling back to the default. +reportTemplatesRouter.get('/', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + const template = await getReportTemplate(user.id); + res.json({ template, isDefault: template === DEFAULT_REPORT_TEMPLATE }); +}); + +// PUT /report-templates — upsert the tenant's template. +reportTemplatesRouter.put('/', async (req: Request, res: Response) => { + const user = (req as AuthedRequest).user; + + const parse = TemplateSchema.safeParse(req.body); + if (!parse.success) { + res.status(400).json({ error: 'invalid template fields', details: parse.error.issues }); + return; + } + + const { logoUrl, headerText, footerText } = parse.data; + + const result = await pool.query( + `INSERT INTO report_templates (surety_id, logo_url, header_text, footer_text) + VALUES ($1, $2, $3, $4) + ON CONFLICT (surety_id) DO UPDATE + SET logo_url = $2, header_text = $3, footer_text = $4, updated_at = now() + RETURNING logo_url, header_text, footer_text`, + [user.id, logoUrl ?? null, headerText ?? null, footerText ?? null] + ); + + const row = result.rows[0]!; + res.json({ + template: { logoUrl: row.logo_url, headerText: row.header_text, footerText: row.footer_text }, + }); +}); diff --git a/apps/web/app/app/page.tsx b/apps/web/app/app/page.tsx index 164c6e5..7aedd48 100644 --- a/apps/web/app/app/page.tsx +++ b/apps/web/app/app/page.tsx @@ -26,6 +26,10 @@ import { ComplianceExpirationCalendar } from '@/components/ComplianceExpirationC import { DashboardSkeleton } from '@/components/DashboardSkeleton'; import { Spinner } from '@/components/Spinner'; import { ErrorBanner } from '@/components/ErrorBanner'; +import { CurrencyDisplaySettings } from '@/components/CurrencyDisplaySettings'; +import { NpsSurvey } from '@/components/NpsSurvey'; +import { useDisplayCurrency } from '@/lib/useDisplayCurrency'; +import { formatConverted } from '@/lib/currency'; import { api, type Importer, @@ -50,6 +54,7 @@ function ImporterDashboard() { const [events, setEvents] = useState([]); const [refreshCount, setRefreshCount] = useState(0); const [showTopUpConfirm, setShowTopUpConfirm] = useState(false); + const displayCurrency = useDisplayCurrency(); const refresh = useCallback(async () => { try { @@ -188,7 +193,11 @@ function ImporterDashboard() { ) : null} -
+
+ +
+ +
@@ -198,6 +207,7 @@ function ImporterDashboard() { shortfall={shortfall} excess={excess} utilization={utilization} + rate={displayCurrency.rate} />
@@ -346,6 +356,11 @@ function ImporterDashboard() { /> + + {/* #1035: a fixed, non-modal corner card — never overlaps deposit/withdraw + controls in the page flow above, and stays hidden entirely while an + action (deposit, withdraw, top-up) is in flight. */} + {busy === null && } ); } @@ -364,11 +379,14 @@ const Stat = memo(function Stat({ value, hint, accent, + converted, }: { label: string; value: string; hint?: string; accent?: 'success' | 'danger'; + /** Issue #1037 — approximate display-currency equivalent shown alongside the base-token value. */ + converted?: string; }) { const color = accent === 'success' ? 'text-success' : accent === 'danger' ? 'text-danger' : 'text-foreground'; @@ -376,6 +394,7 @@ const Stat = memo(function Stat({

{label}

{value}

+ {converted ?

≈ {converted}

: null} {hint ?

{hint}

: null}
); @@ -392,11 +411,14 @@ const BalanceSummary = memo(function BalanceSummary({ shortfall, excess, utilization, + rate, }: { onChainAccount: ImporterDetail['onChainAccount']; shortfall: bigint; excess: bigint; utilization: number; + /** Issue #1037 — optional display-currency conversion, purely presentational. */ + rate?: import('@/lib/currency').ExchangeRate | null; }) { const formatted = useMemo( () => ({ @@ -420,13 +442,23 @@ const BalanceSummary = memo(function BalanceSummary({ return ( <>
- + 0n ? 'danger' : 'success'} + converted={rate ? formatConverted(onChainAccount.collateralBalance, rate) : undefined} + /> + - )}
+ +
+ + +
); diff --git a/apps/web/components/CurrencyDisplaySettings.tsx b/apps/web/components/CurrencyDisplaySettings.tsx new file mode 100644 index 0000000..7f87976 --- /dev/null +++ b/apps/web/components/CurrencyDisplaySettings.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { + SUPPORTED_CURRENCIES, + formatConverted, + type CurrencyCode, + type ExchangeRate, +} from '@/lib/currency'; +import { useDisplayCurrency } from '@/lib/useDisplayCurrency'; + +// Issue #1037 — account-settings-style control for an optional display +// currency, plus the rate disclosure required alongside it. Purely +// presentational: never touches a contract call. +export function CurrencyDisplaySettings({ + currency, + setCurrency, + rate, + loading, + error, +}: ReturnType) { + return ( +
+ + + {currency ? ( + loading ? ( + Fetching exchange rate… + ) : error ? ( + Couldn't load exchange rate: {error} + ) : rate ? ( + + ) : null + ) : ( + + Amounts show in XLM only. Pick a currency for an approximate conversion alongside it. + + )} +
+ ); +} + +function RateDisclosure({ rate }: { rate: ExchangeRate }) { + return ( + + 1 XLM ≈ {formatConverted('10000000', rate)} · source: {rate.source} · as of{' '} + {new Date(rate.asOf).toLocaleString()} + + ); +} diff --git a/apps/web/components/DepositWizard.tsx b/apps/web/components/DepositWizard.tsx index 636c5f0..5324015 100644 --- a/apps/web/components/DepositWizard.tsx +++ b/apps/web/components/DepositWizard.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { api } from '@/lib/api'; import { formatApiError, type FormattedError } from '@/lib/error-formatter'; +import { DepositWizardTour, useDepositWizardTour } from './DepositWizardTour'; type Step = 'amount' | 'preview' | 'confirm' | 'receipt'; @@ -23,6 +24,7 @@ export function DepositWizard({ const [xlm, setXlm] = useState('50'); const [txHash, setTxHash] = useState(null); const [busy, setBusy] = useState(false); + const tour = useDepositWizardTour(); function resetWizard() { setStep('amount'); @@ -55,6 +57,19 @@ export function DepositWizard({ return (
+
+ +
+ {tour.active && } + {step === 'amount' && ( <>
diff --git a/apps/web/components/DepositWizardTour.tsx b/apps/web/components/DepositWizardTour.tsx new file mode 100644 index 0000000..d75e7ee --- /dev/null +++ b/apps/web/components/DepositWizardTour.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { hasTourCompleted, markTourCompleted } from '@/lib/tour'; + +const TOUR_KEY = 'deposit-wizard'; + +const STEPS: { title: string; body: string }[] = [ + { + title: 'Deposit amount', + body: 'Enter how much XLM to send. Collateral deposits count toward your required collateral; reserve deposits fund the auto-top-up pool used to cover shortfalls automatically.', + }, + { + title: 'Required collateral', + body: '"Required collateral" is the minimum on-chain balance the surety needs, recalculated from your tariff exposure. Depositing here raises your posted collateral toward that requirement.', + }, + { + title: 'Auto-top-up threshold', + body: 'If collateral ever falls short, "auto_top_up" moves funds from your reserve pool to cover the gap — reserve deposits are what make that automatic transfer possible.', + }, +]; + +// Issue #1033: renders as a small dismissible card alongside the wizard +// rather than a page-covering modal, so it never blocks or delays the +// underlying deposit form/submission. +export function useDepositWizardTour() { + const [active, setActive] = useState(false); + + // First-time offer, unless already completed/dismissed. + useEffect(() => { + if (!hasTourCompleted(TOUR_KEY)) setActive(true); + }, []); + + return { + active, + launch: () => setActive(true), + close: () => { + setActive(false); + markTourCompleted(TOUR_KEY); + }, + }; +} + +export function DepositWizardTour({ onClose }: { onClose: () => void }) { + const [stepIdx, setStepIdx] = useState(0); + const step = STEPS[stepIdx]!; + const isLast = stepIdx === STEPS.length - 1; + + return ( +
+
+

{step.title}

+ +
+

{step.body}

+
+ + Step {stepIdx + 1} of {STEPS.length} + + +
+
+ ); +} diff --git a/apps/web/components/NpsSurvey.tsx b/apps/web/components/NpsSurvey.tsx new file mode 100644 index 0000000..e47662f --- /dev/null +++ b/apps/web/components/NpsSurvey.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { api } from '@/lib/api'; + +// Issue #1035 — lightweight in-app NPS/feedback survey. Cadence is +// server-controlled (GET /nps/prompt-status), so this never shows on every +// login. Rendered as a small, fixed, non-modal card that a caller can +// additionally suppress during an in-progress deposit/withdrawal flow. +export function NpsSurvey() { + const [visible, setVisible] = useState(false); + const [done, setDone] = useState(false); + const [score, setScore] = useState(null); + const [comment, setComment] = useState(''); + const [busy, setBusy] = useState(false); + + useEffect(() => { + let cancelled = false; + api + .npsPromptStatus() + .then((r) => { + if (!cancelled && r.shouldShow) setVisible(true); + }) + .catch(() => { + // Silently skip the survey if the status check fails — it's non-critical. + }); + return () => { + cancelled = true; + }; + }, []); + + if (!visible) return null; + + async function dismiss() { + setVisible(false); + try { + await api.npsDismiss(); + } catch { + // Best-effort: the survey stays dismissed for this session regardless. + } + } + + async function submit() { + if (score === null) return; + setBusy(true); + try { + await api.npsRespond(score, comment.trim() || undefined); + } catch { + // Best-effort: still show the thank-you state either way. + } finally { + setBusy(false); + setDone(true); + window.setTimeout(() => setVisible(false), 2500); + } + } + + return ( +
+ + + {done ? ( +

Thanks for the feedback! 🙌

+ ) : ( + <> +

+ How likely are you to recommend TariffShield to another importer? +

+
+ {Array.from({ length: 11 }, (_, n) => n).map((n) => ( + + ))} +
+
+ Not likely + Very likely +
+ +