Initial commit: Port Nimara CRM (Layers 0-4)
Some checks failed
Build & Push Docker Images / build-and-push (push) Has been cancelled
Build & Push Docker Images / deploy (push) Has been cancelled
Build & Push Docker Images / lint (push) Has been cancelled

Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 11:52:51 +01:00
commit 67d7e6e3d5
572 changed files with 86496 additions and 0 deletions

15
src/lib/validators/ai.ts Normal file
View File

@@ -0,0 +1,15 @@
import { z } from 'zod';
export const requestScoreSchema = z.object({
interestId: z.string().uuid(),
});
export const requestDraftSchema = z.object({
interestId: z.string().uuid(),
clientId: z.string().uuid(),
context: z.enum(['follow_up', 'introduction', 'stage_update', 'general']),
additionalInstructions: z.string().max(500).optional(),
});
export type RequestScoreInput = z.infer<typeof requestScoreSchema>;
export type RequestDraftInput = z.infer<typeof requestDraftSchema>;

View File

@@ -0,0 +1,97 @@
import { z } from 'zod';
import { BERTH_STATUSES } from '@/lib/constants';
import { baseListQuerySchema } from '@/lib/api/route-helpers';
// ─── Update Berth ─────────────────────────────────────────────────────────────
export const updateBerthSchema = z.object({
area: z.string().optional(),
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(),
widthIsMinimum: z.boolean().optional(),
nominalBoatSize: z.string().optional(),
nominalBoatSizeM: z.string().optional(),
waterDepth: z.coerce.number().optional(),
waterDepthM: z.coerce.number().optional(),
waterDepthIsMinimum: z.boolean().optional(),
sidePontoon: z.string().optional(),
powerCapacity: z.string().optional(),
voltage: z.string().optional(),
mooringType: z.string().optional(),
cleatType: z.string().optional(),
cleatCapacity: z.string().optional(),
bollardType: z.string().optional(),
bollardCapacity: z.string().optional(),
access: z.string().optional(),
price: z.coerce.number().optional(),
priceCurrency: z.string().optional(),
bowFacing: z.string().optional(),
berthApproved: z.boolean().optional(),
tenureType: z.enum(['permanent', 'fixed_term']).optional(),
tenureYears: z.coerce.number().int().optional(),
tenureStartDate: z.string().optional(),
tenureEndDate: z.string().optional(),
});
export type UpdateBerthInput = z.infer<typeof updateBerthSchema>;
// ─── Update Berth Status ──────────────────────────────────────────────────────
export const updateBerthStatusSchema = z.object({
status: z.enum(BERTH_STATUSES),
reason: z.string().min(1, 'Reason is required'),
});
export type UpdateBerthStatusInput = z.infer<typeof updateBerthStatusSchema>;
// ─── List Berths ──────────────────────────────────────────────────────────────
export const listBerthsSchema = baseListQuerySchema.extend({
status: z.enum(BERTH_STATUSES).optional(),
area: z.string().optional(),
minLength: z.coerce.number().optional(),
maxLength: z.coerce.number().optional(),
minPrice: z.coerce.number().optional(),
maxPrice: z.coerce.number().optional(),
tenureType: z.enum(['permanent', 'fixed_term']).optional(),
tagIds: z
.string()
.optional()
.transform((v) => (v ? v.split(',') : undefined)),
});
export type ListBerthsQuery = z.infer<typeof listBerthsSchema>;
// ─── Add Maintenance Log ──────────────────────────────────────────────────────
export const addMaintenanceLogSchema = z.object({
category: z.enum(['routine', 'repair', 'inspection', 'upgrade']),
description: z.string().min(1),
cost: z.coerce.number().optional(),
costCurrency: z.string().optional(),
responsibleParty: z.string().optional(),
performedDate: z.string().min(1, 'Performed date is required'),
photoFileIds: z.array(z.string()).optional(),
});
export type AddMaintenanceLogInput = z.infer<typeof addMaintenanceLogSchema>;
// ─── Update Waiting List ──────────────────────────────────────────────────────
export const updateWaitingListSchema = z.object({
entries: z.array(
z.object({
clientId: z.string(),
position: z.number().int().min(1),
priority: z.enum(['normal', 'high']).optional(),
notifyPref: z.enum(['email', 'in_app', 'both']).optional(),
notes: z.string().optional(),
}),
),
});
export type UpdateWaitingListInput = z.infer<typeof updateWaitingListSchema>;

View File

@@ -0,0 +1,65 @@
import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/route-helpers';
// ─── Contact sub-schema ──────────────────────────────────────────────────────
export const contactSchema = z.object({
channel: z.enum(['email', 'phone', 'whatsapp', 'other']),
value: z.string().min(1),
label: z.string().optional(),
isPrimary: z.boolean().optional().default(false),
notes: z.string().optional(),
});
// ─── Create ──────────────────────────────────────────────────────────────────
export const createClientSchema = z.object({
fullName: z.string().min(1).max(200),
contacts: z.array(contactSchema).min(1, 'At least one contact is required'),
companyName: z.string().optional(),
nationality: z.string().optional(),
isProxy: z.boolean().optional().default(false),
proxyType: z.string().optional(),
actualOwnerName: z.string().optional(),
yachtName: z.string().optional(),
yachtLengthFt: z.string().optional(),
yachtWidthFt: z.string().optional(),
yachtDraftFt: z.string().optional(),
yachtLengthM: z.string().optional(),
yachtWidthM: z.string().optional(),
yachtDraftM: z.string().optional(),
berthSizeDesired: z.string().optional(),
preferredContactMethod: z.enum(['email', 'phone', 'whatsapp']).optional(),
preferredLanguage: z.string().optional(),
timezone: z.string().optional(),
source: z.enum(['website', 'manual', 'referral', 'broker']).optional(),
sourceDetails: z.string().optional(),
tagIds: z.array(z.string()).optional().default([]),
});
// ─── Update ──────────────────────────────────────────────────────────────────
export const updateClientSchema = createClientSchema.omit({ contacts: true, tagIds: true }).partial();
// ─── List ─────────────────────────────────────────────────────────────────────
export const listClientsSchema = baseListQuerySchema.extend({
source: z.enum(['website', 'manual', 'referral', 'broker']).optional(),
nationality: z.string().optional(),
isProxy: z
.string()
.transform((v) => v === 'true')
.optional(),
tagIds: z
.string()
.transform((v) => v.split(',').filter(Boolean))
.optional(),
});
// ─── Types ────────────────────────────────────────────────────────────────────
export type ContactInput = z.infer<typeof contactSchema>;
export type CreateClientInput = z.infer<typeof createClientSchema>;
export type UpdateClientInput = z.infer<typeof updateClientSchema>;
export type ListClientsInput = z.infer<typeof listClientsSchema>;

View File

@@ -0,0 +1,53 @@
import { z } from 'zod';
const CUSTOM_FIELD_TYPES = ['text', 'number', 'date', 'boolean', 'select'] as const;
const CUSTOM_FIELD_ENTITIES = ['client', 'interest', 'berth'] as const;
export const createFieldSchema = z
.object({
entityType: z.enum(CUSTOM_FIELD_ENTITIES),
fieldName: z
.string()
.min(1)
.max(50)
.regex(/^[a-z_][a-z0-9_]*$/, 'Must be snake_case'),
fieldLabel: z.string().min(1).max(100),
fieldType: z.enum(CUSTOM_FIELD_TYPES),
selectOptions: z
.array(z.string().min(1).max(100))
.min(1)
.max(50)
.optional(),
isRequired: z.boolean().default(false),
sortOrder: z.number().int().min(0).default(0),
})
.refine(
(data) =>
data.fieldType !== 'select' ||
(data.selectOptions && data.selectOptions.length > 0),
{
message: 'Select fields must have at least one option',
path: ['selectOptions'],
},
);
export const updateFieldSchema = z.object({
fieldLabel: z.string().min(1).max(100).optional(),
selectOptions: z.array(z.string().min(1).max(100)).optional(),
isRequired: z.boolean().optional(),
sortOrder: z.number().int().min(0).optional(),
// fieldType intentionally omitted — cannot be changed after creation
});
export const setValuesSchema = z.object({
values: z.array(
z.object({
fieldId: z.string().uuid(),
value: z.unknown(),
}),
),
});
export type CreateFieldInput = z.infer<typeof createFieldSchema>;
export type UpdateFieldInput = z.infer<typeof updateFieldSchema>;
export type SetValuesInput = z.infer<typeof setValuesSchema>;

View File

@@ -0,0 +1,105 @@
import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/route-helpers';
export const createTemplateSchema = z.object({
name: z.string().min(1).max(200),
description: z.string().max(500).optional(),
templateType: z.enum([
'welcome_letter',
'handover_checklist',
'acknowledgment',
'correspondence',
'custom',
]),
bodyHtml: z.string().min(1),
mergeFields: z.array(z.string()).optional().default([]),
isActive: z.boolean().default(true),
});
export const updateTemplateSchema = createTemplateSchema.partial();
export const listTemplatesSchema = baseListQuerySchema.extend({
templateType: z.string().optional(),
isActive: z
.enum(['true', 'false'])
.transform((v) => v === 'true')
.optional(),
});
export const generateSchema = z.object({
clientId: z.string().optional(),
interestId: z.string().optional(),
berthId: z.string().optional(),
});
export const generateAndSendSchema = generateSchema.extend({
recipientEmail: z.string().email(),
});
export const generateAndSignSchema = generateSchema.extend({
signers: z
.array(
z.object({
name: z.string().min(1),
email: z.string().email(),
role: z.string().min(1),
signingOrder: z.number().int().min(1),
}),
)
.min(1),
});
export type CreateTemplateInput = z.infer<typeof createTemplateSchema>;
export type UpdateTemplateInput = z.infer<typeof updateTemplateSchema>;
export type ListTemplatesInput = z.infer<typeof listTemplatesSchema>;
export type GenerateInput = z.infer<typeof generateSchema>;
export type GenerateAndSendInput = z.infer<typeof generateAndSendSchema>;
export type GenerateAndSignInput = z.infer<typeof generateAndSignSchema>;
// ─── TipTap-based Admin Template Schemas ─────────────────────────────────────
// Used by /api/v1/admin/templates — the TipTap JSON document store.
export const tiptapDocumentTypes = [
'eoi',
'contract',
'nda',
'reservation_agreement',
'letter',
'other',
] as const;
export const createAdminTemplateSchema = z.object({
name: z.string().min(1).max(200),
type: z.enum(tiptapDocumentTypes),
content: z.record(z.unknown()), // TipTap JSON document
});
export const updateAdminTemplateSchema = z.object({
name: z.string().min(1).max(200).optional(),
content: z.record(z.unknown()).optional(),
isActive: z.boolean().optional(),
});
export const previewAdminTemplateSchema = z.object({
content: z.record(z.unknown()),
sampleData: z.record(z.string()).optional(),
});
export const rollbackAdminTemplateSchema = z.object({
version: z.number().int().min(1),
});
export const listAdminTemplatesSchema = baseListQuerySchema.extend({
type: z.enum(tiptapDocumentTypes).optional(),
isActive: z
.enum(['true', 'false'])
.transform((v) => v === 'true')
.optional(),
});
export type CreateAdminTemplateInput = z.infer<typeof createAdminTemplateSchema>;
export type UpdateAdminTemplateInput = z.infer<typeof updateAdminTemplateSchema>;
export type PreviewAdminTemplateInput = z.infer<typeof previewAdminTemplateSchema>;
export type RollbackAdminTemplateInput = z.infer<typeof rollbackAdminTemplateSchema>;
export type ListAdminTemplatesInput = z.infer<typeof listAdminTemplatesSchema>;

View File

@@ -0,0 +1,38 @@
import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/route-helpers';
import { DOCUMENT_TYPES, DOCUMENT_STATUSES } from '@/lib/constants';
export const createDocumentSchema = z.object({
interestId: z.string().optional(),
clientId: z.string().optional(),
documentType: z.enum(DOCUMENT_TYPES),
title: z.string().min(1).max(200),
notes: z.string().optional(),
});
export const updateDocumentSchema = z.object({
title: z.string().min(1).max(200).optional(),
notes: z.string().optional(),
status: z.enum(DOCUMENT_STATUSES).optional(),
});
export const listDocumentsSchema = baseListQuerySchema.extend({
interestId: z.string().optional(),
clientId: z.string().optional(),
documentType: z.string().optional(),
status: z.string().optional(),
});
export const generateEoiSchema = z.object({
interestId: z.string().min(1),
});
export const uploadSignedSchema = z.object({
documentId: z.string().min(1),
});
export type CreateDocumentInput = z.infer<typeof createDocumentSchema>;
export type UpdateDocumentInput = z.infer<typeof updateDocumentSchema>;
export type ListDocumentsInput = z.infer<typeof listDocumentsSchema>;
export type GenerateEoiInput = z.infer<typeof generateEoiSchema>;

View File

@@ -0,0 +1,37 @@
import { z } from 'zod';
export const connectAccountSchema = z.object({
provider: z.enum(['google', 'outlook', 'custom']),
emailAddress: z.string().email(),
smtpHost: z.string().min(1),
smtpPort: z.number().int().positive(),
imapHost: z.string().min(1),
imapPort: z.number().int().positive(),
username: z.string().min(1),
password: z.string().min(1),
});
export const toggleAccountSchema = z.object({
isActive: z.boolean(),
});
export const composeEmailSchema = z.object({
accountId: z.string().uuid(),
threadId: z.string().uuid().optional(),
to: z.array(z.string().email()).min(1),
cc: z.array(z.string().email()).optional(),
subject: z.string().min(1),
bodyHtml: z.string().min(1),
inReplyToMessageId: z.string().optional(),
});
export const listThreadsSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
clientId: z.string().uuid().optional(),
});
export type ConnectAccountInput = z.infer<typeof connectAccountSchema>;
export type ToggleAccountInput = z.infer<typeof toggleAccountSchema>;
export type ComposeEmailInput = z.infer<typeof composeEmailSchema>;
export type ListThreadsInput = z.infer<typeof listThreadsSchema>;

View File

@@ -0,0 +1,34 @@
import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/route-helpers';
import { EXPENSE_CATEGORIES, PAYMENT_METHODS } from '@/lib/constants';
export const createExpenseSchema = z.object({
establishmentName: z.string().max(200).optional(),
amount: z.coerce.number().positive(),
currency: z.string().length(3).default('USD'),
paymentMethod: z.enum(PAYMENT_METHODS).optional(),
category: z.enum(EXPENSE_CATEGORIES).optional(),
payer: z.string().max(200).optional(),
expenseDate: z.coerce.date(),
description: z.string().max(2000).optional(),
receiptFileIds: z.array(z.string()).optional(),
paymentStatus: z.enum(['unpaid', 'paid', 'partial']).default('unpaid'),
paymentDate: z.string().optional(),
paymentReference: z.string().optional(),
paymentNotes: z.string().optional(),
});
export const updateExpenseSchema = createExpenseSchema.partial();
export const listExpensesSchema = baseListQuerySchema.extend({
category: z.string().optional(),
paymentStatus: z.string().optional(),
dateFrom: z.string().optional(),
dateTo: z.string().optional(),
currency: z.string().optional(),
payer: z.string().optional(),
});
export type CreateExpenseInput = z.infer<typeof createExpenseSchema>;
export type UpdateExpenseInput = z.infer<typeof updateExpenseSchema>;
export type ListExpensesInput = z.infer<typeof listExpensesSchema>;

View File

@@ -0,0 +1,27 @@
import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/route-helpers';
export const uploadFileSchema = z.object({
filename: z.string().min(1).max(255),
clientId: z.string().optional(),
category: z.string().optional(),
entityType: z.string().optional(),
entityId: z.string().optional(),
});
export const updateFileSchema = z.object({
filename: z.string().min(1).max(255).optional(),
category: z.string().optional(),
});
export const listFilesSchema = baseListQuerySchema.extend({
clientId: z.string().optional(),
category: z.string().optional(),
entityType: z.string().optional(),
entityId: z.string().optional(),
});
export type UploadFileInput = z.infer<typeof uploadFileSchema>;
export type UpdateFileInput = z.infer<typeof updateFileSchema>;
export type ListFilesInput = z.infer<typeof listFilesSchema>;

View File

@@ -0,0 +1,96 @@
import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/route-helpers';
import { PIPELINE_STAGES, LEAD_CATEGORIES } from '@/lib/constants';
// ─── Create ──────────────────────────────────────────────────────────────────
export const createInterestSchema = z.object({
clientId: z.string().min(1),
berthId: z.string().optional(),
pipelineStage: z.enum(PIPELINE_STAGES).default('open'),
leadCategory: z.enum(LEAD_CATEGORIES).optional(),
source: z.string().optional(),
notes: z.string().optional(),
tagIds: z.array(z.string()).optional().default([]),
reminderEnabled: z.boolean().optional().default(false),
reminderDays: z.number().int().min(1).optional(),
});
// ─── Update ──────────────────────────────────────────────────────────────────
export const updateInterestSchema = createInterestSchema
.omit({ clientId: true, tagIds: true })
.partial();
// ─── Change Stage ─────────────────────────────────────────────────────────────
export const changeStageSchema = z.object({
pipelineStage: z.enum(PIPELINE_STAGES),
reason: z.string().optional(),
});
// ─── List ─────────────────────────────────────────────────────────────────────
export const listInterestsSchema = baseListQuerySchema.extend({
clientId: z.string().optional(),
berthId: z.string().optional(),
pipelineStage: z
.string()
.transform((v) => v.split(',').filter(Boolean))
.optional(),
leadCategory: z.enum(LEAD_CATEGORIES).optional(),
eoiStatus: z.string().optional(),
tagIds: z
.string()
.transform((v) => v.split(',').filter(Boolean))
.optional(),
});
// ─── Waiting List ─────────────────────────────────────────────────────────────
export const waitingListAddSchema = z.object({
clientId: z.string().min(1),
priority: z.enum(['normal', 'high']).default('normal'),
notifyPref: z.enum(['email', 'in_app', 'both']).default('email'),
notes: z.string().optional(),
});
// ─── Generate Recommendations ─────────────────────────────────────────────────
export const generateRecommendationsSchema = z.object({
interestId: z.string().min(1),
});
// ─── 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(),
});
// ─── Reorder Waiting List ─────────────────────────────────────────────────────
export const reorderWaitingListSchema = z.object({
entryId: z.string().min(1),
newPosition: z.coerce.number().int().min(1),
});
// ─── Types ────────────────────────────────────────────────────────────────────
export type CreateInterestInput = z.infer<typeof createInterestSchema>;
export type UpdateInterestInput = z.infer<typeof updateInterestSchema>;
export type ChangeStageInput = z.infer<typeof changeStageSchema>;
export type ListInterestsInput = z.infer<typeof listInterestsSchema>;
export type WaitingListAddInput = z.infer<typeof waitingListAddSchema>;
export type PublicInterestInput = z.infer<typeof publicInterestSchema>;
export type ReorderWaitingListInput = z.infer<typeof reorderWaitingListSchema>;

View File

@@ -0,0 +1,71 @@
import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/route-helpers';
export const createInvoiceSchema = z
.object({
clientName: z.string().min(1).max(200),
billingEmail: z.string().email().optional(),
billingAddress: z.string().max(500).optional(),
dueDate: z.string().min(1),
paymentTerms: z
.enum(['immediate', 'net10', 'net15', 'net30', 'net45', 'net60'])
.default('net30'),
currency: z.string().length(3).default('USD'),
notes: z.string().max(2000).optional(),
lineItems: z
.array(
z.object({
description: z.string().min(1),
quantity: z.coerce.number().positive().default(1),
unitPrice: z.coerce.number().min(0),
}),
)
.optional(),
expenseIds: z.array(z.string()).optional(),
})
.refine(
(data) =>
(data.lineItems && data.lineItems.length > 0) ||
(data.expenseIds && data.expenseIds.length > 0),
{ message: 'Invoice must have at least one line item or linked expense' },
);
export const updateInvoiceSchema = z.object({
clientName: z.string().min(1).max(200).optional(),
billingEmail: z.string().email().optional(),
billingAddress: z.string().max(500).optional(),
dueDate: z.string().min(1).optional(),
paymentTerms: z
.enum(['immediate', 'net10', 'net15', 'net30', 'net45', 'net60'])
.optional(),
currency: z.string().length(3).optional(),
notes: z.string().max(2000).optional(),
lineItems: z
.array(
z.object({
description: z.string().min(1),
quantity: z.coerce.number().positive().default(1),
unitPrice: z.coerce.number().min(0),
}),
)
.optional(),
expenseIds: z.array(z.string()).optional(),
});
export const recordPaymentSchema = z.object({
paymentDate: z.string().min(1),
paymentMethod: z.string().optional(),
paymentReference: z.string().optional(),
});
export const listInvoicesSchema = baseListQuerySchema.extend({
status: z.string().optional(),
clientName: z.string().optional(),
dateFrom: z.string().optional(),
dateTo: z.string().optional(),
});
export type CreateInvoiceInput = z.infer<typeof createInvoiceSchema>;
export type UpdateInvoiceInput = z.infer<typeof updateInvoiceSchema>;
export type RecordPaymentInput = z.infer<typeof recordPaymentSchema>;
export type ListInvoicesInput = z.infer<typeof listInvoicesSchema>;

View File

@@ -0,0 +1,12 @@
import { z } from 'zod';
export const createNoteSchema = z.object({
content: z.string().min(1),
});
export const updateNoteSchema = z.object({
content: z.string().min(1),
});
export type CreateNoteInput = z.infer<typeof createNoteSchema>;
export type UpdateNoteInput = z.infer<typeof updateNoteSchema>;

View File

@@ -0,0 +1,20 @@
import { z } from 'zod';
export const listNotificationsSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
unreadOnly: z.coerce.boolean().optional().default(false),
});
export const updatePreferencesSchema = z.object({
preferences: z.array(
z.object({
notificationType: z.string(),
inApp: z.boolean(),
email: z.boolean(),
}),
),
});
export type ListNotificationsInput = z.infer<typeof listNotificationsSchema>;
export type UpdatePreferencesInput = z.infer<typeof updatePreferencesSchema>;

View File

@@ -0,0 +1,22 @@
import { z } from 'zod';
export const requestReportSchema = z.object({
reportType: z.enum(['pipeline', 'revenue', 'activity', 'occupancy']),
name: z.string().min(1).max(200),
parameters: z
.object({
dateFrom: z.string().optional(),
dateTo: z.string().optional(),
})
.optional()
.default({}),
});
export const listReportsSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
status: z.enum(['queued', 'processing', 'ready', 'failed']).optional(),
});
export type RequestReportInput = z.infer<typeof requestReportSchema>;
export type ListReportsInput = z.infer<typeof listReportsSchema>;

View File

@@ -0,0 +1,21 @@
import { z } from 'zod';
export const createSavedViewSchema = z.object({
entityType: z.string().min(1),
name: z.string().min(1),
filters: z.record(z.unknown()).default({}),
sortConfig: z
.object({
field: z.string(),
direction: z.enum(['asc', 'desc']),
})
.optional(),
columnConfig: z.record(z.unknown()).optional(),
isShared: z.boolean().optional().default(false),
isDefault: z.boolean().optional().default(false),
});
export const updateSavedViewSchema = createSavedViewSchema.partial();
export type CreateSavedViewInput = z.infer<typeof createSavedViewSchema>;
export type UpdateSavedViewInput = z.infer<typeof updateSavedViewSchema>;

View File

@@ -0,0 +1,7 @@
import { z } from 'zod';
export const searchQuerySchema = z.object({
q: z.string().min(2).max(200),
});
export type SearchQuery = z.infer<typeof searchQuerySchema>;

View File

@@ -0,0 +1,15 @@
import { z } from 'zod';
export const createTagSchema = z.object({
name: z.string().min(1).max(50),
color: z
.string()
.regex(/^#[0-9A-Fa-f]{6}$/, 'Color must be a valid hex color (e.g. #6B7280)')
.optional()
.default('#6B7280'),
});
export const updateTagSchema = createTagSchema.partial();
export type CreateTagInput = z.infer<typeof createTagSchema>;
export type UpdateTagInput = z.infer<typeof updateTagSchema>;

View File

@@ -0,0 +1,43 @@
import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/route-helpers';
import { WEBHOOK_EVENTS } from '@/lib/services/webhook-event-map';
// ─── Create ───────────────────────────────────────────────────────────────────
export const createWebhookSchema = z.object({
name: z.string().min(1).max(200),
url: z.string().url('Must be a valid HTTPS URL').refine(
(u) => u.startsWith('https://'),
'Webhook URL must use HTTPS',
),
events: z
.array(z.enum(WEBHOOK_EVENTS))
.min(1, 'At least one event must be selected'),
isActive: z.boolean().default(true),
});
// ─── Update ───────────────────────────────────────────────────────────────────
export const updateWebhookSchema = z.object({
name: z.string().min(1).max(200).optional(),
url: z
.string()
.url('Must be a valid HTTPS URL')
.refine((u) => u.startsWith('https://'), 'Webhook URL must use HTTPS')
.optional(),
events: z.array(z.enum(WEBHOOK_EVENTS)).min(1).optional(),
isActive: z.boolean().optional(),
});
// ─── List Deliveries ──────────────────────────────────────────────────────────
export const listDeliveriesSchema = baseListQuerySchema.extend({
status: z.enum(['pending', 'success', 'failed', 'dead_letter']).optional(),
});
// ─── Types ────────────────────────────────────────────────────────────────────
export type CreateWebhookInput = z.infer<typeof createWebhookSchema>;
export type UpdateWebhookInput = z.infer<typeof updateWebhookSchema>;
export type ListDeliveriesInput = z.infer<typeof listDeliveriesSchema>;