diff --git a/lib/validations.ts b/lib/validations.ts deleted file mode 100644 index 8ac47ff..0000000 --- a/lib/validations.ts +++ /dev/null @@ -1,1141 +0,0 @@ -import { z } from 'zod' -import { INVITE_ROLES } from './invite-logic' -import { MUTEABLE_CATEGORIES } from './notifications-prefs-logic' -import { DUES_LINK_PLATFORMS, isSafeDuesUrl } from './dues-payments-logic' -import { isValidPermission } from './permissions-catalog' -import { API_METHODS } from './api-call-logic' - -/** - * Accepts every date-ish string the app produces and normalizes to a - * full RFC 3339 datetime so downstream `.datetime()` validation passes. - * - * - `""`, `null`, `undefined` → `null` (treat as unset) - * - `"YYYY-MM-DD"` (HTML date input) → `"YYYY-MM-DDT00:00:00.000Z"` - * - `"YYYY-MM-DDTHH:MM"` (datetime-local input) → ISO string in UTC - * - `"YYYY-MM-DDTHH:MM:SS"` and full RFC 3339 → unchanged / normalised - * - Anything `new Date()` can parse → its ISO form - * - Unparseable → passed through so Zod rejects it - * - * Returns a Zod schema that produces a `string | null` (after preprocess + - * `.datetime().nullable()`). - */ -export const flexibleDateTime = (): z.ZodType => - z.preprocess( - (val) => { - if (val === null || val === undefined || val === '') return null - if (typeof val !== 'string') return val - // Already full ISO datetime? Pass through. - if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?([+-]\d{2}:?\d{2}|Z)$/.test(val)) { - return val - } - // Try Date parsing for everything else (date-only, datetime-local, etc). - const d = new Date(val) - if (Number.isNaN(d.getTime())) return val // let Zod reject - return d.toISOString() - }, - z.string().datetime().nullable(), - ) - -// ─── Auth ──────────────────────────────────────────────────────────────────── - -// Canonicalize every email to trimmed lowercase so case/whitespace variants -// cannot create duplicate members/contacts or fail sign-in. -export const emailField = (msg = 'Invalid email address') => - z.string().max(200).email(msg).transform(s => s.trim().toLowerCase()) - -export const signInSchema = z.object({ - email: emailField(), - password: z.string().min(1, 'Password is required'), -}) - -export const signUpSchema = z.object({ - email: emailField(), - password: z.string().min(8, 'Password must be at least 8 characters'), - fullName: z.string().min(1, 'Full name is required').max(100, 'Name is too long'), - action: z.enum(['create', 'join']), - spaceName: z.string().max(100).optional(), - spaceSlug: z.string().regex(/^[a-z0-9]+$/, 'Slug must be lowercase letters and numbers only').max(50).optional(), - city: z.string().max(100).optional(), - inviteCode: z.string().max(20).optional(), -}) - -export const createSpaceSchema = z.object({ - spaceName: z.string().min(1, 'Space name is required').max(100, 'Name is too long'), - spaceSlug: z.string().min(1, 'Slug is required').max(50).regex(/^[a-z0-9]+$/, 'Slug must be lowercase letters and numbers only'), - spaceCity: z.string().max(100).optional(), - displayName: z.string().min(1, 'Display name is required').max(100), -}) - -export const joinSpaceSchema = z.object({ - inviteCode: z.string().min(1, 'Invite code is required').max(20), - displayName: z.string().min(1, 'Display name is required').max(100), -}) - -// ─── Tasks ─────────────────────────────────────────────────────────────────── - -export const createTaskSchema = z.object({ - title: z.string().min(1, 'Title is required').max(200, 'Title is too long'), - description: z.string().max(2000, 'Description is too long').optional(), - type: z.enum(['task', 'chore']).default('task'), - area: z.string().max(100).optional(), - recurrence: z.enum(['none', 'daily', 'weekly', 'biweekly', 'monthly']).default('none'), - due_date: flexibleDateTime().optional(), -}) - -export const taskIdSchema = z.string().uuid('Invalid task ID') - -// ─── Projects ──────────────────────────────────────────────────────────────── - -export const createProjectSchema = z.object({ - title: z.string().min(1, 'Title is required').max(200), - description: z.string().max(2000).optional(), - area: z.string().max(100).optional(), - tags: z.array(z.string().max(50)).max(20).optional(), - due_date: flexibleDateTime().optional(), -}) - -export const updateProjectStatusSchema = z.object({ - projectId: z.string().uuid('Invalid project ID'), - status: z.enum(['backlog', 'in_progress', 'review', 'done', 'blocked']), -}) - -// ─── Members ───────────────────────────────────────────────────────────────── - -export const addMemberSchema = z.object({ - email: emailField(), - display_name: z.string().min(1, 'Display name is required').max(100), - phone: z.string().max(20).optional().nullable(), - handle: z.string().max(50).optional().nullable(), - role: z.enum(['admin', 'board', 'treasurer', 'member', 'associate']).default('member'), - tier: z.enum(['plus', 'basic', 'associate']).default('basic'), - joined_at: flexibleDateTime().optional(), - has_card_access: z.boolean().optional(), -}) - -export const updateMemberSchema = z.object({ - memberId: z.string().uuid('Invalid member ID'), - display_name: z.string().min(1).max(100).optional(), - email: emailField().optional(), - phone: z.string().max(20).optional().nullable(), - handle: z.string().max(50).optional().nullable(), - role: z.enum(['admin', 'board', 'treasurer', 'member', 'associate']).optional(), - tier: z.enum(['plus', 'basic', 'associate']).optional(), - status: z.enum(['current', 'unverified', 'late', 'inactive']).optional(), - has_card_access: z.boolean().optional(), - payment_status: z.string().max(50).optional().nullable(), - payment_note: z.string().max(500).optional().nullable(), -}) - -// ─── Contacts ──────────────────────────────────────────────────────────────── - -export const createContactSchema = z.object({ - name: z.string().min(1, 'Name is required').max(200), - contact_type: z.enum(['vendor', 'supplier', 'partner', 'landlord', 'city']), - email: emailField().optional().nullable(), - phone: z.string().max(20).optional().nullable(), - details: z.string().max(500).optional().nullable(), - note: z.string().max(2000).optional().nullable(), - group_label: z.string().max(100).optional().nullable(), - tags: z.array(z.string().max(50)).max(10).optional(), - code: z.string().max(50).optional().nullable(), -}) - -export const updateContactSchema = createContactSchema.partial().extend({ - contactId: z.string().uuid('Invalid contact ID'), -}) - -// ─── Knowledge Base ────────────────────────────────────────────────────────── - -export const createKbEntrySchema = z.object({ - title: z.string().min(1, 'Title is required').max(200), - content: z.string().min(1, 'Content is required').max(50000), - area: z.string().max(100).optional().nullable(), - visibility: z.enum(['all_members', 'board', 'admin_only']).default('all_members'), - is_pinned: z.boolean().default(false), - tags: z.array(z.string().max(50)).max(20).optional(), - icon: z.string().max(10).optional().nullable(), -}) - -export const updateKbEntrySchema = createKbEntrySchema.partial().extend({ - entryId: z.string().uuid('Invalid entry ID'), -}) - -// ─── Secrets ───────────────────────────────────────────────────────────────── - -export const createSecretSchema = z.object({ - title: z.string().min(1, 'Title is required').max(200), - value: z.string().min(1, 'Value is required').max(5000), - description: z.string().max(500).optional().nullable(), - area: z.string().max(100).optional().nullable(), - icon: z.string().max(10).optional().nullable(), -}) - -export const updateSecretSchema = z.object({ - title: z.string().min(1).max(200).optional(), - value: z.string().min(1).max(5000).optional(), - description: z.string().max(500).optional().nullable(), - area: z.string().max(100).optional().nullable(), - icon: z.string().max(10).optional().nullable(), -}) - -// ─── Payments ──────────────────────────────────────────────────────────────── - -export const logCashPaymentSchema = z.object({ - amount: z.number().positive('Amount must be positive'), - from_note: z.string().min(1, 'Payer note is required').max(500), - member_id: z.string().uuid('Invalid member ID').optional().nullable(), - transaction_date: flexibleDateTime().optional(), -}) - -export const linkPaymentSchema = z.object({ - paymentId: z.string().uuid('Invalid payment ID'), - memberId: z.string().uuid('Invalid member ID'), -}) - -// ─── Settings ──────────────────────────────────────────────────────────────── - -export const updateSpaceSettingsSchema = z.object({ - name: z.string().min(1).max(100).optional(), - slug: z.string().regex(/^[a-z0-9]+$/, 'Slug must be lowercase letters and numbers only').max(50).optional(), - city: z.string().max(100).optional().nullable(), - require_approval: z.boolean().optional(), - public_member_directory: z.boolean().optional(), - mission_statement: z.string().max(5000).optional().nullable(), -}) - -export const saveIntegrationSchema = z.object({ - platform: z.string().min(1).max(50), - config: z.record(z.string().max(500)), -}) - -// ─── Area Leads ────────────────────────────────────────────────────────────── - -export const upsertAreaLeadSchema = z.object({ - area_code: z.string().min(1, 'Area code is required').max(50), - area_name: z.string().min(1, 'Area name is required').max(100), - lead_id: z.string().uuid().optional().nullable(), - lead_handle: z.string().max(100).optional().nullable(), - status: z.enum(['active', 'vacant', 'handoff']).default('active'), -}) - -// ─── Governance: proposals ─────────────────────────────────────────────────── - -export const proposalTypes = ['bylaw_change','board_action','membership_vote','advisory_poll','recall','budget'] as const -export const proposalStatuses = ['draft','open','decided','withdrawn','expired'] as const -export const thresholdRules = ['simple_majority','two_thirds','three_fourths','unanimous'] as const -export const votePositions = ['yes','no','abstain','recused'] as const - -export const createProposalSchema = z.object({ - title: z.string().min(1, 'Title is required').max(200, 'Title is too long'), - body: z.string().max(20000).default(''), - proposal_type: z.enum(proposalTypes).default('advisory_poll'), - threshold: z.enum(thresholdRules).optional(), - policy_ref_id: z.string().uuid().optional().nullable(), - parent_incident_id: z.string().uuid().optional().nullable(), - voting_opens_at: flexibleDateTime().optional(), - voting_closes_at: flexibleDateTime().optional(), - open_immediately: z.boolean().optional(), -}) - -export const openProposalSchema = z.object({ - proposalId: z.string().uuid('Invalid proposal ID'), - voting_closes_at: flexibleDateTime().optional(), -}) - -export const castVoteSchema = z.object({ - proposalId: z.string().uuid('Invalid proposal ID'), - position: z.enum(votePositions), - recusal_reason: z.string().max(1000).optional().nullable(), - comment: z.string().max(2000).optional().nullable(), -}).refine( - v => v.position !== 'recused' || (v.recusal_reason && v.recusal_reason.trim().length > 0), - { message: 'Recusal requires a reason', path: ['recusal_reason'] }, -) - -export const decideProposalSchema = z.object({ - proposalId: z.string().uuid('Invalid proposal ID'), -}) - -export const withdrawProposalSchema = z.object({ - proposalId: z.string().uuid('Invalid proposal ID'), -}) - -// ─── Governance: incidents ─────────────────────────────────────────────────── - -export const incidentSeverities = ['low','medium','high','critical'] as const -export const incidentStatuses = ['received','under_review','decided','appealed','closed'] as const -export const incidentUpdateVisibilities = ['reporter_only','all_parties','board_only'] as const - -export const fileIncidentSchema = z.object({ - title: z.string().min(1, 'Title is required').max(200), - body: z.string().min(1, 'Description is required').max(20000), - category: z.string().max(50).default('general'), - severity: z.enum(incidentSeverities).default('medium'), - subjects: z.array(z.string().uuid()).max(20).optional(), - is_anonymous: z.boolean().optional(), -}) - -export const updateIncidentStatusSchema = z.object({ - incidentId: z.string().uuid('Invalid incident ID'), - status: z.enum(incidentStatuses), - disposition: z.string().max(20000).optional().nullable(), -}) - -export const addIncidentUpdateSchema = z.object({ - incidentId: z.string().uuid('Invalid incident ID'), - body: z.string().min(1, 'Update body is required').max(20000), - visibility: z.enum(incidentUpdateVisibilities).default('all_parties'), -}) - -export const appealIncidentSchema = z.object({ - incidentId: z.string().uuid('Invalid incident ID'), - title: z.string().min(1).max(200), - body: z.string().max(20000).default(''), -}) - -// Anonymous reporters look their case up with the opaque token they were -// given at filing time (48 hex chars today; bound loosely so the format can -// evolve without breaking old tokens). -export const trackIncidentSchema = z.object({ - token: z.string().trim().min(16).max(128), -}) - -// ─── Governance: policies ──────────────────────────────────────────────────── - -export const policyStatuses = ['draft','active','deprecated','superseded'] as const - -export const createPolicySchema = z.object({ - slug: z.string().min(1, 'Slug is required').max(80).regex(/^[a-z0-9-]+$/, 'Slug must be lowercase letters, numbers, and hyphens only'), - title: z.string().min(1, 'Title is required').max(200), - section_ref: z.string().max(80).optional().nullable(), - parent_policy_id: z.string().uuid().optional().nullable(), - body_formal: z.string().max(100000).default(''), - body_plain: z.string().max(100000).optional().nullable(), - effective_at: flexibleDateTime().optional(), -}) - -export const supersedePolicySchema = z.object({ - policyId: z.string().uuid('Invalid policy ID'), - body_formal: z.string().max(100000).default(''), - body_plain: z.string().max(100000).optional().nullable(), - section_ref: z.string().max(80).optional().nullable(), - title: z.string().min(1).max(200).optional(), - adopted_by_proposal_id: z.string().uuid().optional().nullable(), - effective_at: flexibleDateTime().optional(), -}) - -export const updatePolicyStatusSchema = z.object({ - policyId: z.string().uuid('Invalid policy ID'), - status: z.enum(policyStatuses), -}) - -// ─── Member self-profile and COI ───────────────────────────────────────────── - -export const emailChangeSchema = z.object({ - email: z.string().email('Enter a valid email address').max(254), -}) - -export const updateMyProfileSchema = z.object({ - display_name: z.string().min(1).max(100).optional(), - handle: z.string().max(50).optional().nullable(), - phone: z.string().max(20).optional().nullable(), - bio: z.string().max(2000).optional().nullable(), - skills: z.array(z.string().min(1).max(60)).max(40).optional(), - interests: z.array(z.string().min(1).max(60)).max(40).optional(), - willing_to: z.array(z.string().min(1).max(60)).max(20).optional(), -}) - -// ─── Onboarding ────────────────────────────────────────────────────────────── - -export const onboardingStepTypeSchema = z.enum(['welcome', 'code_of_conduct', 'profile', 'payment', 'content', 'form']) - -export const createOnboardingStepSchema = z.object({ - step_type: onboardingStepTypeSchema, - title: z.string().min(1, 'Title is required').max(200), - body: z.string().max(50000).optional().nullable(), - config: z.record(z.unknown()).optional(), - is_enabled: z.boolean().optional(), - is_required: z.boolean().optional(), - sort_order: z.number().int().min(0).max(100000).optional(), -}) - -export const updateOnboardingStepSchema = z.object({ - title: z.string().min(1).max(200).optional(), - body: z.string().max(50000).optional().nullable(), - config: z.record(z.unknown()).optional(), - is_enabled: z.boolean().optional(), - is_required: z.boolean().optional(), - sort_order: z.number().int().min(0).max(100000).optional(), -}) - -export const discloseAffiliationsSchema = z.object({ - affiliations: z.array(z.string().min(1).max(200)).max(50).default([]), -}) - -// ─── Space settings (visibility) ───────────────────────────────────────────── - -export const financialVisibilities = ['treasurer_only', 'board_visible', 'all_members_visible'] as const -export const directoryVisibilities = [ - 'board_only', - 'member_count_visible', - 'members_visible', - 'public_members_visible', -] as const - -export const updateSpaceVisibilitySchema = z.object({ - financial_visibility: z.enum(financialVisibilities).optional(), - member_directory_visibility: z.enum(directoryVisibilities).optional(), -}) - -// ─── Knowledge base meeting minutes ───────────────────────────────────────── - -export const meetingMinutesSchema = z.object({ - entryId: z.string().uuid('Invalid entry ID'), - is_meeting_minutes: z.boolean().default(true), - meeting_date: flexibleDateTime().optional(), -}) - -// ─── Areas ─────────────────────────────────────────────────────────────────── - -export const createAreaSchema = z.object({ - code: z - .string() - .min(1, 'Code is required') - .max(40) - .regex(/^[a-z0-9-]+$/, 'Code must be lowercase letters, numbers, and hyphens only'), - name: z.string().min(1, 'Name is required').max(80), - icon: z.string().max(20).optional().nullable(), - sort_order: z.number().int().min(0).max(100000).optional(), -}) - -export const updateAreaSchema = z.object({ - areaId: z.string().uuid('Invalid area ID'), - name: z.string().min(1).max(80).optional(), - icon: z.string().max(20).optional().nullable(), - sort_order: z.number().int().min(0).max(100000).optional(), - is_archived: z.boolean().optional(), -}) - -// ─── Forum, comments, tiers, roles, invites ────────────────────────────────── - -export const createForumThreadSchema = z.object({ - title: z.string().min(1, 'Title is required').max(200), - body: z.string().max(20000).optional().nullable(), - category: z.string().min(1).max(50).optional(), -}) - -export const updateForumThreadSchema = z.object({ - title: z.string().min(1).max(200).optional(), - body: z.string().max(20000).optional().nullable(), - category: z.string().min(1).max(50).optional(), - pinned: z.boolean().optional(), - locked: z.boolean().optional(), -}) - -export const commentEntityTypeSchema = z.enum(['forum_thread', 'proposal', 'incident', 'policy']) - -export const createCommentSchema = z.object({ - entity_type: commentEntityTypeSchema, - entity_id: z.string().uuid('Invalid entity ID'), - body: z.string().min(1, 'Comment is required').max(10000), - parent_id: z.string().uuid().optional().nullable(), -}) - -export const updateCommentSchema = z.object({ - body: z.string().min(1).max(10000), -}) - -export const createTierSchema = z.object({ - slug: z.string().min(1).max(50).regex(/^[a-z0-9][a-z0-9_-]{0,49}$/, 'Slug must be lowercase letters, numbers, _ or -'), - name: z.string().min(1).max(100), - description: z.string().max(2000).optional().nullable(), - monthly_price_cents: z.number().int().min(0).max(100_000_000), - billing_cadence: z.enum(['monthly', 'quarterly', 'annual', 'one_time', 'custom']).default('monthly'), - sort_order: z.number().int().min(0).max(100000).optional(), -}) - -export const updateTierSchema = z.object({ - name: z.string().min(1).max(100).optional(), - description: z.string().max(2000).optional().nullable(), - monthly_price_cents: z.number().int().min(0).max(100_000_000).optional(), - billing_cadence: z.enum(['monthly', 'quarterly', 'annual', 'one_time', 'custom']).optional(), - sort_order: z.number().int().min(0).max(100000).optional(), - is_archived: z.boolean().optional(), -}) - -export const upsertRoleLabelSchema = z.object({ - role: z.enum(['admin', 'board', 'treasurer', 'member', 'associate']), - display_name: z.string().max(100).optional().nullable(), - description: z.string().max(500).optional().nullable(), - color: z.string().max(20).optional().nullable(), - sort_order: z.number().int().min(0).max(100000).optional(), -}) - -export const createCustomRoleSchema = z.object({ - slug: z.string().min(1).max(50).regex(/^[a-z0-9][a-z0-9_-]{0,49}$/, 'Slug must be lowercase letters, numbers, _ or -'), - name: z.string().min(1).max(100), - description: z.string().max(500).optional().nullable(), - color: z.string().max(20).optional().nullable(), - sort_order: z.number().int().min(0).max(100000).optional(), -}) - -export const updateCustomRoleSchema = z.object({ - name: z.string().min(1).max(100).optional(), - description: z.string().max(500).optional().nullable(), - color: z.string().max(20).optional().nullable(), - sort_order: z.number().int().min(0).max(100000).optional(), -}) - -export const createInviteSchema = z.object({ - code: z.string().min(4).max(32).regex(/^[A-Z0-9-]+$/, 'Code must be uppercase letters, numbers, or hyphens').optional(), - label: z.string().max(100).optional().nullable(), - expires_at: flexibleDateTime(), - max_uses: z.number().int().min(1).max(100000).optional().nullable(), - is_enabled: z.boolean().optional(), - role: z.enum(INVITE_ROLES).optional().default('member'), -}) - -export const updateInviteSchema = z.object({ - label: z.string().max(100).optional().nullable(), - expires_at: flexibleDateTime(), - max_uses: z.number().int().min(1).max(100000).optional().nullable(), - is_enabled: z.boolean().optional(), - role: z.enum(INVITE_ROLES).optional(), -}) - -export const createChannelSchema = z.object({ - name: z.string().min(1).max(50).regex(/^[a-z0-9][a-z0-9-]{0,49}$/, 'Channel names must be lowercase letters, numbers, or hyphens'), - description: z.string().max(500).optional().nullable(), - channel_type: z.enum(['general', 'area', 'ops', 'project']).default('general'), -}) - -// ─── Permissions, Ops ACLs, area-lead roles ────────────────────────────────── - -// A grant subject is a built-in role or a custom-role slug. -const roleSubject = z.string().min(1).max(50).regex(/^[a-z0-9][a-z0-9_:-]{0,49}$/, 'Invalid role') - -export const setRolePermissionsSchema = z.object({ - subject: roleSubject, - permissions: z.array(z.string().min(1).max(60)).max(64), -}) - -export const opsEntityTypeSchema = z.enum(['secret', 'kb', 'process', 'area_lead']) - -// The UI sends the full desired role list for one item; the server replaces. -// An empty list means "fall back to the item's existing visibility rule". -export const setOpsAclSchema = z.object({ - entity_type: opsEntityTypeSchema, - entity_id: z.string().uuid('Invalid entity ID'), - roles: z.array(z.string().min(1).max(64)).max(64), -}) - -export const createAreaLeadRoleSchema = z.object({ - area_code: z.string().min(1).max(60).regex(/^[a-z0-9][a-z0-9_-]{0,59}$/, 'Invalid area code'), - name: z.string().min(1).max(100), - color: z.string().max(20).optional().nullable(), -}) - -export const updateAreaLeadRoleSchema = z.object({ - name: z.string().min(1).max(100).optional(), - color: z.string().max(20).optional().nullable(), -}) - -export const assignAreaLeadSchema = z.object({ - area_lead_role_id: z.string().uuid('Invalid role ID'), - member_id: z.string().uuid('Invalid member ID'), -}) - -// ─── Bulk import ───────────────────────────────────────────────────────────── - -const importEmail = emailField('Invalid email') - -export const importMembersSchema = z.array( - z.object({ - display_name: z.string().min(1, 'Name required').max(100), - email: importEmail, - phone: z.string().max(20).optional().nullable(), - tier: z.enum(['plus', 'basic', 'associate']).optional(), - joined_at: flexibleDateTime().optional(), - last_paid_at: flexibleDateTime().optional(), - has_card_access: z.boolean().optional(), - }), -).max(5000) - -export const importPaymentsCsvSchema = z.array( - z.object({ - platform: z.enum(['paypal', 'zeffy', 'venmo', 'cash']), - amount: z.number().finite().positive('Amount must be positive').max(10_000_000), - from_identifier: z.string().min(1, 'Payer required').max(200), - from_note: z.string().max(500).optional().nullable(), - transaction_date: flexibleDateTime().optional(), - }), -).max(10000) - -// ─── Custom forms and waivers ──────────────────────────────────────────────── - -export const formFieldTypes = [ - 'short_text', - 'long_text', - 'email', - 'number', - 'date', - 'checkbox', - 'select', - 'radio', -] as const -export const formKinds = ['form', 'waiver'] as const -export const formVisibilities = ['public_anon', 'public_auth', 'members'] as const -export const formStatuses = ['draft', 'published', 'closed'] as const - -export const formSlug = z - .string() - .min(1, 'Slug is required') - .max(80, 'Slug must be 80 characters or fewer') - .regex( - /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/, - 'Slug must be lowercase letters, numbers, and internal hyphens only', - ) - -export const formFieldSchema = z - .object({ - key: z - .string() - .min(1) - .max(60) - .regex(/^[a-z0-9_]+$/, 'Field key must be lowercase letters, numbers, underscores'), - type: z.enum(formFieldTypes), - label: z.string().min(1, 'Field label is required').max(200), - help: z.string().max(1000).optional().nullable(), - required: z.boolean().optional().default(false), - options: z.array(z.string().min(1).max(200)).max(100).optional(), - }) - .superRefine((f, ctx) => { - if ((f.type === 'select' || f.type === 'radio') && (!f.options || f.options.length === 0)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Field "${f.label}" needs at least one option`, - path: ['options'], - }) - } - }) - -// The field array is rejected if two fields share a key (answers are keyed by -// field key, so duplicates would silently overwrite). -export const formSchemaArray = z - .array(formFieldSchema) - .max(200, 'A form cannot have more than 200 fields') - .superRefine((fields, ctx) => { - const seen = new Set() - for (const f of fields) { - if (seen.has(f.key)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Duplicate field key "${f.key}"`, - }) - } - seen.add(f.key) - } - }) - -export const createFormSchema = z.object({ - slug: formSlug, - title: z.string().min(1, 'Title is required').max(200), - description: z.string().max(2000).optional().nullable(), - kind: z.enum(formKinds).optional().default('form'), - visibility: z.enum(formVisibilities).optional().default('members'), - schema: formSchemaArray.optional().default([]), - legal_text: z.string().max(100000).optional().nullable(), -}) - -// slug is intentionally immutable after creation: a published form/waiver may -// already be linked from elsewhere and submissions reference it. -export const updateFormSchema = z.object({ - formId: z.string().uuid('Invalid form ID'), - title: z.string().min(1).max(200).optional(), - description: z.string().max(2000).optional().nullable(), - visibility: z.enum(formVisibilities).optional(), - schema: formSchemaArray.optional(), - legal_text: z.string().max(100000).optional().nullable(), -}) - -export const setFormStatusSchema = z.object({ - formId: z.string().uuid('Invalid form ID'), - status: z.enum(formStatuses), -}) - -export const formIdSchema = z.object({ formId: z.string().uuid('Invalid form ID') }) - -// Envelope only. The answers object is validated dynamically against the -// form's stored field schema inside submitForm (see lib/forms-schema.ts). -// formId is required: form slugs are only unique per space now, so a bare -// slug is ambiguous. Both callers (public page + onboarding) submit by id. -export const submitFormSchema = z.object({ - formId: z.string().uuid('Invalid form ID'), - answers: z.record(z.unknown()).default({}), - email: emailField().optional().nullable(), - consent: z.boolean().optional(), -}) - -export const linkSubmissionsSchema = z.object({ - memberId: z.string().uuid('Invalid member ID'), - email: emailField(), -}) - -export const memberSubmissionsSchema = z.object({ - memberId: z.string().uuid('Invalid member ID'), -}) - -// Deleting a form permanently removes it AND every submission (FK cascade), -// including signed waivers. `confirm` must be explicitly true so an -// accidental/stale client call cannot destroy records. -export const deleteFormSchema = z.object({ - formId: z.string().uuid('Invalid form ID'), - confirm: z.literal(true), -}) - -export const deleteSubmissionSchema = z.object({ - submissionId: z.string().uuid('Invalid submission ID'), -}) - -export const getPublicFormSchema = z.object({ - space: z.string().min(1).max(100), - slug: formSlug, -}) - -// ─── Certifications ────────────────────────────────────────────────────────── - -const certificationName = z - .string() - .min(1, 'Name is required') - .max(200, 'Name must be 200 characters or fewer') - -// validity_months: null/omitted = never expires. When set it must be a -// positive integer (months added to grant time to compute expiry). -const validityMonths = z - .number() - .int('Validity must be a whole number of months') - .positive('Validity must be at least 1 month') - .max(1200, 'Validity is unreasonably large') - .optional() - .nullable() - -export const createCertificationSchema = z.object({ - name: certificationName, - description: z.string().max(2000).optional().nullable(), - validity_months: validityMonths, -}) - -export const updateCertificationSchema = z.object({ - certificationId: z.string().uuid('Invalid certification ID'), - name: certificationName.optional(), - description: z.string().max(2000).optional().nullable(), - validity_months: validityMonths, - is_active: z.boolean().optional(), -}) - -export const certificationIdSchema = z.object({ - certificationId: z.string().uuid('Invalid certification ID'), -}) - -// Award a cert to a member. expires_at is optional: when omitted the action -// computes it from the cert's validity_months; when provided it overrides -// (e.g. an externally dated certificate). Empty string -> null (no expiry). -export const grantCertificationSchema = z.object({ - memberId: z.string().uuid('Invalid member ID'), - certificationId: z.string().uuid('Invalid certification ID'), - note: z.string().max(2000).optional().nullable(), - expires_at: z - .string() - .optional() - .nullable() - .transform(v => (v && v.trim() !== '' ? v : null)), -}) - -export const revokeCertificationSchema = z.object({ - memberCertificationId: z.string().uuid('Invalid grant ID'), - reason: z.string().max(500).optional().nullable(), -}) - -export const renewCertificationSchema = z.object({ - memberCertificationId: z.string().uuid('Invalid grant ID'), - note: z.string().max(2000).optional().nullable(), -}) - -export const listMemberCertificationsSchema = z.object({ - memberId: z.string().uuid('Invalid member ID'), -}) - -// ─── Classes ───────────────────────────────────────────────────────────────── - -// Generic manual payment link only (no live payment integration). Empty -// string -> null so a cleared field is stored as "no link". -const paymentLink = z - .string() - .trim() - .max(2000) - .optional() - .nullable() - .transform(v => (v && v !== '' ? v : null)) - .refine( - v => v === null || /^https?:\/\/.+/i.test(v), - 'Payment link must be an http(s) URL', - ) - -const classCapacity = z - .number() - .int('Capacity must be a whole number') - .positive('Capacity must be at least 1') - .max(100000) - .optional() - .nullable() - -const dateTimeString = z.string().min(1, 'A date and time is required').max(40) - -export const createClassSchema = z.object({ - title: z.string().min(1, 'Title is required').max(200), - description: z.string().max(5000).optional().nullable(), - payment_link: paymentLink, - capacity: classCapacity, - grants_certification_id: z.string().uuid('Invalid certification ID').optional().nullable(), - required_form_id: z.string().uuid('Invalid form ID').optional().nullable(), -}) - -export const updateClassSchema = z.object({ - classId: z.string().uuid('Invalid class ID'), - title: z.string().min(1).max(200).optional(), - description: z.string().max(5000).optional().nullable(), - payment_link: paymentLink, - capacity: classCapacity, - grants_certification_id: z.string().uuid('Invalid certification ID').optional().nullable(), - required_form_id: z.string().uuid('Invalid form ID').optional().nullable(), - is_active: z.boolean().optional(), -}) - -export const classIdSchema = z.object({ - classId: z.string().uuid('Invalid class ID'), -}) - -export const createSessionSchema = z.object({ - classId: z.string().uuid('Invalid class ID'), - starts_at: dateTimeString, - ends_at: z.string().max(40).optional().nullable().transform(v => (v && v !== '' ? v : null)), - location: z.string().max(300).optional().nullable(), - capacity: classCapacity, - notes: z.string().max(5000).optional().nullable(), -}) - -export const updateSessionSchema = z.object({ - sessionId: z.string().uuid('Invalid session ID'), - starts_at: dateTimeString.optional(), - ends_at: z.string().max(40).optional().nullable().transform(v => (v && v !== '' ? v : null)), - location: z.string().max(300).optional().nullable(), - capacity: classCapacity, - notes: z.string().max(5000).optional().nullable(), - status: z.enum(['scheduled', 'cancelled', 'completed']).optional(), -}) - -export const sessionIdSchema = z.object({ - sessionId: z.string().uuid('Invalid session ID'), -}) - -export const signUpForClassSchema = z.object({ - sessionId: z.string().uuid('Invalid session ID'), - // Manager-only: sign another member up on their behalf (also bypasses the - // required-form gate). Ignored for non-managers. - memberId: z.string().uuid('Invalid member ID').optional().nullable(), -}) - -export const cancelSignupSchema = z.object({ - sessionId: z.string().uuid('Invalid session ID'), -}) - -export const markAttendanceSchema = z.object({ - signupId: z.string().uuid('Invalid signup ID'), - attended: z.boolean(), -}) - -export const listSessionSignupsSchema = z.object({ - sessionId: z.string().uuid('Invalid session ID'), -}) - -// ─── Equipment ─────────────────────────────────────────────────────────────── - -const equipmentStatus = z.enum(['available', 'maintenance', 'retired']) - -export const createEquipmentSchema = z.object({ - name: z.string().min(1, 'Name is required').max(200), - description: z.string().max(5000).optional().nullable(), - location: z.string().max(300).optional().nullable(), - status: equipmentStatus.optional().default('available'), - required_certification_id: z.string().uuid('Invalid certification ID').optional().nullable(), - asset_tag: z.string().max(120).optional().nullable(), -}) - -export const updateEquipmentSchema = z.object({ - equipmentId: z.string().uuid('Invalid equipment ID'), - name: z.string().min(1).max(200).optional(), - description: z.string().max(5000).optional().nullable(), - location: z.string().max(300).optional().nullable(), - status: equipmentStatus.optional(), - required_certification_id: z.string().uuid('Invalid certification ID').optional().nullable(), - asset_tag: z.string().max(120).optional().nullable(), - is_active: z.boolean().optional(), -}) - -export const equipmentIdSchema = z.object({ - equipmentId: z.string().uuid('Invalid equipment ID'), -}) - -export const reserveEquipmentSchema = z.object({ - equipmentId: z.string().uuid('Invalid equipment ID'), - starts_at: z.string().min(1, 'A start time is required').max(40), - ends_at: z.string().min(1, 'An end time is required').max(40), - notes: z.string().max(2000).optional().nullable(), - // Manager-only: book on another member's behalf. Ignored for non-managers. - memberId: z.string().uuid('Invalid member ID').optional().nullable(), -}) - -export const cancelReservationSchema = z.object({ - reservationId: z.string().uuid('Invalid reservation ID'), -}) - -export const listEquipmentReservationsSchema = z.object({ - equipmentId: z.string().uuid('Invalid equipment ID'), -}) - -// ─── Member access cards (Door epic) ───────────────────────────────────────── - -const cardUid = z - .string() - .trim() - .min(1, 'Card UID is required') - .max(200, 'Card UID is too long') - -export const addMemberCardSchema = z.object({ - memberId: z.string().uuid('Invalid member ID'), - card_uid: cardUid, - card_type: z.enum(['rfid', 'nfc']).optional().default('rfid'), - label: z.string().max(120).optional().nullable(), -}) - -export const updateMemberCardSchema = z.object({ - cardId: z.string().uuid('Invalid card ID'), - label: z.string().max(120).optional().nullable(), - is_active: z.boolean().optional(), -}) - -export const cardIdSchema = z.object({ - cardId: z.string().uuid('Invalid card ID'), -}) - -export const listMemberCardsSchema = z.object({ - memberId: z.string().uuid('Invalid member ID'), -}) - -// ─── Door connections (Door epic P2) ───────────────────────────────────────── - -const httpUrl = z - .string() - .trim() - .max(2000) - .regex(/^https?:\/\/.+/i, 'Must be an http(s) URL') - -const doorAdapter = z.enum(['native_heatsync', 'generic_http']) -const doorAuthMode = z.enum(['none', 'query', 'header', 'bearer']) - -export const createDoorConnectionSchema = z.object({ - name: z.string().min(1, 'Name is required').max(200), - adapter: doorAdapter.optional().default('generic_http'), - base_url: httpUrl, - pinned_host: z.string().min(1, 'A pinned host is required').max(255), - auth_mode: doorAuthMode.optional().default('none'), - auth_param: z.string().max(120).optional().nullable(), - secret_ref: z.string().uuid('Invalid secret reference').optional().nullable(), - // generic adapter per-verb templates (validated as a flat string map) - verbs: z.record(z.string().max(2000)).optional().default({}), - allow_member_self_entry: z.boolean().optional().default(false), - is_enabled: z.boolean().optional().default(true), - // Inbound access-log ingest (P4). inbound_enabled gates both the poll cron - // and the per-connection webhook; inbound_secret_ref is the webhook bearer - // secret (a secrets-vault row), distinct from secret_ref. - inbound_enabled: z.boolean().optional().default(false), - inbound_secret_ref: z.string().uuid('Invalid secret reference').optional().nullable(), -}) - -export const updateDoorConnectionSchema = z.object({ - connectionId: z.string().uuid('Invalid connection ID'), - name: z.string().min(1).max(200).optional(), - adapter: doorAdapter.optional(), - base_url: httpUrl.optional(), - pinned_host: z.string().min(1).max(255).optional(), - auth_mode: doorAuthMode.optional(), - auth_param: z.string().max(120).optional().nullable(), - secret_ref: z.string().uuid('Invalid secret reference').optional().nullable(), - verbs: z.record(z.string().max(2000)).optional(), - allow_member_self_entry: z.boolean().optional(), - is_enabled: z.boolean().optional(), - inbound_enabled: z.boolean().optional(), - inbound_secret_ref: z.string().uuid('Invalid secret reference').optional().nullable(), -}) - -export const doorConnectionIdSchema = z.object({ - connectionId: z.string().uuid('Invalid connection ID'), -}) - -// ─── Door live actions (door.operate) ──────────────────────────────────────── - -export const doorGrantSchema = z.object({ - connectionId: z.string().uuid('Invalid connection ID'), - cardId: z.string().uuid('Invalid card ID'), - // HeatSync permission mask; defaults to 1 (basic access). - permissionMask: z.number().int().min(0).max(255).optional().default(1), -}) - -export const doorRevokeSchema = z.object({ - connectionId: z.string().uuid('Invalid connection ID'), - cardId: z.string().uuid('Invalid card ID'), -}) - -export const doorControlSchema = z.object({ - connectionId: z.string().uuid('Invalid connection ID'), - verb: z.enum(['open', 'unlock', 'lock']), -}) - -// ─── Door inbound webhook (Door epic P4) ───────────────────────────────────── -// A controller or relay pushes access events to /api/door/inbound/[connection], -// authenticated by the connection's inbound bearer secret. Each event MUST -// carry a stable id (the dedupe token; retries are idempotent). At least one of -// card_uid / card_number identifies the card; result states the access -// decision. The batch is bounded so one request cannot enqueue unbounded work. -const doorWebhookEvent = z.object({ - id: z.string().min(1).max(200), - card_uid: z.string().trim().min(1).max(200).optional().nullable(), - card_number: z.string().trim().regex(/^\d{1,20}$/, 'card_number must be digits').optional().nullable(), - result: z.enum(['granted', 'denied', 'unknown']).optional(), - occurred_at: z.string().datetime({ offset: true }).optional().nullable(), -}) - -export const doorWebhookPayloadSchema = z.object({ - events: z.array(doorWebhookEvent).min(1).max(100), -}) - -// ─── Universal API-call buttons (Door epic P5) ─────────────────────────────── -const apiMethod = z.enum(API_METHODS) -const apiAuthMode = z.enum(['none', 'query', 'header', 'bearer']) -// required_permission must be a real catalog code (any of them; the admin picks -// which capability gates pressing the button, default apicall.invoke). -const requiredPermission = z - .string() - .max(60) - .refine(isValidPermission, 'Unknown permission code') -// A flat string->string header map, bounded in count and value length. -const apiHeaders = z - .record(z.string().max(2000)) - .refine(h => Object.keys(h).length <= 20, 'Too many headers (max 20)') - -export const createApiButtonSchema = z.object({ - label: z.string().min(1, 'Label is required').max(120), - button_group: z.string().min(1).max(60).optional().default('General'), - sort_order: z.number().int().min(0).max(100000).optional().default(0), - method: apiMethod.optional().default('POST'), - base_url: httpUrl, - pinned_host: z.string().min(1, 'A pinned host is required').max(255), - url_template: z.string().max(2000).optional().nullable(), - headers: apiHeaders.optional().default({}), - body_template: z.string().max(8000).optional().nullable(), - auth_mode: apiAuthMode.optional().default('none'), - auth_param: z.string().max(120).optional().nullable(), - secret_ref: z.string().uuid('Invalid secret reference').optional().nullable(), - required_permission: requiredPermission.optional().default('apicall.invoke'), - confirm: z.boolean().optional().default(true), - is_enabled: z.boolean().optional().default(true), -}) - -export const updateApiButtonSchema = z.object({ - buttonId: z.string().uuid('Invalid button ID'), - label: z.string().min(1).max(120).optional(), - button_group: z.string().min(1).max(60).optional(), - sort_order: z.number().int().min(0).max(100000).optional(), - method: apiMethod.optional(), - base_url: httpUrl.optional(), - pinned_host: z.string().min(1).max(255).optional(), - url_template: z.string().max(2000).optional().nullable(), - headers: apiHeaders.optional(), - body_template: z.string().max(8000).optional().nullable(), - auth_mode: apiAuthMode.optional(), - auth_param: z.string().max(120).optional().nullable(), - secret_ref: z.string().uuid('Invalid secret reference').optional().nullable(), - required_permission: requiredPermission.optional(), - confirm: z.boolean().optional(), - is_enabled: z.boolean().optional(), -}) - -export const apiButtonIdSchema = z.object({ - buttonId: z.string().uuid('Invalid button ID'), -}) - -// ─── Presence / attendance ─────────────────────────────────────────────────── - -export const checkInSchema = z.object({ - asHost: z.boolean().optional().default(false), - note: z.string().max(500).optional().nullable(), -}) - -export const checkOutSchema = z.object({ - note: z.string().max(500).optional().nullable(), -}) - -// ─── Generic ID schemas ────────────────────────────────────────────────────── - -export const uuidSchema = z.string().uuid('Invalid ID format') - -export const bulkMemberIdsSchema = z - .array(z.string().uuid('Invalid member ID')) - .min(1, 'Select at least one member') - .max(1000) - -// Stripe dues config (admin). Secret fields are write-only: omitted/blank -// means "keep the stored one". prices maps a membership tier -> Stripe Price. -export const stripeSettingsSchema = z.object({ - mode: z.enum(['test', 'live']), - publishable_key: z.string().trim().max(255).optional().nullable(), - secret_key: z.string().trim().max(255).optional().nullable(), - webhook_secret: z.string().trim().max(255).optional().nullable(), - grace_days: z.number().int().min(0).max(90).optional().default(7), - prices: z.record(z.string().trim().max(255)).optional().default({}), -}) - -// ─── Notification preferences ──────────────────────────────────────────────── - -// Only muteable categories are settable; billing is membership-critical and -// never user-controllable, so it is excluded from the enum (a request to set -// it is rejected at the boundary). -export const notificationPreferenceSchema = z.object({ - category: z.enum(MUTEABLE_CATEGORIES as unknown as [string, ...string[]]), - enabled: z.boolean(), -}) - -// ─── Dues payment methods (admin-configured external links) ────────────────── - -// One external pay-here URL per platform. url must be absolute https (no -// http downgrade, no javascript:/data: scheme) since it is rendered as a -// member-clickable anchor. instructions is an optional memo hint (e.g. "put -// your member name in the note") to help the treasurer reconcile later. -export const duesPaymentMethodSchema = z.object({ - platform: z.enum(DUES_LINK_PLATFORMS as unknown as [string, ...string[]]), - url: z - .string() - .trim() - .max(500) - .refine(isSafeDuesUrl, 'Enter an absolute https:// URL'), - instructions: z.string().trim().max(300).optional().nullable(), - is_active: z.boolean().optional().default(true), - sort_order: z.number().int().min(0).max(999).optional().default(0), -}) - -// Helper type exports -export type CreateTaskInput = z.infer -export type CreateProjectInput = z.infer -export type CreateContactInput = z.infer -export type CreateKbEntryInput = z.infer -export type CreateSecretInput = z.infer diff --git a/lib/validations/area-leads.ts b/lib/validations/area-leads.ts new file mode 100644 index 0000000..3bc57d7 --- /dev/null +++ b/lib/validations/area-leads.ts @@ -0,0 +1,9 @@ +import { z } from 'zod' + +export const upsertAreaLeadSchema = z.object({ + area_code: z.string().min(1, 'Area code is required').max(50), + area_name: z.string().min(1, 'Area name is required').max(100), + lead_id: z.string().uuid().optional().nullable(), + lead_handle: z.string().max(100).optional().nullable(), + status: z.enum(['active', 'vacant', 'handoff']).default('active'), +}) diff --git a/lib/validations/areas.ts b/lib/validations/areas.ts new file mode 100644 index 0000000..1c2ffb5 --- /dev/null +++ b/lib/validations/areas.ts @@ -0,0 +1,20 @@ +import { z } from 'zod' + +export const createAreaSchema = z.object({ + code: z + .string() + .min(1, 'Code is required') + .max(40) + .regex(/^[a-z0-9-]+$/, 'Code must be lowercase letters, numbers, and hyphens only'), + name: z.string().min(1, 'Name is required').max(80), + icon: z.string().max(20).optional().nullable(), + sort_order: z.number().int().min(0).max(100000).optional(), +}) + +export const updateAreaSchema = z.object({ + areaId: z.string().uuid('Invalid area ID'), + name: z.string().min(1).max(80).optional(), + icon: z.string().max(20).optional().nullable(), + sort_order: z.number().int().min(0).max(100000).optional(), + is_archived: z.boolean().optional(), +}) diff --git a/lib/validations/auth.ts b/lib/validations/auth.ts new file mode 100644 index 0000000..45dac4c --- /dev/null +++ b/lib/validations/auth.ts @@ -0,0 +1,30 @@ +import { z } from 'zod' +import { emailField } from './primitives' + +export const signInSchema = z.object({ + email: emailField(), + password: z.string().min(1, 'Password is required'), +}) + +export const signUpSchema = z.object({ + email: emailField(), + password: z.string().min(8, 'Password must be at least 8 characters'), + fullName: z.string().min(1, 'Full name is required').max(100, 'Name is too long'), + action: z.enum(['create', 'join']), + spaceName: z.string().max(100).optional(), + spaceSlug: z.string().regex(/^[a-z0-9]+$/, 'Slug must be lowercase letters and numbers only').max(50).optional(), + city: z.string().max(100).optional(), + inviteCode: z.string().max(20).optional(), +}) + +export const createSpaceSchema = z.object({ + spaceName: z.string().min(1, 'Space name is required').max(100, 'Name is too long'), + spaceSlug: z.string().min(1, 'Slug is required').max(50).regex(/^[a-z0-9]+$/, 'Slug must be lowercase letters and numbers only'), + spaceCity: z.string().max(100).optional(), + displayName: z.string().min(1, 'Display name is required').max(100), +}) + +export const joinSpaceSchema = z.object({ + inviteCode: z.string().min(1, 'Invite code is required').max(20), + displayName: z.string().min(1, 'Display name is required').max(100), +}) diff --git a/lib/validations/certifications.ts b/lib/validations/certifications.ts new file mode 100644 index 0000000..2455545 --- /dev/null +++ b/lib/validations/certifications.ts @@ -0,0 +1,62 @@ +import { z } from 'zod' + +const certificationName = z + .string() + .min(1, 'Name is required') + .max(200, 'Name must be 200 characters or fewer') + +// validity_months: null/omitted = never expires. When set it must be a +// positive integer (months added to grant time to compute expiry). +const validityMonths = z + .number() + .int('Validity must be a whole number of months') + .positive('Validity must be at least 1 month') + .max(1200, 'Validity is unreasonably large') + .optional() + .nullable() + +export const createCertificationSchema = z.object({ + name: certificationName, + description: z.string().max(2000).optional().nullable(), + validity_months: validityMonths, +}) + +export const updateCertificationSchema = z.object({ + certificationId: z.string().uuid('Invalid certification ID'), + name: certificationName.optional(), + description: z.string().max(2000).optional().nullable(), + validity_months: validityMonths, + is_active: z.boolean().optional(), +}) + +export const certificationIdSchema = z.object({ + certificationId: z.string().uuid('Invalid certification ID'), +}) + +// Award a cert to a member. expires_at is optional: when omitted the action +// computes it from the cert's validity_months; when provided it overrides +// (e.g. an externally dated certificate). Empty string -> null (no expiry). +export const grantCertificationSchema = z.object({ + memberId: z.string().uuid('Invalid member ID'), + certificationId: z.string().uuid('Invalid certification ID'), + note: z.string().max(2000).optional().nullable(), + expires_at: z + .string() + .optional() + .nullable() + .transform(v => (v && v.trim() !== '' ? v : null)), +}) + +export const revokeCertificationSchema = z.object({ + memberCertificationId: z.string().uuid('Invalid grant ID'), + reason: z.string().max(500).optional().nullable(), +}) + +export const renewCertificationSchema = z.object({ + memberCertificationId: z.string().uuid('Invalid grant ID'), + note: z.string().max(2000).optional().nullable(), +}) + +export const listMemberCertificationsSchema = z.object({ + memberId: z.string().uuid('Invalid member ID'), +}) diff --git a/lib/validations/classes.ts b/lib/validations/classes.ts new file mode 100644 index 0000000..be84dc2 --- /dev/null +++ b/lib/validations/classes.ts @@ -0,0 +1,92 @@ +import { z } from 'zod' + +// Generic manual payment link only (no live payment integration). Empty +// string -> null so a cleared field is stored as "no link". +const paymentLink = z + .string() + .trim() + .max(2000) + .optional() + .nullable() + .transform(v => (v && v !== '' ? v : null)) + .refine( + v => v === null || /^https?:\/\/.+/i.test(v), + 'Payment link must be an http(s) URL', + ) + +const classCapacity = z + .number() + .int('Capacity must be a whole number') + .positive('Capacity must be at least 1') + .max(100000) + .optional() + .nullable() + +const dateTimeString = z.string().min(1, 'A date and time is required').max(40) + +export const createClassSchema = z.object({ + title: z.string().min(1, 'Title is required').max(200), + description: z.string().max(5000).optional().nullable(), + payment_link: paymentLink, + capacity: classCapacity, + grants_certification_id: z.string().uuid('Invalid certification ID').optional().nullable(), + required_form_id: z.string().uuid('Invalid form ID').optional().nullable(), +}) + +export const updateClassSchema = z.object({ + classId: z.string().uuid('Invalid class ID'), + title: z.string().min(1).max(200).optional(), + description: z.string().max(5000).optional().nullable(), + payment_link: paymentLink, + capacity: classCapacity, + grants_certification_id: z.string().uuid('Invalid certification ID').optional().nullable(), + required_form_id: z.string().uuid('Invalid form ID').optional().nullable(), + is_active: z.boolean().optional(), +}) + +export const classIdSchema = z.object({ + classId: z.string().uuid('Invalid class ID'), +}) + +export const createSessionSchema = z.object({ + classId: z.string().uuid('Invalid class ID'), + starts_at: dateTimeString, + ends_at: z.string().max(40).optional().nullable().transform(v => (v && v !== '' ? v : null)), + location: z.string().max(300).optional().nullable(), + capacity: classCapacity, + notes: z.string().max(5000).optional().nullable(), +}) + +export const updateSessionSchema = z.object({ + sessionId: z.string().uuid('Invalid session ID'), + starts_at: dateTimeString.optional(), + ends_at: z.string().max(40).optional().nullable().transform(v => (v && v !== '' ? v : null)), + location: z.string().max(300).optional().nullable(), + capacity: classCapacity, + notes: z.string().max(5000).optional().nullable(), + status: z.enum(['scheduled', 'cancelled', 'completed']).optional(), +}) + +export const sessionIdSchema = z.object({ + sessionId: z.string().uuid('Invalid session ID'), +}) + +export const signUpForClassSchema = z.object({ + sessionId: z.string().uuid('Invalid session ID'), + // Manager-only: sign another member up on their behalf (also bypasses the + // required-form gate). Ignored for non-managers. + memberId: z.string().uuid('Invalid member ID').optional().nullable(), +}) + +export const cancelSignupSchema = z.object({ + sessionId: z.string().uuid('Invalid session ID'), +}) + +export const markAttendanceSchema = z.object({ + signupId: z.string().uuid('Invalid signup ID'), + attended: z.boolean(), +}) + +export const listSessionSignupsSchema = z.object({ + sessionId: z.string().uuid('Invalid session ID'), +}) diff --git a/lib/validations/contacts.ts b/lib/validations/contacts.ts new file mode 100644 index 0000000..532a3aa --- /dev/null +++ b/lib/validations/contacts.ts @@ -0,0 +1,20 @@ +import { z } from 'zod' +import { emailField } from './primitives' + +export const createContactSchema = z.object({ + name: z.string().min(1, 'Name is required').max(200), + contact_type: z.enum(['vendor', 'supplier', 'partner', 'landlord', 'city']), + email: emailField().optional().nullable(), + phone: z.string().max(20).optional().nullable(), + details: z.string().max(500).optional().nullable(), + note: z.string().max(2000).optional().nullable(), + group_label: z.string().max(100).optional().nullable(), + tags: z.array(z.string().max(50)).max(10).optional(), + code: z.string().max(50).optional().nullable(), +}) + +export const updateContactSchema = createContactSchema.partial().extend({ + contactId: z.string().uuid('Invalid contact ID'), +}) + +export type CreateContactInput = z.infer diff --git a/lib/validations/door.ts b/lib/validations/door.ts new file mode 100644 index 0000000..1cb4e76 --- /dev/null +++ b/lib/validations/door.ts @@ -0,0 +1,174 @@ +import { z } from 'zod' +import { isValidPermission } from '../permissions-catalog' +import { API_METHODS } from '../api-call-logic' + +// ─── Member access cards (Door epic) ───────────────────────────────────────── + +const cardUid = z + .string() + .trim() + .min(1, 'Card UID is required') + .max(200, 'Card UID is too long') + +export const addMemberCardSchema = z.object({ + memberId: z.string().uuid('Invalid member ID'), + card_uid: cardUid, + card_type: z.enum(['rfid', 'nfc']).optional().default('rfid'), + label: z.string().max(120).optional().nullable(), +}) + +export const updateMemberCardSchema = z.object({ + cardId: z.string().uuid('Invalid card ID'), + label: z.string().max(120).optional().nullable(), + is_active: z.boolean().optional(), +}) + +export const cardIdSchema = z.object({ + cardId: z.string().uuid('Invalid card ID'), +}) + +export const listMemberCardsSchema = z.object({ + memberId: z.string().uuid('Invalid member ID'), +}) + +// ─── Door connections (Door epic P2) ───────────────────────────────────────── + +const httpUrl = z + .string() + .trim() + .max(2000) + .regex(/^https?:\/\/.+/i, 'Must be an http(s) URL') + +const doorAdapter = z.enum(['native_heatsync', 'generic_http']) +const doorAuthMode = z.enum(['none', 'query', 'header', 'bearer']) + +export const createDoorConnectionSchema = z.object({ + name: z.string().min(1, 'Name is required').max(200), + adapter: doorAdapter.optional().default('generic_http'), + base_url: httpUrl, + pinned_host: z.string().min(1, 'A pinned host is required').max(255), + auth_mode: doorAuthMode.optional().default('none'), + auth_param: z.string().max(120).optional().nullable(), + secret_ref: z.string().uuid('Invalid secret reference').optional().nullable(), + // generic adapter per-verb templates (validated as a flat string map) + verbs: z.record(z.string().max(2000)).optional().default({}), + allow_member_self_entry: z.boolean().optional().default(false), + is_enabled: z.boolean().optional().default(true), + // Inbound access-log ingest (P4). inbound_enabled gates both the poll cron + // and the per-connection webhook; inbound_secret_ref is the webhook bearer + // secret (a secrets-vault row), distinct from secret_ref. + inbound_enabled: z.boolean().optional().default(false), + inbound_secret_ref: z.string().uuid('Invalid secret reference').optional().nullable(), +}) + +export const updateDoorConnectionSchema = z.object({ + connectionId: z.string().uuid('Invalid connection ID'), + name: z.string().min(1).max(200).optional(), + adapter: doorAdapter.optional(), + base_url: httpUrl.optional(), + pinned_host: z.string().min(1).max(255).optional(), + auth_mode: doorAuthMode.optional(), + auth_param: z.string().max(120).optional().nullable(), + secret_ref: z.string().uuid('Invalid secret reference').optional().nullable(), + verbs: z.record(z.string().max(2000)).optional(), + allow_member_self_entry: z.boolean().optional(), + is_enabled: z.boolean().optional(), + inbound_enabled: z.boolean().optional(), + inbound_secret_ref: z.string().uuid('Invalid secret reference').optional().nullable(), +}) + +export const doorConnectionIdSchema = z.object({ + connectionId: z.string().uuid('Invalid connection ID'), +}) + +// ─── Door live actions (door.operate) ──────────────────────────────────────── + +export const doorGrantSchema = z.object({ + connectionId: z.string().uuid('Invalid connection ID'), + cardId: z.string().uuid('Invalid card ID'), + // HeatSync permission mask; defaults to 1 (basic access). + permissionMask: z.number().int().min(0).max(255).optional().default(1), +}) + +export const doorRevokeSchema = z.object({ + connectionId: z.string().uuid('Invalid connection ID'), + cardId: z.string().uuid('Invalid card ID'), +}) + +export const doorControlSchema = z.object({ + connectionId: z.string().uuid('Invalid connection ID'), + verb: z.enum(['open', 'unlock', 'lock']), +}) + +// ─── Door inbound webhook (Door epic P4) ───────────────────────────────────── +// A controller or relay pushes access events to /api/door/inbound/[connection], +// authenticated by the connection's inbound bearer secret. Each event MUST +// carry a stable id (the dedupe token; retries are idempotent). At least one of +// card_uid / card_number identifies the card; result states the access +// decision. The batch is bounded so one request cannot enqueue unbounded work. +const doorWebhookEvent = z.object({ + id: z.string().min(1).max(200), + card_uid: z.string().trim().min(1).max(200).optional().nullable(), + card_number: z.string().trim().regex(/^\d{1,20}$/, 'card_number must be digits').optional().nullable(), + result: z.enum(['granted', 'denied', 'unknown']).optional(), + occurred_at: z.string().datetime({ offset: true }).optional().nullable(), +}) + +export const doorWebhookPayloadSchema = z.object({ + events: z.array(doorWebhookEvent).min(1).max(100), +}) + +// ─── Universal API-call buttons (Door epic P5) ─────────────────────────────── +const apiMethod = z.enum(API_METHODS) +const apiAuthMode = z.enum(['none', 'query', 'header', 'bearer']) +// required_permission must be a real catalog code (any of them; the admin picks +// which capability gates pressing the button, default apicall.invoke). +const requiredPermission = z + .string() + .max(60) + .refine(isValidPermission, 'Unknown permission code') +// A flat string->string header map, bounded in count and value length. +const apiHeaders = z + .record(z.string().max(2000)) + .refine(h => Object.keys(h).length <= 20, 'Too many headers (max 20)') + +export const createApiButtonSchema = z.object({ + label: z.string().min(1, 'Label is required').max(120), + button_group: z.string().min(1).max(60).optional().default('General'), + sort_order: z.number().int().min(0).max(100000).optional().default(0), + method: apiMethod.optional().default('POST'), + base_url: httpUrl, + pinned_host: z.string().min(1, 'A pinned host is required').max(255), + url_template: z.string().max(2000).optional().nullable(), + headers: apiHeaders.optional().default({}), + body_template: z.string().max(8000).optional().nullable(), + auth_mode: apiAuthMode.optional().default('none'), + auth_param: z.string().max(120).optional().nullable(), + secret_ref: z.string().uuid('Invalid secret reference').optional().nullable(), + required_permission: requiredPermission.optional().default('apicall.invoke'), + confirm: z.boolean().optional().default(true), + is_enabled: z.boolean().optional().default(true), +}) + +export const updateApiButtonSchema = z.object({ + buttonId: z.string().uuid('Invalid button ID'), + label: z.string().min(1).max(120).optional(), + button_group: z.string().min(1).max(60).optional(), + sort_order: z.number().int().min(0).max(100000).optional(), + method: apiMethod.optional(), + base_url: httpUrl.optional(), + pinned_host: z.string().min(1).max(255).optional(), + url_template: z.string().max(2000).optional().nullable(), + headers: apiHeaders.optional(), + body_template: z.string().max(8000).optional().nullable(), + auth_mode: apiAuthMode.optional(), + auth_param: z.string().max(120).optional().nullable(), + secret_ref: z.string().uuid('Invalid secret reference').optional().nullable(), + required_permission: requiredPermission.optional(), + confirm: z.boolean().optional(), + is_enabled: z.boolean().optional(), +}) + +export const apiButtonIdSchema = z.object({ + buttonId: z.string().uuid('Invalid button ID'), +}) diff --git a/lib/validations/dues.ts b/lib/validations/dues.ts new file mode 100644 index 0000000..e8daac0 --- /dev/null +++ b/lib/validations/dues.ts @@ -0,0 +1,18 @@ +import { z } from 'zod' +import { DUES_LINK_PLATFORMS, isSafeDuesUrl } from '../dues-payments-logic' + +// One external pay-here URL per platform. url must be absolute https (no +// http downgrade, no javascript:/data: scheme) since it is rendered as a +// member-clickable anchor. instructions is an optional memo hint (e.g. "put +// your member name in the note") to help the treasurer reconcile later. +export const duesPaymentMethodSchema = z.object({ + platform: z.enum(DUES_LINK_PLATFORMS as unknown as [string, ...string[]]), + url: z + .string() + .trim() + .max(500) + .refine(isSafeDuesUrl, 'Enter an absolute https:// URL'), + instructions: z.string().trim().max(300).optional().nullable(), + is_active: z.boolean().optional().default(true), + sort_order: z.number().int().min(0).max(999).optional().default(0), +}) diff --git a/lib/validations/equipment.ts b/lib/validations/equipment.ts new file mode 100644 index 0000000..24af83d --- /dev/null +++ b/lib/validations/equipment.ts @@ -0,0 +1,44 @@ +import { z } from 'zod' + +const equipmentStatus = z.enum(['available', 'maintenance', 'retired']) + +export const createEquipmentSchema = z.object({ + name: z.string().min(1, 'Name is required').max(200), + description: z.string().max(5000).optional().nullable(), + location: z.string().max(300).optional().nullable(), + status: equipmentStatus.optional().default('available'), + required_certification_id: z.string().uuid('Invalid certification ID').optional().nullable(), + asset_tag: z.string().max(120).optional().nullable(), +}) + +export const updateEquipmentSchema = z.object({ + equipmentId: z.string().uuid('Invalid equipment ID'), + name: z.string().min(1).max(200).optional(), + description: z.string().max(5000).optional().nullable(), + location: z.string().max(300).optional().nullable(), + status: equipmentStatus.optional(), + required_certification_id: z.string().uuid('Invalid certification ID').optional().nullable(), + asset_tag: z.string().max(120).optional().nullable(), + is_active: z.boolean().optional(), +}) + +export const equipmentIdSchema = z.object({ + equipmentId: z.string().uuid('Invalid equipment ID'), +}) + +export const reserveEquipmentSchema = z.object({ + equipmentId: z.string().uuid('Invalid equipment ID'), + starts_at: z.string().min(1, 'A start time is required').max(40), + ends_at: z.string().min(1, 'An end time is required').max(40), + notes: z.string().max(2000).optional().nullable(), + // Manager-only: book on another member's behalf. Ignored for non-managers. + memberId: z.string().uuid('Invalid member ID').optional().nullable(), +}) + +export const cancelReservationSchema = z.object({ + reservationId: z.string().uuid('Invalid reservation ID'), +}) + +export const listEquipmentReservationsSchema = z.object({ + equipmentId: z.string().uuid('Invalid equipment ID'), +}) diff --git a/lib/validations/forms.ts b/lib/validations/forms.ts new file mode 100644 index 0000000..d6a1028 --- /dev/null +++ b/lib/validations/forms.ts @@ -0,0 +1,80 @@ +import { z } from 'zod' +import { emailField, formSchemaArray } from './primitives' + +export const formKinds = ['form', 'waiver'] as const +export const formVisibilities = ['public_anon', 'public_auth', 'members'] as const +export const formStatuses = ['draft', 'published', 'closed'] as const + +export const formSlug = z + .string() + .min(1, 'Slug is required') + .max(80, 'Slug must be 80 characters or fewer') + .regex( + /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/, + 'Slug must be lowercase letters, numbers, and internal hyphens only', + ) + +export const createFormSchema = z.object({ + slug: formSlug, + title: z.string().min(1, 'Title is required').max(200), + description: z.string().max(2000).optional().nullable(), + kind: z.enum(formKinds).optional().default('form'), + visibility: z.enum(formVisibilities).optional().default('members'), + schema: formSchemaArray.optional().default([]), + legal_text: z.string().max(100000).optional().nullable(), +}) + +// slug is intentionally immutable after creation: a published form/waiver may +// already be linked from elsewhere and submissions reference it. +export const updateFormSchema = z.object({ + formId: z.string().uuid('Invalid form ID'), + title: z.string().min(1).max(200).optional(), + description: z.string().max(2000).optional().nullable(), + visibility: z.enum(formVisibilities).optional(), + schema: formSchemaArray.optional(), + legal_text: z.string().max(100000).optional().nullable(), +}) + +export const setFormStatusSchema = z.object({ + formId: z.string().uuid('Invalid form ID'), + status: z.enum(formStatuses), +}) + +export const formIdSchema = z.object({ formId: z.string().uuid('Invalid form ID') }) + +// Envelope only. The answers object is validated dynamically against the +// form's stored field schema inside submitForm (see lib/forms-schema.ts). +// formId is required: form slugs are only unique per space now, so a bare +// slug is ambiguous. Both callers (public page + onboarding) submit by id. +export const submitFormSchema = z.object({ + formId: z.string().uuid('Invalid form ID'), + answers: z.record(z.unknown()).default({}), + email: emailField().optional().nullable(), + consent: z.boolean().optional(), +}) + +export const linkSubmissionsSchema = z.object({ + memberId: z.string().uuid('Invalid member ID'), + email: emailField(), +}) + +export const memberSubmissionsSchema = z.object({ + memberId: z.string().uuid('Invalid member ID'), +}) + +// Deleting a form permanently removes it AND every submission (FK cascade), +// including signed waivers. `confirm` must be explicitly true so an +// accidental/stale client call cannot destroy records. +export const deleteFormSchema = z.object({ + formId: z.string().uuid('Invalid form ID'), + confirm: z.literal(true), +}) + +export const deleteSubmissionSchema = z.object({ + submissionId: z.string().uuid('Invalid submission ID'), +}) + +export const getPublicFormSchema = z.object({ + space: z.string().min(1).max(100), + slug: formSlug, +}) diff --git a/lib/validations/forum.ts b/lib/validations/forum.ts new file mode 100644 index 0000000..88ec10d --- /dev/null +++ b/lib/validations/forum.ts @@ -0,0 +1,94 @@ +import { z } from 'zod' +import { INVITE_ROLES } from '../invite-logic' +import { flexibleDateTime } from './primitives' + +export const createForumThreadSchema = z.object({ + title: z.string().min(1, 'Title is required').max(200), + body: z.string().max(20000).optional().nullable(), + category: z.string().min(1).max(50).optional(), +}) + +export const updateForumThreadSchema = z.object({ + title: z.string().min(1).max(200).optional(), + body: z.string().max(20000).optional().nullable(), + category: z.string().min(1).max(50).optional(), + pinned: z.boolean().optional(), + locked: z.boolean().optional(), +}) + +export const commentEntityTypeSchema = z.enum(['forum_thread', 'proposal', 'incident', 'policy']) + +export const createCommentSchema = z.object({ + entity_type: commentEntityTypeSchema, + entity_id: z.string().uuid('Invalid entity ID'), + body: z.string().min(1, 'Comment is required').max(10000), + parent_id: z.string().uuid().optional().nullable(), +}) + +export const updateCommentSchema = z.object({ + body: z.string().min(1).max(10000), +}) + +export const createTierSchema = z.object({ + slug: z.string().min(1).max(50).regex(/^[a-z0-9][a-z0-9_-]{0,49}$/, 'Slug must be lowercase letters, numbers, _ or -'), + name: z.string().min(1).max(100), + description: z.string().max(2000).optional().nullable(), + monthly_price_cents: z.number().int().min(0).max(100_000_000), + billing_cadence: z.enum(['monthly', 'quarterly', 'annual', 'one_time', 'custom']).default('monthly'), + sort_order: z.number().int().min(0).max(100000).optional(), +}) + +export const updateTierSchema = z.object({ + name: z.string().min(1).max(100).optional(), + description: z.string().max(2000).optional().nullable(), + monthly_price_cents: z.number().int().min(0).max(100_000_000).optional(), + billing_cadence: z.enum(['monthly', 'quarterly', 'annual', 'one_time', 'custom']).optional(), + sort_order: z.number().int().min(0).max(100000).optional(), + is_archived: z.boolean().optional(), +}) + +export const upsertRoleLabelSchema = z.object({ + role: z.enum(['admin', 'board', 'treasurer', 'member', 'associate']), + display_name: z.string().max(100).optional().nullable(), + description: z.string().max(500).optional().nullable(), + color: z.string().max(20).optional().nullable(), + sort_order: z.number().int().min(0).max(100000).optional(), +}) + +export const createCustomRoleSchema = z.object({ + slug: z.string().min(1).max(50).regex(/^[a-z0-9][a-z0-9_-]{0,49}$/, 'Slug must be lowercase letters, numbers, _ or -'), + name: z.string().min(1).max(100), + description: z.string().max(500).optional().nullable(), + color: z.string().max(20).optional().nullable(), + sort_order: z.number().int().min(0).max(100000).optional(), +}) + +export const updateCustomRoleSchema = z.object({ + name: z.string().min(1).max(100).optional(), + description: z.string().max(500).optional().nullable(), + color: z.string().max(20).optional().nullable(), + sort_order: z.number().int().min(0).max(100000).optional(), +}) + +export const createInviteSchema = z.object({ + code: z.string().min(4).max(32).regex(/^[A-Z0-9-]+$/, 'Code must be uppercase letters, numbers, or hyphens').optional(), + label: z.string().max(100).optional().nullable(), + expires_at: flexibleDateTime(), + max_uses: z.number().int().min(1).max(100000).optional().nullable(), + is_enabled: z.boolean().optional(), + role: z.enum(INVITE_ROLES).optional().default('member'), +}) + +export const updateInviteSchema = z.object({ + label: z.string().max(100).optional().nullable(), + expires_at: flexibleDateTime(), + max_uses: z.number().int().min(1).max(100000).optional().nullable(), + is_enabled: z.boolean().optional(), + role: z.enum(INVITE_ROLES).optional(), +}) + +export const createChannelSchema = z.object({ + name: z.string().min(1).max(50).regex(/^[a-z0-9][a-z0-9-]{0,49}$/, 'Channel names must be lowercase letters, numbers, or hyphens'), + description: z.string().max(500).optional().nullable(), + channel_type: z.enum(['general', 'area', 'ops', 'project']).default('general'), +}) diff --git a/lib/validations/governance.ts b/lib/validations/governance.ts new file mode 100644 index 0000000..e22b6b4 --- /dev/null +++ b/lib/validations/governance.ts @@ -0,0 +1,113 @@ +import { z } from 'zod' +import { flexibleDateTime } from './primitives' + +// ─── Governance: proposals ─────────────────────────────────────────────────── + +export const proposalTypes = ['bylaw_change','board_action','membership_vote','advisory_poll','recall','budget'] as const +export const proposalStatuses = ['draft','open','decided','withdrawn','expired'] as const +export const thresholdRules = ['simple_majority','two_thirds','three_fourths','unanimous'] as const +export const votePositions = ['yes','no','abstain','recused'] as const + +export const createProposalSchema = z.object({ + title: z.string().min(1, 'Title is required').max(200, 'Title is too long'), + body: z.string().max(20000).default(''), + proposal_type: z.enum(proposalTypes).default('advisory_poll'), + threshold: z.enum(thresholdRules).optional(), + policy_ref_id: z.string().uuid().optional().nullable(), + parent_incident_id: z.string().uuid().optional().nullable(), + voting_opens_at: flexibleDateTime().optional(), + voting_closes_at: flexibleDateTime().optional(), + open_immediately: z.boolean().optional(), +}) + +export const openProposalSchema = z.object({ + proposalId: z.string().uuid('Invalid proposal ID'), + voting_closes_at: flexibleDateTime().optional(), +}) + +export const castVoteSchema = z.object({ + proposalId: z.string().uuid('Invalid proposal ID'), + position: z.enum(votePositions), + recusal_reason: z.string().max(1000).optional().nullable(), + comment: z.string().max(2000).optional().nullable(), +}).refine( + v => v.position !== 'recused' || (v.recusal_reason && v.recusal_reason.trim().length > 0), + { message: 'Recusal requires a reason', path: ['recusal_reason'] }, +) + +export const decideProposalSchema = z.object({ + proposalId: z.string().uuid('Invalid proposal ID'), +}) + +export const withdrawProposalSchema = z.object({ + proposalId: z.string().uuid('Invalid proposal ID'), +}) + +// ─── Governance: incidents ─────────────────────────────────────────────────── + +export const incidentSeverities = ['low','medium','high','critical'] as const +export const incidentStatuses = ['received','under_review','decided','appealed','closed'] as const +export const incidentUpdateVisibilities = ['reporter_only','all_parties','board_only'] as const + +export const fileIncidentSchema = z.object({ + title: z.string().min(1, 'Title is required').max(200), + body: z.string().min(1, 'Description is required').max(20000), + category: z.string().max(50).default('general'), + severity: z.enum(incidentSeverities).default('medium'), + subjects: z.array(z.string().uuid()).max(20).optional(), + is_anonymous: z.boolean().optional(), +}) + +export const updateIncidentStatusSchema = z.object({ + incidentId: z.string().uuid('Invalid incident ID'), + status: z.enum(incidentStatuses), + disposition: z.string().max(20000).optional().nullable(), +}) + +export const addIncidentUpdateSchema = z.object({ + incidentId: z.string().uuid('Invalid incident ID'), + body: z.string().min(1, 'Update body is required').max(20000), + visibility: z.enum(incidentUpdateVisibilities).default('all_parties'), +}) + +export const appealIncidentSchema = z.object({ + incidentId: z.string().uuid('Invalid incident ID'), + title: z.string().min(1).max(200), + body: z.string().max(20000).default(''), +}) + +// Anonymous reporters look their case up with the opaque token they were +// given at filing time (48 hex chars today; bound loosely so the format can +// evolve without breaking old tokens). +export const trackIncidentSchema = z.object({ + token: z.string().trim().min(16).max(128), +}) + +// ─── Governance: policies ──────────────────────────────────────────────────── + +export const policyStatuses = ['draft','active','deprecated','superseded'] as const + +export const createPolicySchema = z.object({ + slug: z.string().min(1, 'Slug is required').max(80).regex(/^[a-z0-9-]+$/, 'Slug must be lowercase letters, numbers, and hyphens only'), + title: z.string().min(1, 'Title is required').max(200), + section_ref: z.string().max(80).optional().nullable(), + parent_policy_id: z.string().uuid().optional().nullable(), + body_formal: z.string().max(100000).default(''), + body_plain: z.string().max(100000).optional().nullable(), + effective_at: flexibleDateTime().optional(), +}) + +export const supersedePolicySchema = z.object({ + policyId: z.string().uuid('Invalid policy ID'), + body_formal: z.string().max(100000).default(''), + body_plain: z.string().max(100000).optional().nullable(), + section_ref: z.string().max(80).optional().nullable(), + title: z.string().min(1).max(200).optional(), + adopted_by_proposal_id: z.string().uuid().optional().nullable(), + effective_at: flexibleDateTime().optional(), +}) + +export const updatePolicyStatusSchema = z.object({ + policyId: z.string().uuid('Invalid policy ID'), + status: z.enum(policyStatuses), +}) diff --git a/lib/validations/ids.ts b/lib/validations/ids.ts new file mode 100644 index 0000000..1d320cd --- /dev/null +++ b/lib/validations/ids.ts @@ -0,0 +1,19 @@ +import { z } from 'zod' + +export const uuidSchema = z.string().uuid('Invalid ID format') + +export const bulkMemberIdsSchema = z + .array(z.string().uuid('Invalid member ID')) + .min(1, 'Select at least one member') + .max(1000) + +// Stripe dues config (admin). Secret fields are write-only: omitted/blank +// means "keep the stored one". prices maps a membership tier -> Stripe Price. +export const stripeSettingsSchema = z.object({ + mode: z.enum(['test', 'live']), + publishable_key: z.string().trim().max(255).optional().nullable(), + secret_key: z.string().trim().max(255).optional().nullable(), + webhook_secret: z.string().trim().max(255).optional().nullable(), + grace_days: z.number().int().min(0).max(90).optional().default(7), + prices: z.record(z.string().trim().max(255)).optional().default({}), +}) diff --git a/lib/validations/import.ts b/lib/validations/import.ts new file mode 100644 index 0000000..29a077e --- /dev/null +++ b/lib/validations/import.ts @@ -0,0 +1,26 @@ +import { z } from 'zod' +import { emailField, flexibleDateTime } from './primitives' + +const importEmail = emailField('Invalid email') + +export const importMembersSchema = z.array( + z.object({ + display_name: z.string().min(1, 'Name required').max(100), + email: importEmail, + phone: z.string().max(20).optional().nullable(), + tier: z.enum(['plus', 'basic', 'associate']).optional(), + joined_at: flexibleDateTime().optional(), + last_paid_at: flexibleDateTime().optional(), + has_card_access: z.boolean().optional(), + }), +).max(5000) + +export const importPaymentsCsvSchema = z.array( + z.object({ + platform: z.enum(['paypal', 'zeffy', 'venmo', 'cash']), + amount: z.number().finite().positive('Amount must be positive').max(10_000_000), + from_identifier: z.string().min(1, 'Payer required').max(200), + from_note: z.string().max(500).optional().nullable(), + transaction_date: flexibleDateTime().optional(), + }), +).max(10000) diff --git a/lib/validations/index.ts b/lib/validations/index.ts new file mode 100644 index 0000000..47d831c --- /dev/null +++ b/lib/validations/index.ts @@ -0,0 +1,26 @@ +export * from './primitives' +export * from './auth' +export * from './tasks' +export * from './projects' +export * from './members' +export * from './contacts' +export * from './knowledge-base' +export * from './secrets' +export * from './payments' +export * from './settings' +export * from './area-leads' +export * from './governance' +export * from './onboarding' +export * from './areas' +export * from './forum' +export * from './permissions' +export * from './import' +export * from './forms' +export * from './certifications' +export * from './classes' +export * from './equipment' +export * from './door' +export * from './presence' +export * from './ids' +export * from './notification-prefs' +export * from './dues' diff --git a/lib/validations/knowledge-base.ts b/lib/validations/knowledge-base.ts new file mode 100644 index 0000000..cd96784 --- /dev/null +++ b/lib/validations/knowledge-base.ts @@ -0,0 +1,26 @@ +import { z } from 'zod' +import { flexibleDateTime } from './primitives' + +export const createKbEntrySchema = z.object({ + title: z.string().min(1, 'Title is required').max(200), + content: z.string().min(1, 'Content is required').max(50000), + area: z.string().max(100).optional().nullable(), + visibility: z.enum(['all_members', 'board', 'admin_only']).default('all_members'), + is_pinned: z.boolean().default(false), + tags: z.array(z.string().max(50)).max(20).optional(), + icon: z.string().max(10).optional().nullable(), +}) + +export const updateKbEntrySchema = createKbEntrySchema.partial().extend({ + entryId: z.string().uuid('Invalid entry ID'), +}) + +// ─── Knowledge base meeting minutes ───────────────────────────────────────── + +export const meetingMinutesSchema = z.object({ + entryId: z.string().uuid('Invalid entry ID'), + is_meeting_minutes: z.boolean().default(true), + meeting_date: flexibleDateTime().optional(), +}) + +export type CreateKbEntryInput = z.infer diff --git a/lib/validations/members.ts b/lib/validations/members.ts new file mode 100644 index 0000000..1cb3c98 --- /dev/null +++ b/lib/validations/members.ts @@ -0,0 +1,43 @@ +import { z } from 'zod' +import { emailField, flexibleDateTime } from './primitives' + +export const addMemberSchema = z.object({ + email: emailField(), + display_name: z.string().min(1, 'Display name is required').max(100), + phone: z.string().max(20).optional().nullable(), + handle: z.string().max(50).optional().nullable(), + role: z.enum(['admin', 'board', 'treasurer', 'member', 'associate']).default('member'), + tier: z.enum(['plus', 'basic', 'associate']).default('basic'), + joined_at: flexibleDateTime().optional(), + has_card_access: z.boolean().optional(), +}) + +export const updateMemberSchema = z.object({ + memberId: z.string().uuid('Invalid member ID'), + display_name: z.string().min(1).max(100).optional(), + email: emailField().optional(), + phone: z.string().max(20).optional().nullable(), + handle: z.string().max(50).optional().nullable(), + role: z.enum(['admin', 'board', 'treasurer', 'member', 'associate']).optional(), + tier: z.enum(['plus', 'basic', 'associate']).optional(), + status: z.enum(['current', 'unverified', 'late', 'inactive']).optional(), + has_card_access: z.boolean().optional(), + payment_status: z.string().max(50).optional().nullable(), + payment_note: z.string().max(500).optional().nullable(), +}) + +// ─── Member self-profile and COI ───────────────────────────────────────────── + +export const emailChangeSchema = z.object({ + email: z.string().email('Enter a valid email address').max(254), +}) + +export const updateMyProfileSchema = z.object({ + display_name: z.string().min(1).max(100).optional(), + handle: z.string().max(50).optional().nullable(), + phone: z.string().max(20).optional().nullable(), + bio: z.string().max(2000).optional().nullable(), + skills: z.array(z.string().min(1).max(60)).max(40).optional(), + interests: z.array(z.string().min(1).max(60)).max(40).optional(), + willing_to: z.array(z.string().min(1).max(60)).max(20).optional(), +}) diff --git a/lib/validations/notification-prefs.ts b/lib/validations/notification-prefs.ts new file mode 100644 index 0000000..f519a4b --- /dev/null +++ b/lib/validations/notification-prefs.ts @@ -0,0 +1,10 @@ +import { z } from 'zod' +import { MUTEABLE_CATEGORIES } from '../notifications-prefs-logic' + +// Only muteable categories are settable; billing is membership-critical and +// never user-controllable, so it is excluded from the enum (a request to set +// it is rejected at the boundary). +export const notificationPreferenceSchema = z.object({ + category: z.enum(MUTEABLE_CATEGORIES as unknown as [string, ...string[]]), + enabled: z.boolean(), +}) diff --git a/lib/validations/onboarding.ts b/lib/validations/onboarding.ts new file mode 100644 index 0000000..2036d4a --- /dev/null +++ b/lib/validations/onboarding.ts @@ -0,0 +1,26 @@ +import { z } from 'zod' + +export const onboardingStepTypeSchema = z.enum(['welcome', 'code_of_conduct', 'profile', 'payment', 'content', 'form']) + +export const createOnboardingStepSchema = z.object({ + step_type: onboardingStepTypeSchema, + title: z.string().min(1, 'Title is required').max(200), + body: z.string().max(50000).optional().nullable(), + config: z.record(z.unknown()).optional(), + is_enabled: z.boolean().optional(), + is_required: z.boolean().optional(), + sort_order: z.number().int().min(0).max(100000).optional(), +}) + +export const updateOnboardingStepSchema = z.object({ + title: z.string().min(1).max(200).optional(), + body: z.string().max(50000).optional().nullable(), + config: z.record(z.unknown()).optional(), + is_enabled: z.boolean().optional(), + is_required: z.boolean().optional(), + sort_order: z.number().int().min(0).max(100000).optional(), +}) + +export const discloseAffiliationsSchema = z.object({ + affiliations: z.array(z.string().min(1).max(200)).max(50).default([]), +}) diff --git a/lib/validations/payments.ts b/lib/validations/payments.ts new file mode 100644 index 0000000..4e3ad46 --- /dev/null +++ b/lib/validations/payments.ts @@ -0,0 +1,14 @@ +import { z } from 'zod' +import { flexibleDateTime } from './primitives' + +export const logCashPaymentSchema = z.object({ + amount: z.number().positive('Amount must be positive'), + from_note: z.string().min(1, 'Payer note is required').max(500), + member_id: z.string().uuid('Invalid member ID').optional().nullable(), + transaction_date: flexibleDateTime().optional(), +}) + +export const linkPaymentSchema = z.object({ + paymentId: z.string().uuid('Invalid payment ID'), + memberId: z.string().uuid('Invalid member ID'), +}) diff --git a/lib/validations/permissions.ts b/lib/validations/permissions.ts new file mode 100644 index 0000000..5232738 --- /dev/null +++ b/lib/validations/permissions.ts @@ -0,0 +1,35 @@ +import { z } from 'zod' + +// A grant subject is a built-in role or a custom-role slug. +const roleSubject = z.string().min(1).max(50).regex(/^[a-z0-9][a-z0-9_:-]{0,49}$/, 'Invalid role') + +export const setRolePermissionsSchema = z.object({ + subject: roleSubject, + permissions: z.array(z.string().min(1).max(60)).max(64), +}) + +export const opsEntityTypeSchema = z.enum(['secret', 'kb', 'process', 'area_lead']) + +// The UI sends the full desired role list for one item; the server replaces. +// An empty list means "fall back to the item's existing visibility rule". +export const setOpsAclSchema = z.object({ + entity_type: opsEntityTypeSchema, + entity_id: z.string().uuid('Invalid entity ID'), + roles: z.array(z.string().min(1).max(64)).max(64), +}) + +export const createAreaLeadRoleSchema = z.object({ + area_code: z.string().min(1).max(60).regex(/^[a-z0-9][a-z0-9_-]{0,59}$/, 'Invalid area code'), + name: z.string().min(1).max(100), + color: z.string().max(20).optional().nullable(), +}) + +export const updateAreaLeadRoleSchema = z.object({ + name: z.string().min(1).max(100).optional(), + color: z.string().max(20).optional().nullable(), +}) + +export const assignAreaLeadSchema = z.object({ + area_lead_role_id: z.string().uuid('Invalid role ID'), + member_id: z.string().uuid('Invalid member ID'), +}) diff --git a/lib/validations/presence.ts b/lib/validations/presence.ts new file mode 100644 index 0000000..6ebfa45 --- /dev/null +++ b/lib/validations/presence.ts @@ -0,0 +1,10 @@ +import { z } from 'zod' + +export const checkInSchema = z.object({ + asHost: z.boolean().optional().default(false), + note: z.string().max(500).optional().nullable(), +}) + +export const checkOutSchema = z.object({ + note: z.string().max(500).optional().nullable(), +}) diff --git a/lib/validations/primitives.ts b/lib/validations/primitives.ts new file mode 100644 index 0000000..504c4bb --- /dev/null +++ b/lib/validations/primitives.ts @@ -0,0 +1,91 @@ +import { z } from 'zod' + +/** + * Accepts every date-ish string the app produces and normalizes to a + * full RFC 3339 datetime so downstream `.datetime()` validation passes. + * + * - `""`, `null`, `undefined` → `null` (treat as unset) + * - `"YYYY-MM-DD"` (HTML date input) → `"YYYY-MM-DDT00:00:00.000Z"` + * - `"YYYY-MM-DDTHH:MM"` (datetime-local input) → ISO string in UTC + * - `"YYYY-MM-DDTHH:MM:SS"` and full RFC 3339 → unchanged / normalised + * - Anything `new Date()` can parse → its ISO form + * - Unparseable → passed through so Zod rejects it + * + * Returns a Zod schema that produces a `string | null` (after preprocess + + * `.datetime().nullable()`). + */ +export const flexibleDateTime = (): z.ZodType => + z.preprocess( + (val) => { + if (val === null || val === undefined || val === '') return null + if (typeof val !== 'string') return val + // Already full ISO datetime? Pass through. + if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?([+-]\d{2}:?\d{2}|Z)$/.test(val)) { + return val + } + // Try Date parsing for everything else (date-only, datetime-local, etc). + const d = new Date(val) + if (Number.isNaN(d.getTime())) return val // let Zod reject + return d.toISOString() + }, + z.string().datetime().nullable(), + ) + +// Canonicalize every email to trimmed lowercase so case/whitespace variants +// cannot create duplicate members/contacts or fail sign-in. +export const emailField = (msg = 'Invalid email address') => + z.string().max(200).email(msg).transform(s => s.trim().toLowerCase()) + +// ─── Custom forms building blocks (shared) ─────────────────────────────────── + +export const formFieldTypes = [ + 'short_text', + 'long_text', + 'email', + 'number', + 'date', + 'checkbox', + 'select', + 'radio', +] as const + +export const formFieldSchema = z + .object({ + key: z + .string() + .min(1) + .max(60) + .regex(/^[a-z0-9_]+$/, 'Field key must be lowercase letters, numbers, underscores'), + type: z.enum(formFieldTypes), + label: z.string().min(1, 'Field label is required').max(200), + help: z.string().max(1000).optional().nullable(), + required: z.boolean().optional().default(false), + options: z.array(z.string().min(1).max(200)).max(100).optional(), + }) + .superRefine((f, ctx) => { + if ((f.type === 'select' || f.type === 'radio') && (!f.options || f.options.length === 0)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Field "${f.label}" needs at least one option`, + path: ['options'], + }) + } + }) + +// The field array is rejected if two fields share a key (answers are keyed by +// field key, so duplicates would silently overwrite). +export const formSchemaArray = z + .array(formFieldSchema) + .max(200, 'A form cannot have more than 200 fields') + .superRefine((fields, ctx) => { + const seen = new Set() + for (const f of fields) { + if (seen.has(f.key)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate field key "${f.key}"`, + }) + } + seen.add(f.key) + } + }) diff --git a/lib/validations/projects.ts b/lib/validations/projects.ts new file mode 100644 index 0000000..ffd8f5c --- /dev/null +++ b/lib/validations/projects.ts @@ -0,0 +1,17 @@ +import { z } from 'zod' +import { flexibleDateTime } from './primitives' + +export const createProjectSchema = z.object({ + title: z.string().min(1, 'Title is required').max(200), + description: z.string().max(2000).optional(), + area: z.string().max(100).optional(), + tags: z.array(z.string().max(50)).max(20).optional(), + due_date: flexibleDateTime().optional(), +}) + +export const updateProjectStatusSchema = z.object({ + projectId: z.string().uuid('Invalid project ID'), + status: z.enum(['backlog', 'in_progress', 'review', 'done', 'blocked']), +}) + +export type CreateProjectInput = z.infer diff --git a/lib/validations/secrets.ts b/lib/validations/secrets.ts new file mode 100644 index 0000000..366e1e8 --- /dev/null +++ b/lib/validations/secrets.ts @@ -0,0 +1,19 @@ +import { z } from 'zod' + +export const createSecretSchema = z.object({ + title: z.string().min(1, 'Title is required').max(200), + value: z.string().min(1, 'Value is required').max(5000), + description: z.string().max(500).optional().nullable(), + area: z.string().max(100).optional().nullable(), + icon: z.string().max(10).optional().nullable(), +}) + +export const updateSecretSchema = z.object({ + title: z.string().min(1).max(200).optional(), + value: z.string().min(1).max(5000).optional(), + description: z.string().max(500).optional().nullable(), + area: z.string().max(100).optional().nullable(), + icon: z.string().max(10).optional().nullable(), +}) + +export type CreateSecretInput = z.infer diff --git a/lib/validations/settings.ts b/lib/validations/settings.ts new file mode 100644 index 0000000..82d9f83 --- /dev/null +++ b/lib/validations/settings.ts @@ -0,0 +1,30 @@ +import { z } from 'zod' + +export const updateSpaceSettingsSchema = z.object({ + name: z.string().min(1).max(100).optional(), + slug: z.string().regex(/^[a-z0-9]+$/, 'Slug must be lowercase letters and numbers only').max(50).optional(), + city: z.string().max(100).optional().nullable(), + require_approval: z.boolean().optional(), + public_member_directory: z.boolean().optional(), + mission_statement: z.string().max(5000).optional().nullable(), +}) + +export const saveIntegrationSchema = z.object({ + platform: z.string().min(1).max(50), + config: z.record(z.string().max(500)), +}) + +// ─── Space settings (visibility) ───────────────────────────────────────────── + +export const financialVisibilities = ['treasurer_only', 'board_visible', 'all_members_visible'] as const +export const directoryVisibilities = [ + 'board_only', + 'member_count_visible', + 'members_visible', + 'public_members_visible', +] as const + +export const updateSpaceVisibilitySchema = z.object({ + financial_visibility: z.enum(financialVisibilities).optional(), + member_directory_visibility: z.enum(directoryVisibilities).optional(), +}) diff --git a/lib/validations/tasks.ts b/lib/validations/tasks.ts new file mode 100644 index 0000000..4665c5f --- /dev/null +++ b/lib/validations/tasks.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' +import { flexibleDateTime } from './primitives' + +export const createTaskSchema = z.object({ + title: z.string().min(1, 'Title is required').max(200, 'Title is too long'), + description: z.string().max(2000, 'Description is too long').optional(), + type: z.enum(['task', 'chore']).default('task'), + area: z.string().max(100).optional(), + recurrence: z.enum(['none', 'daily', 'weekly', 'biweekly', 'monthly']).default('none'), + due_date: flexibleDateTime().optional(), +}) + +export const taskIdSchema = z.string().uuid('Invalid task ID') + +export type CreateTaskInput = z.infer