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
27 changes: 17 additions & 10 deletions app/components/agreements/SignerList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { m } from "~/paraglide/messages";
* via data-color-scheme (no hardcoded colors).
*/

type SignerStatus = "pending" | "sent" | "viewed" | "signed" | "declined" | "expired";
type SignerStatus = "pending" | "sent" | "viewed" | "signed" | "declined" | "expired" | "delivery_failed";

export interface SignerRow {
id: string;
Expand All @@ -37,15 +37,20 @@ export function isTerminal(status: string): boolean {
type ChipTone = "sat" | "monitor" | "defect" | "info" | "neutral";

/** Status → (tone, label) for the per-signer status chip. */
export function statusChip(status: string): { tone: ChipTone; label: string } {
export function statusChip(status: string): { tone: ChipTone; label: string; tooltip?: string } {
switch (status) {
case "signed": return { tone: "sat", label: "Signed" };
case "viewed": return { tone: "info", label: "Viewed" };
case "sent": return { tone: "monitor", label: "Sent" };
case "declined": return { tone: "defect", label: "Declined" };
case "expired": return { tone: "neutral", label: "Expired" };
case "pending": return { tone: "neutral", label: "Pending" };
default: return { tone: "neutral", label: status.charAt(0).toUpperCase() + status.slice(1) };
case "signed": return { tone: "sat", label: "Signed" };
case "viewed": return { tone: "info", label: "Viewed" };
case "sent": return { tone: "monitor", label: "Sent" };
case "declined": return { tone: "defect", label: "Declined" };
case "expired": return { tone: "neutral", label: "Expired" };
case "pending": return { tone: "neutral", label: "Pending" };
case "delivery_failed": return {
tone: "defect",
label: m.agreement_signers_status_delivery_failed(),
tooltip: m.agreement_signers_status_delivery_failed_tooltip(),
};
default: return { tone: "neutral", label: status.charAt(0).toUpperCase() + status.slice(1) };
}
}

Expand Down Expand Up @@ -145,7 +150,9 @@ export function SignerList({ signers, onRemind, onCopyLink, busy, nowMs }: Signe
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[13px] font-semibold text-ih-fg-1 truncate">{s.name}</span>
<Pill tone="neutral">{roleLabel(s.role)}</Pill>
<Pill tone={chip.tone}>{chip.label}</Pill>
<span title={chip.tooltip}>
<Pill tone={chip.tone}>{chip.label}</Pill>
</span>
{s.channel === "in_person" && <Pill tone="info">{m.agreement_signers_in_person()}</Pill>}
</div>
<div className="text-[12px] text-ih-fg-3 mt-0.5 truncate">{s.email}</div>
Expand Down
2 changes: 1 addition & 1 deletion app/routes/agent/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ export default function AgentDashboardPage() {
const groups = new Map<string, { label: string; rows: Referral[]; recency: number }>();
for (const r of visible) {
const key = propertyGroupKey(r.propertyAddress, r.id);
const g = groups.get(key) || { label: r.propertyAddress?.trim() || m.agent_portal_no_address(), rows: [], recency: -Infinity };
const g = groups.get(key) || { label: r.propertyAddress?.trim() || m.agent_portal_no_address(), rows: [] as Referral[], recency: -Infinity };
if (welcomeReferral && r.id === welcomeReferral.id) g.rows.unshift(r);
else g.rows.push(r);
g.recency = Math.max(g.recency, inspectionDateValue(r.date));
Expand Down
2 changes: 1 addition & 1 deletion app/routes/agent/repair-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ export function groupByProperty(items: RepairItemRow[]): PropertySection[] {
key,
label: row.propertyAddress?.trim() || m.agent_portal_no_address(),
recency: -Infinity,
blocks: [],
blocks: [] as InspectionBlock[],
};
let block = section.blocks.find((b) => b.inspectionId === row.inspectionId);
if (!block) {
Expand Down
8 changes: 4 additions & 4 deletions docs/reference/database-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ from the Drizzle definitions in `server/lib/db/schema/` — the two that
| Columns | 1263 |
| Indexes (excluding primary keys) | 183 |
| Database foreign keys (all legacy, frozen) | 51 |
| Columns carrying a source comment | 609 (48%) |
| Columns carrying a source comment | 610 (48%) |

**Tables without `tenant_id`.** Every table holding tenant data must carry it —
`npm run lint:tenant-scope` is the gate. These are the tables that are not *about*
Expand Down Expand Up @@ -105,8 +105,8 @@ neither is left blank. `[more]` marks a column whose source comment runs past
| `agreement_id` | text | NN FK→`agreements.id` | | | *App-layer reference to another row — no database foreign key.* |
| `client_email` | text | NN | | | *An email address.* |
| `client_name` | text | | | | *A name.* |
| `status` | text | NN | `'pending'` | `pending, sent, viewed, signed, declined, expired` | *State-machine column — see the Values column for the vocabulary.* |
| `signed_at` | integer | | | | Envelope completion time — THAT it completed and WHEN, which is the envelope's own fact. The signature is not: it belongs to the person who made it and lives on their `agreement_signers` row. |
| `status` | text | NN | `'pending'` | `pending, sent, viewed, signed, declined, expired, delivery_failed` | *State-machine column — see the Values column for the vocabulary.* |
| `signed_at` | integer | | | | envelope's own fact. The signature is not: it belongs to the person who made it and lives on their `agreement_signers` row. |
| `viewed_at` | integer | | | | *Timestamp, epoch milliseconds. NULL means it has not happened.* |
| `sent_at` | integer | | | | *Timestamp, epoch milliseconds. NULL means it has not happened.* |
| `last_error` | text | | | | The decline REASON, truncated to 500 chars. Written only by markDeclinedBySigner, and only when the signer's decline actually drags the envelope aggregate to 'declined' — a decline that leaves a 'one'-policy envelope live records nothing here. **[more]** |
Expand Down Expand Up @@ -149,7 +149,7 @@ neither is left blank. `[more]` marks a column whose source comment runs past
| `contact_id` | text | | | | → contacts.id (app-layer, optional) |
| `token_hash` | text | UQ | | | SHA-256 hex; NULL on backfilled rows until first link build |
| `token_enc` | text | | | | 't1:iv:cipher' sealed plaintext (config-crypto sealToken) |
| `status` | text | NN | `'pending'` | `pending, sent, viewed, signed, declined, expired` | *State-machine column — see the Values column for the vocabulary.* |
| `status` | text | NN | `'pending'` | `pending, sent, viewed, signed, declined, expired, delivery_failed` | `delivery_failed` marks a signer whose email was rejected at the provider level (transient failure or unclassified error). |
| `signature_base64` | text | | | | The drawn signature image. Bare base64 OR a full `data:` URL — both are accepted, and agreements-render prefixes the bare form when composing the signed PDF. |
| `signed_at` | integer | | | | *Timestamp, epoch milliseconds. NULL means it has not happened.* |
| `viewed_at` | integer | | | | *Timestamp, epoch milliseconds. NULL means it has not happened.* |
Expand Down
4 changes: 3 additions & 1 deletion messages/en/checkout.json
Original file line number Diff line number Diff line change
Expand Up @@ -191,5 +191,7 @@
"agreement_send_add": "+ Add signer",
"agreement_send_completion_legend": "Completion",
"agreement_send_policy_all": "Everyone must sign",
"agreement_send_policy_one": "Any one signature completes it"
"agreement_send_policy_one": "Any one signature completes it",
"agreement_signers_status_delivery_failed": "Not delivered",
"agreement_signers_status_delivery_failed_tooltip": "Email could not be delivered to this address. Use Remind to try again, or update the address."
}
4 changes: 3 additions & 1 deletion messages/es-419/checkout.json
Original file line number Diff line number Diff line change
Expand Up @@ -191,5 +191,7 @@
"agreement_send_add": "+ Agregar firmante",
"agreement_send_completion_legend": "Avance",
"agreement_send_policy_all": "Todos deben firmar",
"agreement_send_policy_one": "Basta con una sola firma para completarlo"
"agreement_send_policy_one": "Basta con una sola firma para completarlo",
"agreement_signers_status_delivery_failed": "No entregado",
"agreement_signers_status_delivery_failed_tooltip": "No se pudo entregar el correo a esta dirección. Use Recordar para intentarlo de nuevo, o actualice la dirección."
}
18 changes: 9 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -268,12 +268,12 @@
"overrides": {
"esbuild": "^0.28.1",
"uuid": "^11.1.1",
"undici": "^7.29.0",
"undici": "^7.30.0",
"ws": "^8.21.0",
"sharp": ">=0.35.4",
"@hono/node-server": ">=2.0.5",
"brace-expansion": "^5.0.9",
"ip-address": ">=10.2.2",
"brace-expansion": "^5.0.12",
"ip-address": ">=10.5.1",
"fast-uri": "^3.1.6",
"qs": "^6.16.0",
"@humanfs/node": "^0.16.8",
Expand Down
6 changes: 4 additions & 2 deletions scripts/file-size-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"scripts/check-idempotency-coverage.mjs": 797,
"app/routes/settings-communication.tsx": 783,
"server/services/inspection.service.ts": 770,
"server/api/inspections/report-delivery.ts": 750,
"server/api/inspections/report-delivery.ts": 756,
"server/services/inspection/inspection-analytics.service.ts": 748,
"server/api/admin/admin-settings.ts": 747,
"server/index.ts": 747,
Expand All @@ -26,7 +26,7 @@
"app/components/editor/ItemEditor.tsx": 651,
"app/components/inspection-edit/CompliancePanel.tsx": 638,
"server/api/public-report.ts": 628,
"server/api/auth.ts": 614,
"server/api/auth.ts": 621,
"app/hooks/useInspection.ts": 600,
"server/services/concierge.service.ts": 596,
"server/api/bookings/agreement.ts": 583,
Expand Down Expand Up @@ -82,6 +82,8 @@
"server/services/agreement/signer-state.ts": 413,
"app/components/portal/sections/ReportView.tsx": 410,
"app/routes/team.tsx": 405,
"server/api/admin/admin-data.ts": 406,
"server/services/email/base.ts": 430,
"scripts/check-non-translatable.mjs": 402,
"playwright.config.ts": 401
}
15 changes: 14 additions & 1 deletion server/api/admin/admin-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import { InspectorAuditLogSchema } from '../../lib/validations/audit-log-write.s
import { erasureLog } from '../../lib/db/schema';
import { withMcpMetadata } from "../../lib/route-metadata-standards";
import { getDrizzle } from '../../lib/route-helpers';
import { AppError } from '../../lib/errors';
import { logger } from '../../lib/logger';


/**
Expand Down Expand Up @@ -281,7 +283,18 @@ const adminDataRoutes = createApiRouter()
const inviteLink = inviteAcceptUrl(c, inviteId);

const emailPromise = c.var.services.email.sendInvitation(body.email, inviteLink)
.catch(() => { /* email delivery is best-effort */ });
.catch((e: unknown) => {
// The invite row and link are already created; the admin has the
// inviteLink in the response and can share it manually. But we
// want to distinguish a platform-level refusal (domain unverified,
// quota exhausted, address suppressed) from a transient failure so
// ops can act on it rather than assuming everything is fine.
if (e instanceof AppError) {
logger.warn('admin.invite.email.refused', { code: e.code });
} else {
logger.error('admin.invite.email.failed', {}, e instanceof Error ? e : undefined);
}
});
c.executionCtx.waitUntil(emailPromise);

return c.json({ success: true, data: { inviteLink, expiresAt: expiresAt.toISOString() } }, 201);
Expand Down
13 changes: 10 additions & 3 deletions server/api/admin/admin-esign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { auditFromContext } from '../../lib/audit';
import { getBookingHost, resolveTenantSlug } from '../../lib/url';
import { lookupSenderSignature, buildSignUrl } from '../../lib/signature-helpers';
import { getTenantId, getDrizzle } from '../../lib/route-helpers';
import { Errors } from '../../lib/errors';
import { Errors, AppError } from '../../lib/errors';
import { logger } from '../../lib/logger';
import { agreements as agreementsTable, agreementRequests as agreementRequestsTable } from '../../lib/db/schema';
import { withMcpMetadata } from "../../lib/route-metadata-standards";
Expand Down Expand Up @@ -357,8 +357,15 @@ const adminEsignRoutes = createApiRouter()
const token = await svc.getSignerLink(tenantId, requestId, signerId);
const signUrl = await buildSignUrl(c, tenantId, row.inspectionId, tenantSlug, token);
const sigInspector = await lookupSenderSignature(c, tenantId);
await c.var.services.email.sendAgreementRequest(row.email, row.name, 'Agreement', signUrl, sigInspector, getBookingHost(c))
.catch((e: unknown) => logger.error('Failed to send agreement reminder', {}, e instanceof Error ? e : undefined));
try {
await c.var.services.email.sendAgreementRequest(row.email, row.name, 'Agreement', signUrl, sigInspector, getBookingHost(c));
} catch (e: unknown) {
// Platform-level refusals (cooling window, suppressed address, domain
// unverified, quota exhausted) must surface to the caller so the admin
// sees why the reminder wasn't sent rather than getting a silent 200.
if (e instanceof AppError) throw e;
logger.error('Failed to send agreement reminder', {}, e instanceof Error ? e : undefined);
}

await db.update(schema.agreementSigners).set({ lastRemindedAt: new Date(now) })
.where(eqDz(schema.agreementSigners.id, signerId));
Expand Down
3 changes: 2 additions & 1 deletion server/api/agreements-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
signaturesRecordCurrentDisclosure,
} from '../lib/legal/agreement-language-disclosure';
import { safeISODate } from '../lib/date';
import { logger } from '../lib/logger';

/**
* Human-readable UTC string from a Drizzle-hydrated date value (Date object, or
Expand Down Expand Up @@ -52,7 +53,7 @@ function verifyQrHtml(
`</div>`;
} catch (e) {
// QR generation failure is non-fatal; render without it.
console.warn(`${logPrefix} QR generation failed`, { error: (e as Error).message });
logger.warn(`${logPrefix} QR generation failed`, { error: (e as Error).message });
return '';
}
}
Expand Down
11 changes: 9 additions & 2 deletions server/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createApiRouter } from '../lib/openapi-router';
import { and, eq, isNull, sql } from 'drizzle-orm';
import { users } from '../lib/db/schema';
import { setCookie } from 'hono/cookie';
import { Errors } from '../lib/errors';
import { Errors, AppError } from '../lib/errors';
import { logger } from '../lib/logger';
import { getDeploymentProfile } from '../lib/deployment-profile';
import { getBaseUrl } from '../lib/url';
Expand Down Expand Up @@ -509,7 +509,14 @@ const coreAuthRoutes = createApiRouter()
const resetLink = `${baseUrl}/reset-password?token=${resetToken}`;

await c.var.services.email.sendPasswordReset(body.email, resetLink)
.catch(() => { /* email delivery is best-effort */ });
.catch((e: unknown) => {
// Platform-level refusals (suppressed address, domain unverified,
// quota exhausted) are actionable: surface them so the user knows
// the reset email was refused rather than waiting forever.
if (e instanceof AppError) throw e;
// Transient failures are best-effort — the user can retry.
logger.warn('[auth] password-reset email delivery failed (transient)', { error: e instanceof Error ? e.message : String(e) });
});

return c.json({ success: true }, 200);
})
Expand Down
8 changes: 7 additions & 1 deletion server/api/inspections/report-delivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { auditFromContext } from '../../lib/audit';
import { getBookingHost, getBaseUrl, resolveTenantSlug } from '../../lib/url';
import { reportUrl as buildReportUrl, buildRenderReportUrl, paymentUrl } from '../../lib/public-urls';
import { buildPortalUrl } from '../../lib/portal-urls';
import { Errors } from '../../lib/errors';
import { Errors, AppError } from '../../lib/errors';
import { logger } from '../../lib/logger';
import { createApiResponseSchema } from '../../lib/validations/shared.schema';
import {
Expand Down Expand Up @@ -450,6 +450,12 @@ const reportDeliveryRoutes = createApiRouter()
sentTo.push(recipientEmail);
await logManualSend({ recipient: recipientEmail, contactId: recipient.contactId ?? null, roleKey: recipient.roleKey, status: 'sent' });
} catch (err) {
// Platform-level refusals (cooling window, quota exhausted, domain
// unverified, address suppressed) apply to ALL recipients — abort
// the batch and let the route return a 4xx so the caller knows why
// nothing was sent, rather than silently counting every recipient
// as a per-recipient skip.
if (err instanceof AppError) throw err;
logger.error('[send-report-pdf] recipient send failed', { inspectionId: id, recipient: recipientLabel }, err instanceof Error ? err : undefined);
const reason = err instanceof Error ? err.message : 'Send failed';
skipped.push({ recipient: recipientLabel, reason });
Expand Down
Loading
Loading