fix(uat): batch — timeline overshoot, name-sync, reset-password, dashboard cleanup, queue/seed hygiene + alpha UAT findings doc
UAT findings landed across the last few Playwright + React Grab passes; single grouped commit so the index doesn't fragment into 30 one-liners. User & auth: - `user-settings`: name now updates the avatar + topbar menu after save (was reading stale session). - `me/password-reset`: 3 bugs (token validation, error response shape, redirect chain). - Admin user permission-overrides route honours the same envelope as the rest of the admin surface. Dashboard: - Removed obsolete `revenue-breakdown-chart` + `dashboard-widgets-card` (replaced by the customisable widget grid). - Strip `revenue_breakdown` from analytics route + use-analytics + service + integration test so nothing renders an empty card. - Activity log timeline overshoot fix (`interest-timeline` + `entity-activity-feed`). - Tightened tiles: active-deals, berth-heat-widget, pipeline-value, kpi-tile. - `dev-mode-banner`: derive dismissed state synchronously instead of via an effect (set-state-in-effect lint rule). Forms & lists (assorted polish): - client / company / yacht / interest / reminder forms — validation + empty-state copy + tab transitions. - companies/yachts list tweaks; berth recommender panel; qualification checklist; supplemental info request button. Infra & misc: - Queue workers (ai / email / notifications) — log shape + per-job timeout consistency. - Auth / brochures / users schema small adjustments; seeds reflect permissions matrix changes. - Scan shell + scanner manifest + AI admin page small fixes. - `next.config.transpilePackages` adds `echarts`/`zrender`/`echarts-for-react` (recommended config from echarts-for-react inside Next). Docs: - `docs/superpowers/audits/alpha-uat-master.md` — single rolling cross-cutting UAT findings doc (per CLAUDE.md convention). - `docs/BACKLOG.md`: dashboard stats cards (§I) + activity-log normalization (§J). - 2026-05-18 audit log updated with this batch. - `CLAUDE.md` — small manual UAT scaffold notes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -150,6 +150,13 @@ export const documentSends = pgTable(
|
||||
bounceStatus: text('bounce_status'), // 'hard' | 'soft' | 'ooo'
|
||||
bounceReason: text('bounce_reason'),
|
||||
bounceDetectedAt: timestamp('bounce_detected_at', { withTimezone: true }),
|
||||
// Phase 4b — email open tracking. When `trackOpens` is true the send
|
||||
// includes a 1×1 pixel pointing at /api/public/email-pixel/[sendId].
|
||||
// `firstOpenedAt` + `openCount` are denormalised aggregates so the
|
||||
// sends list can render an "opened" pill without a JOIN.
|
||||
trackOpens: boolean('track_opens').notNull().default(false),
|
||||
firstOpenedAt: timestamp('first_opened_at', { withTimezone: true }),
|
||||
openCount: integer('open_count').notNull().default(0),
|
||||
},
|
||||
(t) => [
|
||||
index('idx_ds_client').on(t.clientId, t.sentAt),
|
||||
@@ -164,9 +171,40 @@ export const documentSends = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Per-open log for emails with `trackOpens=true`. The 1×1 pixel
|
||||
* endpoint inserts here on every fetch (Apple Mail privacy proxy will
|
||||
* over-count; most other clients under-count when images are blocked —
|
||||
* this is the universal email-tracking caveat). Cached aggregates on
|
||||
* `document_sends` keep list rendering fast.
|
||||
*/
|
||||
export const documentSendOpens = pgTable(
|
||||
'document_send_opens',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id),
|
||||
sendId: text('send_id')
|
||||
.notNull()
|
||||
.references(() => documentSends.id, { onDelete: 'cascade' }),
|
||||
openedAt: timestamp('opened_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
userAgent: text('user_agent'),
|
||||
referer: text('referer'),
|
||||
},
|
||||
(t) => [
|
||||
index('idx_dso_send').on(t.sendId, t.openedAt),
|
||||
index('idx_dso_port').on(t.portId, t.openedAt),
|
||||
],
|
||||
);
|
||||
|
||||
export type Brochure = typeof brochures.$inferSelect;
|
||||
export type NewBrochure = typeof brochures.$inferInsert;
|
||||
export type BrochureVersion = typeof brochureVersions.$inferSelect;
|
||||
export type NewBrochureVersion = typeof brochureVersions.$inferInsert;
|
||||
export type DocumentSend = typeof documentSends.$inferSelect;
|
||||
export type NewDocumentSend = typeof documentSends.$inferInsert;
|
||||
export type DocumentSendOpen = typeof documentSendOpens.$inferSelect;
|
||||
export type NewDocumentSendOpen = typeof documentSendOpens.$inferInsert;
|
||||
|
||||
@@ -31,6 +31,15 @@ export type RolePermissions = {
|
||||
edit: boolean;
|
||||
import: boolean;
|
||||
manage_waiting_list: boolean;
|
||||
/**
|
||||
* Update berth `price` / `priceCurrency` via the dedicated single
|
||||
* (`PATCH /api/v1/berths/[id]/price`) and bulk
|
||||
* (`POST /api/v1/berths/bulk-update-prices`) endpoints. Carved out
|
||||
* from generic `berths.edit` so admins can grant sales reps the
|
||||
* ability to retune prices without exposing the full berth-edit
|
||||
* surface (dimensions, mooring type, etc.). Always audited.
|
||||
*/
|
||||
update_prices: boolean;
|
||||
};
|
||||
documents: {
|
||||
view: boolean;
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
* per-port fixture builders.
|
||||
*/
|
||||
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db } from './index';
|
||||
import { ports } from './schema/ports';
|
||||
import { roles, userProfiles } from './schema/users';
|
||||
import { systemSettings } from './schema/system';
|
||||
import {
|
||||
ALL_PERMISSIONS,
|
||||
DIRECTOR_PERMISSIONS,
|
||||
@@ -28,12 +29,21 @@ export interface BootstrappedPort {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
interface PortBrandingSeed {
|
||||
logoUrl?: string;
|
||||
emailBackgroundUrl?: string;
|
||||
appName?: string;
|
||||
emailHeaderHtml?: string;
|
||||
emailFooterHtml?: string;
|
||||
}
|
||||
|
||||
export const PORT_DEFINITIONS: Array<{
|
||||
name: string;
|
||||
slug: string;
|
||||
primaryColor: string;
|
||||
defaultCurrency: string;
|
||||
timezone: string;
|
||||
branding?: PortBrandingSeed;
|
||||
}> = [
|
||||
{
|
||||
name: 'Port Nimara',
|
||||
@@ -41,6 +51,12 @@ export const PORT_DEFINITIONS: Array<{
|
||||
primaryColor: '#0F4C81',
|
||||
defaultCurrency: 'USD',
|
||||
timezone: 'America/Anguilla',
|
||||
branding: {
|
||||
logoUrl:
|
||||
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png',
|
||||
emailBackgroundUrl: 'https://s3.portnimara.com/images/Overhead_1_blur.png',
|
||||
appName: 'Port Nimara CRM',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Port Amador',
|
||||
@@ -48,6 +64,8 @@ export const PORT_DEFINITIONS: Array<{
|
||||
primaryColor: '#D97706',
|
||||
defaultCurrency: 'USD',
|
||||
timezone: 'America/Panama',
|
||||
// Branding intentionally left empty — admin uploads their own assets
|
||||
// via /admin/branding rather than inheriting Port Nimara's look.
|
||||
},
|
||||
];
|
||||
|
||||
@@ -75,16 +93,51 @@ export async function seedBootstrap(): Promise<BootstrappedPort[]> {
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
|
||||
let portId: string | null = null;
|
||||
if (inserted) {
|
||||
console.log(` Port created: ${def.name} (${inserted.id})`);
|
||||
portIds.push({ id: inserted.id, name: def.name, slug: def.slug });
|
||||
portId = inserted.id;
|
||||
} else {
|
||||
const [existing] = await db.select().from(ports).where(eq(ports.slug, def.slug)).limit(1);
|
||||
if (existing) {
|
||||
console.log(` Port exists: ${def.name} (${existing.id})`);
|
||||
portIds.push({ id: existing.id, name: def.name, slug: def.slug });
|
||||
portId = existing.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Seed branding settings idempotently. `onConflictDoNothing` so an
|
||||
// admin who's tweaked the values via /admin/branding doesn't get
|
||||
// their work overwritten by a re-seed.
|
||||
if (portId && def.branding) {
|
||||
const brandingPairs: Array<[string, unknown]> = [];
|
||||
if (def.branding.logoUrl) brandingPairs.push(['branding_logo_url', def.branding.logoUrl]);
|
||||
if (def.branding.emailBackgroundUrl)
|
||||
brandingPairs.push(['branding_email_background_url', def.branding.emailBackgroundUrl]);
|
||||
if (def.branding.appName) brandingPairs.push(['branding_app_name', def.branding.appName]);
|
||||
if (def.branding.emailHeaderHtml)
|
||||
brandingPairs.push(['branding_email_header_html', def.branding.emailHeaderHtml]);
|
||||
if (def.branding.emailFooterHtml)
|
||||
brandingPairs.push(['branding_email_footer_html', def.branding.emailFooterHtml]);
|
||||
brandingPairs.push(['branding_primary_color', def.primaryColor]);
|
||||
|
||||
for (const [key, value] of brandingPairs) {
|
||||
// Skip when an existing row is already present — preserves admin
|
||||
// edits across re-seeds. Pair (key, portId) is uniquely indexed.
|
||||
const existing = await db.query.systemSettings.findFirst({
|
||||
where: and(eq(systemSettings.key, key), eq(systemSettings.portId, portId)),
|
||||
});
|
||||
if (existing) continue;
|
||||
await db.insert(systemSettings).values({
|
||||
key,
|
||||
value: value as Record<string, unknown>,
|
||||
portId,
|
||||
updatedBy: SUPER_ADMIN_USER_ID,
|
||||
});
|
||||
}
|
||||
console.log(` Branding seeded for ${def.name} (${brandingPairs.length} keys)`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── System roles ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -23,7 +23,7 @@ export const ALL_PERMISSIONS: RolePermissions = {
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
berths: { view: true, edit: true, import: true, manage_waiting_list: true },
|
||||
berths: { view: true, edit: true, import: true, manage_waiting_list: true, update_prices: true },
|
||||
documents: {
|
||||
view: true,
|
||||
create: true,
|
||||
@@ -102,7 +102,7 @@ export const DIRECTOR_PERMISSIONS: RolePermissions = {
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
berths: { view: true, edit: true, import: true, manage_waiting_list: true },
|
||||
berths: { view: true, edit: true, import: true, manage_waiting_list: true, update_prices: true },
|
||||
documents: {
|
||||
view: true,
|
||||
create: true,
|
||||
@@ -181,7 +181,7 @@ export const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
berths: { view: true, edit: true, import: false, manage_waiting_list: true },
|
||||
berths: { view: true, edit: true, import: false, manage_waiting_list: true, update_prices: true },
|
||||
documents: {
|
||||
view: true,
|
||||
create: true,
|
||||
@@ -260,7 +260,7 @@ export const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
berths: { view: true, edit: true, import: false, manage_waiting_list: true },
|
||||
berths: { view: true, edit: true, import: false, manage_waiting_list: true, update_prices: true },
|
||||
documents: {
|
||||
view: true,
|
||||
create: true,
|
||||
@@ -339,7 +339,13 @@ export const VIEWER_PERMISSIONS: RolePermissions = {
|
||||
generate_eoi: false,
|
||||
export: false,
|
||||
},
|
||||
berths: { view: true, edit: false, import: false, manage_waiting_list: false },
|
||||
berths: {
|
||||
view: true,
|
||||
edit: false,
|
||||
import: false,
|
||||
manage_waiting_list: false,
|
||||
update_prices: false,
|
||||
},
|
||||
documents: {
|
||||
view: true,
|
||||
create: false,
|
||||
@@ -421,7 +427,13 @@ export const RESIDENTIAL_PARTNER_PERMISSIONS: RolePermissions = {
|
||||
generate_eoi: false,
|
||||
export: false,
|
||||
},
|
||||
berths: { view: false, edit: false, import: false, manage_waiting_list: false },
|
||||
berths: {
|
||||
view: false,
|
||||
edit: false,
|
||||
import: false,
|
||||
manage_waiting_list: false,
|
||||
update_prices: false,
|
||||
},
|
||||
documents: {
|
||||
view: false,
|
||||
create: false,
|
||||
|
||||
Reference in New Issue
Block a user