Compare commits
16 Commits
082d4f20e3
...
4552187b9f
| Author | SHA1 | Date | |
|---|---|---|---|
| 4552187b9f | |||
| d0c12d74e4 | |||
| 7313d8b3d0 | |||
| c5c45accfc | |||
| 9a0c28020d | |||
| 44982a2878 | |||
| ae19170da8 | |||
| f90dba036f | |||
| 59dd418542 | |||
| f659073b8f | |||
| a8b93fd862 | |||
| 8df8ded46c | |||
| 4fdd9e3207 | |||
| c8320023cc | |||
| f60159e91a | |||
| a13d7503cc |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,6 +11,7 @@ drizzle/*.sql
|
||||
coverage/
|
||||
.turbo/
|
||||
out/
|
||||
dist/
|
||||
test-results/
|
||||
playwright-report/
|
||||
nginx/certs/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
pnpm exec lint-staged
|
||||
npx pnpm exec lint-staged
|
||||
# Verify no .env files staged
|
||||
if git diff --cached --name-only | grep -qE '\.env($|\.)'; then
|
||||
echo "❌ .env files must not be committed"
|
||||
|
||||
91
CLAUDE.md
Normal file
91
CLAUDE.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Port Nimara CRM
|
||||
|
||||
Multi-tenant CRM for marina/port management. Built with Next.js 15 App Router (standalone output), React 19, TypeScript (strict), Tailwind CSS 3, and Drizzle ORM on PostgreSQL.
|
||||
|
||||
## Quick reference
|
||||
|
||||
```bash
|
||||
pnpm dev # Start dev server
|
||||
pnpm build # Production build
|
||||
pnpm lint # ESLint
|
||||
pnpm format # Prettier
|
||||
pnpm db:generate # Generate Drizzle migrations
|
||||
pnpm db:push # Push schema to DB
|
||||
pnpm db:studio # Drizzle Studio GUI
|
||||
pnpm db:seed # Seed database (tsx src/lib/db/seed.ts)
|
||||
```
|
||||
|
||||
## Tech stack
|
||||
|
||||
- **Framework:** Next.js 15.1 App Router, `output: 'standalone'`, `experimental.typedRoutes`
|
||||
- **Auth:** better-auth (session cookie: `pn-crm.session_token`)
|
||||
- **Database:** PostgreSQL via `postgres` driver + Drizzle ORM
|
||||
- **Queue:** BullMQ + Redis (ioredis)
|
||||
- **Storage:** MinIO (S3-compatible)
|
||||
- **Realtime:** Socket.IO with Redis adapter
|
||||
- **UI:** Radix UI primitives, shadcn/ui components (`src/components/ui/`), Lucide icons, CVA + tailwind-merge + clsx
|
||||
- **Forms:** react-hook-form + zod resolvers
|
||||
- **Tables:** TanStack Table
|
||||
- **State:** Zustand stores (`src/stores/`), TanStack React Query
|
||||
- **PDF:** pdfme
|
||||
- **Email:** nodemailer + imapflow + mailparser
|
||||
- **AI:** OpenAI SDK (optional)
|
||||
- **Testing:** Vitest (unit), Playwright (e2e)
|
||||
- **Logging:** pino + pino-pretty
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
src/
|
||||
app/
|
||||
(auth)/ # Login/auth pages
|
||||
(dashboard)/ # Main app - route: /[portSlug]/...
|
||||
(portal)/ # Client portal
|
||||
api/ # API routes
|
||||
components/
|
||||
ui/ # shadcn/ui base components
|
||||
layout/ # Shell, sidebar, header
|
||||
[domain]/ # Domain components (clients, invoices, berths, etc.)
|
||||
shared/ # Cross-domain shared components
|
||||
hooks/ # React hooks (use-auth, use-permissions, use-socket, etc.)
|
||||
lib/
|
||||
api/ # API client utilities
|
||||
auth/ # better-auth config
|
||||
db/
|
||||
schema/ # Drizzle schema (one file per domain)
|
||||
migrations/ # Generated Drizzle migrations
|
||||
env.ts # Zod env validation (SKIP_ENV_VALIDATION=1 bypasses)
|
||||
services/ # Business logic services
|
||||
validators/ # Zod schemas for API input validation
|
||||
utils/ # Shared utilities
|
||||
middleware.ts # Auth middleware (cookie check, redirects)
|
||||
providers/ # React context providers
|
||||
stores/ # Zustand stores
|
||||
types/ # Shared TypeScript types
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- **TypeScript:** Strict mode with `noUncheckedIndexedAccess`. No `any` (ESLint error).
|
||||
- **Formatting:** Prettier - single quotes, semicolons, trailing commas, 2-space indent, 100 char line width.
|
||||
- **Lint:** ESLint flat config extending `next/core-web-vitals`, `next/typescript`, `prettier`. Unused vars prefixed with `_` are allowed.
|
||||
- **Imports:** Use `@/*` path alias (maps to `src/*`).
|
||||
- **Components:** shadcn/ui pattern - base components in `src/components/ui/`, domain components in `src/components/[domain]/`.
|
||||
- **DB schema:** One file per domain in `src/lib/db/schema/`, re-exported from `index.ts`. Relations in `relations.ts`.
|
||||
- **Routes:** Multi-tenant via `[portSlug]` dynamic segment. Typed routes enabled.
|
||||
- **Pre-commit:** Husky + lint-staged runs ESLint fix + Prettier on staged `.ts`/`.tsx` files.
|
||||
|
||||
## Environment
|
||||
|
||||
Copy `.env.example` to `.env` for local dev. See `src/lib/env.ts` for the full schema. Set `SKIP_ENV_VALIDATION=1` to bypass validation (used in Docker build).
|
||||
|
||||
## Docker
|
||||
|
||||
- `Dockerfile` - Production multi-stage build (deps -> build -> runner)
|
||||
- `Dockerfile.dev` - Dev with bind-mounted source
|
||||
- `Dockerfile.worker` - BullMQ worker process
|
||||
- `docker-compose.yml` / `docker-compose.dev.yml` / `docker-compose.prod.yml`
|
||||
|
||||
## Architecture docs
|
||||
|
||||
Numbered spec files in repo root (`01-CONSOLIDATED-SYSTEM-SPEC.md` through `15-DESIGN-TOKENS.md`) contain detailed architecture decisions, feature specs, DB schema docs, API catalog, and implementation sequence.
|
||||
@@ -12,6 +12,7 @@ WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV SKIP_ENV_VALIDATION=1
|
||||
RUN pnpm build
|
||||
|
||||
# Stage 3: Production runner
|
||||
@@ -23,6 +24,7 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/dist/server.js ./server-custom.js
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
CMD ["node", "server-custom.js"]
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
# Stage 1: Install production dependencies
|
||||
# Stage 1: Install dependencies (dev deps needed for esbuild)
|
||||
FROM node:20-alpine AS deps
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
RUN pnpm install --frozen-lockfile --prod=false
|
||||
|
||||
# Stage 2: Production runner
|
||||
FROM node:20-alpine AS runner
|
||||
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 worker
|
||||
# Stage 2: Build the worker bundle
|
||||
FROM node:20-alpine AS builder
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY dist/worker.js ./worker.js
|
||||
COPY . .
|
||||
ENV SKIP_ENV_VALIDATION=1
|
||||
RUN pnpm build:worker
|
||||
|
||||
# Stage 3: Production runner (prod deps only)
|
||||
FROM node:20-alpine AS runner
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 worker
|
||||
COPY --from=builder --chown=worker:nodejs /app/dist/worker.js ./worker.js
|
||||
USER worker
|
||||
CMD ["node", "worker.js"]
|
||||
|
||||
1119
docs/superpowers/plans/2026-04-14-inquiry-notifications.md
Normal file
1119
docs/superpowers/plans/2026-04-14-inquiry-notifications.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,201 @@
|
||||
# Inquiry Notifications System Design
|
||||
|
||||
Migrates the ActivePieces-powered inquiry notification flow into the CRM. When a client registers interest via the Port Nimara website, the system sends a confirmation email to the client and notifies the sales team -- all using the CRM's own database and email infrastructure instead of NocoDB + ActivePieces.
|
||||
|
||||
## Scope
|
||||
|
||||
- Expand the public interest API to accept all website form fields
|
||||
- Add client address storage (multi-address with primary flag)
|
||||
- Send branded confirmation email to the client
|
||||
- Send notification to sales team (CRM users + optional external recipients)
|
||||
- Make notification recipients and contact email configurable by admins
|
||||
|
||||
## Database Changes
|
||||
|
||||
### New table: `client_addresses`
|
||||
|
||||
| Column | Type | Notes |
|
||||
| ---------------- | ----------------- | ---------------------------------------------------------------- |
|
||||
| `id` | uuid PK | `crypto.randomUUID()` |
|
||||
| `client_id` | uuid FK → clients | cascade delete |
|
||||
| `port_id` | uuid FK → ports | cascade delete |
|
||||
| `label` | text | e.g., "Home", "Office", "Billing" |
|
||||
| `street_address` | text | |
|
||||
| `city` | text | |
|
||||
| `state_province` | text | |
|
||||
| `postal_code` | text | |
|
||||
| `country` | text | |
|
||||
| `is_primary` | boolean | default `true`, one-primary-per-client enforced in service layer |
|
||||
| `created_at` | timestamp | default `now()` |
|
||||
| `updated_at` | timestamp | default `now()` |
|
||||
|
||||
Schema file: `src/lib/db/schema/clients.ts` (alongside existing client tables).
|
||||
Relations: added to `src/lib/db/schema/relations.ts` (client has many addresses).
|
||||
|
||||
### No changes to existing tables
|
||||
|
||||
- `clients.preferred_contact_method` already exists -- we populate it from the form.
|
||||
- `interests.berth_id` already exists -- we resolve `mooringNumber` to a berth and link it.
|
||||
- `notifications.type` already has `new_registration` -- we fire it.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
### `POST /api/public/interests`
|
||||
|
||||
Expanded request schema:
|
||||
|
||||
```typescript
|
||||
// Required
|
||||
firstName: string; // max 100
|
||||
lastName: string; // max 100
|
||||
email: string; // email format
|
||||
phone: string;
|
||||
|
||||
// Optional
|
||||
preferredContactMethod: 'email' | 'phone' | 'sms';
|
||||
mooringNumber: string; // e.g., "A3" -- resolved against berths.mooring_number
|
||||
companyName: string;
|
||||
yachtName: string;
|
||||
yachtLengthFt: number;
|
||||
yachtWidthFt: number;
|
||||
yachtDraftFt: number;
|
||||
preferredBerthSize: string;
|
||||
notes: string; // max 2000
|
||||
address: {
|
||||
street: string;
|
||||
city: string;
|
||||
stateProvince: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
// Backward compatibility
|
||||
fullName: string; // accepted if firstName/lastName not provided
|
||||
```
|
||||
|
||||
Backward compatibility: if `fullName` is provided without `firstName`/`lastName`, it is used as-is for `clients.full_name`. If `firstName`+`lastName` are provided, they are concatenated.
|
||||
|
||||
### Behavior after record creation
|
||||
|
||||
1. Resolve `mooringNumber` against `berths.mooring_number` for the port. Link `interests.berth_id` if found; leave null if not.
|
||||
2. Store `address` in `client_addresses` with `is_primary: true` and `label: 'Primary'`.
|
||||
3. Set `clients.preferred_contact_method` from the form value.
|
||||
4. Queue client confirmation email (see Email Templates below).
|
||||
5. Fire `new_registration` notifications to sales team (see Notification Flow below).
|
||||
6. Return `201 { data: { id, message } }` unchanged.
|
||||
|
||||
Rate limiting remains 5 requests/hour per IP.
|
||||
|
||||
## Email Templates
|
||||
|
||||
Located in `src/lib/email/templates/`. Each exports a function that accepts a typed data object and returns `{ subject: string, html: string, text: string }`.
|
||||
|
||||
### `inquiry-client-confirmation.ts`
|
||||
|
||||
Sent to the client who submitted the form.
|
||||
|
||||
**Input data:**
|
||||
|
||||
- `firstName` -- for the greeting
|
||||
- `mooringNumber` -- berth identifier (nullable)
|
||||
- `contactEmail` -- from `inquiry_contact_email` system setting
|
||||
|
||||
**Subject:** "Thank You for Your Interest in Berth {mooringNumber}" or "Thank You for Your Interest in a Port Nimara Berth" if no berth.
|
||||
|
||||
**Body:** Greeting with first name, confirmation their interest is registered, mention they'll be contacted by preferred method, link to the contact email address.
|
||||
|
||||
**Styling:** Branded -- Port Nimara logo, background image, white card layout. Matches the existing ActivePieces client confirmation template.
|
||||
|
||||
### `inquiry-sales-notification.ts`
|
||||
|
||||
Sent to CRM users and optional external recipients.
|
||||
|
||||
**Input data:**
|
||||
|
||||
- `fullName`
|
||||
- `email`
|
||||
- `phone`
|
||||
- `mooringNumber` (nullable, defaults to "None")
|
||||
- `crmUrl` -- link to the interest detail page in the CRM (built from port slug + interest ID)
|
||||
|
||||
**Subject:** "New Interest - Port Nimara"
|
||||
|
||||
**Body:** Notifies that a new interest has been registered, shows client details and berth selected, links to the CRM.
|
||||
|
||||
**Styling:** Branded -- Port Nimara logo, background image, white card layout. Matches the existing ActivePieces admin notification template.
|
||||
|
||||
Both templates include a plain-text fallback.
|
||||
|
||||
## Notification & Delivery Flow
|
||||
|
||||
### Client confirmation email
|
||||
|
||||
1. After record creation, queue a `send-inquiry-confirmation` job on the `email` BullMQ queue.
|
||||
2. Email worker renders the `inquiry-client-confirmation` template with the interest data.
|
||||
3. Sends via system SMTP (`src/lib/email/index.ts`).
|
||||
4. No in-app notification (client is not a CRM user).
|
||||
|
||||
### Sales team notification
|
||||
|
||||
1. Query all users on the port who have `interests` read permission via their role.
|
||||
2. For each user, call `createNotification()` with type `new_registration`.
|
||||
- The existing notification service checks `user_notification_preferences` (in-app / email / both / neither).
|
||||
- Creates in-app notification + Socket.IO push if `in_app: true`.
|
||||
- Queues `send-notification-email` job if `email: true`.
|
||||
3. Fetch `inquiry_notification_recipients` system setting for the port.
|
||||
4. For each external email, queue a `send-inquiry-sales-notification` job on the `email` queue (bypasses notification preferences since these are not CRM users).
|
||||
|
||||
### Independence
|
||||
|
||||
Client confirmation and sales notifications are independent -- a failure in one does not block the other. The `201` response returns immediately after record creation, before any emails are sent.
|
||||
|
||||
## Admin Configuration
|
||||
|
||||
Two new system settings, managed via the existing admin settings UI:
|
||||
|
||||
### `inquiry_contact_email` (string, per-port)
|
||||
|
||||
The reply-to / contact email shown in client confirmation emails.
|
||||
|
||||
- Default: `sales@portnimara.com`
|
||||
- Displayed as a mailto link in the client confirmation email.
|
||||
|
||||
### `inquiry_notification_recipients` (JSON array of strings, per-port)
|
||||
|
||||
Additional external email addresses that receive the sales team notification.
|
||||
|
||||
- Default: `[]` (empty)
|
||||
- Only CRM users with interests permissions are notified by default.
|
||||
- External recipients receive the sales notification email directly.
|
||||
|
||||
### Existing infrastructure (no changes needed)
|
||||
|
||||
- **Which CRM users get notified**: controlled by roles/permissions.
|
||||
- **How each user receives notifications**: `user_notification_preferences` table.
|
||||
- **Admin settings UI**: already supports custom key-value pairs in `system_settings`.
|
||||
|
||||
## Files to Create or Modify
|
||||
|
||||
### New files
|
||||
|
||||
- `src/lib/db/schema/client-addresses.ts` -- (or added to `clients.ts`)
|
||||
- `src/lib/email/templates/inquiry-client-confirmation.ts`
|
||||
- `src/lib/email/templates/inquiry-sales-notification.ts`
|
||||
|
||||
### Modified files
|
||||
|
||||
- `src/lib/db/schema/clients.ts` -- add `clientAddresses` table export
|
||||
- `src/lib/db/schema/index.ts` -- re-export new table
|
||||
- `src/lib/db/schema/relations.ts` -- add client addresses relations
|
||||
- `src/lib/validators/public-interest.ts` (or wherever `publicInterestSchema` lives) -- expand schema
|
||||
- `src/app/api/public/interests/route.ts` -- berth resolution, address storage, notification + email triggers
|
||||
- `src/lib/queue/workers/email.ts` -- handle `send-inquiry-confirmation` and `send-inquiry-sales-notification` jobs
|
||||
- `src/lib/services/interests.service.ts` -- helper to find users with interests permissions on a port
|
||||
- `src/app/(dashboard)/[portSlug]/admin/settings/settings-manager.tsx` -- register the two new setting keys
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Editing email templates from the admin UI (templates are in code).
|
||||
- Supplemental forms for collecting missing info (separate feature using existing `form_templates` / `form_submissions` infrastructure).
|
||||
- Documenso EOI integration with address merge fields (separate feature).
|
||||
- Changes to the Port Nimara website form itself (website team wires the form to our API).
|
||||
@@ -4,7 +4,9 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"build": "next build && pnpm build:server",
|
||||
"build:server": "esbuild src/server.ts --bundle --platform=node --target=node20 --format=cjs --outdir=dist --packages=external --tsconfig=tsconfig.server.json",
|
||||
"build:worker": "esbuild src/worker.ts --bundle --platform=node --target=node20 --format=cjs --outdir=dist --packages=external --tsconfig=tsconfig.server.json",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,json,css}\"",
|
||||
@@ -89,6 +91,7 @@
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"esbuild": "^0.25.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"drizzle-kit": "^0.30.0",
|
||||
"eslint": "^9.0.0",
|
||||
|
||||
333
pnpm-lock.yaml
generated
333
pnpm-lock.yaml
generated
@@ -103,7 +103,7 @@ importers:
|
||||
version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
better-auth:
|
||||
specifier: ^1.2.0
|
||||
version: 1.5.5(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(@types/react@19.2.14)(kysely@0.28.11)(postgres@3.4.8)(react@19.2.4))(mongodb@7.1.0(socks@2.8.7))(next@15.1.0(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.19.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)))
|
||||
version: 1.5.5(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(@types/react@19.2.14)(kysely@0.28.11)(postgres@3.4.8)(react@19.2.4))(mongodb@7.1.0(socks@2.8.7))(next@15.1.0(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)))
|
||||
bullmq:
|
||||
specifier: ^5.25.0
|
||||
version: 5.71.0
|
||||
@@ -221,7 +221,7 @@ importers:
|
||||
version: 19.2.3(@types/react@19.2.14)
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0(vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.19.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)))
|
||||
version: 4.1.0(vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)))
|
||||
autoprefixer:
|
||||
specifier: ^10.4.27
|
||||
version: 10.4.27(postcss@8.5.8)
|
||||
@@ -231,6 +231,9 @@ importers:
|
||||
drizzle-kit:
|
||||
specifier: ^0.30.0
|
||||
version: 0.30.6
|
||||
esbuild:
|
||||
specifier: ^0.25.0
|
||||
version: 0.25.12
|
||||
eslint:
|
||||
specifier: ^9.0.0
|
||||
version: 9.39.4(jiti@1.21.7)
|
||||
@@ -263,7 +266,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.19.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))
|
||||
version: 4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -469,6 +472,12 @@ packages:
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.12':
|
||||
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.4':
|
||||
resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -487,6 +496,12 @@ packages:
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm64@0.27.4':
|
||||
resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -505,6 +520,12 @@ packages:
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.25.12':
|
||||
resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.27.4':
|
||||
resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -523,6 +544,12 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.25.12':
|
||||
resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.27.4':
|
||||
resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -541,6 +568,12 @@ packages:
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-arm64@0.27.4':
|
||||
resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -559,6 +592,12 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.25.12':
|
||||
resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.27.4':
|
||||
resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -577,6 +616,12 @@ packages:
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.27.4':
|
||||
resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -595,6 +640,12 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.25.12':
|
||||
resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.27.4':
|
||||
resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -613,6 +664,12 @@ packages:
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm64@0.27.4':
|
||||
resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -631,6 +688,12 @@ packages:
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.25.12':
|
||||
resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.27.4':
|
||||
resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -649,6 +712,12 @@ packages:
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.25.12':
|
||||
resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.27.4':
|
||||
resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -667,6 +736,12 @@ packages:
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.25.12':
|
||||
resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.27.4':
|
||||
resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -685,6 +760,12 @@ packages:
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.25.12':
|
||||
resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.27.4':
|
||||
resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -703,6 +784,12 @@ packages:
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.25.12':
|
||||
resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.27.4':
|
||||
resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -721,6 +808,12 @@ packages:
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.25.12':
|
||||
resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.27.4':
|
||||
resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -739,6 +832,12 @@ packages:
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.25.12':
|
||||
resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.27.4':
|
||||
resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -757,12 +856,24 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.25.12':
|
||||
resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.27.4':
|
||||
resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.27.4':
|
||||
resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -781,12 +892,24 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.25.12':
|
||||
resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.27.4':
|
||||
resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.27.4':
|
||||
resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -805,12 +928,24 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.25.12':
|
||||
resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.27.4':
|
||||
resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openharmony-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@esbuild/openharmony-arm64@0.27.4':
|
||||
resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -829,6 +964,12 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/sunos-x64@0.25.12':
|
||||
resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/sunos-x64@0.27.4':
|
||||
resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -847,6 +988,12 @@ packages:
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-arm64@0.27.4':
|
||||
resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -865,6 +1012,12 @@ packages:
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.25.12':
|
||||
resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.27.4':
|
||||
resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -883,6 +1036,12 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.25.12':
|
||||
resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.27.4':
|
||||
resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -989,67 +1148,79 @@ packages:
|
||||
resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.0.5':
|
||||
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.0.4':
|
||||
resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.0.4':
|
||||
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.0.4':
|
||||
resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.0.4':
|
||||
resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linux-arm64@0.33.5':
|
||||
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-arm@0.33.5':
|
||||
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-s390x@0.33.5':
|
||||
resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-x64@0.33.5':
|
||||
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.33.5':
|
||||
resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.33.5':
|
||||
resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-wasm32@0.33.5':
|
||||
resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
|
||||
@@ -1149,24 +1320,28 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-arm64-musl@15.1.0':
|
||||
resolution: {integrity: sha512-Qn6vOuwaTCx3pNwygpSGtdIu0TfS1KiaYLYXLH5zq1scoTXdwYfdZtwvJTpB1WrLgiQE2Ne2kt8MZok3HlFqmg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-linux-x64-gnu@15.1.0':
|
||||
resolution: {integrity: sha512-yeNh9ofMqzOZ5yTOk+2rwncBzucc6a1lyqtg8xZv0rH5znyjxHOWsoUtSq4cUTeeBIiXXX51QOOe+VoCjdXJRw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-x64-musl@15.1.0':
|
||||
resolution: {integrity: sha512-t9IfNkHQs/uKgPoyEtU912MG6a1j7Had37cSUyLTKx9MnUpjj+ZDKw9OyqTI9OwIIv0wmkr1pkZy+3T5pxhJPg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@15.1.0':
|
||||
resolution: {integrity: sha512-WEAoHyG14t5sTavZa1c6BnOIEukll9iqFRTavqRVPfYmfegOAd5MaZfXgOGG6kGo1RduyGdTHD4+YZQSdsNZXg==}
|
||||
@@ -1919,36 +2094,42 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.9':
|
||||
resolution: {integrity: sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9':
|
||||
resolution: {integrity: sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9':
|
||||
resolution: {integrity: sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.9':
|
||||
resolution: {integrity: sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.0.0-rc.9':
|
||||
resolution: {integrity: sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.0.0-rc.9':
|
||||
resolution: {integrity: sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==}
|
||||
@@ -2216,41 +2397,49 @@ packages:
|
||||
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
|
||||
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
|
||||
@@ -3124,6 +3313,11 @@ packages:
|
||||
engines: {node: '>=12'}
|
||||
hasBin: true
|
||||
|
||||
esbuild@0.25.12:
|
||||
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
esbuild@0.27.4:
|
||||
resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -3816,24 +4010,28 @@ packages:
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.32.0:
|
||||
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.32.0:
|
||||
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
||||
@@ -5687,6 +5885,9 @@ snapshots:
|
||||
'@esbuild/aix-ppc64@0.19.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.4':
|
||||
optional: true
|
||||
|
||||
@@ -5696,6 +5897,9 @@ snapshots:
|
||||
'@esbuild/android-arm64@0.19.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.27.4':
|
||||
optional: true
|
||||
|
||||
@@ -5705,6 +5909,9 @@ snapshots:
|
||||
'@esbuild/android-arm@0.19.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.27.4':
|
||||
optional: true
|
||||
|
||||
@@ -5714,6 +5921,9 @@ snapshots:
|
||||
'@esbuild/android-x64@0.19.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.27.4':
|
||||
optional: true
|
||||
|
||||
@@ -5723,6 +5933,9 @@ snapshots:
|
||||
'@esbuild/darwin-arm64@0.19.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.27.4':
|
||||
optional: true
|
||||
|
||||
@@ -5732,6 +5945,9 @@ snapshots:
|
||||
'@esbuild/darwin-x64@0.19.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.27.4':
|
||||
optional: true
|
||||
|
||||
@@ -5741,6 +5957,9 @@ snapshots:
|
||||
'@esbuild/freebsd-arm64@0.19.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.27.4':
|
||||
optional: true
|
||||
|
||||
@@ -5750,6 +5969,9 @@ snapshots:
|
||||
'@esbuild/freebsd-x64@0.19.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.27.4':
|
||||
optional: true
|
||||
|
||||
@@ -5759,6 +5981,9 @@ snapshots:
|
||||
'@esbuild/linux-arm64@0.19.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.27.4':
|
||||
optional: true
|
||||
|
||||
@@ -5768,6 +5993,9 @@ snapshots:
|
||||
'@esbuild/linux-arm@0.19.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.27.4':
|
||||
optional: true
|
||||
|
||||
@@ -5777,6 +6005,9 @@ snapshots:
|
||||
'@esbuild/linux-ia32@0.19.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.27.4':
|
||||
optional: true
|
||||
|
||||
@@ -5786,6 +6017,9 @@ snapshots:
|
||||
'@esbuild/linux-loong64@0.19.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.27.4':
|
||||
optional: true
|
||||
|
||||
@@ -5795,6 +6029,9 @@ snapshots:
|
||||
'@esbuild/linux-mips64el@0.19.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.27.4':
|
||||
optional: true
|
||||
|
||||
@@ -5804,6 +6041,9 @@ snapshots:
|
||||
'@esbuild/linux-ppc64@0.19.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.27.4':
|
||||
optional: true
|
||||
|
||||
@@ -5813,6 +6053,9 @@ snapshots:
|
||||
'@esbuild/linux-riscv64@0.19.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.27.4':
|
||||
optional: true
|
||||
|
||||
@@ -5822,6 +6065,9 @@ snapshots:
|
||||
'@esbuild/linux-s390x@0.19.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.27.4':
|
||||
optional: true
|
||||
|
||||
@@ -5831,9 +6077,15 @@ snapshots:
|
||||
'@esbuild/linux-x64@0.19.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.27.4':
|
||||
optional: true
|
||||
|
||||
@@ -5843,9 +6095,15 @@ snapshots:
|
||||
'@esbuild/netbsd-x64@0.19.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.27.4':
|
||||
optional: true
|
||||
|
||||
@@ -5855,9 +6113,15 @@ snapshots:
|
||||
'@esbuild/openbsd-x64@0.19.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openharmony-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openharmony-arm64@0.27.4':
|
||||
optional: true
|
||||
|
||||
@@ -5867,6 +6131,9 @@ snapshots:
|
||||
'@esbuild/sunos-x64@0.19.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.27.4':
|
||||
optional: true
|
||||
|
||||
@@ -5876,6 +6143,9 @@ snapshots:
|
||||
'@esbuild/win32-arm64@0.19.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.27.4':
|
||||
optional: true
|
||||
|
||||
@@ -5885,6 +6155,9 @@ snapshots:
|
||||
'@esbuild/win32-ia32@0.19.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.27.4':
|
||||
optional: true
|
||||
|
||||
@@ -5894,6 +6167,9 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.19.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.27.4':
|
||||
optional: true
|
||||
|
||||
@@ -7215,7 +7491,7 @@ snapshots:
|
||||
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
|
||||
optional: true
|
||||
|
||||
'@vitest/coverage-v8@4.1.0(vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.19.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)))':
|
||||
'@vitest/coverage-v8@4.1.0(vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)))':
|
||||
dependencies:
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
'@vitest/utils': 4.1.0
|
||||
@@ -7227,7 +7503,7 @@ snapshots:
|
||||
obug: 2.1.1
|
||||
std-env: 4.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.19.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))
|
||||
vitest: 4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))
|
||||
|
||||
'@vitest/expect@4.1.0':
|
||||
dependencies:
|
||||
@@ -7238,13 +7514,13 @@ snapshots:
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/mocker@4.1.0(vite@8.0.0(@types/node@22.19.15)(esbuild@0.19.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
'@vitest/mocker@4.1.0(vite@8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.0
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 8.0.0(@types/node@22.19.15)(esbuild@0.19.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
'@vitest/pretty-format@4.1.0':
|
||||
dependencies:
|
||||
@@ -7519,7 +7795,7 @@ snapshots:
|
||||
|
||||
baseline-browser-mapping@2.10.8: {}
|
||||
|
||||
better-auth@1.5.5(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(@types/react@19.2.14)(kysely@0.28.11)(postgres@3.4.8)(react@19.2.4))(mongodb@7.1.0(socks@2.8.7))(next@15.1.0(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.19.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))):
|
||||
better-auth@1.5.5(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(@types/react@19.2.14)(kysely@0.28.11)(postgres@3.4.8)(react@19.2.4))(mongodb@7.1.0(socks@2.8.7))(next@15.1.0(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))):
|
||||
dependencies:
|
||||
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@3.25.76))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)
|
||||
'@better-auth/drizzle-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@3.25.76))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.38.4(@types/react@19.2.14)(kysely@0.28.11)(postgres@3.4.8)(react@19.2.4))
|
||||
@@ -7545,7 +7821,7 @@ snapshots:
|
||||
next: 15.1.0(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
vitest: 4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.19.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))
|
||||
vitest: 4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))
|
||||
transitivePeerDependencies:
|
||||
- '@cloudflare/workers-types'
|
||||
|
||||
@@ -8155,6 +8431,35 @@ snapshots:
|
||||
'@esbuild/win32-ia32': 0.19.12
|
||||
'@esbuild/win32-x64': 0.19.12
|
||||
|
||||
esbuild@0.25.12:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.25.12
|
||||
'@esbuild/android-arm': 0.25.12
|
||||
'@esbuild/android-arm64': 0.25.12
|
||||
'@esbuild/android-x64': 0.25.12
|
||||
'@esbuild/darwin-arm64': 0.25.12
|
||||
'@esbuild/darwin-x64': 0.25.12
|
||||
'@esbuild/freebsd-arm64': 0.25.12
|
||||
'@esbuild/freebsd-x64': 0.25.12
|
||||
'@esbuild/linux-arm': 0.25.12
|
||||
'@esbuild/linux-arm64': 0.25.12
|
||||
'@esbuild/linux-ia32': 0.25.12
|
||||
'@esbuild/linux-loong64': 0.25.12
|
||||
'@esbuild/linux-mips64el': 0.25.12
|
||||
'@esbuild/linux-ppc64': 0.25.12
|
||||
'@esbuild/linux-riscv64': 0.25.12
|
||||
'@esbuild/linux-s390x': 0.25.12
|
||||
'@esbuild/linux-x64': 0.25.12
|
||||
'@esbuild/netbsd-arm64': 0.25.12
|
||||
'@esbuild/netbsd-x64': 0.25.12
|
||||
'@esbuild/openbsd-arm64': 0.25.12
|
||||
'@esbuild/openbsd-x64': 0.25.12
|
||||
'@esbuild/openharmony-arm64': 0.25.12
|
||||
'@esbuild/sunos-x64': 0.25.12
|
||||
'@esbuild/win32-arm64': 0.25.12
|
||||
'@esbuild/win32-ia32': 0.25.12
|
||||
'@esbuild/win32-x64': 0.25.12
|
||||
|
||||
esbuild@0.27.4:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.27.4
|
||||
@@ -10691,7 +10996,7 @@ snapshots:
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
vite@8.0.0(@types/node@22.19.15)(esbuild@0.19.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2):
|
||||
vite@8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2):
|
||||
dependencies:
|
||||
'@oxc-project/runtime': 0.115.0
|
||||
lightningcss: 1.32.0
|
||||
@@ -10701,16 +11006,16 @@ snapshots:
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
'@types/node': 22.19.15
|
||||
esbuild: 0.19.12
|
||||
esbuild: 0.25.12
|
||||
fsevents: 2.3.3
|
||||
jiti: 1.21.7
|
||||
tsx: 4.21.0
|
||||
yaml: 2.8.2
|
||||
|
||||
vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.19.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)):
|
||||
vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.0
|
||||
'@vitest/mocker': 4.1.0(vite@8.0.0(@types/node@22.19.15)(esbuild@0.19.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@vitest/mocker': 4.1.0(vite@8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@vitest/pretty-format': 4.1.0
|
||||
'@vitest/runner': 4.1.0
|
||||
'@vitest/snapshot': 4.1.0
|
||||
@@ -10727,7 +11032,7 @@ snapshots:
|
||||
tinyexec: 1.0.4
|
||||
tinyglobby: 0.2.15
|
||||
tinyrainbow: 3.1.0
|
||||
vite: 8.0.0(@types/node@22.19.15)(esbuild@0.19.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vite: 8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 22.19.15
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
import { AuditLogList } from '@/components/admin/audit/audit-log-list';
|
||||
|
||||
export default function AuditLogPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Audit Log</h1>
|
||||
<p className="text-muted-foreground">Review system activity and changes</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 2</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This feature will be implemented in the next phase.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <AuditLogList />;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
import { PortList } from '@/components/admin/ports/port-list';
|
||||
|
||||
export default function PortManagementPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Port Management</h1>
|
||||
<p className="text-muted-foreground">Manage port locations and configurations</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 2</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This feature will be implemented in the next phase.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <PortList />;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
import { RoleList } from '@/components/admin/roles/role-list';
|
||||
|
||||
export default function RoleManagementPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Role Management</h1>
|
||||
<p className="text-muted-foreground">Configure roles and permissions</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 2</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This feature will be implemented in the next phase.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <RoleList />;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
import { SettingsManager } from '@/components/admin/settings/settings-manager';
|
||||
|
||||
export default function SystemSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">System Settings</h1>
|
||||
<p className="text-muted-foreground">Configure system-wide settings</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 2</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This feature will be implemented in the next phase.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <SettingsManager />;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
import { UserList } from '@/components/admin/users/user-list';
|
||||
|
||||
export default function UserManagementPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">User Management</h1>
|
||||
<p className="text-muted-foreground">Manage user accounts and access</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 2</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This feature will be implemented in the next phase.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <UserList />;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
import { ReminderList } from '@/components/reminders/reminder-list';
|
||||
|
||||
export default function RemindersPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Reminders</h1>
|
||||
<p className="text-muted-foreground">Manage tasks and follow-up reminders</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 2</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This feature will be implemented in the next phase.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <ReminderList />;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
import { UserSettings } from '@/components/settings/user-settings';
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Settings</h1>
|
||||
<p className="text-muted-foreground">Manage your account and port preferences</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 2</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This feature will be implemented in the next phase.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <UserSettings />;
|
||||
}
|
||||
|
||||
@@ -3,10 +3,13 @@ import { and, eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
import { clients, clientContacts } from '@/lib/db/schema/clients';
|
||||
import { clients, clientContacts, clientAddresses } from '@/lib/db/schema/clients';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { errorResponse, RateLimitError } from '@/lib/errors';
|
||||
import { publicInterestSchema } from '@/lib/validators/interests';
|
||||
import { sendInquiryNotifications } from '@/lib/services/inquiry-notifications.service';
|
||||
|
||||
// ─── Simple in-memory rate limiter ───────────────────────────────────────────
|
||||
// Max 5 requests per hour per IP
|
||||
@@ -47,90 +50,68 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Port context required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Resolve the full name
|
||||
const fullName =
|
||||
data.firstName && data.lastName
|
||||
? `${data.firstName} ${data.lastName}`
|
||||
: (data.fullName ?? 'Unknown');
|
||||
|
||||
const firstName = data.firstName ?? fullName.split(/\s+/)[0] ?? 'Valued Guest';
|
||||
|
||||
// Resolve berth by mooring number (if provided)
|
||||
let berthId: string | null = null;
|
||||
let resolvedMooringNumber: string | null = data.mooringNumber ?? null;
|
||||
|
||||
if (data.mooringNumber) {
|
||||
const berth = await db.query.berths.findFirst({
|
||||
where: and(eq(berths.mooringNumber, data.mooringNumber), eq(berths.portId, portId)),
|
||||
});
|
||||
if (berth) {
|
||||
berthId = berth.id;
|
||||
resolvedMooringNumber = berth.mooringNumber;
|
||||
}
|
||||
}
|
||||
|
||||
// Find or create client by email
|
||||
let clientId: string;
|
||||
|
||||
const existingContact = await db.query.clientContacts.findFirst({
|
||||
where: and(
|
||||
eq(clientContacts.channel, 'email'),
|
||||
eq(clientContacts.value, data.email),
|
||||
),
|
||||
where: and(eq(clientContacts.channel, 'email'), eq(clientContacts.value, data.email)),
|
||||
});
|
||||
|
||||
if (existingContact) {
|
||||
// Find the client associated with this contact
|
||||
const existingClient = await db.query.clients.findFirst({
|
||||
where: eq(clients.id, existingContact.clientId),
|
||||
});
|
||||
if (existingClient && existingClient.portId === portId) {
|
||||
clientId = existingClient.id;
|
||||
} else {
|
||||
// Create new client for this port
|
||||
const [newClient] = await db
|
||||
.insert(clients)
|
||||
.values({
|
||||
portId,
|
||||
fullName: data.fullName,
|
||||
companyName: data.companyName,
|
||||
yachtName: data.yachtName,
|
||||
yachtLengthFt: data.yachtLengthFt != null ? String(data.yachtLengthFt) : undefined,
|
||||
yachtWidthFt: data.yachtWidthFt != null ? String(data.yachtWidthFt) : undefined,
|
||||
yachtDraftFt: data.yachtDraftFt != null ? String(data.yachtDraftFt) : undefined,
|
||||
berthSizeDesired: data.preferredBerthSize,
|
||||
source: 'website',
|
||||
})
|
||||
.returning();
|
||||
clientId = newClient!.id;
|
||||
|
||||
await db.insert(clientContacts).values({
|
||||
clientId,
|
||||
channel: 'email',
|
||||
value: data.email,
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
if (data.phone) {
|
||||
await db.insert(clientContacts).values({
|
||||
clientId,
|
||||
channel: 'phone',
|
||||
value: data.phone,
|
||||
isPrimary: false,
|
||||
});
|
||||
// Update preferred contact method if provided
|
||||
if (data.preferredContactMethod) {
|
||||
await db
|
||||
.update(clients)
|
||||
.set({ preferredContactMethod: data.preferredContactMethod })
|
||||
.where(eq(clients.id, clientId));
|
||||
}
|
||||
} else {
|
||||
clientId = await createNewClient(portId, fullName, data);
|
||||
}
|
||||
} else {
|
||||
// Create brand-new client
|
||||
const [newClient] = await db
|
||||
.insert(clients)
|
||||
.values({
|
||||
portId,
|
||||
fullName: data.fullName,
|
||||
companyName: data.companyName,
|
||||
yachtName: data.yachtName,
|
||||
yachtLengthFt: data.yachtLengthFt != null ? String(data.yachtLengthFt) : undefined,
|
||||
yachtWidthFt: data.yachtWidthFt != null ? String(data.yachtWidthFt) : undefined,
|
||||
yachtDraftFt: data.yachtDraftFt != null ? String(data.yachtDraftFt) : undefined,
|
||||
berthSizeDesired: data.preferredBerthSize,
|
||||
source: 'website',
|
||||
})
|
||||
.returning();
|
||||
clientId = newClient!.id;
|
||||
clientId = await createNewClient(portId, fullName, data);
|
||||
}
|
||||
|
||||
await db.insert(clientContacts).values({
|
||||
// Store address if provided
|
||||
if (data.address && Object.values(data.address).some(Boolean)) {
|
||||
await db.insert(clientAddresses).values({
|
||||
clientId,
|
||||
channel: 'email',
|
||||
value: data.email,
|
||||
portId,
|
||||
label: 'Primary',
|
||||
streetAddress: data.address.street ?? null,
|
||||
city: data.address.city ?? null,
|
||||
stateProvince: data.address.stateProvince ?? null,
|
||||
postalCode: data.address.postalCode ?? null,
|
||||
country: data.address.country ?? null,
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
if (data.phone) {
|
||||
await db.insert(clientContacts).values({
|
||||
clientId,
|
||||
channel: 'phone',
|
||||
value: data.phone,
|
||||
isPrimary: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create the interest
|
||||
@@ -139,6 +120,7 @@ export async function POST(req: NextRequest) {
|
||||
.values({
|
||||
portId,
|
||||
clientId,
|
||||
berthId,
|
||||
source: 'website',
|
||||
pipelineStage: 'open',
|
||||
notes: data.notes,
|
||||
@@ -151,12 +133,29 @@ export async function POST(req: NextRequest) {
|
||||
action: 'create',
|
||||
entityType: 'interest',
|
||||
entityId: interest!.id,
|
||||
newValue: { clientId, source: 'website', pipelineStage: 'open' },
|
||||
newValue: { clientId, source: 'website', pipelineStage: 'open', berthId },
|
||||
metadata: { type: 'public_registration', ip },
|
||||
ipAddress: ip,
|
||||
userAgent: req.headers.get('user-agent') ?? 'unknown',
|
||||
});
|
||||
|
||||
// Fire notifications asynchronously (non-blocking)
|
||||
const port = await db.query.ports.findFirst({
|
||||
where: eq(ports.id, portId),
|
||||
columns: { slug: true },
|
||||
});
|
||||
|
||||
void sendInquiryNotifications({
|
||||
portId,
|
||||
portSlug: port?.slug ?? portId,
|
||||
interestId: interest!.id,
|
||||
clientFullName: fullName,
|
||||
clientEmail: data.email,
|
||||
clientPhone: data.phone,
|
||||
mooringNumber: resolvedMooringNumber,
|
||||
firstName,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ data: { id: interest!.id, message: 'Interest registered successfully' } },
|
||||
{ status: 201 },
|
||||
@@ -165,3 +164,52 @@ export async function POST(req: NextRequest) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function createNewClient(
|
||||
portId: string,
|
||||
fullName: string,
|
||||
data: {
|
||||
email: string;
|
||||
phone: string;
|
||||
companyName?: string;
|
||||
yachtName?: string;
|
||||
yachtLengthFt?: number;
|
||||
yachtWidthFt?: number;
|
||||
yachtDraftFt?: number;
|
||||
preferredBerthSize?: string;
|
||||
preferredContactMethod?: string;
|
||||
},
|
||||
): Promise<string> {
|
||||
const [newClient] = await db
|
||||
.insert(clients)
|
||||
.values({
|
||||
portId,
|
||||
fullName,
|
||||
companyName: data.companyName,
|
||||
yachtName: data.yachtName,
|
||||
yachtLengthFt: data.yachtLengthFt != null ? String(data.yachtLengthFt) : undefined,
|
||||
yachtWidthFt: data.yachtWidthFt != null ? String(data.yachtWidthFt) : undefined,
|
||||
yachtDraftFt: data.yachtDraftFt != null ? String(data.yachtDraftFt) : undefined,
|
||||
berthSizeDesired: data.preferredBerthSize,
|
||||
preferredContactMethod: data.preferredContactMethod,
|
||||
source: 'website',
|
||||
})
|
||||
.returning();
|
||||
const clientId = newClient!.id;
|
||||
|
||||
await db.insert(clientContacts).values({
|
||||
clientId,
|
||||
channel: 'email',
|
||||
value: data.email,
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
await db.insert(clientContacts).values({
|
||||
clientId,
|
||||
channel: 'phone',
|
||||
value: data.phone,
|
||||
isPrimary: false,
|
||||
});
|
||||
|
||||
return clientId;
|
||||
}
|
||||
|
||||
31
src/app/api/v1/admin/audit/route.ts
Normal file
31
src/app/api/v1/admin/audit/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseQuery } from '@/lib/api/route-helpers';
|
||||
import { listAuditLogs } from '@/lib/services/audit.service';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
const auditQuerySchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||
entityType: z.string().optional(),
|
||||
action: z.string().optional(),
|
||||
userId: z.string().optional(),
|
||||
entityId: z.string().optional(),
|
||||
dateFrom: z.string().optional(),
|
||||
dateTo: z.string().optional(),
|
||||
search: z.string().optional(),
|
||||
});
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'view_audit_log', async (req, ctx) => {
|
||||
try {
|
||||
const query = parseQuery(req, auditQuerySchema);
|
||||
const result = await listAuditLogs(ctx.portId, query);
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
35
src/app/api/v1/admin/ports/[id]/route.ts
Normal file
35
src/app/api/v1/admin/ports/[id]/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { getPort, updatePort } from '@/lib/services/ports.service';
|
||||
import { updatePortSchema } from '@/lib/validators/ports';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (_req, _ctx, params) => {
|
||||
try {
|
||||
const data = await getPort(params.id!);
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const PATCH = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, updatePortSchema);
|
||||
const data = await updatePort(params.id!, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
35
src/app/api/v1/admin/ports/route.ts
Normal file
35
src/app/api/v1/admin/ports/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { listPorts, createPort } from '@/lib/services/ports.service';
|
||||
import { createPortSchema } from '@/lib/validators/ports';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'manage_settings', async () => {
|
||||
try {
|
||||
const data = await listPorts();
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, createPortSchema);
|
||||
const data = await createPort(body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1,28 +1,49 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { db } from '@/lib/db';
|
||||
import { roles } from '@/lib/db/schema';
|
||||
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { getRole, updateRole, deleteRole } from '@/lib/services/roles.service';
|
||||
import { updateRoleSchema } from '@/lib/validators/roles';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'manage_users', async (_req, _ctx, params) => {
|
||||
try {
|
||||
const { id } = params;
|
||||
if (!id) {
|
||||
throw new NotFoundError('Role');
|
||||
}
|
||||
|
||||
const role = await db.query.roles.findFirst({
|
||||
where: eq(roles.id, id),
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
throw new NotFoundError('Role');
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: role });
|
||||
const data = await getRole(params.id!);
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const PATCH = withAuth(
|
||||
withPermission('admin', 'manage_users', async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, updateRoleSchema);
|
||||
const data = await updateRole(params.id!, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const DELETE = withAuth(
|
||||
withPermission('admin', 'manage_users', async (_req, ctx, params) => {
|
||||
try {
|
||||
await deleteRole(params.id!, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,35 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { db } from '@/lib/db';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { listRoles, createRole } from '@/lib/services/roles.service';
|
||||
import { createRoleSchema } from '@/lib/validators/roles';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'manage_users', async (_req, _ctx) => {
|
||||
withPermission('admin', 'manage_users', async () => {
|
||||
try {
|
||||
const data = await db.query.roles.findMany({
|
||||
orderBy: (roles, { asc }) => [asc(roles.name)],
|
||||
});
|
||||
|
||||
const data = await listRoles();
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('admin', 'manage_users', async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, createRoleSchema);
|
||||
const data = await createRole(body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
52
src/app/api/v1/admin/settings/route.ts
Normal file
52
src/app/api/v1/admin/settings/route.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { listSettings, upsertSetting, deleteSetting } from '@/lib/services/settings.service';
|
||||
import { upsertSettingSchema, deleteSettingSchema } from '@/lib/validators/settings';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (_req, ctx) => {
|
||||
try {
|
||||
const data = await listSettings(ctx.portId);
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const PUT = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
||||
try {
|
||||
const { key, value } = await parseBody(req, upsertSettingSchema);
|
||||
const data = await upsertSetting(key, value, ctx.portId, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const DELETE = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
||||
try {
|
||||
const { key } = await parseBody(req, deleteSettingSchema);
|
||||
await deleteSetting(key, ctx.portId, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
51
src/app/api/v1/admin/users/[id]/route.ts
Normal file
51
src/app/api/v1/admin/users/[id]/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { getUser, updateUser, removeUserFromPort } from '@/lib/services/users.service';
|
||||
import { updateUserSchema } from '@/lib/validators/users';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'manage_users', async (_req, ctx, params) => {
|
||||
try {
|
||||
const data = await getUser(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const PATCH = withAuth(
|
||||
withPermission('admin', 'manage_users', async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, updateUserSchema);
|
||||
const data = await updateUser(params.id!, ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const DELETE = withAuth(
|
||||
withPermission('admin', 'manage_users', async (_req, ctx, params) => {
|
||||
try {
|
||||
await removeUserFromPort(params.id!, ctx.portId, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1,40 +1,35 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { db } from '@/lib/db';
|
||||
import { userPortRoles, userProfiles, roles } from '@/lib/db/schema';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { listUsers, createUser } from '@/lib/services/users.service';
|
||||
import { createUserSchema } from '@/lib/validators/users';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'manage_users', async (_req, ctx) => {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
userId: userPortRoles.userId,
|
||||
displayName: userProfiles.displayName,
|
||||
isActive: userProfiles.isActive,
|
||||
lastLoginAt: userProfiles.lastLoginAt,
|
||||
roleId: roles.id,
|
||||
roleName: roles.name,
|
||||
})
|
||||
.from(userPortRoles)
|
||||
.innerJoin(userProfiles, eq(userPortRoles.userId, userProfiles.userId))
|
||||
.innerJoin(roles, eq(userPortRoles.roleId, roles.id))
|
||||
.where(eq(userPortRoles.portId, ctx.portId))
|
||||
.orderBy(userProfiles.displayName);
|
||||
|
||||
const data = rows.map((row) => ({
|
||||
userId: row.userId,
|
||||
displayName: row.displayName,
|
||||
isActive: row.isActive,
|
||||
lastLoginAt: row.lastLoginAt,
|
||||
role: { id: row.roleId, name: row.roleName },
|
||||
}));
|
||||
|
||||
const data = await listUsers(ctx.portId);
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('admin', 'manage_users', async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, createUserSchema);
|
||||
const data = await createUser(ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NextResponse } from 'next/server';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { updateBerthSchema } from '@/lib/validators/berths';
|
||||
import { getBerthById, updateBerth } from '@/lib/services/berths.service';
|
||||
import { getBerthById, updateBerth, deleteBerth } from '@/lib/services/berths.service';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
// GET /api/v1/berths/[id]
|
||||
@@ -18,7 +18,7 @@ export const GET = withAuth(
|
||||
}),
|
||||
);
|
||||
|
||||
// PATCH /api/v1/berths/[id] — update berth fields (no DELETE — import-only)
|
||||
// PATCH /api/v1/berths/[id]
|
||||
export const PATCH = withAuth(
|
||||
withPermission('berths', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
@@ -35,3 +35,20 @@ export const PATCH = withAuth(
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// DELETE /api/v1/berths/[id]
|
||||
export const DELETE = withAuth(
|
||||
withPermission('berths', 'edit', async (_req, ctx, params) => {
|
||||
try {
|
||||
await deleteBerth(params.id!, ctx.portId, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseQuery } from '@/lib/api/route-helpers';
|
||||
import { listBerthsSchema } from '@/lib/validators/berths';
|
||||
import { listBerths } from '@/lib/services/berths.service';
|
||||
import { parseBody, parseQuery } from '@/lib/api/route-helpers';
|
||||
import { listBerthsSchema, createBerthSchema } from '@/lib/validators/berths';
|
||||
import { listBerths, createBerth } from '@/lib/services/berths.service';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
// GET /api/v1/berths — list berths for the current port (no POST — import-only)
|
||||
export const GET = withAuth(
|
||||
withPermission('berths', 'view', async (req, ctx) => {
|
||||
try {
|
||||
@@ -34,3 +33,20 @@ export const GET = withAuth(
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('berths', 'edit', async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, createBerthSchema);
|
||||
const data = await createBerth(ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
23
src/app/api/v1/clients/duplicates/route.ts
Normal file
23
src/app/api/v1/clients/duplicates/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseQuery } from '@/lib/api/route-helpers';
|
||||
import { findDuplicates } from '@/lib/services/clients.service';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
const duplicateQuerySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
});
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('clients', 'view', async (req, ctx) => {
|
||||
try {
|
||||
const { name } = parseQuery(req, duplicateQuerySchema);
|
||||
const data = await findDuplicates(ctx.portId, name);
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1,5 +1,26 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { withAuth, type AuthContext } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { db } from '@/lib/db';
|
||||
import { userProfiles } from '@/lib/db/schema';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { z } from 'zod';
|
||||
|
||||
const updateProfileSchema = z.object({
|
||||
displayName: z.string().min(1).max(200).optional(),
|
||||
phone: z.string().nullable().optional(),
|
||||
avatarUrl: z.string().url().nullable().optional(),
|
||||
preferences: z
|
||||
.object({
|
||||
dark_mode: z.boolean().optional(),
|
||||
locale: z.string().optional(),
|
||||
timezone: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const GET = withAuth(async (_req, ctx: AuthContext) => {
|
||||
return NextResponse.json({
|
||||
@@ -13,3 +34,45 @@ export const GET = withAuth(async (_req, ctx: AuthContext) => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export const PATCH = withAuth(async (req, ctx: AuthContext) => {
|
||||
try {
|
||||
const body = await parseBody(req, updateProfileSchema);
|
||||
|
||||
const profile = await db.query.userProfiles.findFirst({
|
||||
where: eq(userProfiles.userId, ctx.userId),
|
||||
});
|
||||
if (!profile) {
|
||||
return NextResponse.json({ error: 'Profile not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (body.displayName !== undefined) updates.displayName = body.displayName;
|
||||
if (body.phone !== undefined) updates.phone = body.phone;
|
||||
if (body.avatarUrl !== undefined) updates.avatarUrl = body.avatarUrl;
|
||||
if (body.preferences !== undefined) {
|
||||
updates.preferences = {
|
||||
...((profile.preferences as Record<string, unknown>) ?? {}),
|
||||
...body.preferences,
|
||||
};
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(userProfiles)
|
||||
.set(updates)
|
||||
.where(eq(userProfiles.userId, ctx.userId))
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
userId: updated!.userId,
|
||||
displayName: updated!.displayName,
|
||||
phone: updated!.phone,
|
||||
avatarUrl: updated!.avatarUrl,
|
||||
preferences: updated!.preferences,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
|
||||
21
src/app/api/v1/reminders/[id]/complete/route.ts
Normal file
21
src/app/api/v1/reminders/[id]/complete/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { completeReminder } from '@/lib/services/reminders.service';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('reminders', 'edit_own', async (_req, ctx, params) => {
|
||||
try {
|
||||
const data = await completeReminder(params.id!, ctx.portId, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
21
src/app/api/v1/reminders/[id]/dismiss/route.ts
Normal file
21
src/app/api/v1/reminders/[id]/dismiss/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { dismissReminder } from '@/lib/services/reminders.service';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('reminders', 'edit_own', async (_req, ctx, params) => {
|
||||
try {
|
||||
const data = await dismissReminder(params.id!, ctx.portId, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
51
src/app/api/v1/reminders/[id]/route.ts
Normal file
51
src/app/api/v1/reminders/[id]/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { getReminder, updateReminder, deleteReminder } from '@/lib/services/reminders.service';
|
||||
import { updateReminderSchema } from '@/lib/validators/reminders';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('reminders', 'view_own', async (_req, ctx, params) => {
|
||||
try {
|
||||
const data = await getReminder(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const PATCH = withAuth(
|
||||
withPermission('reminders', 'edit_own', async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, updateReminderSchema);
|
||||
const data = await updateReminder(params.id!, ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const DELETE = withAuth(
|
||||
withPermission('reminders', 'edit_own', async (_req, ctx, params) => {
|
||||
try {
|
||||
await deleteReminder(params.id!, ctx.portId, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
24
src/app/api/v1/reminders/[id]/snooze/route.ts
Normal file
24
src/app/api/v1/reminders/[id]/snooze/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { snoozeReminder } from '@/lib/services/reminders.service';
|
||||
import { snoozeReminderSchema } from '@/lib/validators/reminders';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('reminders', 'edit_own', async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, snoozeReminderSchema);
|
||||
const data = await snoozeReminder(params.id!, ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
16
src/app/api/v1/reminders/my/route.ts
Normal file
16
src/app/api/v1/reminders/my/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { getMyReminders } from '@/lib/services/reminders.service';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('reminders', 'view_own', async (_req, ctx) => {
|
||||
try {
|
||||
const data = await getMyReminders(ctx.userId, ctx.portId);
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
16
src/app/api/v1/reminders/overdue/route.ts
Normal file
16
src/app/api/v1/reminders/overdue/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { getOverdueReminders } from '@/lib/services/reminders.service';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('reminders', 'view_all', async (_req, ctx) => {
|
||||
try {
|
||||
const data = await getOverdueReminders(ctx.portId);
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
50
src/app/api/v1/reminders/route.ts
Normal file
50
src/app/api/v1/reminders/route.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody, parseQuery } from '@/lib/api/route-helpers';
|
||||
import { listReminders, createReminder } from '@/lib/services/reminders.service';
|
||||
import { reminderListQuerySchema, createReminderSchema } from '@/lib/validators/reminders';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('reminders', 'view_own', async (req, ctx) => {
|
||||
try {
|
||||
const query = parseQuery(req, reminderListQuerySchema);
|
||||
const result = await listReminders(ctx.portId, query);
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('reminders', 'create', async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, createReminderSchema);
|
||||
|
||||
// Check assign_others permission if assigning to someone else
|
||||
if (body.assignedTo && body.assignedTo !== ctx.userId) {
|
||||
if (!ctx.isSuperAdmin) {
|
||||
const perms = ctx.permissions?.reminders;
|
||||
if (!perms?.assign_others) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot assign reminders to other users' },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const data = await createReminder(ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
16
src/app/api/v1/reminders/upcoming/route.ts
Normal file
16
src/app/api/v1/reminders/upcoming/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { getUpcomingReminders } from '@/lib/services/reminders.service';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('reminders', 'view_own', async (_req, ctx) => {
|
||||
try {
|
||||
const data = await getUpcomingReminders(ctx.portId);
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
257
src/components/admin/audit/audit-log-list.tsx
Normal file
257
src/components/admin/audit/audit-log-list.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { type ColumnDef } from '@tanstack/react-table';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
import { DataTable } from '@/components/shared/data-table';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface AuditEntry {
|
||||
id: string;
|
||||
userId: string | null;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
fieldChanged: string | null;
|
||||
oldValue: Record<string, unknown> | null;
|
||||
newValue: Record<string, unknown> | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
ipAddress: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const ACTION_COLORS: Record<string, string> = {
|
||||
create: 'bg-green-600',
|
||||
update: 'bg-blue-500',
|
||||
delete: 'bg-red-600',
|
||||
archive: 'bg-orange-500',
|
||||
restore: 'bg-teal-500',
|
||||
login: 'bg-gray-500',
|
||||
permission_denied: 'bg-red-800',
|
||||
};
|
||||
|
||||
const ENTITY_TYPES = [
|
||||
'client',
|
||||
'interest',
|
||||
'berth',
|
||||
'document',
|
||||
'expense',
|
||||
'invoice',
|
||||
'reminder',
|
||||
'user',
|
||||
'role',
|
||||
'port',
|
||||
'setting',
|
||||
'tag',
|
||||
'webhook',
|
||||
];
|
||||
|
||||
export function AuditLogList() {
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [entityTypeFilter, setEntityTypeFilter] = useState<string>('all');
|
||||
const [actionFilter, setActionFilter] = useState<string>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
limit: '50',
|
||||
});
|
||||
if (entityTypeFilter !== 'all') params.set('entityType', entityTypeFilter);
|
||||
if (actionFilter !== 'all') params.set('action', actionFilter);
|
||||
if (search) params.set('search', search);
|
||||
|
||||
const res = await apiFetch<{
|
||||
data: AuditEntry[];
|
||||
pagination: { total: number };
|
||||
}>(`/api/v1/admin/audit?${params}`);
|
||||
setEntries(res.data);
|
||||
setTotal(res.pagination.total);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, entityTypeFilter, actionFilter, search]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchLogs();
|
||||
}, [fetchLogs]);
|
||||
|
||||
const columns: ColumnDef<AuditEntry, unknown>[] = [
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: 'Time',
|
||||
cell: ({ row }) => (
|
||||
<div className="text-sm">
|
||||
<div>{new Date(row.original.createdAt).toLocaleString()}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(row.original.createdAt), { addSuffix: true })}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
size: 180,
|
||||
},
|
||||
{
|
||||
accessorKey: 'action',
|
||||
header: 'Action',
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
className={`${ACTION_COLORS[row.original.action] ?? 'bg-gray-500'} text-white text-xs`}
|
||||
>
|
||||
{row.original.action}
|
||||
</Badge>
|
||||
),
|
||||
size: 100,
|
||||
},
|
||||
{
|
||||
accessorKey: 'entityType',
|
||||
header: 'Entity',
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<span className="font-medium capitalize">{row.original.entityType}</span>
|
||||
<code className="ml-2 text-xs text-muted-foreground">
|
||||
{row.original.entityId.slice(0, 8)}...
|
||||
</code>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'changes',
|
||||
header: 'Changes',
|
||||
cell: ({ row }) => {
|
||||
const { newValue, fieldChanged } = row.original;
|
||||
if (fieldChanged) return <span className="text-sm">{fieldChanged}</span>;
|
||||
if (newValue) {
|
||||
const keys = Object.keys(newValue);
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{keys.slice(0, 3).join(', ')}
|
||||
{keys.length > 3 ? ` +${keys.length - 3} more` : ''}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="text-xs text-muted-foreground">—</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'userId',
|
||||
header: 'User',
|
||||
cell: ({ row }) => (
|
||||
<code className="text-xs">
|
||||
{row.original.userId ? row.original.userId.slice(0, 8) + '...' : 'system'}
|
||||
</code>
|
||||
),
|
||||
size: 100,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Audit Log" description={`${total} entries`} />
|
||||
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={entityTypeFilter}
|
||||
onValueChange={(v) => {
|
||||
setEntityTypeFilter(v);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Entities</SelectItem>
|
||||
{ENTITY_TYPES.map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={actionFilter}
|
||||
onValueChange={(v) => {
|
||||
setActionFilter(v);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Actions</SelectItem>
|
||||
<SelectItem value="create">Create</SelectItem>
|
||||
<SelectItem value="update">Update</SelectItem>
|
||||
<SelectItem value="delete">Delete</SelectItem>
|
||||
<SelectItem value="archive">Archive</SelectItem>
|
||||
<SelectItem value="restore">Restore</SelectItem>
|
||||
<SelectItem value="permission_denied">Permission Denied</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={entries}
|
||||
isLoading={loading}
|
||||
getRowId={(row) => row.id}
|
||||
emptyState={
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">No audit log entries found.</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{total > 50 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-4">
|
||||
<button
|
||||
className="text-sm text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {page} of {Math.ceil(total / 50)}
|
||||
</span>
|
||||
<button
|
||||
className="text-sm text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||
disabled={page >= Math.ceil(total / 50)}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
209
src/components/admin/ports/port-form.tsx
Normal file
209
src/components/admin/ports/port-form.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface PortFormProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
port?: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
logoUrl: string | null;
|
||||
primaryColor: string | null;
|
||||
defaultCurrency: string;
|
||||
timezone: string;
|
||||
isActive: boolean;
|
||||
} | null;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function PortForm({ open, onOpenChange, port, onSuccess }: PortFormProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [primaryColor, setPrimaryColor] = useState('#0F4C81');
|
||||
const [defaultCurrency, setDefaultCurrency] = useState('USD');
|
||||
const [timezone, setTimezone] = useState('America/Anguilla');
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isEdit = !!port;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (port) {
|
||||
setName(port.name);
|
||||
setSlug(port.slug);
|
||||
setPrimaryColor(port.primaryColor ?? '#0F4C81');
|
||||
setDefaultCurrency(port.defaultCurrency);
|
||||
setTimezone(port.timezone);
|
||||
setIsActive(port.isActive);
|
||||
} else {
|
||||
setName('');
|
||||
setSlug('');
|
||||
setPrimaryColor('#0F4C81');
|
||||
setDefaultCurrency('USD');
|
||||
setTimezone('America/Anguilla');
|
||||
setIsActive(true);
|
||||
}
|
||||
setError(null);
|
||||
}
|
||||
}, [open, port]);
|
||||
|
||||
function handleNameChange(value: string) {
|
||||
setName(value);
|
||||
if (!isEdit) {
|
||||
setSlug(
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
await apiFetch(`/api/v1/admin/ports/${port.id}`, {
|
||||
method: 'PATCH',
|
||||
body: { name, slug, primaryColor, defaultCurrency, timezone, isActive },
|
||||
});
|
||||
} else {
|
||||
await apiFetch('/api/v1/admin/ports', {
|
||||
method: 'POST',
|
||||
body: { name, slug, primaryColor, defaultCurrency, timezone },
|
||||
});
|
||||
}
|
||||
onSuccess();
|
||||
onOpenChange(false);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Something went wrong';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isEdit ? 'Edit Port' : 'New Port'}</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="port-name">Name</Label>
|
||||
<Input
|
||||
id="port-name"
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="Port Nimara"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="port-slug">Slug</Label>
|
||||
<Input
|
||||
id="port-slug"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
placeholder="port-nimara"
|
||||
pattern="^[a-z0-9-]+$"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used in URLs. Lowercase letters, numbers, and hyphens only.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="port-color">Brand Color</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
id="port-color"
|
||||
value={primaryColor}
|
||||
onChange={(e) => setPrimaryColor(e.target.value)}
|
||||
className="h-9 w-9 rounded border cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
value={primaryColor}
|
||||
onChange={(e) => setPrimaryColor(e.target.value)}
|
||||
placeholder="#0F4C81"
|
||||
className="w-28 font-mono text-sm"
|
||||
maxLength={7}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="port-currency">Currency</Label>
|
||||
<Input
|
||||
id="port-currency"
|
||||
value={defaultCurrency}
|
||||
onChange={(e) => setDefaultCurrency(e.target.value.toUpperCase())}
|
||||
placeholder="USD"
|
||||
maxLength={3}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="port-timezone">Timezone</Label>
|
||||
<Input
|
||||
id="port-timezone"
|
||||
value={timezone}
|
||||
onChange={(e) => setTimezone(e.target.value)}
|
||||
placeholder="America/Anguilla"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEdit && (
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div>
|
||||
<Label htmlFor="port-active">Port Active</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Inactive ports are hidden from users
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="port-active" checked={isActive} onCheckedChange={setIsActive} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<SheetFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading || !name.trim() || !slug.trim()}>
|
||||
{loading ? 'Saving...' : isEdit ? 'Save Changes' : 'Create Port'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
147
src/components/admin/ports/port-list.tsx
Normal file
147
src/components/admin/ports/port-list.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { type ColumnDef } from '@tanstack/react-table';
|
||||
import { Pencil, Plus } from 'lucide-react';
|
||||
|
||||
import { DataTable } from '@/components/shared/data-table';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { PortForm } from './port-form';
|
||||
|
||||
interface PortRow {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
logoUrl: string | null;
|
||||
primaryColor: string | null;
|
||||
defaultCurrency: string;
|
||||
timezone: string;
|
||||
isActive: boolean;
|
||||
settings: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function PortList() {
|
||||
const [ports, setPorts] = useState<PortRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingPort, setEditingPort] = useState<PortRow | null>(null);
|
||||
|
||||
const fetchPorts = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch<{ data: PortRow[] }>('/api/v1/admin/ports');
|
||||
setPorts(res.data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchPorts();
|
||||
}, [fetchPorts]);
|
||||
|
||||
function handleNewPort() {
|
||||
setEditingPort(null);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function handleEditPort(port: PortRow) {
|
||||
setEditingPort(port);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
const columns: ColumnDef<PortRow, unknown>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{row.original.primaryColor && (
|
||||
<span
|
||||
className="inline-block h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: row.original.primaryColor }}
|
||||
/>
|
||||
)}
|
||||
<span className="font-medium">{row.original.name}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'slug',
|
||||
header: 'Slug',
|
||||
cell: ({ row }) => (
|
||||
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">{row.original.slug}</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'defaultCurrency',
|
||||
header: 'Currency',
|
||||
},
|
||||
{
|
||||
accessorKey: 'timezone',
|
||||
header: 'Timezone',
|
||||
},
|
||||
{
|
||||
accessorKey: 'isActive',
|
||||
header: 'Status',
|
||||
cell: ({ row }) =>
|
||||
row.original.isActive ? (
|
||||
<Badge variant="default" className="bg-green-600">
|
||||
Active
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive">Inactive</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
cell: ({ row }) => (
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEditPort(row.original)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span className="sr-only">Edit</span>
|
||||
</Button>
|
||||
),
|
||||
enableSorting: false,
|
||||
size: 60,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Port Management"
|
||||
description="Manage marina ports and their configuration"
|
||||
actions={
|
||||
<Button onClick={handleNewPort}>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
New Port
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={ports}
|
||||
isLoading={loading}
|
||||
getRowId={(row) => row.id}
|
||||
emptyState={
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">No ports configured.</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<PortForm
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
port={editingPort}
|
||||
onSuccess={fetchPorts}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
297
src/components/admin/roles/role-form.tsx
Normal file
297
src/components/admin/roles/role-form.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
/** Default permissions structure matching RolePermissions type */
|
||||
const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
|
||||
clients: { view: false, create: false, edit: false, delete: false, merge: false, export: false },
|
||||
interests: {
|
||||
view: false,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false,
|
||||
change_stage: false,
|
||||
generate_eoi: false,
|
||||
export: false,
|
||||
},
|
||||
berths: { view: false, edit: false, import: false, manage_waiting_list: false },
|
||||
documents: {
|
||||
view: false,
|
||||
create: false,
|
||||
send_for_signing: false,
|
||||
upload_signed: false,
|
||||
delete: false,
|
||||
},
|
||||
expenses: {
|
||||
view: false,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false,
|
||||
export: false,
|
||||
scan_receipt: false,
|
||||
},
|
||||
invoices: {
|
||||
view: false,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false,
|
||||
send: false,
|
||||
record_payment: false,
|
||||
export: false,
|
||||
},
|
||||
files: { view: false, upload: false, delete: false, manage_folders: false },
|
||||
email: { view: false, send: false, configure_account: false },
|
||||
reminders: {
|
||||
view_own: false,
|
||||
view_all: false,
|
||||
create: false,
|
||||
edit_own: false,
|
||||
edit_all: false,
|
||||
assign_others: false,
|
||||
},
|
||||
calendar: { connect: false, view_events: false },
|
||||
reports: { view_dashboard: false, view_analytics: false, export: false },
|
||||
document_templates: { view: false, generate: false, manage: false },
|
||||
admin: {
|
||||
manage_users: false,
|
||||
view_audit_log: false,
|
||||
manage_settings: false,
|
||||
manage_webhooks: false,
|
||||
manage_reports: false,
|
||||
manage_custom_fields: false,
|
||||
manage_forms: false,
|
||||
manage_tags: false,
|
||||
system_backup: false,
|
||||
},
|
||||
};
|
||||
|
||||
const GROUP_LABELS: Record<string, string> = {
|
||||
clients: 'Clients',
|
||||
interests: 'Interests / Pipeline',
|
||||
berths: 'Berths',
|
||||
documents: 'Documents',
|
||||
expenses: 'Expenses',
|
||||
invoices: 'Invoices',
|
||||
files: 'Files',
|
||||
email: 'Email',
|
||||
reminders: 'Reminders',
|
||||
calendar: 'Calendar',
|
||||
reports: 'Reports',
|
||||
document_templates: 'Document Templates',
|
||||
admin: 'Administration',
|
||||
};
|
||||
|
||||
function formatAction(action: string): string {
|
||||
return action.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
interface RoleFormProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
role?: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
isSystem: boolean;
|
||||
permissions: Record<string, Record<string, boolean>>;
|
||||
} | null;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function RoleForm({ open, onOpenChange, role, onSuccess }: RoleFormProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [permissions, setPermissions] = useState<Record<string, Record<string, boolean>>>(
|
||||
structuredClone(DEFAULT_PERMISSIONS),
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isEdit = !!role;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (role) {
|
||||
setName(role.name);
|
||||
setDescription(role.description ?? '');
|
||||
// Merge role permissions over defaults to fill any missing keys
|
||||
const merged = structuredClone(DEFAULT_PERMISSIONS);
|
||||
for (const [group, actions] of Object.entries(role.permissions)) {
|
||||
if (merged[group]) {
|
||||
for (const [action, value] of Object.entries(actions as Record<string, boolean>)) {
|
||||
merged[group]![action] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
setPermissions(merged);
|
||||
} else {
|
||||
setName('');
|
||||
setDescription('');
|
||||
setPermissions(structuredClone(DEFAULT_PERMISSIONS));
|
||||
}
|
||||
setError(null);
|
||||
}
|
||||
}, [open, role]);
|
||||
|
||||
function togglePermission(group: string, action: string) {
|
||||
setPermissions((prev) => {
|
||||
const next = structuredClone(prev);
|
||||
next[group]![action] = !next[group]![action];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleGroup(group: string, value: boolean) {
|
||||
setPermissions((prev) => {
|
||||
const next = structuredClone(prev);
|
||||
for (const action of Object.keys(next[group]!)) {
|
||||
next[group]![action] = value;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function isGroupAllChecked(group: string): boolean {
|
||||
return Object.values(permissions[group]!).every(Boolean);
|
||||
}
|
||||
|
||||
function isGroupPartial(group: string): boolean {
|
||||
const vals = Object.values(permissions[group]!);
|
||||
return vals.some(Boolean) && !vals.every(Boolean);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
await apiFetch(`/api/v1/admin/roles/${role.id}`, {
|
||||
method: 'PATCH',
|
||||
body: { name, description: description || null, permissions },
|
||||
});
|
||||
} else {
|
||||
await apiFetch('/api/v1/admin/roles', {
|
||||
method: 'POST',
|
||||
body: { name, description: description || undefined, permissions },
|
||||
});
|
||||
}
|
||||
onSuccess();
|
||||
onOpenChange(false);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Something went wrong';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-[500px] sm:max-w-[500px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isEdit ? 'Edit Role' : 'New Role'}</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-6 flex flex-col h-[calc(100vh-140px)]">
|
||||
<div className="space-y-4 mb-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role-name">Name</Label>
|
||||
<Input
|
||||
id="role-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Sales Manager"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role-description">Description</Label>
|
||||
<Textarea
|
||||
id="role-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What this role is for..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Label className="mb-2">Permissions</Label>
|
||||
<ScrollArea className="flex-1 rounded-md border">
|
||||
<Accordion type="multiple" className="px-3">
|
||||
{Object.entries(permissions).map(([group, actions]) => (
|
||||
<AccordionItem key={group} value={group}>
|
||||
<AccordionTrigger className="text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={isGroupAllChecked(group)}
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
(el as unknown as HTMLInputElement).indeterminate =
|
||||
isGroupPartial(group);
|
||||
}
|
||||
}}
|
||||
onCheckedChange={(checked) => toggleGroup(group, checked === true)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span>{GROUP_LABELS[group] ?? group}</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="grid grid-cols-2 gap-2 pl-8 pb-2">
|
||||
{Object.entries(actions).map(([action, checked]) => (
|
||||
<label
|
||||
key={action}
|
||||
className="flex items-center gap-2 text-sm cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={() => togglePermission(group, action)}
|
||||
/>
|
||||
{formatAction(action)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</ScrollArea>
|
||||
|
||||
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
|
||||
|
||||
<SheetFooter className="mt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading || !name.trim()}>
|
||||
{loading ? 'Saving...' : isEdit ? 'Save Changes' : 'Create Role'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
176
src/components/admin/roles/role-list.tsx
Normal file
176
src/components/admin/roles/role-list.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { type ColumnDef } from '@tanstack/react-table';
|
||||
import { Pencil, Trash2, Plus, Lock } from 'lucide-react';
|
||||
|
||||
import { DataTable } from '@/components/shared/data-table';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { RoleForm } from './role-form';
|
||||
|
||||
interface Role {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
isSystem: boolean;
|
||||
isGlobal: boolean;
|
||||
permissions: Record<string, Record<string, boolean>>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function RoleList() {
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingRole, setEditingRole] = useState<Role | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const fetchRoles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch<{ data: Role[] }>('/api/v1/admin/roles');
|
||||
setRoles(res.data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchRoles();
|
||||
}, [fetchRoles]);
|
||||
|
||||
function handleNewRole() {
|
||||
setEditingRole(null);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function handleEditRole(role: Role) {
|
||||
setEditingRole(role);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
async function handleDeleteRole(id: string) {
|
||||
setDeletingId(id);
|
||||
try {
|
||||
await apiFetch(`/api/v1/admin/roles/${id}`, { method: 'DELETE' });
|
||||
await fetchRoles();
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function countPermissions(perms: Record<string, Record<string, boolean>>): string {
|
||||
let granted = 0;
|
||||
let total = 0;
|
||||
for (const group of Object.values(perms)) {
|
||||
for (const val of Object.values(group)) {
|
||||
total++;
|
||||
if (val) granted++;
|
||||
}
|
||||
}
|
||||
return `${granted}/${total}`;
|
||||
}
|
||||
|
||||
const columns: ColumnDef<Role, unknown>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{row.original.name}</span>
|
||||
{row.original.isSystem && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Lock className="mr-1 h-3 w-3" />
|
||||
System
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'description',
|
||||
header: 'Description',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-sm">{row.original.description ?? '—'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'permissions',
|
||||
header: 'Permissions',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="secondary">{countPermissions(row.original.permissions)}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEditRole(row.original)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span className="sr-only">Edit</span>
|
||||
</Button>
|
||||
{!row.original.isSystem && (
|
||||
<ConfirmationDialog
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">Delete</span>
|
||||
</Button>
|
||||
}
|
||||
title="Delete Role"
|
||||
description={`Delete "${row.original.name}"? Users assigned to this role must be reassigned first.`}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDeleteRole(row.original.id)}
|
||||
loading={deletingId === row.original.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
size: 80,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Role Management"
|
||||
description="Manage roles and their permissions"
|
||||
actions={
|
||||
<Button onClick={handleNewRole}>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
New Role
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={roles}
|
||||
isLoading={loading}
|
||||
getRowId={(row) => row.id}
|
||||
emptyState={
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">No roles defined.</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<RoleForm
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
role={editingRole}
|
||||
onSuccess={fetchRoles}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
399
src/components/admin/settings/settings-manager.tsx
Normal file
399
src/components/admin/settings/settings-manager.tsx
Normal file
@@ -0,0 +1,399 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Trash2, Plus, Save } from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface Setting {
|
||||
key: string;
|
||||
value: unknown;
|
||||
portId: string | null;
|
||||
updatedBy: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Well-known settings with their display metadata */
|
||||
const KNOWN_SETTINGS: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
type: 'boolean' | 'number' | 'json' | 'string';
|
||||
defaultValue: unknown;
|
||||
}> = [
|
||||
{
|
||||
key: 'ai_interest_scoring',
|
||||
label: 'AI Interest Scoring',
|
||||
description: 'Enable AI-powered interest scoring based on engagement signals',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: 'ai_email_drafts',
|
||||
label: 'AI Email Drafts',
|
||||
description: 'Enable AI-assisted email draft generation',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: 'invoice_net10_discount',
|
||||
label: 'Net-10 Invoice Discount (%)',
|
||||
description: 'Discount percentage applied when payment terms are net-10',
|
||||
type: 'number',
|
||||
defaultValue: 2,
|
||||
},
|
||||
{
|
||||
key: 'pipeline_weights',
|
||||
label: 'Pipeline Stage Weights',
|
||||
description: 'Probability weights for revenue forecast by pipeline stage (JSON)',
|
||||
type: 'json',
|
||||
defaultValue: {
|
||||
open: 0.05,
|
||||
details_sent: 0.1,
|
||||
in_communication: 0.2,
|
||||
signed_eoi_nda: 0.4,
|
||||
deposit_10pct: 0.6,
|
||||
contract: 0.8,
|
||||
completed: 1.0,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'berth_rules',
|
||||
label: 'Berth Status Rules',
|
||||
description: 'Auto/suggest/off rules for berth status transitions (JSON)',
|
||||
type: 'json',
|
||||
defaultValue: [],
|
||||
},
|
||||
{
|
||||
key: 'inquiry_contact_email',
|
||||
label: 'Inquiry Contact Email',
|
||||
description:
|
||||
'Reply-to email shown in client confirmation emails when a new interest is registered',
|
||||
type: 'string',
|
||||
defaultValue: 'sales@portnimara.com',
|
||||
},
|
||||
{
|
||||
key: 'inquiry_notification_recipients',
|
||||
label: 'External Notification Recipients',
|
||||
description:
|
||||
'Additional email addresses that receive sales notifications for new interests (JSON array)',
|
||||
type: 'json',
|
||||
defaultValue: [],
|
||||
},
|
||||
];
|
||||
|
||||
export function SettingsManager() {
|
||||
const [portSettings, setPortSettings] = useState<Setting[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
const [customKey, setCustomKey] = useState('');
|
||||
const [customValue, setCustomValue] = useState('');
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch<{ data: { portSettings: Setting[]; globalSettings: Setting[] } }>(
|
||||
'/api/v1/admin/settings',
|
||||
);
|
||||
setPortSettings(res.data.portSettings);
|
||||
|
||||
// Build values map from existing settings
|
||||
const vals: Record<string, unknown> = {};
|
||||
for (const s of res.data.portSettings) {
|
||||
vals[s.key] = s.value;
|
||||
}
|
||||
setValues(vals);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchSettings();
|
||||
}, [fetchSettings]);
|
||||
|
||||
async function saveSetting(key: string, value: unknown) {
|
||||
setSaving(key);
|
||||
try {
|
||||
await apiFetch('/api/v1/admin/settings', {
|
||||
method: 'PUT',
|
||||
body: { key, value },
|
||||
});
|
||||
await fetchSettings();
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteSetting(key: string) {
|
||||
await apiFetch('/api/v1/admin/settings', {
|
||||
method: 'DELETE',
|
||||
body: { key },
|
||||
});
|
||||
await fetchSettings();
|
||||
}
|
||||
|
||||
async function handleAddCustom() {
|
||||
if (!customKey.trim()) return;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(customValue);
|
||||
} catch {
|
||||
parsed = customValue;
|
||||
}
|
||||
await saveSetting(customKey, parsed);
|
||||
setCustomKey('');
|
||||
setCustomValue('');
|
||||
}
|
||||
|
||||
function getEffectiveValue(key: string, defaultValue: unknown): unknown {
|
||||
return values[key] ?? defaultValue;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="System Settings" description="Configure system behavior for this port" />
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom settings = port settings that aren't in KNOWN_SETTINGS
|
||||
const knownKeys = new Set(KNOWN_SETTINGS.map((s) => s.key));
|
||||
const customSettings = portSettings.filter((s) => !knownKeys.has(s.key));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="System Settings" description="Configure system behavior for this port" />
|
||||
|
||||
<div className="space-y-6 mt-6">
|
||||
{/* Feature Flags */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Feature Flags</CardTitle>
|
||||
<CardDescription>Enable or disable optional features</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{KNOWN_SETTINGS.filter((s) => s.type === 'boolean').map((setting) => (
|
||||
<div key={setting.key} className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>{setting.label}</Label>
|
||||
<p className="text-xs text-muted-foreground">{setting.description}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={getEffectiveValue(setting.key, setting.defaultValue) === true}
|
||||
disabled={saving === setting.key}
|
||||
onCheckedChange={(checked) => saveSetting(setting.key, checked)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* String Settings */}
|
||||
{KNOWN_SETTINGS.some((s) => s.type === 'string') && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Inquiry Settings</CardTitle>
|
||||
<CardDescription>Configure inquiry notification behavior</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{KNOWN_SETTINGS.filter((s) => s.type === 'string').map((setting) => (
|
||||
<div key={setting.key} className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<Label>{setting.label}</Label>
|
||||
<p className="text-xs text-muted-foreground">{setting.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
className="w-64"
|
||||
value={String(getEffectiveValue(setting.key, setting.defaultValue) ?? '')}
|
||||
onChange={(e) =>
|
||||
setValues((prev) => ({
|
||||
...prev,
|
||||
[setting.key]: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={saving === setting.key}
|
||||
onClick={() =>
|
||||
saveSetting(setting.key, values[setting.key] ?? setting.defaultValue)
|
||||
}
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Numeric Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Business Rules</CardTitle>
|
||||
<CardDescription>Configure financial and operational parameters</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{KNOWN_SETTINGS.filter((s) => s.type === 'number').map((setting) => (
|
||||
<div key={setting.key} className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<Label>{setting.label}</Label>
|
||||
<p className="text-xs text-muted-foreground">{setting.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
className="w-24"
|
||||
value={String(getEffectiveValue(setting.key, setting.defaultValue) ?? '')}
|
||||
onChange={(e) =>
|
||||
setValues((prev) => ({
|
||||
...prev,
|
||||
[setting.key]: parseFloat(e.target.value) || 0,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={saving === setting.key}
|
||||
onClick={() =>
|
||||
saveSetting(setting.key, values[setting.key] ?? setting.defaultValue)
|
||||
}
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* JSON Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Advanced Configuration</CardTitle>
|
||||
<CardDescription>
|
||||
JSON-based settings for pipeline weights and berth rules
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{KNOWN_SETTINGS.filter((s) => s.type === 'json').map((setting) => {
|
||||
const currentValue = getEffectiveValue(setting.key, setting.defaultValue);
|
||||
const jsonStr =
|
||||
values[`${setting.key}_edit`] !== undefined
|
||||
? String(values[`${setting.key}_edit`])
|
||||
: JSON.stringify(currentValue, null, 2);
|
||||
return (
|
||||
<div key={setting.key} className="space-y-2">
|
||||
<Label>{setting.label}</Label>
|
||||
<p className="text-xs text-muted-foreground">{setting.description}</p>
|
||||
<Textarea
|
||||
className="font-mono text-xs"
|
||||
rows={6}
|
||||
value={jsonStr}
|
||||
onChange={(e) =>
|
||||
setValues((prev) => ({ ...prev, [`${setting.key}_edit`]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={saving === setting.key}
|
||||
onClick={() => {
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
String(values[`${setting.key}_edit`] ?? JSON.stringify(currentValue)),
|
||||
);
|
||||
void saveSetting(setting.key, parsed);
|
||||
} catch {
|
||||
// invalid JSON — do nothing
|
||||
}
|
||||
}}
|
||||
>
|
||||
{saving === setting.key ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Custom Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Custom Settings</CardTitle>
|
||||
<CardDescription>Additional key-value settings for this port</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{customSettings.map((setting) => (
|
||||
<div key={setting.key} className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<code className="text-sm font-mono">{setting.key}</code>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{typeof setting.value === 'object'
|
||||
? JSON.stringify(setting.value)
|
||||
: String(setting.value)}
|
||||
</p>
|
||||
</div>
|
||||
<ConfirmationDialog
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
title="Delete Setting"
|
||||
description={`Delete "${setting.key}"? This may affect system behavior.`}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDeleteSetting(setting.key)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Key"
|
||||
value={customKey}
|
||||
onChange={(e) => setCustomKey(e.target.value)}
|
||||
className="w-40"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Value (JSON or string)"
|
||||
value={customValue}
|
||||
onChange={(e) => setCustomValue(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button variant="outline" onClick={handleAddCustom} disabled={!customKey.trim()}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
222
src/components/admin/users/user-form.tsx
Normal file
222
src/components/admin/users/user-form.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface Role {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface UserFormProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
user?: {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
isActive: boolean;
|
||||
role: { id: string; name: string };
|
||||
} | null;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps) {
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [roleId, setRoleId] = useState('');
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isEdit = !!user;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
void apiFetch<{ data: Role[] }>('/api/v1/admin/roles').then((res) => setRoles(res.data));
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (user) {
|
||||
setName(user.displayName);
|
||||
setEmail(user.email);
|
||||
setDisplayName(user.displayName);
|
||||
setPhone(user.phone ?? '');
|
||||
setRoleId(user.role.id);
|
||||
setIsActive(user.isActive);
|
||||
setPassword('');
|
||||
} else {
|
||||
setName('');
|
||||
setEmail('');
|
||||
setDisplayName('');
|
||||
setPhone('');
|
||||
setRoleId('');
|
||||
setIsActive(true);
|
||||
setPassword('');
|
||||
}
|
||||
setError(null);
|
||||
}
|
||||
}, [open, user]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
await apiFetch(`/api/v1/admin/users/${user.userId}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
displayName,
|
||||
phone: phone || null,
|
||||
roleId,
|
||||
isActive,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await apiFetch('/api/v1/admin/users', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: name || displayName,
|
||||
email,
|
||||
password,
|
||||
displayName,
|
||||
phone: phone || undefined,
|
||||
roleId,
|
||||
},
|
||||
});
|
||||
}
|
||||
onSuccess();
|
||||
onOpenChange(false);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Something went wrong';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isEdit ? 'Edit User' : 'New User'}</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||
{!isEdit && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="user-email">Email</Label>
|
||||
<Input
|
||||
id="user-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="user@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="user-password">Password</Label>
|
||||
<Input
|
||||
id="user-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Min 12 characters"
|
||||
minLength={12}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="user-display-name">Display Name</Label>
|
||||
<Input
|
||||
id="user-display-name"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="John Smith"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="user-phone">Phone</Label>
|
||||
<Input
|
||||
id="user-phone"
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
placeholder="+1 555-0123"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="user-role">Role</Label>
|
||||
<Select value={roleId} onValueChange={setRoleId} required>
|
||||
<SelectTrigger id="user-role">
|
||||
<SelectValue placeholder="Select a role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isEdit && (
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div>
|
||||
<Label htmlFor="user-active">Account Active</Label>
|
||||
<p className="text-xs text-muted-foreground">Disabled users cannot sign in</p>
|
||||
</div>
|
||||
<Switch id="user-active" checked={isActive} onCheckedChange={setIsActive} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<SheetFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading || !displayName.trim() || !roleId}>
|
||||
{loading ? 'Saving...' : isEdit ? 'Save Changes' : 'Create User'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
173
src/components/admin/users/user-list.tsx
Normal file
173
src/components/admin/users/user-list.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { type ColumnDef } from '@tanstack/react-table';
|
||||
import { Pencil, Trash2, Plus, ShieldCheck, ShieldOff } from 'lucide-react';
|
||||
|
||||
import { DataTable } from '@/components/shared/data-table';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { UserForm } from './user-form';
|
||||
|
||||
interface UserRow {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
isActive: boolean;
|
||||
isSuperAdmin: boolean;
|
||||
lastLoginAt: string | null;
|
||||
role: { id: string; name: string };
|
||||
assignedAt: string;
|
||||
}
|
||||
|
||||
export function UserList() {
|
||||
const [users, setUsers] = useState<UserRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<UserRow | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch<{ data: UserRow[] }>('/api/v1/admin/users');
|
||||
setUsers(res.data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
|
||||
function handleNewUser() {
|
||||
setEditingUser(null);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function handleEditUser(user: UserRow) {
|
||||
setEditingUser(user);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
async function handleRemoveUser(userId: string) {
|
||||
setDeletingId(userId);
|
||||
try {
|
||||
await apiFetch(`/api/v1/admin/users/${userId}`, { method: 'DELETE' });
|
||||
await fetchUsers();
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnDef<UserRow, unknown>[] = [
|
||||
{
|
||||
accessorKey: 'displayName',
|
||||
header: 'Name',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{row.original.displayName}</span>
|
||||
<span className="text-xs text-muted-foreground">{row.original.email}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'role',
|
||||
header: 'Role',
|
||||
cell: ({ row }) => <Badge variant="secondary">{row.original.role.name}</Badge>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'isActive',
|
||||
header: 'Status',
|
||||
cell: ({ row }) =>
|
||||
row.original.isActive ? (
|
||||
<Badge variant="default" className="bg-green-600">
|
||||
<ShieldCheck className="mr-1 h-3 w-3" />
|
||||
Active
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive">
|
||||
<ShieldOff className="mr-1 h-3 w-3" />
|
||||
Disabled
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'lastLoginAt',
|
||||
header: 'Last Login',
|
||||
cell: ({ row }) =>
|
||||
row.original.lastLoginAt
|
||||
? new Date(row.original.lastLoginAt).toLocaleDateString()
|
||||
: 'Never',
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEditUser(row.original)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span className="sr-only">Edit</span>
|
||||
</Button>
|
||||
<ConfirmationDialog
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">Remove</span>
|
||||
</Button>
|
||||
}
|
||||
title="Remove User"
|
||||
description={`Remove "${row.original.displayName}" from this port? They will lose access but their account remains.`}
|
||||
confirmLabel="Remove"
|
||||
onConfirm={() => handleRemoveUser(row.original.userId)}
|
||||
loading={deletingId === row.original.userId}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
size: 80,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="User Management"
|
||||
description="Manage users and their roles for this port"
|
||||
actions={
|
||||
<Button onClick={handleNewUser}>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
New User
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={users}
|
||||
isLoading={loading}
|
||||
getRowId={(row) => row.userId}
|
||||
emptyState={
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">No users assigned to this port.</p>
|
||||
<Button variant="link" onClick={handleNewUser} className="mt-2">
|
||||
Add the first user
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<UserForm
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
user={editingUser}
|
||||
onSuccess={fetchUsers}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
267
src/components/reminders/reminder-form.tsx
Normal file
267
src/components/reminders/reminder-form.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { usePermissions } from '@/hooks/use-permissions';
|
||||
|
||||
interface UserOption {
|
||||
id: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
interface ReminderFormProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
reminder?: {
|
||||
id: string;
|
||||
title: string;
|
||||
note: string | null;
|
||||
dueAt: string;
|
||||
priority: string;
|
||||
assignedTo: string | null;
|
||||
clientId: string | null;
|
||||
interestId: string | null;
|
||||
berthId: string | null;
|
||||
} | null;
|
||||
// Pre-fill entity link when creating from entity detail pages
|
||||
defaultClientId?: string;
|
||||
defaultInterestId?: string;
|
||||
defaultBerthId?: string;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function ReminderForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
reminder,
|
||||
defaultClientId,
|
||||
defaultInterestId,
|
||||
defaultBerthId,
|
||||
onSuccess,
|
||||
}: ReminderFormProps) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [note, setNote] = useState('');
|
||||
const [dueAt, setDueAt] = useState('');
|
||||
const [priority, setPriority] = useState('medium');
|
||||
const [assignedTo, setAssignedTo] = useState('');
|
||||
const [clientId, setClientId] = useState('');
|
||||
const [interestId, setInterestId] = useState('');
|
||||
const [berthId, setBerthId] = useState('');
|
||||
const [users, setUsers] = useState<UserOption[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { can } = usePermissions();
|
||||
const canAssignOthers = can('reminders', 'assign_others');
|
||||
|
||||
const isEdit = !!reminder;
|
||||
|
||||
useEffect(() => {
|
||||
if (open && canAssignOthers) {
|
||||
void apiFetch<{ data: UserOption[] }>('/api/v1/admin/users/options').then((res) =>
|
||||
setUsers(res.data),
|
||||
);
|
||||
}
|
||||
}, [open, canAssignOthers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (reminder) {
|
||||
setTitle(reminder.title);
|
||||
setNote(reminder.note ?? '');
|
||||
setDueAt(reminder.dueAt.slice(0, 16)); // datetime-local format
|
||||
setPriority(reminder.priority);
|
||||
setAssignedTo(reminder.assignedTo ?? '');
|
||||
setClientId(reminder.clientId ?? '');
|
||||
setInterestId(reminder.interestId ?? '');
|
||||
setBerthId(reminder.berthId ?? '');
|
||||
} else {
|
||||
setTitle('');
|
||||
setNote('');
|
||||
// Default to tomorrow 9 AM
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(9, 0, 0, 0);
|
||||
setDueAt(tomorrow.toISOString().slice(0, 16));
|
||||
setPriority('medium');
|
||||
setAssignedTo('');
|
||||
setClientId(defaultClientId ?? '');
|
||||
setInterestId(defaultInterestId ?? '');
|
||||
setBerthId(defaultBerthId ?? '');
|
||||
}
|
||||
setError(null);
|
||||
}
|
||||
}, [open, reminder, defaultClientId, defaultInterestId, defaultBerthId]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const body = {
|
||||
title,
|
||||
note: note || undefined,
|
||||
dueAt: new Date(dueAt).toISOString(),
|
||||
priority,
|
||||
assignedTo: assignedTo || undefined,
|
||||
clientId: clientId || undefined,
|
||||
interestId: interestId || undefined,
|
||||
berthId: berthId || undefined,
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
await apiFetch(`/api/v1/reminders/${reminder.id}`, {
|
||||
method: 'PATCH',
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await apiFetch('/api/v1/reminders', {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
}
|
||||
onSuccess();
|
||||
onOpenChange(false);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Something went wrong';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isEdit ? 'Edit Reminder' : 'New Reminder'}</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reminder-title">Title</Label>
|
||||
<Input
|
||||
id="reminder-title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Follow up with client..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reminder-note">Note</Label>
|
||||
<Textarea
|
||||
id="reminder-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="Additional details..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reminder-due">Due Date & Time</Label>
|
||||
<Input
|
||||
id="reminder-due"
|
||||
type="datetime-local"
|
||||
value={dueAt}
|
||||
onChange={(e) => setDueAt(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reminder-priority">Priority</Label>
|
||||
<Select value={priority} onValueChange={setPriority}>
|
||||
<SelectTrigger id="reminder-priority">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
<SelectItem value="urgent">Urgent</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canAssignOthers && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reminder-assign">Assign To</Label>
|
||||
<Select value={assignedTo} onValueChange={setAssignedTo}>
|
||||
<SelectTrigger id="reminder-assign">
|
||||
<SelectValue placeholder="Myself" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Myself</SelectItem>
|
||||
{users.map((u) => (
|
||||
<SelectItem key={u.id} value={u.id}>
|
||||
{u.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
Link to Entity (optional — paste UUIDs, or leave blank)
|
||||
</Label>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<Input
|
||||
placeholder="Client ID"
|
||||
value={clientId}
|
||||
onChange={(e) => setClientId(e.target.value)}
|
||||
className="text-xs"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Interest ID"
|
||||
value={interestId}
|
||||
onChange={(e) => setInterestId(e.target.value)}
|
||||
className="text-xs"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Berth ID"
|
||||
value={berthId}
|
||||
onChange={(e) => setBerthId(e.target.value)}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<SheetFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading || !title.trim() || !dueAt}>
|
||||
{loading ? 'Saving...' : isEdit ? 'Save Changes' : 'Create Reminder'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
328
src/components/reminders/reminder-list.tsx
Normal file
328
src/components/reminders/reminder-list.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { type ColumnDef } from '@tanstack/react-table';
|
||||
import { Plus, CheckCircle2, Clock, XCircle, AlertTriangle, Bell } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
import { DataTable } from '@/components/shared/data-table';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { usePermissions } from '@/hooks/use-permissions';
|
||||
import { ReminderForm } from './reminder-form';
|
||||
import { SnoozeDialog } from './snooze-dialog';
|
||||
|
||||
interface Reminder {
|
||||
id: string;
|
||||
title: string;
|
||||
note: string | null;
|
||||
dueAt: string;
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||
status: 'pending' | 'snoozed' | 'completed' | 'dismissed';
|
||||
assignedTo: string | null;
|
||||
createdBy: string;
|
||||
clientId: string | null;
|
||||
interestId: string | null;
|
||||
berthId: string | null;
|
||||
autoGenerated: boolean;
|
||||
snoozedUntil: string | null;
|
||||
completedAt: string | null;
|
||||
createdAt: string;
|
||||
client?: { id: string; fullName: string } | null;
|
||||
interest?: { id: string; pipelineStage: string } | null;
|
||||
berth?: { id: string; mooringNumber: string } | null;
|
||||
}
|
||||
|
||||
const PRIORITY_CONFIG = {
|
||||
urgent: { label: 'Urgent', className: 'bg-red-600 text-white' },
|
||||
high: { label: 'High', className: 'bg-orange-500 text-white' },
|
||||
medium: { label: 'Medium', className: 'bg-blue-500 text-white' },
|
||||
low: { label: 'Low', className: 'bg-gray-400 text-white' },
|
||||
} as const;
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
pending: { label: 'Pending', icon: Bell },
|
||||
snoozed: { label: 'Snoozed', icon: Clock },
|
||||
completed: { label: 'Completed', icon: CheckCircle2 },
|
||||
dismissed: { label: 'Dismissed', icon: XCircle },
|
||||
} as const;
|
||||
|
||||
export function ReminderList() {
|
||||
const [reminders, setReminders] = useState<Reminder[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingReminder, setEditingReminder] = useState<Reminder | null>(null);
|
||||
const [snoozingId, setSnoozingId] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'my' | 'all'>('my');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('active');
|
||||
const [priorityFilter, setPriorityFilter] = useState<string>('all');
|
||||
const [total, setTotal] = useState(0);
|
||||
const { can } = usePermissions();
|
||||
const canViewAll = can('reminders', 'view_all');
|
||||
|
||||
const fetchReminders = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (viewMode === 'my') {
|
||||
const res = await apiFetch<{ data: Reminder[] }>('/api/v1/reminders/my');
|
||||
let filtered = res.data;
|
||||
if (priorityFilter !== 'all') {
|
||||
filtered = filtered.filter((r) => r.priority === priorityFilter);
|
||||
}
|
||||
setReminders(filtered);
|
||||
setTotal(filtered.length);
|
||||
} else {
|
||||
const params = new URLSearchParams({ limit: '50', order: 'asc', sort: 'dueAt' });
|
||||
if (statusFilter === 'active') {
|
||||
params.set('status', 'pending');
|
||||
} else if (statusFilter !== 'all') {
|
||||
params.set('status', statusFilter);
|
||||
}
|
||||
if (priorityFilter !== 'all') {
|
||||
params.set('priority', priorityFilter);
|
||||
}
|
||||
const res = await apiFetch<{
|
||||
data: Reminder[];
|
||||
pagination: { total: number };
|
||||
}>(`/api/v1/reminders?${params}`);
|
||||
setReminders(res.data);
|
||||
setTotal(res.pagination.total);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [viewMode, statusFilter, priorityFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchReminders();
|
||||
}, [fetchReminders]);
|
||||
|
||||
async function handleComplete(id: string) {
|
||||
await apiFetch(`/api/v1/reminders/${id}/complete`, { method: 'POST' });
|
||||
await fetchReminders();
|
||||
}
|
||||
|
||||
async function handleDismiss(id: string) {
|
||||
await apiFetch(`/api/v1/reminders/${id}/dismiss`, { method: 'POST' });
|
||||
await fetchReminders();
|
||||
}
|
||||
|
||||
function isOverdue(dueAt: string, status: string): boolean {
|
||||
return (status === 'pending' || status === 'snoozed') && new Date(dueAt) < new Date();
|
||||
}
|
||||
|
||||
const columns: ColumnDef<Reminder, unknown>[] = [
|
||||
{
|
||||
accessorKey: 'priority',
|
||||
header: '',
|
||||
cell: ({ row }) => {
|
||||
const config = PRIORITY_CONFIG[row.original.priority];
|
||||
return <Badge className={`${config.className} text-[10px] px-1.5`}>{config.label}</Badge>;
|
||||
},
|
||||
size: 70,
|
||||
},
|
||||
{
|
||||
accessorKey: 'title',
|
||||
header: 'Reminder',
|
||||
cell: ({ row }) => {
|
||||
const overdue = isOverdue(row.original.dueAt, row.original.status);
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{row.original.title}</span>
|
||||
{row.original.autoGenerated && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
Auto
|
||||
</Badge>
|
||||
)}
|
||||
{overdue && <AlertTriangle className="h-3.5 w-3.5 text-destructive" />}
|
||||
</div>
|
||||
{row.original.client && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Client: {row.original.client.fullName}
|
||||
</span>
|
||||
)}
|
||||
{row.original.berth && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Berth: {row.original.berth.mooringNumber}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'dueAt',
|
||||
header: 'Due',
|
||||
cell: ({ row }) => {
|
||||
const overdue = isOverdue(row.original.dueAt, row.original.status);
|
||||
const date = new Date(row.original.dueAt);
|
||||
return (
|
||||
<div className={overdue ? 'text-destructive font-medium' : ''}>
|
||||
<div className="text-sm">{date.toLocaleDateString()}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(date, { addSuffix: true })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => {
|
||||
const config = STATUS_CONFIG[row.original.status];
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{config.label}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
cell: ({ row }) => {
|
||||
if (row.original.status === 'completed' || row.original.status === 'dismissed') {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-green-600 hover:text-green-700"
|
||||
onClick={() => handleComplete(row.original.id)}
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setSnoozingId(row.original.id)}>
|
||||
<Clock className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => handleDismiss(row.original.id)}
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
size: 120,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Reminders"
|
||||
description={`${total} reminder${total !== 1 ? 's' : ''}`}
|
||||
actions={
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditingReminder(null);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
New Reminder
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
{canViewAll && (
|
||||
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as 'my' | 'all')}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="my">My Reminders</TabsTrigger>
|
||||
<TabsTrigger value="all">All Reminders</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
{viewMode === 'all' && (
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="snoozed">Snoozed</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
<SelectItem value="dismissed">Dismissed</SelectItem>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Priorities</SelectItem>
|
||||
<SelectItem value="urgent">Urgent</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={reminders}
|
||||
isLoading={loading}
|
||||
getRowId={(row) => row.id}
|
||||
emptyState={
|
||||
<div className="text-center py-8">
|
||||
<Bell className="mx-auto h-8 w-8 text-muted-foreground mb-2" />
|
||||
<p className="text-muted-foreground">No reminders.</p>
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => {
|
||||
setEditingReminder(null);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
className="mt-2"
|
||||
>
|
||||
Create your first reminder
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<ReminderForm
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
reminder={editingReminder}
|
||||
onSuccess={fetchReminders}
|
||||
/>
|
||||
|
||||
<SnoozeDialog
|
||||
open={!!snoozingId}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSnoozingId(null);
|
||||
}}
|
||||
reminderId={snoozingId}
|
||||
onSuccess={fetchReminders}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
src/components/reminders/snooze-dialog.tsx
Normal file
119
src/components/reminders/snooze-dialog.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface SnoozeDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
reminderId: string | null;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const PRESETS = [
|
||||
{ label: '1 hour', hours: 1 },
|
||||
{ label: '4 hours', hours: 4 },
|
||||
{ label: 'Tomorrow 9 AM', hours: -1 }, // special case
|
||||
{ label: 'Next week', hours: -2 }, // special case
|
||||
] as const;
|
||||
|
||||
function getPresetDate(preset: (typeof PRESETS)[number]): Date {
|
||||
const now = new Date();
|
||||
if (preset.hours === -1) {
|
||||
// Tomorrow 9 AM
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(9, 0, 0, 0);
|
||||
return tomorrow;
|
||||
}
|
||||
if (preset.hours === -2) {
|
||||
// Next Monday 9 AM
|
||||
const next = new Date(now);
|
||||
const daysUntilMonday = (8 - next.getDay()) % 7 || 7;
|
||||
next.setDate(next.getDate() + daysUntilMonday);
|
||||
next.setHours(9, 0, 0, 0);
|
||||
return next;
|
||||
}
|
||||
return new Date(now.getTime() + preset.hours * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
export function SnoozeDialog({ open, onOpenChange, reminderId, onSuccess }: SnoozeDialogProps) {
|
||||
const [customDate, setCustomDate] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSnooze(snoozeUntil: string) {
|
||||
if (!reminderId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await apiFetch(`/api/v1/reminders/${reminderId}/snooze`, {
|
||||
method: 'POST',
|
||||
body: { snoozeUntil },
|
||||
});
|
||||
onSuccess();
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Snooze Reminder</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.label}
|
||||
variant="outline"
|
||||
className="justify-start"
|
||||
disabled={loading}
|
||||
onClick={() => handleSnooze(getPresetDate(preset).toISOString())}
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
<Label htmlFor="custom-snooze">Custom date & time</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="custom-snooze"
|
||||
type="datetime-local"
|
||||
value={customDate}
|
||||
onChange={(e) => setCustomDate(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
disabled={loading || !customDate}
|
||||
onClick={() => handleSnooze(new Date(customDate).toISOString())}
|
||||
>
|
||||
Snooze
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
174
src/components/settings/user-settings.tsx
Normal file
174
src/components/settings/user-settings.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Save } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface NotificationPrefs {
|
||||
reminder_due: boolean;
|
||||
reminder_overdue: boolean;
|
||||
eoi_signed: boolean;
|
||||
eoi_completed: boolean;
|
||||
invoice_overdue: boolean;
|
||||
duplicate_alert: boolean;
|
||||
[key: string]: boolean;
|
||||
}
|
||||
|
||||
export function UserSettings() {
|
||||
const [notifPrefs, setNotifPrefs] = useState<NotificationPrefs | null>(null);
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [timezone, setTimezone] = useState('');
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProfile();
|
||||
void loadNotificationPrefs();
|
||||
}, []);
|
||||
|
||||
async function loadProfile() {
|
||||
const res = await apiFetch<{ data: { user?: { name: string } } }>('/api/v1/me', {
|
||||
method: 'GET',
|
||||
});
|
||||
setDisplayName(res.data.user?.name ?? '');
|
||||
}
|
||||
|
||||
async function loadNotificationPrefs() {
|
||||
try {
|
||||
const res = await apiFetch<{ data: NotificationPrefs }>('/api/v1/notifications/preferences');
|
||||
setNotifPrefs(res.data);
|
||||
} catch {
|
||||
// Preferences may not exist yet
|
||||
setNotifPrefs({
|
||||
reminder_due: true,
|
||||
reminder_overdue: true,
|
||||
eoi_signed: true,
|
||||
eoi_completed: true,
|
||||
invoice_overdue: true,
|
||||
duplicate_alert: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
setSaving('profile');
|
||||
setMessage(null);
|
||||
try {
|
||||
await apiFetch('/api/v1/me', {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
displayName: displayName || undefined,
|
||||
phone: phone || null,
|
||||
preferences: { timezone: timezone || undefined },
|
||||
},
|
||||
});
|
||||
setMessage('Profile saved');
|
||||
} catch (err: unknown) {
|
||||
setMessage(err instanceof Error ? err.message : 'Failed to save');
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleNotifPref(key: string, value: boolean) {
|
||||
setSaving(key);
|
||||
try {
|
||||
await apiFetch('/api/v1/notifications/preferences', {
|
||||
method: 'PATCH',
|
||||
body: { [key]: value },
|
||||
});
|
||||
setNotifPrefs((prev) => (prev ? { ...prev, [key]: value } : prev));
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
}
|
||||
|
||||
const NOTIF_LABELS: Record<string, string> = {
|
||||
reminder_due: 'Reminder due',
|
||||
reminder_overdue: 'Reminder overdue',
|
||||
eoi_signed: 'EOI signed by a party',
|
||||
eoi_completed: 'EOI fully completed',
|
||||
invoice_overdue: 'Invoice overdue',
|
||||
duplicate_alert: 'Duplicate client detected',
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Settings" description="Manage your profile and notification preferences" />
|
||||
|
||||
<div className="mt-6 space-y-6 max-w-2xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profile</CardTitle>
|
||||
<CardDescription>Update your display name and contact info</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settings-name">Display Name</Label>
|
||||
<Input
|
||||
id="settings-name"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Your name"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settings-phone">Phone</Label>
|
||||
<Input
|
||||
id="settings-phone"
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
placeholder="+1 555-0123"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settings-tz">Timezone</Label>
|
||||
<Input
|
||||
id="settings-tz"
|
||||
value={timezone}
|
||||
onChange={(e) => setTimezone(e.target.value)}
|
||||
placeholder="America/Anguilla"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button onClick={saveProfile} disabled={saving === 'profile'}>
|
||||
<Save className="mr-1.5 h-4 w-4" />
|
||||
{saving === 'profile' ? 'Saving...' : 'Save Profile'}
|
||||
</Button>
|
||||
{message && <span className="text-sm text-muted-foreground">{message}</span>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notifications</CardTitle>
|
||||
<CardDescription>Choose which notifications you receive</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{notifPrefs &&
|
||||
Object.entries(NOTIF_LABELS).map(([key, label]) => (
|
||||
<div key={key} className="flex items-center justify-between">
|
||||
<Label>{label}</Label>
|
||||
<Switch
|
||||
checked={notifPrefs[key] ?? true}
|
||||
disabled={saving === key}
|
||||
onCheckedChange={(checked) => toggleNotifPref(key, checked)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
956
src/lib/db/migrations/0000_narrow_longshot.sql
Normal file
956
src/lib/db/migrations/0000_narrow_longshot.sql
Normal file
@@ -0,0 +1,956 @@
|
||||
CREATE TABLE "berth_maintenance_log" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"berth_id" text NOT NULL,
|
||||
"port_id" text NOT NULL,
|
||||
"category" text NOT NULL,
|
||||
"description" text NOT NULL,
|
||||
"cost" numeric,
|
||||
"cost_currency" text DEFAULT 'USD',
|
||||
"responsible_party" text,
|
||||
"performed_date" date NOT NULL,
|
||||
"photo_file_ids" text[],
|
||||
"created_by" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "berth_map_data" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"berth_id" text NOT NULL,
|
||||
"svg_path" text,
|
||||
"x" numeric,
|
||||
"y" numeric,
|
||||
"transform" text,
|
||||
"font_size" numeric,
|
||||
"extra_data" jsonb DEFAULT '{}'::jsonb,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "berth_map_data_berth_id_unique" UNIQUE("berth_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "berth_recommendations" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"interest_id" text NOT NULL,
|
||||
"berth_id" text NOT NULL,
|
||||
"match_score" numeric,
|
||||
"match_reasons" jsonb,
|
||||
"source" text DEFAULT 'ai' NOT NULL,
|
||||
"created_by" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "berth_tags" (
|
||||
"berth_id" text NOT NULL,
|
||||
"tag_id" text NOT NULL,
|
||||
CONSTRAINT "berth_tags_berth_id_tag_id_pk" PRIMARY KEY("berth_id","tag_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "berth_waiting_list" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"berth_id" text NOT NULL,
|
||||
"client_id" text NOT NULL,
|
||||
"position" integer NOT NULL,
|
||||
"priority" text DEFAULT 'normal' NOT NULL,
|
||||
"notify_pref" text DEFAULT 'email',
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "berths" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"port_id" text NOT NULL,
|
||||
"mooring_number" text NOT NULL,
|
||||
"area" text,
|
||||
"status" text DEFAULT 'available' NOT NULL,
|
||||
"length_ft" numeric,
|
||||
"width_ft" numeric,
|
||||
"draft_ft" numeric,
|
||||
"length_m" numeric,
|
||||
"width_m" numeric,
|
||||
"draft_m" numeric,
|
||||
"width_is_minimum" boolean DEFAULT false,
|
||||
"nominal_boat_size" text,
|
||||
"nominal_boat_size_m" text,
|
||||
"water_depth" numeric,
|
||||
"water_depth_m" numeric,
|
||||
"water_depth_is_minimum" boolean DEFAULT false,
|
||||
"side_pontoon" text,
|
||||
"power_capacity" text,
|
||||
"voltage" text,
|
||||
"mooring_type" text,
|
||||
"cleat_type" text,
|
||||
"cleat_capacity" text,
|
||||
"bollard_type" text,
|
||||
"bollard_capacity" text,
|
||||
"access" text,
|
||||
"price" numeric,
|
||||
"price_currency" text DEFAULT 'USD' NOT NULL,
|
||||
"bow_facing" text,
|
||||
"berth_approved" boolean DEFAULT false,
|
||||
"tenure_type" text DEFAULT 'permanent' NOT NULL,
|
||||
"tenure_years" integer,
|
||||
"tenure_start_date" date,
|
||||
"tenure_end_date" date,
|
||||
"status_last_changed_by" text,
|
||||
"status_last_changed_reason" text,
|
||||
"status_last_modified" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "client_addresses" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"client_id" text NOT NULL,
|
||||
"port_id" text NOT NULL,
|
||||
"label" text DEFAULT 'Primary' NOT NULL,
|
||||
"street_address" text,
|
||||
"city" text,
|
||||
"state_province" text,
|
||||
"postal_code" text,
|
||||
"country" text,
|
||||
"is_primary" boolean DEFAULT true NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "client_contacts" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"client_id" text NOT NULL,
|
||||
"channel" text NOT NULL,
|
||||
"value" text NOT NULL,
|
||||
"label" text,
|
||||
"is_primary" boolean DEFAULT false NOT NULL,
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "client_merge_log" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"port_id" text NOT NULL,
|
||||
"surviving_client_id" text NOT NULL,
|
||||
"merged_client_id" text NOT NULL,
|
||||
"merged_by" text NOT NULL,
|
||||
"merge_details" jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "client_notes" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"client_id" text NOT NULL,
|
||||
"author_id" text NOT NULL,
|
||||
"content" text NOT NULL,
|
||||
"mentions" text[],
|
||||
"is_locked" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "client_relationships" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"port_id" text NOT NULL,
|
||||
"client_a_id" text NOT NULL,
|
||||
"client_b_id" text NOT NULL,
|
||||
"relationship_type" text NOT NULL,
|
||||
"description" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "client_tags" (
|
||||
"client_id" text NOT NULL,
|
||||
"tag_id" text NOT NULL,
|
||||
CONSTRAINT "client_tags_client_id_tag_id_pk" PRIMARY KEY("client_id","tag_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "clients" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"port_id" text NOT NULL,
|
||||
"full_name" text NOT NULL,
|
||||
"company_name" text,
|
||||
"nationality" text,
|
||||
"is_proxy" boolean DEFAULT false NOT NULL,
|
||||
"proxy_type" text,
|
||||
"actual_owner_name" text,
|
||||
"relationship_notes" text,
|
||||
"yacht_name" text,
|
||||
"yacht_length_ft" numeric,
|
||||
"yacht_width_ft" numeric,
|
||||
"yacht_draft_ft" numeric,
|
||||
"yacht_length_m" numeric,
|
||||
"yacht_width_m" numeric,
|
||||
"yacht_draft_m" numeric,
|
||||
"berth_size_desired" text,
|
||||
"preferred_contact_method" text,
|
||||
"preferred_language" text,
|
||||
"timezone" text,
|
||||
"source" text,
|
||||
"source_details" text,
|
||||
"archived_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "document_events" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"document_id" text NOT NULL,
|
||||
"event_type" text NOT NULL,
|
||||
"signer_id" text,
|
||||
"event_data" jsonb DEFAULT '{}'::jsonb,
|
||||
"signature_hash" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "document_signers" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"document_id" text NOT NULL,
|
||||
"signer_name" text NOT NULL,
|
||||
"signer_email" text NOT NULL,
|
||||
"signer_role" text NOT NULL,
|
||||
"signing_order" integer NOT NULL,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"signed_at" timestamp with time zone,
|
||||
"signing_url" text,
|
||||
"embedded_url" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "document_templates" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"port_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"template_type" text NOT NULL,
|
||||
"body_html" text NOT NULL,
|
||||
"merge_fields" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"is_active" boolean DEFAULT true NOT NULL,
|
||||
"created_by" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "documents" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"port_id" text NOT NULL,
|
||||
"interest_id" text,
|
||||
"client_id" text,
|
||||
"document_type" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"status" text DEFAULT 'draft' NOT NULL,
|
||||
"documenso_id" text,
|
||||
"file_id" text,
|
||||
"signed_file_id" text,
|
||||
"is_manual_upload" boolean DEFAULT false NOT NULL,
|
||||
"notes" text,
|
||||
"created_by" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "files" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"port_id" text NOT NULL,
|
||||
"client_id" text,
|
||||
"filename" text NOT NULL,
|
||||
"original_name" text NOT NULL,
|
||||
"mime_type" text,
|
||||
"size_bytes" text,
|
||||
"storage_path" text NOT NULL,
|
||||
"storage_bucket" text DEFAULT 'crm-files' NOT NULL,
|
||||
"category" text,
|
||||
"uploaded_by" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "form_submissions" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"form_template_id" text NOT NULL,
|
||||
"client_id" text,
|
||||
"interest_id" text,
|
||||
"token" text NOT NULL,
|
||||
"prefilled_data" jsonb DEFAULT '{}'::jsonb,
|
||||
"submitted_data" jsonb,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"expires_at" timestamp with time zone,
|
||||
"submitted_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "form_submissions_token_unique" UNIQUE("token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "form_templates" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"port_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"fields" jsonb NOT NULL,
|
||||
"branding" jsonb DEFAULT '{}'::jsonb,
|
||||
"is_active" boolean DEFAULT true NOT NULL,
|
||||
"created_by" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "email_accounts" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"port_id" text NOT NULL,
|
||||
"provider" text NOT NULL,
|
||||
"email_address" text NOT NULL,
|
||||
"smtp_host" text NOT NULL,
|
||||
"smtp_port" integer NOT NULL,
|
||||
"imap_host" text NOT NULL,
|
||||
"imap_port" integer NOT NULL,
|
||||
"credentials_enc" text NOT NULL,
|
||||
"is_active" boolean DEFAULT true NOT NULL,
|
||||
"last_sync_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "email_messages" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"thread_id" text NOT NULL,
|
||||
"message_id_header" text,
|
||||
"from_address" text NOT NULL,
|
||||
"to_addresses" text[] NOT NULL,
|
||||
"cc_addresses" text[],
|
||||
"subject" text,
|
||||
"body_text" text,
|
||||
"body_html" text,
|
||||
"direction" text NOT NULL,
|
||||
"sent_at" timestamp with time zone NOT NULL,
|
||||
"attachment_file_ids" text[],
|
||||
"raw_file_id" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "email_threads" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"port_id" text NOT NULL,
|
||||
"client_id" text,
|
||||
"subject" text,
|
||||
"last_message_at" timestamp with time zone,
|
||||
"message_count" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "expenses" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"port_id" text NOT NULL,
|
||||
"establishment_name" text,
|
||||
"amount" numeric NOT NULL,
|
||||
"currency" text DEFAULT 'USD' NOT NULL,
|
||||
"amount_usd" numeric,
|
||||
"exchange_rate" numeric,
|
||||
"payment_method" text,
|
||||
"category" text,
|
||||
"payer" text,
|
||||
"expense_date" timestamp with time zone NOT NULL,
|
||||
"description" text,
|
||||
"receipt_file_ids" text[],
|
||||
"payment_status" text DEFAULT 'unpaid',
|
||||
"payment_date" date,
|
||||
"payment_reference" text,
|
||||
"payment_notes" text,
|
||||
"created_by" text NOT NULL,
|
||||
"archived_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "invoice_expenses" (
|
||||
"invoice_id" text NOT NULL,
|
||||
"expense_id" text NOT NULL,
|
||||
CONSTRAINT "invoice_expenses_invoice_id_expense_id_pk" PRIMARY KEY("invoice_id","expense_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "invoice_line_items" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"invoice_id" text NOT NULL,
|
||||
"description" text NOT NULL,
|
||||
"quantity" numeric DEFAULT '1' NOT NULL,
|
||||
"unit_price" numeric NOT NULL,
|
||||
"total" numeric NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "invoices" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"port_id" text NOT NULL,
|
||||
"invoice_number" text NOT NULL,
|
||||
"client_name" text NOT NULL,
|
||||
"billing_email" text,
|
||||
"billing_address" text,
|
||||
"due_date" date NOT NULL,
|
||||
"payment_terms" text DEFAULT 'net30' NOT NULL,
|
||||
"currency" text DEFAULT 'USD' NOT NULL,
|
||||
"subtotal" numeric NOT NULL,
|
||||
"discount_pct" numeric DEFAULT '0',
|
||||
"discount_amount" numeric DEFAULT '0',
|
||||
"fee_pct" numeric DEFAULT '0',
|
||||
"fee_amount" numeric DEFAULT '0',
|
||||
"total" numeric NOT NULL,
|
||||
"status" text DEFAULT 'draft' NOT NULL,
|
||||
"payment_status" text DEFAULT 'unpaid',
|
||||
"payment_date" date,
|
||||
"payment_method" text,
|
||||
"payment_reference" text,
|
||||
"pdf_file_id" text,
|
||||
"notes" text,
|
||||
"created_by" text NOT NULL,
|
||||
"archived_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ports" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"logo_url" text,
|
||||
"primary_color" text,
|
||||
"default_currency" text DEFAULT 'USD' NOT NULL,
|
||||
"timezone" text DEFAULT 'America/Anguilla' NOT NULL,
|
||||
"settings" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"is_active" boolean DEFAULT true NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "account" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"account_id" text NOT NULL,
|
||||
"provider_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"access_token" text,
|
||||
"refresh_token" text,
|
||||
"id_token" text,
|
||||
"access_token_expires_at" timestamp with time zone,
|
||||
"refresh_token_expires_at" timestamp with time zone,
|
||||
"scope" text,
|
||||
"password" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "port_role_overrides" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"port_id" text NOT NULL,
|
||||
"role_id" text NOT NULL,
|
||||
"permission_overrides" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "roles" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"permissions" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"is_global" boolean DEFAULT true NOT NULL,
|
||||
"is_system" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "session" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"token" text NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"ip_address" text,
|
||||
"user_agent" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "session_token_unique" UNIQUE("token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "user" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"email_verified" boolean DEFAULT false NOT NULL,
|
||||
"image" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "user_email_unique" UNIQUE("email")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "user_port_roles" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"port_id" text NOT NULL,
|
||||
"role_id" text NOT NULL,
|
||||
"assigned_by" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "user_profiles" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"display_name" text NOT NULL,
|
||||
"avatar_url" text,
|
||||
"phone" text,
|
||||
"is_super_admin" boolean DEFAULT false NOT NULL,
|
||||
"is_active" boolean DEFAULT true NOT NULL,
|
||||
"last_login_at" timestamp with time zone,
|
||||
"preferences" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "user_profiles_user_id_unique" UNIQUE("user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "verification" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"identifier" text NOT NULL,
|
||||
"value" text NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"created_at" timestamp with time zone,
|
||||
"updated_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "interest_notes" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"interest_id" text NOT NULL,
|
||||
"author_id" text NOT NULL,
|
||||
"content" text NOT NULL,
|
||||
"mentions" text[],
|
||||
"is_locked" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "interest_tags" (
|
||||
"interest_id" text NOT NULL,
|
||||
"tag_id" text NOT NULL,
|
||||
CONSTRAINT "interest_tags_interest_id_tag_id_pk" PRIMARY KEY("interest_id","tag_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "interests" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"port_id" text NOT NULL,
|
||||
"client_id" text NOT NULL,
|
||||
"berth_id" text,
|
||||
"pipeline_stage" text DEFAULT 'open' NOT NULL,
|
||||
"lead_category" text,
|
||||
"source" text,
|
||||
"eoi_status" text,
|
||||
"documenso_id" text,
|
||||
"contract_status" text,
|
||||
"deposit_status" text,
|
||||
"reservation_status" text,
|
||||
"date_first_contact" timestamp with time zone,
|
||||
"date_last_contact" timestamp with time zone,
|
||||
"date_eoi_sent" timestamp with time zone,
|
||||
"date_eoi_signed" timestamp with time zone,
|
||||
"date_contract_sent" timestamp with time zone,
|
||||
"date_contract_signed" timestamp with time zone,
|
||||
"date_deposit_received" timestamp with time zone,
|
||||
"reminder_enabled" boolean DEFAULT false NOT NULL,
|
||||
"reminder_days" integer,
|
||||
"reminder_last_fired" timestamp with time zone,
|
||||
"notes" text,
|
||||
"archived_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "generated_reports" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"port_id" text NOT NULL,
|
||||
"scheduled_report_id" text,
|
||||
"report_type" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"status" text DEFAULT 'queued' NOT NULL,
|
||||
"parameters" jsonb DEFAULT '{}'::jsonb,
|
||||
"file_id" text,
|
||||
"error_message" text,
|
||||
"requested_by" text NOT NULL,
|
||||
"started_at" timestamp with time zone,
|
||||
"completed_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "google_calendar_cache" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"event_id" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"start_at" timestamp with time zone NOT NULL,
|
||||
"end_at" timestamp with time zone,
|
||||
"location" text,
|
||||
"description" text,
|
||||
"is_crm_pushed" boolean DEFAULT false NOT NULL,
|
||||
"reminder_id" text,
|
||||
"fetched_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "google_calendar_tokens" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"access_token" text NOT NULL,
|
||||
"refresh_token" text NOT NULL,
|
||||
"token_expiry" timestamp with time zone NOT NULL,
|
||||
"calendar_id" text DEFAULT 'primary' NOT NULL,
|
||||
"connected_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"last_sync_at" timestamp with time zone,
|
||||
"sync_enabled" boolean DEFAULT true NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "google_calendar_tokens_user_id_unique" UNIQUE("user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "notifications" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"port_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"description" text,
|
||||
"link" text,
|
||||
"entity_type" text,
|
||||
"entity_id" text,
|
||||
"is_read" boolean DEFAULT false NOT NULL,
|
||||
"email_sent" boolean DEFAULT false NOT NULL,
|
||||
"metadata" jsonb DEFAULT '{}'::jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "reminders" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"port_id" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"note" text,
|
||||
"due_at" timestamp with time zone NOT NULL,
|
||||
"priority" text DEFAULT 'medium' NOT NULL,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"assigned_to" text,
|
||||
"created_by" text NOT NULL,
|
||||
"client_id" text,
|
||||
"interest_id" text,
|
||||
"berth_id" text,
|
||||
"auto_generated" boolean DEFAULT false NOT NULL,
|
||||
"google_calendar_event_id" text,
|
||||
"google_calendar_synced" boolean DEFAULT false NOT NULL,
|
||||
"snoozed_until" timestamp with time zone,
|
||||
"completed_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "report_recipients" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"report_id" text NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"user_id" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "scheduled_reports" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"port_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"report_type" text NOT NULL,
|
||||
"schedule" text NOT NULL,
|
||||
"last_run_at" timestamp with time zone,
|
||||
"next_run_at" timestamp with time zone,
|
||||
"is_active" boolean DEFAULT true NOT NULL,
|
||||
"config" jsonb DEFAULT '{}'::jsonb,
|
||||
"created_by" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "audit_logs" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"port_id" text,
|
||||
"user_id" text,
|
||||
"action" text NOT NULL,
|
||||
"entity_type" text NOT NULL,
|
||||
"entity_id" text,
|
||||
"field_changed" text,
|
||||
"old_value" jsonb,
|
||||
"new_value" jsonb,
|
||||
"ip_address" text,
|
||||
"user_agent" text,
|
||||
"reverted_by" text,
|
||||
"reverted_at" timestamp with time zone,
|
||||
"revert_of" text,
|
||||
"metadata" jsonb DEFAULT '{}'::jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "currency_rates" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"base_currency" text NOT NULL,
|
||||
"target_currency" text NOT NULL,
|
||||
"rate" numeric NOT NULL,
|
||||
"source" text DEFAULT 'frankfurter' NOT NULL,
|
||||
"fetched_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "custom_field_definitions" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"port_id" text NOT NULL,
|
||||
"entity_type" text NOT NULL,
|
||||
"field_name" text NOT NULL,
|
||||
"field_label" text NOT NULL,
|
||||
"field_type" text NOT NULL,
|
||||
"select_options" jsonb,
|
||||
"is_required" boolean DEFAULT false NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "custom_field_values" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"field_id" text NOT NULL,
|
||||
"entity_id" text NOT NULL,
|
||||
"value" jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "saved_views" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"port_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"entity_type" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"filters" jsonb NOT NULL,
|
||||
"sort_config" jsonb,
|
||||
"column_config" jsonb,
|
||||
"is_shared" boolean DEFAULT false NOT NULL,
|
||||
"is_default" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "scratchpad_notes" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"content" text NOT NULL,
|
||||
"linked_client_id" text,
|
||||
"linked_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "system_settings" (
|
||||
"key" text NOT NULL,
|
||||
"value" jsonb NOT NULL,
|
||||
"port_id" text,
|
||||
"updated_by" text,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "tags" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"port_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"color" text DEFAULT '#6B7280' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "user_notification_preferences" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"port_id" text NOT NULL,
|
||||
"notification_type" text NOT NULL,
|
||||
"in_app" boolean DEFAULT true NOT NULL,
|
||||
"email" boolean DEFAULT true NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "webhook_deliveries" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"webhook_id" text NOT NULL,
|
||||
"event_type" text NOT NULL,
|
||||
"payload" jsonb NOT NULL,
|
||||
"response_status" integer,
|
||||
"response_body" text,
|
||||
"attempt" integer DEFAULT 1 NOT NULL,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"delivered_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "webhooks" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"port_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"url" text NOT NULL,
|
||||
"secret" text,
|
||||
"events" text[] NOT NULL,
|
||||
"is_active" boolean DEFAULT true NOT NULL,
|
||||
"created_by" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "berth_maintenance_log" ADD CONSTRAINT "berth_maintenance_log_berth_id_berths_id_fk" FOREIGN KEY ("berth_id") REFERENCES "public"."berths"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "berth_maintenance_log" ADD CONSTRAINT "berth_maintenance_log_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "berth_map_data" ADD CONSTRAINT "berth_map_data_berth_id_berths_id_fk" FOREIGN KEY ("berth_id") REFERENCES "public"."berths"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "berth_recommendations" ADD CONSTRAINT "berth_recommendations_berth_id_berths_id_fk" FOREIGN KEY ("berth_id") REFERENCES "public"."berths"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "berth_tags" ADD CONSTRAINT "berth_tags_berth_id_berths_id_fk" FOREIGN KEY ("berth_id") REFERENCES "public"."berths"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "berth_waiting_list" ADD CONSTRAINT "berth_waiting_list_berth_id_berths_id_fk" FOREIGN KEY ("berth_id") REFERENCES "public"."berths"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "berth_waiting_list" ADD CONSTRAINT "berth_waiting_list_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "berths" ADD CONSTRAINT "berths_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "client_addresses" ADD CONSTRAINT "client_addresses_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "client_addresses" ADD CONSTRAINT "client_addresses_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "client_contacts" ADD CONSTRAINT "client_contacts_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "client_merge_log" ADD CONSTRAINT "client_merge_log_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "client_merge_log" ADD CONSTRAINT "client_merge_log_surviving_client_id_clients_id_fk" FOREIGN KEY ("surviving_client_id") REFERENCES "public"."clients"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "client_notes" ADD CONSTRAINT "client_notes_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "client_relationships" ADD CONSTRAINT "client_relationships_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "client_relationships" ADD CONSTRAINT "client_relationships_client_a_id_clients_id_fk" FOREIGN KEY ("client_a_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "client_relationships" ADD CONSTRAINT "client_relationships_client_b_id_clients_id_fk" FOREIGN KEY ("client_b_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "client_tags" ADD CONSTRAINT "client_tags_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "clients" ADD CONSTRAINT "clients_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "document_events" ADD CONSTRAINT "document_events_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "document_events" ADD CONSTRAINT "document_events_signer_id_document_signers_id_fk" FOREIGN KEY ("signer_id") REFERENCES "public"."document_signers"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "document_signers" ADD CONSTRAINT "document_signers_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "document_templates" ADD CONSTRAINT "document_templates_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "documents" ADD CONSTRAINT "documents_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "documents" ADD CONSTRAINT "documents_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "documents" ADD CONSTRAINT "documents_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "documents" ADD CONSTRAINT "documents_signed_file_id_files_id_fk" FOREIGN KEY ("signed_file_id") REFERENCES "public"."files"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "files" ADD CONSTRAINT "files_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "files" ADD CONSTRAINT "files_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "form_submissions" ADD CONSTRAINT "form_submissions_form_template_id_form_templates_id_fk" FOREIGN KEY ("form_template_id") REFERENCES "public"."form_templates"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "form_submissions" ADD CONSTRAINT "form_submissions_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "form_templates" ADD CONSTRAINT "form_templates_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "email_accounts" ADD CONSTRAINT "email_accounts_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "email_messages" ADD CONSTRAINT "email_messages_thread_id_email_threads_id_fk" FOREIGN KEY ("thread_id") REFERENCES "public"."email_threads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "email_messages" ADD CONSTRAINT "email_messages_raw_file_id_files_id_fk" FOREIGN KEY ("raw_file_id") REFERENCES "public"."files"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "email_threads" ADD CONSTRAINT "email_threads_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "email_threads" ADD CONSTRAINT "email_threads_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "expenses" ADD CONSTRAINT "expenses_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "invoice_expenses" ADD CONSTRAINT "invoice_expenses_invoice_id_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."invoices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "invoice_expenses" ADD CONSTRAINT "invoice_expenses_expense_id_expenses_id_fk" FOREIGN KEY ("expense_id") REFERENCES "public"."expenses"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "invoice_line_items" ADD CONSTRAINT "invoice_line_items_invoice_id_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."invoices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_pdf_file_id_files_id_fk" FOREIGN KEY ("pdf_file_id") REFERENCES "public"."files"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "port_role_overrides" ADD CONSTRAINT "port_role_overrides_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "port_role_overrides" ADD CONSTRAINT "port_role_overrides_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user_port_roles" ADD CONSTRAINT "user_port_roles_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user_port_roles" ADD CONSTRAINT "user_port_roles_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "interest_notes" ADD CONSTRAINT "interest_notes_interest_id_interests_id_fk" FOREIGN KEY ("interest_id") REFERENCES "public"."interests"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "interest_tags" ADD CONSTRAINT "interest_tags_interest_id_interests_id_fk" FOREIGN KEY ("interest_id") REFERENCES "public"."interests"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "interests" ADD CONSTRAINT "interests_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "interests" ADD CONSTRAINT "interests_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "generated_reports" ADD CONSTRAINT "generated_reports_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "generated_reports" ADD CONSTRAINT "generated_reports_scheduled_report_id_scheduled_reports_id_fk" FOREIGN KEY ("scheduled_report_id") REFERENCES "public"."scheduled_reports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "generated_reports" ADD CONSTRAINT "generated_reports_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "google_calendar_cache" ADD CONSTRAINT "google_calendar_cache_reminder_id_reminders_id_fk" FOREIGN KEY ("reminder_id") REFERENCES "public"."reminders"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "reminders" ADD CONSTRAINT "reminders_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "reminders" ADD CONSTRAINT "reminders_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "report_recipients" ADD CONSTRAINT "report_recipients_report_id_scheduled_reports_id_fk" FOREIGN KEY ("report_id") REFERENCES "public"."scheduled_reports"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "scheduled_reports" ADD CONSTRAINT "scheduled_reports_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_revert_of_audit_logs_id_fk" FOREIGN KEY ("revert_of") REFERENCES "public"."audit_logs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "custom_field_definitions" ADD CONSTRAINT "custom_field_definitions_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "custom_field_values" ADD CONSTRAINT "custom_field_values_field_id_custom_field_definitions_id_fk" FOREIGN KEY ("field_id") REFERENCES "public"."custom_field_definitions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "saved_views" ADD CONSTRAINT "saved_views_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "scratchpad_notes" ADD CONSTRAINT "scratchpad_notes_linked_client_id_clients_id_fk" FOREIGN KEY ("linked_client_id") REFERENCES "public"."clients"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "system_settings" ADD CONSTRAINT "system_settings_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "tags" ADD CONSTRAINT "tags_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user_notification_preferences" ADD CONSTRAINT "user_notification_preferences_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "webhook_deliveries" ADD CONSTRAINT "webhook_deliveries_webhook_id_webhooks_id_fk" FOREIGN KEY ("webhook_id") REFERENCES "public"."webhooks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "webhooks" ADD CONSTRAINT "webhooks_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_bml_berth" ON "berth_maintenance_log" USING btree ("berth_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_bml_port" ON "berth_maintenance_log" USING btree ("port_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "berth_map_data_berth_id_idx" ON "berth_map_data" USING btree ("berth_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "berth_rec_interest_berth_idx" ON "berth_recommendations" USING btree ("interest_id","berth_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_br_interest" ON "berth_recommendations" USING btree ("interest_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "berth_waiting_list_berth_client_idx" ON "berth_waiting_list" USING btree ("berth_id","client_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_bwl_berth" ON "berth_waiting_list" USING btree ("berth_id","position");--> statement-breakpoint
|
||||
CREATE INDEX "idx_berths_port" ON "berths" USING btree ("port_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_berths_status" ON "berths" USING btree ("port_id","status");--> statement-breakpoint
|
||||
CREATE INDEX "idx_berths_area" ON "berths" USING btree ("port_id","area");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_berths_mooring" ON "berths" USING btree ("port_id","mooring_number");--> statement-breakpoint
|
||||
CREATE INDEX "idx_ca_client" ON "client_addresses" USING btree ("client_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_ca_port" ON "client_addresses" USING btree ("port_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_cc_client" ON "client_contacts" USING btree ("client_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_cc_email" ON "client_contacts" USING btree ("channel","value") WHERE "client_contacts"."channel" = 'email';--> statement-breakpoint
|
||||
CREATE INDEX "idx_cc_phone" ON "client_contacts" USING btree ("channel","value") WHERE "client_contacts"."channel" = 'phone';--> statement-breakpoint
|
||||
CREATE INDEX "idx_cml_port" ON "client_merge_log" USING btree ("port_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_cn_client" ON "client_notes" USING btree ("client_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_cr_port" ON "client_relationships" USING btree ("port_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_clients_port" ON "clients" USING btree ("port_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_clients_name" ON "clients" USING btree ("port_id","full_name");--> statement-breakpoint
|
||||
CREATE INDEX "idx_clients_archived" ON "clients" USING btree ("port_id","archived_at");--> statement-breakpoint
|
||||
CREATE INDEX "idx_de_doc" ON "document_events" USING btree ("document_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_de_dedup" ON "document_events" USING btree ("document_id","signature_hash") WHERE "document_events"."signature_hash" IS NOT NULL;--> statement-breakpoint
|
||||
CREATE INDEX "idx_ds_doc" ON "document_signers" USING btree ("document_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_dt_port" ON "document_templates" USING btree ("port_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_dt_type" ON "document_templates" USING btree ("port_id","template_type");--> statement-breakpoint
|
||||
CREATE INDEX "idx_docs_port" ON "documents" USING btree ("port_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_docs_interest" ON "documents" USING btree ("interest_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_docs_client" ON "documents" USING btree ("client_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_docs_type" ON "documents" USING btree ("port_id","document_type");--> statement-breakpoint
|
||||
CREATE INDEX "idx_files_port" ON "files" USING btree ("port_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_files_client" ON "files" USING btree ("client_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_fs_token" ON "form_submissions" USING btree ("token");--> statement-breakpoint
|
||||
CREATE INDEX "idx_ft_port" ON "form_templates" USING btree ("port_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_ea_user" ON "email_accounts" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_ea_port" ON "email_accounts" USING btree ("port_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_em_thread" ON "email_messages" USING btree ("thread_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_em_message_id" ON "email_messages" USING btree ("message_id_header") WHERE "email_messages"."message_id_header" IS NOT NULL;--> statement-breakpoint
|
||||
CREATE INDEX "idx_et_client" ON "email_threads" USING btree ("client_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_et_port" ON "email_threads" USING btree ("port_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_expenses_port" ON "expenses" USING btree ("port_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_expenses_date" ON "expenses" USING btree ("port_id","expense_date");--> statement-breakpoint
|
||||
CREATE INDEX "idx_expenses_category" ON "expenses" USING btree ("port_id","category");--> statement-breakpoint
|
||||
CREATE INDEX "idx_ili_invoice" ON "invoice_line_items" USING btree ("invoice_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_invoices_number" ON "invoices" USING btree ("port_id","invoice_number");--> statement-breakpoint
|
||||
CREATE INDEX "idx_invoices_port" ON "invoices" USING btree ("port_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_invoices_status" ON "invoices" USING btree ("port_id","status");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "ports_slug_idx" ON "ports" USING btree ("slug");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "port_role_overrides_port_role_idx" ON "port_role_overrides" USING btree ("port_id","role_id");--> statement-breakpoint
|
||||
CREATE INDEX "port_role_overrides_port_idx" ON "port_role_overrides" USING btree ("port_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "sessions_token_idx" ON "session" USING btree ("token");--> statement-breakpoint
|
||||
CREATE INDEX "sessions_user_id_idx" ON "session" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "user_port_roles_user_port_role_idx" ON "user_port_roles" USING btree ("user_id","port_id","role_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_upr_user" ON "user_port_roles" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_upr_port" ON "user_port_roles" USING btree ("port_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "user_profiles_user_id_idx" ON "user_profiles" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_in_interest" ON "interest_notes" USING btree ("interest_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_interests_port" ON "interests" USING btree ("port_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_interests_client" ON "interests" USING btree ("client_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_interests_berth" ON "interests" USING btree ("berth_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_interests_stage" ON "interests" USING btree ("port_id","pipeline_stage");--> statement-breakpoint
|
||||
CREATE INDEX "idx_interests_archived" ON "interests" USING btree ("port_id","archived_at");--> statement-breakpoint
|
||||
CREATE INDEX "idx_gr_port_created" ON "generated_reports" USING btree ("port_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "idx_gr_port_status" ON "generated_reports" USING btree ("port_id","status");--> statement-breakpoint
|
||||
CREATE INDEX "idx_gr_scheduled" ON "generated_reports" USING btree ("scheduled_report_id") WHERE "generated_reports"."scheduled_report_id" IS NOT NULL;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "gcal_cache_user_event_idx" ON "google_calendar_cache" USING btree ("user_id","event_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_gcal_cache_user" ON "google_calendar_cache" USING btree ("user_id","start_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "gcal_tokens_user_id_idx" ON "google_calendar_tokens" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_notif_user" ON "notifications" USING btree ("user_id","is_read");--> statement-breakpoint
|
||||
CREATE INDEX "idx_notif_port" ON "notifications" USING btree ("port_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_notifications_user_type" ON "notifications" USING btree ("user_id","type","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "idx_reminders_port" ON "reminders" USING btree ("port_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_reminders_assigned" ON "reminders" USING btree ("assigned_to","status");--> statement-breakpoint
|
||||
CREATE INDEX "idx_reminders_due" ON "reminders" USING btree ("port_id","due_at") WHERE "reminders"."status" IN ('pending', 'snoozed');--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "report_recipients_report_email_idx" ON "report_recipients" USING btree ("report_id","email");--> statement-breakpoint
|
||||
CREATE INDEX "idx_rr_report" ON "report_recipients" USING btree ("report_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_sr_port" ON "scheduled_reports" USING btree ("port_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_al_port" ON "audit_logs" USING btree ("port_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "idx_al_entity" ON "audit_logs" USING btree ("entity_type","entity_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_al_user" ON "audit_logs" USING btree ("user_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "idx_al_created" ON "audit_logs" USING btree ("created_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "currency_rates_base_target_idx" ON "currency_rates" USING btree ("base_currency","target_currency");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "cfd_port_entity_name_idx" ON "custom_field_definitions" USING btree ("port_id","entity_type","field_name");--> statement-breakpoint
|
||||
CREATE INDEX "idx_cfd_port" ON "custom_field_definitions" USING btree ("port_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "cfv_field_entity_idx" ON "custom_field_values" USING btree ("field_id","entity_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_cfv_entity" ON "custom_field_values" USING btree ("entity_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_sv_user" ON "saved_views" USING btree ("user_id","entity_type");--> statement-breakpoint
|
||||
CREATE INDEX "idx_sp_user" ON "scratchpad_notes" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "system_settings_key_port_idx" ON "system_settings" USING btree ("key","port_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "tags_port_name_idx" ON "tags" USING btree ("port_id","name");--> statement-breakpoint
|
||||
CREATE INDEX "idx_tags_port" ON "tags" USING btree ("port_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "unp_user_port_type_idx" ON "user_notification_preferences" USING btree ("user_id","port_id","notification_type");--> statement-breakpoint
|
||||
CREATE INDEX "idx_wd_webhook" ON "webhook_deliveries" USING btree ("webhook_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "idx_webhooks_port" ON "webhooks" USING btree ("port_id");
|
||||
1
src/lib/db/migrations/0001_soft_ender_wiggin.sql
Normal file
1
src/lib/db/migrations/0001_soft_ender_wiggin.sql
Normal file
@@ -0,0 +1 @@
|
||||
CREATE UNIQUE INDEX "idx_ca_primary" ON "client_addresses" USING btree ("client_id") WHERE "client_addresses"."is_primary" = true;
|
||||
7074
src/lib/db/migrations/meta/0000_snapshot.json
Normal file
7074
src/lib/db/migrations/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
7090
src/lib/db/migrations/meta/0001_snapshot.json
Normal file
7090
src/lib/db/migrations/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
20
src/lib/db/migrations/meta/_journal.json
Normal file
20
src/lib/db/migrations/meta/_journal.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1776185027494,
|
||||
"tag": "0000_narrow_longshot",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1776185487775,
|
||||
"tag": "0001_soft_ender_wiggin",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
numeric,
|
||||
jsonb,
|
||||
index,
|
||||
uniqueIndex,
|
||||
primaryKey,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
@@ -14,7 +15,9 @@ import { ports } from './ports';
|
||||
export const clients = pgTable(
|
||||
'clients',
|
||||
{
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id),
|
||||
@@ -52,7 +55,9 @@ export const clients = pgTable(
|
||||
export const clientContacts = pgTable(
|
||||
'client_contacts',
|
||||
{
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
clientId: text('client_id')
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: 'cascade' }),
|
||||
@@ -66,15 +71,21 @@ export const clientContacts = pgTable(
|
||||
},
|
||||
(table) => [
|
||||
index('idx_cc_client').on(table.clientId),
|
||||
index('idx_cc_email').on(table.channel, table.value).where(sql`${table.channel} = 'email'`),
|
||||
index('idx_cc_phone').on(table.channel, table.value).where(sql`${table.channel} = 'phone'`),
|
||||
index('idx_cc_email')
|
||||
.on(table.channel, table.value)
|
||||
.where(sql`${table.channel} = 'email'`),
|
||||
index('idx_cc_phone')
|
||||
.on(table.channel, table.value)
|
||||
.where(sql`${table.channel} = 'phone'`),
|
||||
],
|
||||
);
|
||||
|
||||
export const clientRelationships = pgTable(
|
||||
'client_relationships',
|
||||
{
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id),
|
||||
@@ -94,7 +105,9 @@ export const clientRelationships = pgTable(
|
||||
export const clientNotes = pgTable(
|
||||
'client_notes',
|
||||
{
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
clientId: text('client_id')
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: 'cascade' }),
|
||||
@@ -122,7 +135,9 @@ export const clientTags = pgTable(
|
||||
export const clientMergeLog = pgTable(
|
||||
'client_merge_log',
|
||||
{
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id),
|
||||
@@ -137,6 +152,37 @@ export const clientMergeLog = pgTable(
|
||||
(table) => [index('idx_cml_port').on(table.portId)],
|
||||
);
|
||||
|
||||
export const clientAddresses = pgTable(
|
||||
'client_addresses',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
clientId: text('client_id')
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: 'cascade' }),
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id, { onDelete: 'cascade' }),
|
||||
label: text('label').notNull().default('Primary'),
|
||||
streetAddress: text('street_address'),
|
||||
city: text('city'),
|
||||
stateProvince: text('state_province'),
|
||||
postalCode: text('postal_code'),
|
||||
country: text('country'),
|
||||
isPrimary: boolean('is_primary').notNull().default(true),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index('idx_ca_client').on(table.clientId),
|
||||
index('idx_ca_port').on(table.portId),
|
||||
uniqueIndex('idx_ca_primary')
|
||||
.on(table.clientId)
|
||||
.where(sql`${table.isPrimary} = true`),
|
||||
],
|
||||
);
|
||||
|
||||
export type Client = typeof clients.$inferSelect;
|
||||
export type NewClient = typeof clients.$inferInsert;
|
||||
export type ClientContact = typeof clientContacts.$inferSelect;
|
||||
@@ -147,3 +193,5 @@ export type ClientNote = typeof clientNotes.$inferSelect;
|
||||
export type NewClientNote = typeof clientNotes.$inferInsert;
|
||||
export type ClientMergeLog = typeof clientMergeLog.$inferSelect;
|
||||
export type NewClientMergeLog = typeof clientMergeLog.$inferInsert;
|
||||
export type ClientAddress = typeof clientAddresses.$inferSelect;
|
||||
export type NewClientAddress = typeof clientAddresses.$inferInsert;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
clientNotes,
|
||||
clientTags,
|
||||
clientMergeLog,
|
||||
clientAddresses,
|
||||
} from './clients';
|
||||
|
||||
// Interests
|
||||
@@ -100,6 +101,7 @@ export const portsRelations = relations(ports, ({ many }) => ({
|
||||
berthMaintenanceLogs: many(berthMaintenanceLog),
|
||||
clientMergeLogs: many(clientMergeLog),
|
||||
clientRelationships: many(clientRelationships),
|
||||
clientAddresses: many(clientAddresses),
|
||||
}));
|
||||
|
||||
// ─── Users ────────────────────────────────────────────────────────────────────
|
||||
@@ -156,6 +158,7 @@ export const clientsRelations = relations(clients, ({ one, many }) => ({
|
||||
waitingListEntries: many(berthWaitingList),
|
||||
scratchpadNotes: many(scratchpadNotes),
|
||||
formSubmissions: many(formSubmissions),
|
||||
addresses: many(clientAddresses),
|
||||
}));
|
||||
|
||||
export const clientContactsRelations = relations(clientContacts, ({ one }) => ({
|
||||
@@ -165,6 +168,17 @@ export const clientContactsRelations = relations(clientContacts, ({ one }) => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
export const clientAddressesRelations = relations(clientAddresses, ({ one }) => ({
|
||||
client: one(clients, {
|
||||
fields: [clientAddresses.clientId],
|
||||
references: [clients.id],
|
||||
}),
|
||||
port: one(ports, {
|
||||
fields: [clientAddresses.portId],
|
||||
references: [ports.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const clientRelationshipsRelations = relations(clientRelationships, ({ one }) => ({
|
||||
port: one(ports, {
|
||||
fields: [clientRelationships.portId],
|
||||
|
||||
@@ -38,6 +38,7 @@ export async function sendEmail(
|
||||
subject: string,
|
||||
html: string,
|
||||
from?: string,
|
||||
text?: string,
|
||||
): Promise<nodemailer.SentMessageInfo> {
|
||||
const transporter = createTransporter();
|
||||
|
||||
@@ -46,12 +47,10 @@ export async function sendEmail(
|
||||
to: Array.isArray(to) ? to.join(', ') : to,
|
||||
subject,
|
||||
html,
|
||||
...(text ? { text } : {}),
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
{ messageId: info.messageId, to, subject },
|
||||
'Email sent',
|
||||
);
|
||||
logger.debug({ messageId: info.messageId, to, subject }, 'Email sent');
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
82
src/lib/email/templates/inquiry-client-confirmation.ts
Normal file
82
src/lib/email/templates/inquiry-client-confirmation.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
export interface InquiryClientConfirmationData {
|
||||
firstName: string;
|
||||
mooringNumber: string | null;
|
||||
contactEmail: string;
|
||||
}
|
||||
|
||||
export function inquiryClientConfirmation(data: InquiryClientConfirmationData) {
|
||||
const { firstName, mooringNumber, contactEmail } = data;
|
||||
|
||||
const berthText = mooringNumber ? `Berth ${mooringNumber}` : 'a Port Nimara Berth';
|
||||
|
||||
const subject = mooringNumber
|
||||
? `Thank You for Your Interest in Berth ${mooringNumber}`
|
||||
: 'Thank You for Your Interest in a Port Nimara Berth';
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<title>${subject}</title>
|
||||
<style type="text/css">
|
||||
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||
img { border: 0; display: block; }
|
||||
p { margin: 0; padding: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0; padding:0; background-color:#f2f2f2;">
|
||||
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('https://s3.portnimara.com/images/Overhead_1_blur.png'); background-size: cover; background-position: center; background-color:#f2f2f2;">
|
||||
<tr>
|
||||
<td align="center" style="padding:30px;">
|
||||
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
|
||||
<tr>
|
||||
<td style="padding:20px; font-family: Arial, sans-serif; color:#333333;">
|
||||
<center>
|
||||
<img src="https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
|
||||
</center>
|
||||
<p style="margin-bottom:10px; font-size:16px;">Dear ${escapeHtml(firstName)},</p>
|
||||
<p style="margin-bottom:10px; font-size:16px;">
|
||||
Thank you for expressing interest in ${escapeHtml(berthText)}.
|
||||
Our team has registered your interest, and we will reach out to you very shortly
|
||||
by your preferred method of contact with more information.
|
||||
</p>
|
||||
<p style="margin-bottom:10px; font-size:16px;">
|
||||
If you have any questions, please feel free to reach out to us at
|
||||
<a href="mailto:${escapeHtml(contactEmail)}" style="color:#007bff; text-decoration:underline;">${escapeHtml(contactEmail)}</a>.
|
||||
</p>
|
||||
<p style="font-size:16px;">
|
||||
Best regards,<br />
|
||||
The Port Nimara Sales Team
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const text = [
|
||||
`Dear ${firstName},`,
|
||||
'',
|
||||
`Thank you for expressing interest in ${berthText}. Our team has registered your interest, and we will reach out to you very shortly by your preferred method of contact with more information.`,
|
||||
'',
|
||||
`If you have any questions, please feel free to reach out to us at ${contactEmail}.`,
|
||||
'',
|
||||
'Best regards,',
|
||||
'The Port Nimara Sales Team',
|
||||
].join('\n');
|
||||
|
||||
return { subject, html, text };
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
80
src/lib/email/templates/inquiry-sales-notification.ts
Normal file
80
src/lib/email/templates/inquiry-sales-notification.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
export interface InquirySalesNotificationData {
|
||||
fullName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
mooringNumber: string | null;
|
||||
crmUrl: string;
|
||||
}
|
||||
|
||||
export function inquirySalesNotification(data: InquirySalesNotificationData) {
|
||||
const { fullName, email, phone, mooringNumber, crmUrl } = data;
|
||||
const mooringDisplay = mooringNumber || 'None';
|
||||
|
||||
const subject = 'New Interest - Port Nimara';
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<title>New Interest - Port Nimara</title>
|
||||
<style type="text/css">
|
||||
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||
img { border: 0; display: block; }
|
||||
p { margin: 0; padding: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0; padding:0; background-color:#f2f2f2;">
|
||||
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('https://s3.portnimara.com/images/Overhead_1_blur.png'); background-size: cover; background-position: center; background-color:#f2f2f2;">
|
||||
<tr>
|
||||
<td align="center" style="padding:30px;">
|
||||
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
|
||||
<tr>
|
||||
<td style="padding:20px; font-family: Arial, sans-serif; color:#333333;">
|
||||
<center>
|
||||
<img src="https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
|
||||
</center>
|
||||
<p style="margin-bottom:10px; font-size:16px;">Dear Administrator,</p>
|
||||
<p style="margin-bottom:10px; font-size:16px;">${escapeHtml(fullName)} has expressed their interest in <strong>Port Nimara</strong>. Here are their details:</p>
|
||||
<p style="margin-bottom:0; font-size:16px;"><strong>Name:</strong> ${escapeHtml(fullName)}</p>
|
||||
<p style="margin-bottom:0; font-size:16px;"><strong>Email:</strong> ${escapeHtml(email)}</p>
|
||||
<p style="margin-bottom:0; font-size:16px;"><strong>Telephone:</strong> ${escapeHtml(phone)}</p>
|
||||
<p style="margin:0 0 16px 0; font-size:16px;"><strong>Berths Selected:</strong> ${escapeHtml(mooringDisplay)}</p>
|
||||
<p style="margin-bottom:10px; font-size:16px;">Please visit the <a href="${escapeHtml(crmUrl)}" target="_blank" style="color:#007bff; text-decoration:underline;">Port Nimara CRM</a> to view more information.</p>
|
||||
<p style="font-size:16px;">Thank you,<br/>Port Nimara CRM</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const text = [
|
||||
'Dear Administrator,',
|
||||
'',
|
||||
`${fullName} has expressed their interest in Port Nimara. Here are their details:`,
|
||||
'',
|
||||
`Name: ${fullName}`,
|
||||
`Email: ${email}`,
|
||||
`Telephone: ${phone}`,
|
||||
`Berths Selected: ${mooringDisplay}`,
|
||||
'',
|
||||
`Please visit the Port Nimara CRM (${crmUrl}) to view more information.`,
|
||||
'',
|
||||
'Thank you',
|
||||
'Port Nimara CRM',
|
||||
].join('\n');
|
||||
|
||||
return { subject, html, text };
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
@@ -52,6 +52,10 @@ const envSchema = z.object({
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
function validateEnv(): Env {
|
||||
if (process.env.SKIP_ENV_VALIDATION === '1') {
|
||||
return process.env as unknown as Env;
|
||||
}
|
||||
|
||||
const result = envSchema.safeParse(process.env);
|
||||
if (!result.success) {
|
||||
console.error('Invalid environment variables:');
|
||||
|
||||
@@ -15,6 +15,42 @@ export const emailWorker = new Worker(
|
||||
await syncInbox(accountId);
|
||||
break;
|
||||
}
|
||||
case 'send-inquiry-confirmation': {
|
||||
const { to, firstName, mooringNumber, contactEmail } = job.data as {
|
||||
to: string;
|
||||
firstName: string;
|
||||
mooringNumber: string | null;
|
||||
contactEmail: string;
|
||||
};
|
||||
const { inquiryClientConfirmation } =
|
||||
await import('@/lib/email/templates/inquiry-client-confirmation');
|
||||
const { sendEmail } = await import('@/lib/email/index');
|
||||
const email = inquiryClientConfirmation({ firstName, mooringNumber, contactEmail });
|
||||
await sendEmail(to, email.subject, email.html, undefined, email.text);
|
||||
break;
|
||||
}
|
||||
case 'send-inquiry-sales-notification': {
|
||||
const { to, fullName, email, phone, mooringNumber, crmUrl } = job.data as {
|
||||
to: string;
|
||||
fullName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
mooringNumber: string | null;
|
||||
crmUrl: string;
|
||||
};
|
||||
const { inquirySalesNotification } =
|
||||
await import('@/lib/email/templates/inquiry-sales-notification');
|
||||
const { sendEmail } = await import('@/lib/email/index');
|
||||
const notification = inquirySalesNotification({
|
||||
fullName,
|
||||
email,
|
||||
phone,
|
||||
mooringNumber,
|
||||
crmUrl,
|
||||
});
|
||||
await sendEmail(to, notification.subject, notification.html, undefined, notification.text);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
logger.warn({ jobName: job.name }, 'Unknown email job');
|
||||
}
|
||||
|
||||
@@ -24,10 +24,17 @@ export const notificationsWorker = new Worker(
|
||||
break;
|
||||
}
|
||||
case 'reminder-check': {
|
||||
const { processDocumentReminders } = await import(
|
||||
'@/jobs/processors/document-reminder'
|
||||
);
|
||||
// Document signing reminders (EOI)
|
||||
const { processDocumentReminders } = await import('@/jobs/processors/document-reminder');
|
||||
await processDocumentReminders();
|
||||
// CRM follow-up reminders (BR-060)
|
||||
const { processFollowUpReminders } = await import('@/lib/services/reminders.service');
|
||||
await processFollowUpReminders();
|
||||
break;
|
||||
}
|
||||
case 'reminder-overdue-check': {
|
||||
const { processOverdueReminders } = await import('@/lib/services/reminders.service');
|
||||
await processOverdueReminders();
|
||||
break;
|
||||
}
|
||||
case 'send-notification-email': {
|
||||
@@ -57,9 +64,7 @@ export const notificationsWorker = new Worker(
|
||||
authUser.email,
|
||||
`[Port Nimara] ${notif.title}`,
|
||||
`<p>${notif.description ?? notif.title}</p>${
|
||||
notif.link
|
||||
? `<p><a href="${process.env.APP_URL}${notif.link}">View in CRM</a></p>`
|
||||
: ''
|
||||
notif.link ? `<p><a href="${process.env.APP_URL}${notif.link}">View in CRM</a></p>` : ''
|
||||
}`,
|
||||
);
|
||||
|
||||
|
||||
57
src/lib/services/audit.service.ts
Normal file
57
src/lib/services/audit.service.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { and, eq, desc, sql, gte, lte } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { auditLogs } from '@/lib/db/schema';
|
||||
|
||||
interface AuditListQuery {
|
||||
page: number;
|
||||
limit: number;
|
||||
entityType?: string;
|
||||
action?: string;
|
||||
userId?: string;
|
||||
entityId?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export async function listAuditLogs(portId: string, query: AuditListQuery) {
|
||||
const conditions = [eq(auditLogs.portId, portId)];
|
||||
|
||||
if (query.entityType) conditions.push(eq(auditLogs.entityType, query.entityType));
|
||||
if (query.action) conditions.push(eq(auditLogs.action, query.action));
|
||||
if (query.userId) conditions.push(eq(auditLogs.userId, query.userId));
|
||||
if (query.entityId) conditions.push(eq(auditLogs.entityId, query.entityId));
|
||||
if (query.dateFrom) conditions.push(gte(auditLogs.createdAt, new Date(query.dateFrom)));
|
||||
if (query.dateTo) conditions.push(lte(auditLogs.createdAt, new Date(query.dateTo)));
|
||||
if (query.search) {
|
||||
conditions.push(
|
||||
sql`(${auditLogs.entityType} ILIKE ${'%' + query.search + '%'} OR ${auditLogs.action} ILIKE ${'%' + query.search + '%'})`,
|
||||
);
|
||||
}
|
||||
|
||||
const offset = (query.page - 1) * query.limit;
|
||||
|
||||
const [data, countResult] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(auditLogs)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(auditLogs.createdAt))
|
||||
.limit(query.limit)
|
||||
.offset(offset),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(auditLogs)
|
||||
.where(and(...conditions)),
|
||||
]);
|
||||
|
||||
return {
|
||||
data,
|
||||
pagination: {
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
total: Number(countResult[0]?.count ?? 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,19 +1,16 @@
|
||||
import { and, eq, gte, lte, inArray } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import {
|
||||
berths,
|
||||
berthTags,
|
||||
berthWaitingList,
|
||||
berthMaintenanceLog,
|
||||
} from '@/lib/db/schema/berths';
|
||||
import { berths, berthTags, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths';
|
||||
import { tags } from '@/lib/db/schema/system';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { diffEntity } from '@/lib/entity-diff';
|
||||
import { NotFoundError } from '@/lib/errors';
|
||||
import { buildListQuery } from '@/lib/db/query-builder';
|
||||
import { emitToRoom } from '@/lib/socket/server';
|
||||
import { ConflictError } from '@/lib/errors';
|
||||
import type {
|
||||
CreateBerthInput,
|
||||
UpdateBerthInput,
|
||||
UpdateBerthStatusInput,
|
||||
ListBerthsQuery,
|
||||
@@ -71,12 +68,18 @@ export async function listBerths(portId: string, query: ListBerthsQuery) {
|
||||
|
||||
const sortColumn = (() => {
|
||||
switch (query.sort) {
|
||||
case 'mooringNumber': return berths.mooringNumber;
|
||||
case 'area': return berths.area;
|
||||
case 'price': return berths.price;
|
||||
case 'status': return berths.status;
|
||||
case 'lengthM': return berths.lengthM;
|
||||
default: return berths.updatedAt;
|
||||
case 'mooringNumber':
|
||||
return berths.mooringNumber;
|
||||
case 'area':
|
||||
return berths.area;
|
||||
case 'price':
|
||||
return berths.price;
|
||||
case 'status':
|
||||
return berths.status;
|
||||
case 'lengthM':
|
||||
return berths.lengthM;
|
||||
default:
|
||||
return berths.updatedAt;
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -161,7 +164,10 @@ export async function updateBerth(
|
||||
});
|
||||
if (!existing) throw new NotFoundError('Berth');
|
||||
|
||||
const { changed, diff } = diffEntity(existing as Record<string, unknown>, data as Record<string, unknown>);
|
||||
const { changed, diff } = diffEntity(
|
||||
existing as Record<string, unknown>,
|
||||
data as Record<string, unknown>,
|
||||
);
|
||||
|
||||
if (!changed) return existing;
|
||||
|
||||
@@ -288,12 +294,7 @@ export async function updateBerthStatus(
|
||||
|
||||
// ─── Set Tags ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function setBerthTags(
|
||||
id: string,
|
||||
portId: string,
|
||||
tagIds: string[],
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
export async function setBerthTags(id: string, portId: string, tagIds: string[], meta: AuditMeta) {
|
||||
const existing = await db.query.berths.findFirst({
|
||||
where: and(eq(berths.id, id), eq(berths.portId, portId)),
|
||||
});
|
||||
@@ -454,6 +455,90 @@ export async function updateWaitingList(
|
||||
return data.entries;
|
||||
}
|
||||
|
||||
// ─── Create ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createBerth(portId: string, data: CreateBerthInput, meta: AuditMeta) {
|
||||
// Check mooring number uniqueness within port
|
||||
const existing = await db.query.berths.findFirst({
|
||||
where: and(eq(berths.portId, portId), eq(berths.mooringNumber, data.mooringNumber)),
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictError(`Berth "${data.mooringNumber}" already exists in this port`);
|
||||
}
|
||||
|
||||
const [berth] = await db
|
||||
.insert(berths)
|
||||
.values({
|
||||
portId,
|
||||
mooringNumber: data.mooringNumber,
|
||||
area: data.area,
|
||||
status: data.status ?? 'available',
|
||||
lengthFt: data.lengthFt?.toString(),
|
||||
lengthM: data.lengthM?.toString(),
|
||||
widthFt: data.widthFt?.toString(),
|
||||
widthM: data.widthM?.toString(),
|
||||
draftFt: data.draftFt?.toString(),
|
||||
draftM: data.draftM?.toString(),
|
||||
price: data.price?.toString(),
|
||||
priceCurrency: data.priceCurrency ?? 'USD',
|
||||
tenureType: data.tenureType ?? 'permanent',
|
||||
mooringType: data.mooringType,
|
||||
powerCapacity: data.powerCapacity,
|
||||
voltage: data.voltage,
|
||||
access: data.access,
|
||||
bowFacing: data.bowFacing,
|
||||
sidePontoon: data.sidePontoon,
|
||||
})
|
||||
.returning();
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'create',
|
||||
entityType: 'berth',
|
||||
entityId: berth!.id,
|
||||
newValue: { mooringNumber: berth!.mooringNumber, area: berth!.area },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'system:alert', {
|
||||
alertType: 'berth:created',
|
||||
message: `Berth "${berth!.mooringNumber}" created`,
|
||||
severity: 'info',
|
||||
});
|
||||
|
||||
return berth!;
|
||||
}
|
||||
|
||||
// ─── Delete ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function deleteBerth(id: string, portId: string, meta: AuditMeta) {
|
||||
const berth = await db.query.berths.findFirst({
|
||||
where: and(eq(berths.id, id), eq(berths.portId, portId)),
|
||||
});
|
||||
if (!berth) throw new NotFoundError('Berth');
|
||||
|
||||
await db.delete(berths).where(and(eq(berths.id, id), eq(berths.portId, portId)));
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'delete',
|
||||
entityType: 'berth',
|
||||
entityId: id,
|
||||
oldValue: { mooringNumber: berth.mooringNumber, area: berth.area },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'system:alert', {
|
||||
alertType: 'berth:deleted',
|
||||
message: `Berth "${berth.mooringNumber}" deleted`,
|
||||
severity: 'info',
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Options ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getBerthOptions(portId: string) {
|
||||
|
||||
137
src/lib/services/inquiry-notifications.service.ts
Normal file
137
src/lib/services/inquiry-notifications.service.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { userPortRoles, roles } from '@/lib/db/schema/users';
|
||||
import type { RolePermissions } from '@/lib/db/schema/users';
|
||||
import { createNotification } from '@/lib/services/notifications.service';
|
||||
import { getSetting } from '@/lib/services/settings.service';
|
||||
import { getQueue } from '@/lib/queue';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
interface InquiryNotificationParams {
|
||||
portId: string;
|
||||
portSlug: string;
|
||||
interestId: string;
|
||||
clientFullName: string;
|
||||
clientEmail: string;
|
||||
clientPhone: string;
|
||||
mooringNumber: string | null;
|
||||
firstName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends inquiry notifications to all relevant parties:
|
||||
* 1. Confirmation email to the client
|
||||
* 2. In-app + email notifications to CRM users with interests.view permission
|
||||
* 3. Email to any external recipients configured in system settings
|
||||
*
|
||||
* All operations are fire-and-forget (errors are logged, not thrown).
|
||||
*/
|
||||
export async function sendInquiryNotifications(params: InquiryNotificationParams): Promise<void> {
|
||||
const {
|
||||
portId,
|
||||
portSlug,
|
||||
interestId,
|
||||
clientFullName,
|
||||
clientEmail,
|
||||
clientPhone,
|
||||
mooringNumber,
|
||||
firstName,
|
||||
} = params;
|
||||
|
||||
// 1. Queue client confirmation email
|
||||
try {
|
||||
const contactEmailSetting = await getSetting('inquiry_contact_email', portId);
|
||||
const contactEmail =
|
||||
typeof contactEmailSetting?.value === 'string'
|
||||
? contactEmailSetting.value
|
||||
: 'sales@portnimara.com';
|
||||
|
||||
const emailQueue = getQueue('email');
|
||||
await emailQueue.add('send-inquiry-confirmation', {
|
||||
to: clientEmail,
|
||||
firstName,
|
||||
mooringNumber,
|
||||
contactEmail,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ err, interestId }, 'Failed to queue client confirmation email');
|
||||
}
|
||||
|
||||
// 2. Notify CRM users with interests.view permission on this port
|
||||
try {
|
||||
const usersWithAccess = await findUsersWithInterestsPermission(portId);
|
||||
const crmUrl = `/${portSlug}/interests/${interestId}`;
|
||||
|
||||
for (const userId of usersWithAccess) {
|
||||
try {
|
||||
await createNotification({
|
||||
portId,
|
||||
userId,
|
||||
type: 'new_registration',
|
||||
title: 'New Interest Registered',
|
||||
description: `${clientFullName} has registered interest${mooringNumber ? ` in Berth ${mooringNumber}` : ''} via the website`,
|
||||
link: crmUrl,
|
||||
entityType: 'interest',
|
||||
entityId: interestId,
|
||||
dedupeKey: `inquiry-${interestId}`,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ err, userId, interestId }, 'Failed to create notification for user');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err, interestId }, 'Failed to notify CRM users');
|
||||
}
|
||||
|
||||
// 3. Notify external recipients
|
||||
try {
|
||||
const recipientsSetting = await getSetting('inquiry_notification_recipients', portId);
|
||||
const externalEmails: string[] = Array.isArray(recipientsSetting?.value)
|
||||
? recipientsSetting.value
|
||||
: [];
|
||||
|
||||
if (externalEmails.length > 0) {
|
||||
const emailQueue = getQueue('email');
|
||||
const appUrl = process.env.APP_URL ?? '';
|
||||
const crmUrl = `${appUrl}/${portSlug}/interests/${interestId}`;
|
||||
|
||||
for (const externalEmail of externalEmails) {
|
||||
await emailQueue.add('send-inquiry-sales-notification', {
|
||||
to: externalEmail,
|
||||
fullName: clientFullName,
|
||||
email: clientEmail,
|
||||
phone: clientPhone,
|
||||
mooringNumber,
|
||||
crmUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err, interestId }, 'Failed to notify external recipients');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all user IDs on a port whose role grants `interests.view` permission.
|
||||
*/
|
||||
async function findUsersWithInterestsPermission(portId: string): Promise<string[]> {
|
||||
const assignments = await db
|
||||
.select({
|
||||
userId: userPortRoles.userId,
|
||||
permissions: roles.permissions,
|
||||
})
|
||||
.from(userPortRoles)
|
||||
.innerJoin(roles, eq(userPortRoles.roleId, roles.id))
|
||||
.where(eq(userPortRoles.portId, portId));
|
||||
|
||||
const userIds = new Set<string>();
|
||||
for (const row of assignments) {
|
||||
const perms = row.permissions as RolePermissions | null;
|
||||
if (perms?.interests?.view) {
|
||||
userIds.add(row.userId);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(userIds);
|
||||
}
|
||||
110
src/lib/services/ports.service.ts
Normal file
110
src/lib/services/ports.service.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema';
|
||||
import type { PortSettings } from '@/lib/db/schema/ports';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { ConflictError, NotFoundError } from '@/lib/errors';
|
||||
import { emitToRoom } from '@/lib/socket/server';
|
||||
import type { CreatePortInput, UpdatePortInput } from '@/lib/validators/ports';
|
||||
|
||||
interface AuditMeta {
|
||||
userId: string;
|
||||
portId: string;
|
||||
ipAddress: string;
|
||||
userAgent: string;
|
||||
}
|
||||
|
||||
export async function listPorts() {
|
||||
return db.select().from(ports).orderBy(ports.name);
|
||||
}
|
||||
|
||||
export async function getPort(id: string) {
|
||||
const port = await db.query.ports.findFirst({
|
||||
where: eq(ports.id, id),
|
||||
});
|
||||
if (!port) throw new NotFoundError('Port');
|
||||
return port;
|
||||
}
|
||||
|
||||
export async function createPort(data: CreatePortInput, meta: AuditMeta) {
|
||||
const existing = await db.query.ports.findFirst({
|
||||
where: eq(ports.slug, data.slug),
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictError(`A port with slug "${data.slug}" already exists`);
|
||||
}
|
||||
|
||||
const [port] = await db
|
||||
.insert(ports)
|
||||
.values({
|
||||
name: data.name,
|
||||
slug: data.slug,
|
||||
logoUrl: data.logoUrl ?? null,
|
||||
primaryColor: data.primaryColor ?? null,
|
||||
defaultCurrency: data.defaultCurrency,
|
||||
timezone: data.timezone,
|
||||
})
|
||||
.returning();
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId: meta.portId,
|
||||
action: 'create',
|
||||
entityType: 'port',
|
||||
entityId: port!.id,
|
||||
newValue: { name: port!.name, slug: port!.slug },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
return port!;
|
||||
}
|
||||
|
||||
export async function updatePort(id: string, data: UpdatePortInput, meta: AuditMeta) {
|
||||
const port = await db.query.ports.findFirst({
|
||||
where: eq(ports.id, id),
|
||||
});
|
||||
if (!port) throw new NotFoundError('Port');
|
||||
|
||||
if (data.slug && data.slug !== port.slug) {
|
||||
const conflict = await db.query.ports.findFirst({
|
||||
where: eq(ports.slug, data.slug),
|
||||
});
|
||||
if (conflict) {
|
||||
throw new ConflictError(`A port with slug "${data.slug}" already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (data.name !== undefined) updates.name = data.name;
|
||||
if (data.slug !== undefined) updates.slug = data.slug;
|
||||
if (data.logoUrl !== undefined) updates.logoUrl = data.logoUrl;
|
||||
if (data.primaryColor !== undefined) updates.primaryColor = data.primaryColor;
|
||||
if (data.defaultCurrency !== undefined) updates.defaultCurrency = data.defaultCurrency;
|
||||
if (data.timezone !== undefined) updates.timezone = data.timezone;
|
||||
if (data.isActive !== undefined) updates.isActive = data.isActive;
|
||||
if (data.settings !== undefined) updates.settings = data.settings as PortSettings;
|
||||
|
||||
const [updated] = await db.update(ports).set(updates).where(eq(ports.id, id)).returning();
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId: meta.portId,
|
||||
action: 'update',
|
||||
entityType: 'port',
|
||||
entityId: id,
|
||||
oldValue: { name: port.name, slug: port.slug, isActive: port.isActive },
|
||||
newValue: { name: updated!.name, slug: updated!.slug, isActive: updated!.isActive },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${id}`, 'system:alert', {
|
||||
alertType: 'port:updated',
|
||||
message: `Port "${updated!.name}" updated`,
|
||||
severity: 'info',
|
||||
});
|
||||
|
||||
return updated!;
|
||||
}
|
||||
468
src/lib/services/reminders.service.ts
Normal file
468
src/lib/services/reminders.service.ts
Normal file
@@ -0,0 +1,468 @@
|
||||
import { and, eq, lte, gte, desc, asc, inArray, sql, isNull } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { reminders, interests, clients } from '@/lib/db/schema';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import { emitToRoom } from '@/lib/socket/server';
|
||||
import { createNotification } from '@/lib/services/notifications.service';
|
||||
import { logger } from '@/lib/logger';
|
||||
import type {
|
||||
CreateReminderInput,
|
||||
UpdateReminderInput,
|
||||
SnoozeReminderInput,
|
||||
ReminderListQuery,
|
||||
} from '@/lib/validators/reminders';
|
||||
|
||||
interface AuditMeta {
|
||||
userId: string;
|
||||
portId: string;
|
||||
ipAddress: string;
|
||||
userAgent: string;
|
||||
}
|
||||
|
||||
// ─── List ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listReminders(portId: string, query: ReminderListQuery) {
|
||||
const conditions = [eq(reminders.portId, portId)];
|
||||
|
||||
if (query.status) conditions.push(eq(reminders.status, query.status));
|
||||
if (query.priority) conditions.push(eq(reminders.priority, query.priority));
|
||||
if (query.assignedTo) conditions.push(eq(reminders.assignedTo, query.assignedTo));
|
||||
if (query.clientId) conditions.push(eq(reminders.clientId, query.clientId));
|
||||
if (query.interestId) conditions.push(eq(reminders.interestId, query.interestId));
|
||||
if (query.berthId) conditions.push(eq(reminders.berthId, query.berthId));
|
||||
if (query.dueBefore) conditions.push(lte(reminders.dueAt, new Date(query.dueBefore)));
|
||||
if (query.dueAfter) conditions.push(gte(reminders.dueAt, new Date(query.dueAfter)));
|
||||
|
||||
if (query.search) {
|
||||
conditions.push(sql`${reminders.title} ILIKE ${'%' + query.search + '%'}`);
|
||||
}
|
||||
|
||||
const orderDir = query.order === 'asc' ? asc : desc;
|
||||
const orderCol = query.sort === 'priority' ? reminders.priority : reminders.dueAt;
|
||||
|
||||
const offset = (query.page - 1) * query.limit;
|
||||
|
||||
const [data, countResult] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(reminders)
|
||||
.where(and(...conditions))
|
||||
.orderBy(orderDir(orderCol))
|
||||
.limit(query.limit)
|
||||
.offset(offset),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(reminders)
|
||||
.where(and(...conditions)),
|
||||
]);
|
||||
|
||||
return {
|
||||
data,
|
||||
pagination: {
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
total: Number(countResult[0]?.count ?? 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getMyReminders(userId: string, portId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(reminders)
|
||||
.where(
|
||||
and(
|
||||
eq(reminders.portId, portId),
|
||||
eq(reminders.assignedTo, userId),
|
||||
inArray(reminders.status, ['pending', 'snoozed']),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(reminders.dueAt));
|
||||
}
|
||||
|
||||
export async function getOverdueReminders(portId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(reminders)
|
||||
.where(
|
||||
and(
|
||||
eq(reminders.portId, portId),
|
||||
inArray(reminders.status, ['pending', 'snoozed']),
|
||||
lte(reminders.dueAt, new Date()),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(reminders.dueAt));
|
||||
}
|
||||
|
||||
export async function getUpcomingReminders(portId: string, days: number = 14) {
|
||||
const until = new Date();
|
||||
until.setDate(until.getDate() + days);
|
||||
|
||||
return db
|
||||
.select()
|
||||
.from(reminders)
|
||||
.where(
|
||||
and(
|
||||
eq(reminders.portId, portId),
|
||||
inArray(reminders.status, ['pending', 'snoozed']),
|
||||
lte(reminders.dueAt, until),
|
||||
gte(reminders.dueAt, new Date()),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(reminders.dueAt));
|
||||
}
|
||||
|
||||
// ─── CRUD ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getReminder(id: string, portId: string) {
|
||||
const reminder = await db.query.reminders.findFirst({
|
||||
where: and(eq(reminders.id, id), eq(reminders.portId, portId)),
|
||||
with: { client: true, interest: true, berth: true },
|
||||
});
|
||||
if (!reminder) throw new NotFoundError('Reminder');
|
||||
return reminder;
|
||||
}
|
||||
|
||||
export async function createReminder(portId: string, data: CreateReminderInput, meta: AuditMeta) {
|
||||
const [reminder] = await db
|
||||
.insert(reminders)
|
||||
.values({
|
||||
portId,
|
||||
title: data.title,
|
||||
note: data.note ?? null,
|
||||
dueAt: new Date(data.dueAt),
|
||||
priority: data.priority,
|
||||
assignedTo: data.assignedTo ?? meta.userId,
|
||||
createdBy: meta.userId,
|
||||
clientId: data.clientId ?? null,
|
||||
interestId: data.interestId ?? null,
|
||||
berthId: data.berthId ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'create',
|
||||
entityType: 'reminder',
|
||||
entityId: reminder!.id,
|
||||
newValue: { title: reminder!.title, dueAt: reminder!.dueAt, priority: reminder!.priority },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'reminder:created', {
|
||||
reminderId: reminder!.id,
|
||||
title: reminder!.title,
|
||||
dueAt: reminder!.dueAt.toISOString(),
|
||||
assignedTo: reminder!.assignedTo ?? meta.userId,
|
||||
});
|
||||
|
||||
if (reminder!.assignedTo) {
|
||||
emitToRoom(`user:${reminder!.assignedTo}`, 'reminder:created', {
|
||||
reminderId: reminder!.id,
|
||||
title: reminder!.title,
|
||||
dueAt: reminder!.dueAt.toISOString(),
|
||||
assignedTo: reminder!.assignedTo,
|
||||
});
|
||||
}
|
||||
|
||||
return reminder!;
|
||||
}
|
||||
|
||||
export async function updateReminder(
|
||||
id: string,
|
||||
portId: string,
|
||||
data: UpdateReminderInput,
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
const existing = await db.query.reminders.findFirst({
|
||||
where: and(eq(reminders.id, id), eq(reminders.portId, portId)),
|
||||
});
|
||||
if (!existing) throw new NotFoundError('Reminder');
|
||||
|
||||
const updates: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (data.title !== undefined) updates.title = data.title;
|
||||
if (data.note !== undefined) updates.note = data.note;
|
||||
if (data.dueAt !== undefined) updates.dueAt = new Date(data.dueAt);
|
||||
if (data.priority !== undefined) updates.priority = data.priority;
|
||||
if (data.assignedTo !== undefined) updates.assignedTo = data.assignedTo;
|
||||
if (data.clientId !== undefined) updates.clientId = data.clientId;
|
||||
if (data.interestId !== undefined) updates.interestId = data.interestId;
|
||||
if (data.berthId !== undefined) updates.berthId = data.berthId;
|
||||
|
||||
const [updated] = await db
|
||||
.update(reminders)
|
||||
.set(updates)
|
||||
.where(and(eq(reminders.id, id), eq(reminders.portId, portId)))
|
||||
.returning();
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'update',
|
||||
entityType: 'reminder',
|
||||
entityId: id,
|
||||
oldValue: { title: existing.title, dueAt: existing.dueAt, priority: existing.priority },
|
||||
newValue: { title: updated!.title, dueAt: updated!.dueAt, priority: updated!.priority },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'reminder:updated', {
|
||||
reminderId: updated!.id,
|
||||
changedFields: Object.keys(data),
|
||||
});
|
||||
|
||||
return updated!;
|
||||
}
|
||||
|
||||
export async function deleteReminder(id: string, portId: string, meta: AuditMeta) {
|
||||
const existing = await db.query.reminders.findFirst({
|
||||
where: and(eq(reminders.id, id), eq(reminders.portId, portId)),
|
||||
});
|
||||
if (!existing) throw new NotFoundError('Reminder');
|
||||
|
||||
await db.delete(reminders).where(and(eq(reminders.id, id), eq(reminders.portId, portId)));
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'delete',
|
||||
entityType: 'reminder',
|
||||
entityId: id,
|
||||
oldValue: { title: existing.title },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Status Actions ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function completeReminder(id: string, portId: string, meta: AuditMeta) {
|
||||
const existing = await db.query.reminders.findFirst({
|
||||
where: and(eq(reminders.id, id), eq(reminders.portId, portId)),
|
||||
});
|
||||
if (!existing) throw new NotFoundError('Reminder');
|
||||
if (existing.status === 'completed') throw new ValidationError('Reminder already completed');
|
||||
|
||||
const [updated] = await db
|
||||
.update(reminders)
|
||||
.set({
|
||||
status: 'completed',
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(reminders.id, id), eq(reminders.portId, portId)))
|
||||
.returning();
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'update',
|
||||
entityType: 'reminder',
|
||||
entityId: id,
|
||||
oldValue: { status: existing.status },
|
||||
newValue: { status: 'completed' },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'reminder:completed', {
|
||||
reminderId: updated!.id,
|
||||
title: updated!.title,
|
||||
completedBy: meta.userId,
|
||||
});
|
||||
|
||||
return updated!;
|
||||
}
|
||||
|
||||
export async function snoozeReminder(
|
||||
id: string,
|
||||
portId: string,
|
||||
data: SnoozeReminderInput,
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
const existing = await db.query.reminders.findFirst({
|
||||
where: and(eq(reminders.id, id), eq(reminders.portId, portId)),
|
||||
});
|
||||
if (!existing) throw new NotFoundError('Reminder');
|
||||
|
||||
const [updated] = await db
|
||||
.update(reminders)
|
||||
.set({
|
||||
status: 'snoozed',
|
||||
snoozedUntil: new Date(data.snoozeUntil),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(reminders.id, id), eq(reminders.portId, portId)))
|
||||
.returning();
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'update',
|
||||
entityType: 'reminder',
|
||||
entityId: id,
|
||||
oldValue: { status: existing.status },
|
||||
newValue: { status: 'snoozed', snoozedUntil: data.snoozeUntil },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'reminder:snoozed', {
|
||||
reminderId: updated!.id,
|
||||
snoozedUntil: data.snoozeUntil,
|
||||
});
|
||||
|
||||
return updated!;
|
||||
}
|
||||
|
||||
export async function dismissReminder(id: string, portId: string, meta: AuditMeta) {
|
||||
const existing = await db.query.reminders.findFirst({
|
||||
where: and(eq(reminders.id, id), eq(reminders.portId, portId)),
|
||||
});
|
||||
if (!existing) throw new NotFoundError('Reminder');
|
||||
|
||||
const [updated] = await db
|
||||
.update(reminders)
|
||||
.set({ status: 'dismissed', updatedAt: new Date() })
|
||||
.where(and(eq(reminders.id, id), eq(reminders.portId, portId)))
|
||||
.returning();
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'update',
|
||||
entityType: 'reminder',
|
||||
entityId: id,
|
||||
oldValue: { status: existing.status },
|
||||
newValue: { status: 'dismissed' },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
return updated!;
|
||||
}
|
||||
|
||||
// ─── Background Processors ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hourly check: creates auto-follow-up reminders for interests with
|
||||
* reminderEnabled=true where no activity in reminderDays days (BR-060).
|
||||
*/
|
||||
export async function processFollowUpReminders() {
|
||||
const ports = await db.query.ports.findMany({ where: eq(sql`true`, true) });
|
||||
|
||||
for (const port of ports) {
|
||||
const enabledInterests = await db
|
||||
.select({
|
||||
id: interests.id,
|
||||
clientId: interests.clientId,
|
||||
reminderDays: interests.reminderDays,
|
||||
reminderLastFired: interests.reminderLastFired,
|
||||
updatedAt: interests.updatedAt,
|
||||
})
|
||||
.from(interests)
|
||||
.where(
|
||||
and(
|
||||
eq(interests.portId, port.id),
|
||||
eq(interests.reminderEnabled, true),
|
||||
isNull(interests.archivedAt),
|
||||
),
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
for (const interest of enabledInterests) {
|
||||
if (!interest.reminderDays) continue;
|
||||
|
||||
// Check if enough days have passed since last activity
|
||||
const lastActivity = interest.reminderLastFired ?? interest.updatedAt;
|
||||
const daysSinceActivity = (now.getTime() - lastActivity.getTime()) / (1000 * 60 * 60 * 24);
|
||||
|
||||
if (daysSinceActivity < interest.reminderDays) continue;
|
||||
|
||||
// Get client name for the reminder title
|
||||
const client = interest.clientId
|
||||
? await db.query.clients.findFirst({ where: eq(clients.id, interest.clientId) })
|
||||
: null;
|
||||
|
||||
const title = client ? `Follow up with ${client.fullName}` : 'Follow up on interest';
|
||||
|
||||
// Find the assigned user (first userPortRole for this port, or fallback)
|
||||
// For now, leave assignedTo null — the notification goes to the port room
|
||||
await db.insert(reminders).values({
|
||||
portId: port.id,
|
||||
title,
|
||||
note: 'Auto-generated: no activity detected within the configured follow-up window.',
|
||||
dueAt: now,
|
||||
priority: 'medium',
|
||||
assignedTo: null,
|
||||
createdBy: 'system',
|
||||
interestId: interest.id,
|
||||
clientId: interest.clientId,
|
||||
autoGenerated: true,
|
||||
});
|
||||
|
||||
// Update last fired timestamp
|
||||
await db
|
||||
.update(interests)
|
||||
.set({ reminderLastFired: now })
|
||||
.where(eq(interests.id, interest.id));
|
||||
|
||||
// Fire notification to the port room
|
||||
emitToRoom(`port:${port.id}`, 'system:alert', {
|
||||
alertType: 'follow_up_created',
|
||||
message: title,
|
||||
severity: 'info',
|
||||
});
|
||||
|
||||
logger.info({ interestId: interest.id, portId: port.id }, 'Auto follow-up reminder created');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every 15 minutes: checks for past-due reminders and creates overdue notifications.
|
||||
*/
|
||||
export async function processOverdueReminders() {
|
||||
const now = new Date();
|
||||
|
||||
// Find pending reminders past their due date
|
||||
const overdueReminders = await db
|
||||
.select()
|
||||
.from(reminders)
|
||||
.where(and(eq(reminders.status, 'pending'), lte(reminders.dueAt, now)));
|
||||
|
||||
for (const reminder of overdueReminders) {
|
||||
if (reminder.assignedTo) {
|
||||
void createNotification({
|
||||
portId: reminder.portId,
|
||||
userId: reminder.assignedTo,
|
||||
type: 'reminder_overdue',
|
||||
title: 'Reminder overdue',
|
||||
description: reminder.title,
|
||||
entityType: 'reminder',
|
||||
entityId: reminder.id,
|
||||
link: '/reminders',
|
||||
});
|
||||
|
||||
emitToRoom(`user:${reminder.assignedTo}`, 'reminder:overdue', {
|
||||
reminderId: reminder.id,
|
||||
title: reminder.title,
|
||||
dueAt: reminder.dueAt.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Also un-snooze reminders whose snooze period has passed
|
||||
await db
|
||||
.update(reminders)
|
||||
.set({ status: 'pending', snoozedUntil: null, updatedAt: now })
|
||||
.where(and(eq(reminders.status, 'snoozed'), lte(reminders.snoozedUntil, now)));
|
||||
|
||||
logger.info({ overdueCount: overdueReminders.length }, 'Processed overdue reminders');
|
||||
}
|
||||
148
src/lib/services/roles.service.ts
Normal file
148
src/lib/services/roles.service.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { roles, userPortRoles } from '@/lib/db/schema';
|
||||
import type { RolePermissions } from '@/lib/db/schema/users';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import { emitToRoom } from '@/lib/socket/server';
|
||||
import type { CreateRoleInput, UpdateRoleInput } from '@/lib/validators/roles';
|
||||
|
||||
interface AuditMeta {
|
||||
userId: string;
|
||||
portId: string;
|
||||
ipAddress: string;
|
||||
userAgent: string;
|
||||
}
|
||||
|
||||
export async function listRoles() {
|
||||
return db.select().from(roles).orderBy(roles.name);
|
||||
}
|
||||
|
||||
export async function getRole(id: string) {
|
||||
const role = await db.query.roles.findFirst({
|
||||
where: eq(roles.id, id),
|
||||
});
|
||||
if (!role) throw new NotFoundError('Role');
|
||||
return role;
|
||||
}
|
||||
|
||||
export async function createRole(data: CreateRoleInput, meta: AuditMeta) {
|
||||
// Check name uniqueness
|
||||
const existing = await db.query.roles.findFirst({
|
||||
where: eq(roles.name, data.name),
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictError(`A role named "${data.name}" already exists`);
|
||||
}
|
||||
|
||||
const [role] = await db
|
||||
.insert(roles)
|
||||
.values({
|
||||
name: data.name,
|
||||
description: data.description ?? null,
|
||||
permissions: data.permissions as RolePermissions,
|
||||
})
|
||||
.returning();
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId: meta.portId,
|
||||
action: 'create',
|
||||
entityType: 'role',
|
||||
entityId: role!.id,
|
||||
newValue: { name: role!.name },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${meta.portId}`, 'system:alert', {
|
||||
alertType: 'role:created',
|
||||
message: `Role "${role!.name}" created`,
|
||||
severity: 'info',
|
||||
});
|
||||
|
||||
return role!;
|
||||
}
|
||||
|
||||
export async function updateRole(id: string, data: UpdateRoleInput, meta: AuditMeta) {
|
||||
const role = await db.query.roles.findFirst({
|
||||
where: eq(roles.id, id),
|
||||
});
|
||||
if (!role) throw new NotFoundError('Role');
|
||||
|
||||
// Check name uniqueness if changing name
|
||||
if (data.name && data.name !== role.name) {
|
||||
const conflict = await db.query.roles.findFirst({
|
||||
where: eq(roles.name, data.name),
|
||||
});
|
||||
if (conflict) {
|
||||
throw new ConflictError(`A role named "${data.name}" already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (data.name !== undefined) updates.name = data.name;
|
||||
if (data.description !== undefined) updates.description = data.description;
|
||||
if (data.permissions !== undefined) updates.permissions = data.permissions as RolePermissions;
|
||||
|
||||
const [updated] = await db.update(roles).set(updates).where(eq(roles.id, id)).returning();
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId: meta.portId,
|
||||
action: 'update',
|
||||
entityType: 'role',
|
||||
entityId: id,
|
||||
oldValue: { name: role.name, permissions: role.permissions },
|
||||
newValue: { name: updated!.name, permissions: updated!.permissions },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${meta.portId}`, 'system:alert', {
|
||||
alertType: 'role:updated',
|
||||
message: `Role "${updated!.name}" updated`,
|
||||
severity: 'info',
|
||||
});
|
||||
|
||||
return updated!;
|
||||
}
|
||||
|
||||
export async function deleteRole(id: string, meta: AuditMeta) {
|
||||
const role = await db.query.roles.findFirst({
|
||||
where: eq(roles.id, id),
|
||||
});
|
||||
if (!role) throw new NotFoundError('Role');
|
||||
|
||||
if (role.isSystem) {
|
||||
throw new ValidationError('System roles cannot be deleted');
|
||||
}
|
||||
|
||||
// Check if any users are assigned this role
|
||||
const assignments = await db.query.userPortRoles.findFirst({
|
||||
where: eq(userPortRoles.roleId, id),
|
||||
});
|
||||
if (assignments) {
|
||||
throw new ConflictError('Cannot delete a role that is assigned to users. Reassign them first.');
|
||||
}
|
||||
|
||||
await db.delete(roles).where(eq(roles.id, id));
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId: meta.portId,
|
||||
action: 'delete',
|
||||
entityType: 'role',
|
||||
entityId: id,
|
||||
oldValue: { name: role.name },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${meta.portId}`, 'system:alert', {
|
||||
alertType: 'role:deleted',
|
||||
message: `Role "${role.name}" deleted`,
|
||||
severity: 'info',
|
||||
});
|
||||
}
|
||||
107
src/lib/services/settings.service.ts
Normal file
107
src/lib/services/settings.service.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { and, eq, isNull } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { systemSettings } from '@/lib/db/schema';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { NotFoundError } from '@/lib/errors';
|
||||
import { emitToRoom } from '@/lib/socket/server';
|
||||
|
||||
interface AuditMeta {
|
||||
userId: string;
|
||||
portId: string;
|
||||
ipAddress: string;
|
||||
userAgent: string;
|
||||
}
|
||||
|
||||
export async function listSettings(portId: string) {
|
||||
// Get port-specific settings
|
||||
const portSettings = await db
|
||||
.select()
|
||||
.from(systemSettings)
|
||||
.where(eq(systemSettings.portId, portId))
|
||||
.orderBy(systemSettings.key);
|
||||
|
||||
// Get global settings (portId is null)
|
||||
const globalSettings = await db
|
||||
.select()
|
||||
.from(systemSettings)
|
||||
.where(isNull(systemSettings.portId))
|
||||
.orderBy(systemSettings.key);
|
||||
|
||||
return { portSettings, globalSettings };
|
||||
}
|
||||
|
||||
export async function getSetting(key: string, portId: string) {
|
||||
// Try port-specific first, fall back to global
|
||||
const setting = await db.query.systemSettings.findFirst({
|
||||
where: and(eq(systemSettings.key, key), eq(systemSettings.portId, portId)),
|
||||
});
|
||||
if (setting) return setting;
|
||||
|
||||
const global = await db.query.systemSettings.findFirst({
|
||||
where: and(eq(systemSettings.key, key), isNull(systemSettings.portId)),
|
||||
});
|
||||
return global ?? null;
|
||||
}
|
||||
|
||||
export async function upsertSetting(key: string, value: unknown, portId: string, meta: AuditMeta) {
|
||||
const existing = await db.query.systemSettings.findFirst({
|
||||
where: and(eq(systemSettings.key, key), eq(systemSettings.portId, portId)),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await db
|
||||
.update(systemSettings)
|
||||
.set({ value, updatedBy: meta.userId, updatedAt: new Date() })
|
||||
.where(and(eq(systemSettings.key, key), eq(systemSettings.portId, portId)));
|
||||
} else {
|
||||
await db.insert(systemSettings).values({
|
||||
key,
|
||||
value,
|
||||
portId,
|
||||
updatedBy: meta.userId,
|
||||
});
|
||||
}
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: existing ? 'update' : 'create',
|
||||
entityType: 'setting',
|
||||
entityId: key,
|
||||
oldValue: existing ? { value: existing.value } : undefined,
|
||||
newValue: { value },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'system:alert', {
|
||||
alertType: 'setting:updated',
|
||||
message: `Setting "${key}" updated`,
|
||||
severity: 'info',
|
||||
});
|
||||
|
||||
return { key, value, portId };
|
||||
}
|
||||
|
||||
export async function deleteSetting(key: string, portId: string, meta: AuditMeta) {
|
||||
const existing = await db.query.systemSettings.findFirst({
|
||||
where: and(eq(systemSettings.key, key), eq(systemSettings.portId, portId)),
|
||||
});
|
||||
if (!existing) throw new NotFoundError('Setting');
|
||||
|
||||
await db
|
||||
.delete(systemSettings)
|
||||
.where(and(eq(systemSettings.key, key), eq(systemSettings.portId, portId)));
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'delete',
|
||||
entityType: 'setting',
|
||||
entityId: key,
|
||||
oldValue: { value: existing.value },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
}
|
||||
243
src/lib/services/users.service.ts
Normal file
243
src/lib/services/users.service.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { user, userProfiles, userPortRoles, roles } from '@/lib/db/schema';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import { emitToRoom } from '@/lib/socket/server';
|
||||
import type { CreateUserInput, UpdateUserInput } from '@/lib/validators/users';
|
||||
|
||||
interface AuditMeta {
|
||||
userId: string;
|
||||
portId: string;
|
||||
ipAddress: string;
|
||||
userAgent: string;
|
||||
}
|
||||
|
||||
export async function listUsers(portId: string) {
|
||||
const rows = await db
|
||||
.select({
|
||||
userId: userPortRoles.userId,
|
||||
displayName: userProfiles.displayName,
|
||||
email: user.email,
|
||||
phone: userProfiles.phone,
|
||||
isActive: userProfiles.isActive,
|
||||
isSuperAdmin: userProfiles.isSuperAdmin,
|
||||
lastLoginAt: userProfiles.lastLoginAt,
|
||||
roleId: roles.id,
|
||||
roleName: roles.name,
|
||||
assignedAt: userPortRoles.createdAt,
|
||||
})
|
||||
.from(userPortRoles)
|
||||
.innerJoin(userProfiles, eq(userPortRoles.userId, userProfiles.userId))
|
||||
.innerJoin(user, eq(userPortRoles.userId, user.id))
|
||||
.innerJoin(roles, eq(userPortRoles.roleId, roles.id))
|
||||
.where(eq(userPortRoles.portId, portId))
|
||||
.orderBy(userProfiles.displayName);
|
||||
|
||||
return rows.map((row) => ({
|
||||
userId: row.userId,
|
||||
displayName: row.displayName,
|
||||
email: row.email,
|
||||
phone: row.phone,
|
||||
isActive: row.isActive,
|
||||
isSuperAdmin: row.isSuperAdmin,
|
||||
lastLoginAt: row.lastLoginAt,
|
||||
role: { id: row.roleId, name: row.roleName },
|
||||
assignedAt: row.assignedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getUser(userId: string, portId: string) {
|
||||
const profile = await db.query.userProfiles.findFirst({
|
||||
where: eq(userProfiles.userId, userId),
|
||||
});
|
||||
if (!profile) throw new NotFoundError('User');
|
||||
|
||||
const authUser = await db.query.user.findFirst({
|
||||
where: eq(user.id, userId),
|
||||
});
|
||||
|
||||
const portRole = await db.query.userPortRoles.findFirst({
|
||||
where: and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId)),
|
||||
with: { role: true },
|
||||
});
|
||||
if (!portRole) throw new NotFoundError('User not assigned to this port');
|
||||
|
||||
return {
|
||||
userId: profile.userId,
|
||||
displayName: profile.displayName,
|
||||
email: authUser?.email ?? '',
|
||||
phone: profile.phone,
|
||||
isActive: profile.isActive,
|
||||
isSuperAdmin: profile.isSuperAdmin,
|
||||
lastLoginAt: profile.lastLoginAt,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
preferences: profile.preferences,
|
||||
role: { id: portRole.role.id, name: portRole.role.name },
|
||||
createdAt: profile.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createUser(portId: string, data: CreateUserInput, meta: AuditMeta) {
|
||||
// Check email uniqueness
|
||||
const existingUser = await db.query.user.findFirst({
|
||||
where: eq(user.email, data.email.toLowerCase()),
|
||||
});
|
||||
if (existingUser) {
|
||||
throw new ConflictError('A user with this email already exists');
|
||||
}
|
||||
|
||||
// Validate role exists
|
||||
const role = await db.query.roles.findFirst({
|
||||
where: eq(roles.id, data.roleId),
|
||||
});
|
||||
if (!role) throw new ValidationError('Invalid role ID');
|
||||
|
||||
// Create Better Auth user
|
||||
const authResult = await auth.api.signUpEmail({
|
||||
body: {
|
||||
email: data.email.toLowerCase(),
|
||||
password: data.password,
|
||||
name: data.name,
|
||||
},
|
||||
});
|
||||
|
||||
const newUserId = authResult.user.id;
|
||||
|
||||
// Create CRM profile
|
||||
await db.insert(userProfiles).values({
|
||||
userId: newUserId,
|
||||
displayName: data.displayName,
|
||||
phone: data.phone ?? null,
|
||||
});
|
||||
|
||||
// Assign to port with role
|
||||
await db.insert(userPortRoles).values({
|
||||
userId: newUserId,
|
||||
portId,
|
||||
roleId: data.roleId,
|
||||
assignedBy: meta.userId,
|
||||
});
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'create',
|
||||
entityType: 'user',
|
||||
entityId: newUserId,
|
||||
newValue: { email: data.email, displayName: data.displayName, role: role.name },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'system:alert', {
|
||||
alertType: 'user:created',
|
||||
message: `User "${data.displayName}" added`,
|
||||
severity: 'info',
|
||||
});
|
||||
|
||||
return getUser(newUserId, portId);
|
||||
}
|
||||
|
||||
export async function updateUser(
|
||||
userId: string,
|
||||
portId: string,
|
||||
data: UpdateUserInput,
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
const profile = await db.query.userProfiles.findFirst({
|
||||
where: eq(userProfiles.userId, userId),
|
||||
});
|
||||
if (!profile) throw new NotFoundError('User');
|
||||
|
||||
const portRole = await db.query.userPortRoles.findFirst({
|
||||
where: and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId)),
|
||||
});
|
||||
if (!portRole) throw new NotFoundError('User not assigned to this port');
|
||||
|
||||
// Update profile fields
|
||||
const profileUpdates: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (data.displayName !== undefined) profileUpdates.displayName = data.displayName;
|
||||
if (data.phone !== undefined) profileUpdates.phone = data.phone;
|
||||
if (data.isActive !== undefined) profileUpdates.isActive = data.isActive;
|
||||
|
||||
if (Object.keys(profileUpdates).length > 1) {
|
||||
await db.update(userProfiles).set(profileUpdates).where(eq(userProfiles.userId, userId));
|
||||
}
|
||||
|
||||
// Update role assignment
|
||||
if (data.roleId && data.roleId !== portRole.roleId) {
|
||||
const newRole = await db.query.roles.findFirst({
|
||||
where: eq(roles.id, data.roleId),
|
||||
});
|
||||
if (!newRole) throw new ValidationError('Invalid role ID');
|
||||
|
||||
await db
|
||||
.update(userPortRoles)
|
||||
.set({ roleId: data.roleId, assignedBy: meta.userId })
|
||||
.where(and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId)));
|
||||
}
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'update',
|
||||
entityType: 'user',
|
||||
entityId: userId,
|
||||
oldValue: {
|
||||
displayName: profile.displayName,
|
||||
isActive: profile.isActive,
|
||||
roleId: portRole.roleId,
|
||||
},
|
||||
newValue: data,
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'system:alert', {
|
||||
alertType: 'user:updated',
|
||||
message: `User "${data.displayName ?? profile.displayName}" updated`,
|
||||
severity: 'info',
|
||||
});
|
||||
|
||||
return getUser(userId, portId);
|
||||
}
|
||||
|
||||
export async function removeUserFromPort(userId: string, portId: string, meta: AuditMeta) {
|
||||
const portRole = await db.query.userPortRoles.findFirst({
|
||||
where: and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId)),
|
||||
});
|
||||
if (!portRole) throw new NotFoundError('User not assigned to this port');
|
||||
|
||||
// Prevent removing yourself
|
||||
if (userId === meta.userId) {
|
||||
throw new ValidationError('Cannot remove yourself from the port');
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(userPortRoles)
|
||||
.where(and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId)));
|
||||
|
||||
const profile = await db.query.userProfiles.findFirst({
|
||||
where: eq(userProfiles.userId, userId),
|
||||
});
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'delete',
|
||||
entityType: 'user',
|
||||
entityId: userId,
|
||||
oldValue: { displayName: profile?.displayName, roleId: portRole.roleId },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'system:alert', {
|
||||
alertType: 'user:removed',
|
||||
message: `User "${profile?.displayName}" removed from port`,
|
||||
severity: 'info',
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,31 @@ import { z } from 'zod';
|
||||
import { BERTH_STATUSES } from '@/lib/constants';
|
||||
import { baseListQuerySchema } from '@/lib/api/route-helpers';
|
||||
|
||||
// ─── Create Berth ────────────────────────────────────────────────────────────
|
||||
|
||||
export const createBerthSchema = z.object({
|
||||
mooringNumber: z.string().min(1),
|
||||
area: z.string().min(1),
|
||||
lengthFt: z.coerce.number().optional(),
|
||||
lengthM: z.coerce.number().optional(),
|
||||
widthFt: z.coerce.number().optional(),
|
||||
widthM: z.coerce.number().optional(),
|
||||
draftFt: z.coerce.number().optional(),
|
||||
draftM: z.coerce.number().optional(),
|
||||
price: z.coerce.number().optional(),
|
||||
priceCurrency: z.string().optional(),
|
||||
status: z.enum(BERTH_STATUSES).default('available'),
|
||||
tenureType: z.enum(['permanent', 'fixed_term']).optional(),
|
||||
mooringType: z.string().optional(),
|
||||
powerCapacity: z.string().optional(),
|
||||
voltage: z.string().optional(),
|
||||
access: z.string().optional(),
|
||||
bowFacing: z.string().optional(),
|
||||
sidePontoon: z.string().optional(),
|
||||
});
|
||||
|
||||
export type CreateBerthInput = z.infer<typeof createBerthSchema>;
|
||||
|
||||
// ─── Update Berth ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const updateBerthSchema = z.object({
|
||||
|
||||
@@ -64,20 +64,40 @@ export const generateRecommendationsSchema = z.object({
|
||||
|
||||
// ─── Public Interest ──────────────────────────────────────────────────────────
|
||||
|
||||
export const publicInterestSchema = z.object({
|
||||
fullName: z.string().min(1).max(200),
|
||||
email: z.string().email(),
|
||||
phone: z.string().optional(),
|
||||
companyName: z.string().optional(),
|
||||
yachtName: z.string().optional(),
|
||||
yachtLengthFt: z.coerce.number().positive().optional(),
|
||||
yachtWidthFt: z.coerce.number().positive().optional(),
|
||||
yachtDraftFt: z.coerce.number().positive().optional(),
|
||||
preferredBerthSize: z.string().optional(),
|
||||
source: z.literal('website').default('website'),
|
||||
notes: z.string().max(2000).optional(),
|
||||
const addressSchema = z.object({
|
||||
street: z.string().max(500).optional(),
|
||||
city: z.string().max(200).optional(),
|
||||
stateProvince: z.string().max(200).optional(),
|
||||
postalCode: z.string().max(50).optional(),
|
||||
country: z.string().max(100).optional(),
|
||||
});
|
||||
|
||||
export const publicInterestSchema = z
|
||||
.object({
|
||||
// New: first/last split
|
||||
firstName: z.string().min(1).max(100).optional(),
|
||||
lastName: z.string().min(1).max(100).optional(),
|
||||
// Backward compat
|
||||
fullName: z.string().min(1).max(200).optional(),
|
||||
email: z.string().email(),
|
||||
phone: z.string().min(1),
|
||||
preferredContactMethod: z.enum(['email', 'phone', 'sms']).optional(),
|
||||
mooringNumber: z.string().max(50).optional(),
|
||||
companyName: z.string().optional(),
|
||||
yachtName: z.string().optional(),
|
||||
yachtLengthFt: z.coerce.number().positive().optional(),
|
||||
yachtWidthFt: z.coerce.number().positive().optional(),
|
||||
yachtDraftFt: z.coerce.number().positive().optional(),
|
||||
preferredBerthSize: z.string().optional(),
|
||||
source: z.literal('website').default('website'),
|
||||
notes: z.string().max(2000).optional(),
|
||||
address: addressSchema.optional(),
|
||||
})
|
||||
.refine((data) => data.fullName || (data.firstName && data.lastName), {
|
||||
message: 'Either fullName or both firstName and lastName are required',
|
||||
path: ['fullName'],
|
||||
});
|
||||
|
||||
// ─── Reorder Waiting List ─────────────────────────────────────────────────────
|
||||
|
||||
export const reorderWaitingListSchema = z.object({
|
||||
|
||||
41
src/lib/validators/ports.ts
Normal file
41
src/lib/validators/ports.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createPortSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.regex(/^[a-z0-9-]+$/, 'Slug must be lowercase alphanumeric with hyphens'),
|
||||
logoUrl: z.string().url().optional(),
|
||||
primaryColor: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/)
|
||||
.optional(),
|
||||
defaultCurrency: z.string().length(3).default('USD'),
|
||||
timezone: z.string().min(1).default('America/Anguilla'),
|
||||
});
|
||||
|
||||
export type CreatePortInput = z.infer<typeof createPortSchema>;
|
||||
|
||||
export const updatePortSchema = z.object({
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.regex(/^[a-z0-9-]+$/, 'Slug must be lowercase alphanumeric with hyphens')
|
||||
.optional(),
|
||||
logoUrl: z.string().url().nullable().optional(),
|
||||
primaryColor: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/)
|
||||
.nullable()
|
||||
.optional(),
|
||||
defaultCurrency: z.string().length(3).optional(),
|
||||
timezone: z.string().min(1).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
settings: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export type UpdatePortInput = z.infer<typeof updatePortSchema>;
|
||||
47
src/lib/validators/reminders.ts
Normal file
47
src/lib/validators/reminders.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { z } from 'zod';
|
||||
import { baseListQuerySchema } from '@/lib/api/route-helpers';
|
||||
|
||||
export const createReminderSchema = z.object({
|
||||
title: z.string().min(1).max(300),
|
||||
note: z.string().max(2000).optional(),
|
||||
dueAt: z.string().datetime(),
|
||||
priority: z.enum(['low', 'medium', 'high', 'urgent']).default('medium'),
|
||||
assignedTo: z.string().optional(),
|
||||
clientId: z.string().uuid().optional(),
|
||||
interestId: z.string().uuid().optional(),
|
||||
berthId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
export type CreateReminderInput = z.infer<typeof createReminderSchema>;
|
||||
|
||||
export const updateReminderSchema = z.object({
|
||||
title: z.string().min(1).max(300).optional(),
|
||||
note: z.string().max(2000).nullable().optional(),
|
||||
dueAt: z.string().datetime().optional(),
|
||||
priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
|
||||
assignedTo: z.string().nullable().optional(),
|
||||
clientId: z.string().uuid().nullable().optional(),
|
||||
interestId: z.string().uuid().nullable().optional(),
|
||||
berthId: z.string().uuid().nullable().optional(),
|
||||
});
|
||||
|
||||
export type UpdateReminderInput = z.infer<typeof updateReminderSchema>;
|
||||
|
||||
export const snoozeReminderSchema = z.object({
|
||||
snoozeUntil: z.string().datetime(),
|
||||
});
|
||||
|
||||
export type SnoozeReminderInput = z.infer<typeof snoozeReminderSchema>;
|
||||
|
||||
export const reminderListQuerySchema = baseListQuerySchema.extend({
|
||||
status: z.enum(['pending', 'snoozed', 'completed', 'dismissed']).optional(),
|
||||
priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
|
||||
assignedTo: z.string().optional(),
|
||||
clientId: z.string().uuid().optional(),
|
||||
interestId: z.string().uuid().optional(),
|
||||
berthId: z.string().uuid().optional(),
|
||||
dueBefore: z.string().datetime().optional(),
|
||||
dueAfter: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
export type ReminderListQuery = z.infer<typeof reminderListQuerySchema>;
|
||||
35
src/lib/validators/roles.ts
Normal file
35
src/lib/validators/roles.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const permissionGroupSchema = z.record(z.string(), z.boolean());
|
||||
|
||||
const rolePermissionsSchema = z.object({
|
||||
clients: permissionGroupSchema,
|
||||
interests: permissionGroupSchema,
|
||||
berths: permissionGroupSchema,
|
||||
documents: permissionGroupSchema,
|
||||
expenses: permissionGroupSchema,
|
||||
invoices: permissionGroupSchema,
|
||||
files: permissionGroupSchema,
|
||||
email: permissionGroupSchema,
|
||||
reminders: permissionGroupSchema,
|
||||
calendar: permissionGroupSchema,
|
||||
reports: permissionGroupSchema,
|
||||
document_templates: permissionGroupSchema,
|
||||
admin: permissionGroupSchema,
|
||||
});
|
||||
|
||||
export const createRoleSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
permissions: rolePermissionsSchema,
|
||||
});
|
||||
|
||||
export type CreateRoleInput = z.infer<typeof createRoleSchema>;
|
||||
|
||||
export const updateRoleSchema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
description: z.string().max(500).nullable().optional(),
|
||||
permissions: rolePermissionsSchema.optional(),
|
||||
});
|
||||
|
||||
export type UpdateRoleInput = z.infer<typeof updateRoleSchema>;
|
||||
14
src/lib/validators/settings.ts
Normal file
14
src/lib/validators/settings.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const upsertSettingSchema = z.object({
|
||||
key: z.string().min(1).max(100),
|
||||
value: z.unknown(),
|
||||
});
|
||||
|
||||
export type UpsertSettingInput = z.infer<typeof upsertSettingSchema>;
|
||||
|
||||
export const deleteSettingSchema = z.object({
|
||||
key: z.string().min(1).max(100),
|
||||
});
|
||||
|
||||
export type DeleteSettingInput = z.infer<typeof deleteSettingSchema>;
|
||||
27
src/lib/validators/users.ts
Normal file
27
src/lib/validators/users.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createUserSchema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().min(1).max(200),
|
||||
password: z.string().min(12),
|
||||
displayName: z.string().min(1).max(200),
|
||||
phone: z.string().optional(),
|
||||
roleId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export type CreateUserInput = z.infer<typeof createUserSchema>;
|
||||
|
||||
export const updateUserSchema = z.object({
|
||||
displayName: z.string().min(1).max(200).optional(),
|
||||
phone: z.string().nullable().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
roleId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
|
||||
|
||||
export const resetPasswordSchema = z.object({
|
||||
newPassword: z.string().min(12),
|
||||
});
|
||||
|
||||
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
|
||||
43
src/worker.ts
Normal file
43
src/worker.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Worker entry point for the crm-worker container.
|
||||
*
|
||||
* Imports all BullMQ workers and registers recurring job schedules.
|
||||
* In production this runs as a separate process (Dockerfile.worker).
|
||||
* In development, server.ts imports workers inline instead.
|
||||
*/
|
||||
|
||||
import { logger } from '@/lib/logger';
|
||||
import { registerRecurringJobs } from '@/lib/queue/scheduler';
|
||||
|
||||
// Import all workers — the act of importing starts them
|
||||
import { emailWorker } from '@/lib/queue/workers/email';
|
||||
import { documentsWorker } from '@/lib/queue/workers/documents';
|
||||
import { notificationsWorker } from '@/lib/queue/workers/notifications';
|
||||
import { importWorker } from '@/lib/queue/workers/import';
|
||||
import { exportWorker } from '@/lib/queue/workers/export';
|
||||
|
||||
// Keep references so workers aren't GC'd
|
||||
const workers = [emailWorker, documentsWorker, notificationsWorker, importWorker, exportWorker];
|
||||
|
||||
async function main(): Promise<void> {
|
||||
logger.info({ workerCount: workers.length }, 'BullMQ workers started');
|
||||
|
||||
await registerRecurringJobs();
|
||||
logger.info('Recurring jobs registered');
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = async () => {
|
||||
logger.info('Shutting down workers...');
|
||||
await Promise.all(workers.map((w) => w.close()));
|
||||
logger.info('All workers closed');
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
logger.error(err, 'Worker process failed to start');
|
||||
process.exit(1);
|
||||
});
|
||||
12
tsconfig.server.json
Normal file
12
tsconfig.server.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"noEmit": false,
|
||||
"outDir": "./dist",
|
||||
"plugins": []
|
||||
},
|
||||
"include": ["src/server.ts", "src/worker.ts", "src/**/*.ts"],
|
||||
"exclude": ["node_modules", "client-portal", "**/*.tsx"]
|
||||
}
|
||||
Reference in New Issue
Block a user