feat: import row validation + KB/process per-item ACLs

Continuing the audit backlog and finishing the permissions UI.

Import validation (recurring audit finding):
- Added importMembersSchema / importPaymentsCsvSchema. importMembers
  and importPaymentsCsv now validate per row (a single bad row no
  longer rejects the whole file), lowercase emails, normalize dates
  through flexibleDateTime(), enforce tier/platform enums and
  positive finite amounts, and return a `skipped` count instead of
  silently dropping invalid rows.

Permissions UI completion:
- OpsAclEditor is now wired into the KB/process viewer modal
  (admin/board), with entity type derived from the `process` tag.
  Per-item Ops access control now covers secrets + KB + processes,
  matching "all ops facilities stuff".

Left as-is with rationale in HANDOFF: /ops + /import denial paths
(unreachable behind the layout gate or already a clear non-leaking
denial); members-row make-area-lead shortcut (Customize -> Area
leads fully covers assignment); types/database.ts + docs regen
(pure hygiene, zero runtime/security impact, still tracked).

271/271 tests pass. Build clean. No migration this pass.
This commit is contained in:
Moheeb Zara
2026-05-15 18:33:53 -07:00
parent f5d670477b
commit 86d44f6efc
6 changed files with 111 additions and 54 deletions
+26
View File
@@ -522,6 +522,32 @@ export const assignAreaLeadSchema = z.object({
member_id: z.string().uuid('Invalid member ID'),
})
// ─── Bulk import ─────────────────────────────────────────────────────────────
const importEmail = z.string().email('Invalid email').max(200).transform(s => s.trim().toLowerCase())
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)
// ─── Generic ID schemas ──────────────────────────────────────────────────────
export const uuidSchema = z.string().uuid('Invalid ID format')