Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions apps/api/src/migrations/0010_nps_survey_and_report_templates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { PoolClient } from 'pg';

export const up = async (client: PoolClient): Promise<void> => {
// ── #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<void> => {
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;
`);
};
7 changes: 6 additions & 1 deletion apps/api/src/routes/compliance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
tosReacceptanceGate,
type AuthedRequest,
} from '../auth.js';
import { getReportTemplate } from './report-templates.js';

export const complianceRouter = Router();
complianceRouter.use(authMiddleware);
Expand Down Expand Up @@ -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 });
});
123 changes: 123 additions & 0 deletions apps/api/src/routes/nps.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
30 changes: 25 additions & 5 deletions apps/api/src/routes/regulatory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });

Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
Expand Down Expand Up @@ -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');

Expand All @@ -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';
}
92 changes: 92 additions & 0 deletions apps/api/src/routes/report-templates.ts
Original file line number Diff line number Diff line change
@@ -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<ReportTemplate> {
const row = await pool.query<ReportTemplateRow>(
`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 },
});
});
Loading