(initialMembers)
@@ -280,6 +285,9 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo
{canGrantCerts && (
| CERTS |
)}
+ {canManageCards && (
+ CARDS |
+ )}
{isAdmin(currentRole) && (
ACTIONS |
)}
@@ -344,6 +352,16 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo
)}
+ {canManageCards && (
+
+
+ |
+ )}
{isAdmin(currentRole) && (
@@ -389,7 +407,7 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo
)
}) : (
- |
+ |
@@ -450,6 +468,13 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo
onClose={() => setCertMember(null)}
/>
)}
+
+ {canManageCards && (
+ setCardMember(null)}
+ />
+ )}
)
}
diff --git a/app/(app)/members/page.tsx b/app/(app)/members/page.tsx
index e058ba3..e803ff6 100644
--- a/app/(app)/members/page.tsx
+++ b/app/(app)/members/page.tsx
@@ -95,12 +95,18 @@ export default async function MembersPage() {
sid: self.space_id,
perm: 'certifications.grant',
})
+ const { data: canManageCards } = await supabase.rpc('user_has_permission', {
+ uid: user.id,
+ sid: self.space_id,
+ perm: 'door.manage',
+ })
return (
}
inviteSlot={
canInvite ? (
diff --git a/components/door/member-cards-dialog.tsx b/components/door/member-cards-dialog.tsx
new file mode 100644
index 0000000..89ef101
--- /dev/null
+++ b/components/door/member-cards-dialog.tsx
@@ -0,0 +1,185 @@
+'use client'
+
+import { useEffect, useState, useCallback } from 'react'
+import { toast } from 'sonner'
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+import { useConfirm } from '@/components/ui/confirm'
+import {
+ listMemberCards,
+ addMemberCard,
+ updateMemberCard,
+ deleteMemberCard,
+} from '@/lib/actions'
+
+type Card = {
+ id: string
+ card_uid: string
+ card_type: string
+ label: string | null
+ is_active: boolean
+ created_at: string
+}
+
+export function MemberCardsDialog({
+ member,
+ onClose,
+}: {
+ member: { id: string; display_name: string | null } | null
+ onClose: () => void
+}) {
+ const confirm = useConfirm()
+ const open = !!member
+ const [loading, setLoading] = useState(false)
+ const [cards, setCards] = useState([])
+ const [uid, setUid] = useState('')
+ const [type, setType] = useState<'rfid' | 'nfc'>('rfid')
+ const [label, setLabel] = useState('')
+ const [busy, setBusy] = useState(false)
+
+ const load = useCallback(async (memberId: string) => {
+ setLoading(true)
+ const res = await listMemberCards({ memberId })
+ setLoading(false)
+ if ('error' in res && res.error) {
+ toast.error(res.error)
+ return
+ }
+ setCards(((res as { data: Card[] }).data) ?? [])
+ }, [])
+
+ useEffect(() => {
+ if (member) {
+ setUid('')
+ setType('rfid')
+ setLabel('')
+ load(member.id)
+ }
+ }, [member, load])
+
+ async function onAdd(e: React.FormEvent) {
+ e.preventDefault()
+ if (!member) return
+ if (!uid.trim()) {
+ toast.error('Card UID is required')
+ return
+ }
+ setBusy(true)
+ const res = await addMemberCard({
+ memberId: member.id,
+ card_uid: uid.trim(),
+ card_type: type,
+ label: label.trim() || null,
+ })
+ setBusy(false)
+ if ('error' in res && res.error) {
+ toast.error(res.error)
+ return
+ }
+ toast.success('Card added')
+ setUid('')
+ setLabel('')
+ load(member.id)
+ }
+
+ async function onToggle(c: Card) {
+ const res = await updateMemberCard({ cardId: c.id, is_active: !c.is_active })
+ if ('error' in res && res.error) {
+ toast.error(res.error)
+ return
+ }
+ setCards(prev => prev.map(x => (x.id === c.id ? { ...x, is_active: !x.is_active } : x)))
+ }
+
+ async function onDelete(c: Card) {
+ const ok = await confirm({
+ title: 'Delete card',
+ description: 'This removes the card association. If a controller is connected, revoke it there too.',
+ confirmText: 'Delete',
+ destructive: true,
+ })
+ if (!ok || !member) return
+ const res = await deleteMemberCard({ cardId: c.id })
+ if ('error' in res && res.error) {
+ toast.error(res.error)
+ return
+ }
+ setCards(prev => prev.filter(x => x.id !== c.id))
+ toast.success('Deleted')
+ }
+
+ return (
+
+ )
+}
diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md
index 9946866..b2e9f6e 100644
--- a/docs/DATABASE_SCHEMA.md
+++ b/docs/DATABASE_SCHEMA.md
@@ -686,7 +686,7 @@ CREATE INDEX idx_activity_space ON activity_log(space_id, created_at);
---
-## Migrations 014-033 (additions since the 13-table baseline)
+## Migrations 014-034 (additions since the 13-table baseline)
`scripts/schema.sql` is the canonical idempotent schema; each numbered
migration is mirrored as a section in it. Tables/columns added:
@@ -713,6 +713,7 @@ migration is mirrored as a section in it. Tables/columns added:
| 031 | `secrets_select` additively also honors the `ops.secrets.read` role permission (was admin/board OR per-secret `ops_acl` only; the permission was never consulted). Access-neutral unless an admin has granted the permission. Reveal/list server-side gates updated to let RLS be the boundary; write paths unchanged |
| 032 | `classes`, `class_sessions`, `class_signups` (class offerings, scheduled sessions, member signups; optional `payment_link` and `grants_certification_id`; partial unique = one non-cancelled signup per member+session). RLS additive + default-deny: classes SELECT = `classes.manage` (all) or member (`is_active`), class_sessions SELECT = any space member, class_signups SELECT = manage/instruct (all) or member (own), UPDATE = `classes.instruct`, NO INSERT/DELETE policy (signup/cancel via a validated service-client action enforcing capacity/waitlist/dedupe). New `classes.manage` + `classes.instruct` permissions seeded to board + backfilled. Cert-on-completion uses the normal certifications path (still needs `certifications.grant`). No anonymous path |
| 033 | `equipment` (registry; status available/maintenance/retired; optional `required_certification_id`; `is_active` archive) + `equipment_reservations` (member time-window reservation; denormalized space_id; no DB overlap constraint). RLS additive + default-deny: equipment SELECT = `equipment.manage` (all) or member (`is_active`), reservations SELECT = `equipment.manage` (all) or member (own), UPDATE = `equipment.manage`, NO INSERT/DELETE policy (reserve/cancel via validated service-client action enforcing status + no-overlap + required-cert, manager override). New `equipment.manage` permission seeded to board + backfilled. No anonymous path |
+| 034 | `member_cards` (RFID/NFC card UID -> member; the UID is a credential -- `door.manage`-only RLS, no member SELECT policy; masked self-view via a server action). New `door.manage` + `door.operate` permissions (group Access; the whole Door epic uses them) seeded to board + backfilled. No controller calls in this phase, no anonymous path |
New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`.
diff --git a/lib/actions/index.ts b/lib/actions/index.ts
index 5e9fe73..f0596f2 100644
--- a/lib/actions/index.ts
+++ b/lib/actions/index.ts
@@ -56,3 +56,6 @@ export * from './classes'
// Equipment registry + reservations
export * from './equipment'
+
+// Member access cards (Door epic)
+export * from './member-cards'
diff --git a/lib/actions/member-cards.ts b/lib/actions/member-cards.ts
new file mode 100644
index 0000000..9f6f9c1
--- /dev/null
+++ b/lib/actions/member-cards.ts
@@ -0,0 +1,180 @@
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { createClient } from '@/lib/supabase/server'
+import { createAdminClient } from '@/lib/supabase/admin'
+import {
+ requireMember,
+ logActivity,
+ parseInput,
+ type Member,
+ type ServerSupabase,
+} from '@/lib/auth-helpers'
+import {
+ addMemberCardSchema,
+ updateMemberCardSchema,
+ cardIdSchema,
+ listMemberCardsSchema,
+} from '@/lib/validations'
+import { last4 } from '@/lib/door-logic'
+
+type Gate =
+ | { ok: true; supabase: ServerSupabase; member: Member }
+ | { ok: false; error: string }
+
+async function requireDoorManager(): Promise {
+ const supabase = await createClient()
+ const auth = await requireMember(supabase)
+ if (!auth.ok) return { ok: false, error: auth.error }
+ const { member } = auth
+ const { data: allowed, error } = await supabase.rpc('user_has_permission', {
+ uid: member.user_id as string,
+ sid: member.space_id,
+ perm: 'door.manage',
+ })
+ if (error) return { ok: false, error: error.message }
+ if (!allowed) return { ok: false, error: 'You do not have permission to manage access cards' }
+ return { ok: true, supabase, member }
+}
+
+function isUniqueViolation(message: string): boolean {
+ return /duplicate key value|already exists|unique constraint/i.test(message)
+}
+
+export async function addMemberCard(input: unknown) {
+ const gate = await requireDoorManager()
+ if (!gate.ok) return { error: gate.error }
+ const { supabase, member } = gate
+
+ const v = parseInput(addMemberCardSchema, input)
+ if (!v.ok) return { error: v.error }
+ const c = v.data
+
+ const { data: target } = await supabase
+ .from('space_members')
+ .select('id')
+ .eq('id', c.memberId)
+ .eq('space_id', member.space_id)
+ .maybeSingle()
+ if (!target) return { error: 'Member not found in this space' }
+
+ const { data, error } = await supabase
+ .from('member_cards')
+ .insert({
+ space_id: member.space_id,
+ member_id: c.memberId,
+ card_uid: c.card_uid,
+ card_type: c.card_type,
+ label: c.label ?? null,
+ created_by: member.id,
+ })
+ .select('id')
+ .single()
+ if (error) {
+ if (isUniqueViolation(error.message)) {
+ return { error: 'That card UID is already registered in this space.' }
+ }
+ return { error: error.message }
+ }
+
+ // Audit without ever logging the raw UID.
+ await logActivity(supabase, member, 'added', 'member_card', data.id as string, `card •••${last4(c.card_uid)}`)
+ revalidatePath('/members')
+ revalidatePath('/me')
+ return { data: { id: data.id as string } }
+}
+
+export async function updateMemberCard(input: unknown) {
+ const gate = await requireDoorManager()
+ if (!gate.ok) return { error: gate.error }
+ const { supabase, member } = gate
+
+ const v = parseInput(updateMemberCardSchema, input)
+ if (!v.ok) return { error: v.error }
+ const u = v.data
+
+ const patch: Record = {}
+ if (u.label !== undefined) patch.label = u.label ?? null
+ if (u.is_active !== undefined) patch.is_active = u.is_active
+ if (Object.keys(patch).length === 0) return { data: { id: u.cardId } }
+
+ const { error } = await supabase
+ .from('member_cards')
+ .update(patch)
+ .eq('id', u.cardId)
+ .eq('space_id', member.space_id)
+ if (error) return { error: error.message }
+
+ await logActivity(supabase, member, 'updated', 'member_card', u.cardId)
+ revalidatePath('/members')
+ revalidatePath('/me')
+ return { data: { id: u.cardId } }
+}
+
+export async function deleteMemberCard(input: unknown) {
+ const gate = await requireDoorManager()
+ if (!gate.ok) return { error: gate.error }
+ const { supabase, member } = gate
+
+ const v = parseInput(cardIdSchema, input)
+ if (!v.ok) return { error: v.error }
+
+ const { error } = await supabase
+ .from('member_cards')
+ .delete()
+ .eq('id', v.data.cardId)
+ .eq('space_id', member.space_id)
+ if (error) return { error: error.message }
+
+ await logActivity(supabase, member, 'deleted', 'member_card', v.data.cardId)
+ revalidatePath('/members')
+ revalidatePath('/me')
+ return { data: { id: v.data.cardId } }
+}
+
+// Manager view: full UID (door.manage holders only, enforced by RLS too).
+export async function listMemberCards(input: unknown) {
+ const gate = await requireDoorManager()
+ if (!gate.ok) return { error: gate.error }
+ const { supabase, member } = gate
+
+ const v = parseInput(listMemberCardsSchema, input)
+ if (!v.ok) return { error: v.error }
+
+ const { data, error } = await supabase
+ .from('member_cards')
+ .select('id, card_uid, card_type, label, is_active, created_at')
+ .eq('space_id', member.space_id)
+ .eq('member_id', v.data.memberId)
+ .order('created_at', { ascending: false })
+ if (error) return { error: error.message }
+ return { data: data ?? [] }
+}
+
+// The signed-in member's own cards, MASKED. The raw UID is a credential and
+// is never returned here; member_cards has no member-facing RLS SELECT
+// policy, so this uses the service client and strips card_uid to last4.
+export async function getMyCards() {
+ const supabase = await createClient()
+ const auth = await requireMember(supabase)
+ if (!auth.ok) return { error: auth.error }
+ const { member } = auth
+
+ const admin = createAdminClient()
+ const { data, error } = await admin
+ .from('member_cards')
+ .select('id, card_uid, card_type, label, is_active')
+ .eq('space_id', member.space_id)
+ .eq('member_id', member.id)
+ .order('created_at', { ascending: false })
+ if (error) return { error: error.message }
+ return {
+ data: (data ?? []).map(c => ({
+ id: c.id as string,
+ card_type: c.card_type as string,
+ label: (c.label as string | null) ?? null,
+ is_active: c.is_active as boolean,
+ last4: last4(c.card_uid as string),
+ })),
+ }
+}
diff --git a/lib/door-logic.ts b/lib/door-logic.ts
new file mode 100644
index 0000000..0fb5e91
--- /dev/null
+++ b/lib/door-logic.ts
@@ -0,0 +1,14 @@
+// Pure, dependency-free logic for the Door epic. No Supabase/React/Next.
+// Unit-tested directly. The card UID is a credential, so the canonical
+// masking lives here and is reused everywhere a non-manager could see it.
+
+export function last4(uid: string): string {
+ return uid.length <= 4 ? uid : uid.slice(-4)
+}
+
+// "••••AB12" — never reveals more than the last 4 characters. For a UID of 4
+// or fewer characters nothing is revealed.
+export function maskCardUid(uid: string): string {
+ if (uid.length <= 4) return '•'.repeat(uid.length || 4)
+ return '•'.repeat(uid.length - 4) + uid.slice(-4)
+}
diff --git a/lib/permissions-catalog.ts b/lib/permissions-catalog.ts
index 0327ca4..0a529b4 100644
--- a/lib/permissions-catalog.ts
+++ b/lib/permissions-catalog.ts
@@ -27,6 +27,8 @@ export const PERMISSIONS = [
{ code: 'classes.manage', group: 'Classes', label: 'Manage classes and schedule sessions' },
{ code: 'classes.instruct', group: 'Classes', label: 'Run classes: attendance, completion, attendees' },
{ code: 'equipment.manage', group: 'Equipment', label: 'Manage equipment and reservations' },
+ { code: 'door.manage', group: 'Access', label: 'Configure door integrations, buttons, and member cards' },
+ { code: 'door.operate', group: 'Access', label: 'Operate doors: open/lock, push/revoke cards' },
{ code: 'customize.manage', group: 'Admin', label: 'Customize roles, tiers, areas, invites, onboarding' },
{ code: 'settings.manage', group: 'Admin', label: 'Space settings, integrations, webhooks' },
] as const
@@ -49,6 +51,7 @@ export const DEFAULT_ROLE_PERMISSIONS: Record = {
'certifications.manage', 'certifications.grant',
'classes.manage', 'classes.instruct',
'equipment.manage',
+ 'door.manage', 'door.operate',
'customize.manage', 'settings.manage',
],
treasurer: ['payments.manage', 'ops.kb.read', 'ops.process.read'],
diff --git a/lib/validations.ts b/lib/validations.ts
index 34853cf..3601961 100644
--- a/lib/validations.ts
+++ b/lib/validations.ts
@@ -874,6 +874,35 @@ 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'),
+})
+
// ─── Generic ID schemas ──────────────────────────────────────────────────────
export const uuidSchema = z.string().uuid('Invalid ID format')
diff --git a/scripts/034_member_cards.sql b/scripts/034_member_cards.sql
new file mode 100644
index 0000000..496a158
--- /dev/null
+++ b/scripts/034_member_cards.sql
@@ -0,0 +1,102 @@
+-- =============================================================================
+-- 034: Member access cards + door permissions (Door epic, phase 1)
+-- =============================================================================
+-- member_cards associates an RFID/NFC card UID with a member. The card UID is
+-- a physical-access CREDENTIAL: it is treated like a secret. RLS exposes the
+-- table only to door.manage holders; the owning member never reads the raw
+-- UID through PostgREST -- a member's "my cards" view is served by a server
+-- action that returns only a count and the last 4 characters.
+--
+-- Two new permission codes (lib/permissions-catalog.ts, group "Access"),
+-- introduced now because the whole Door epic uses them:
+--
+-- door.manage configure door connections + the API-call buttons, and
+-- manage member cards.
+-- door.operate perform live actions (open/lock/unlock, push/revoke a
+-- card, run a configured button). The Host/Champion
+-- capability.
+--
+-- Both are assignable to any role via the existing additive model (NOT new
+-- built-in roles), seeded to board and backfilled for existing spaces.
+--
+-- RLS posture (additive, default-deny; the guarded surface):
+--
+-- * member_cards SELECT/INSERT/UPDATE/DELETE: door.manage only. There is
+-- deliberately no member-facing SELECT policy -- the raw UID is a
+-- credential. The masked self-view goes through a validated server
+-- action using the service client.
+--
+-- No anonymous path. No controller calls in this phase.
+--
+-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS, DROP POLICY IF EXISTS then
+-- CREATE, CREATE OR REPLACE FUNCTION, ON CONFLICT DO NOTHING backfill.
+-- =============================================================================
+
+CREATE TABLE IF NOT EXISTS public.member_cards (
+ id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
+ space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
+ member_id uuid NOT NULL REFERENCES public.space_members(id) ON DELETE CASCADE,
+ card_uid text NOT NULL CHECK (char_length(card_uid) BETWEEN 1 AND 200),
+ card_type text NOT NULL DEFAULT 'rfid' CHECK (card_type IN ('rfid','nfc')),
+ label text,
+ is_active boolean NOT NULL DEFAULT true,
+ created_by uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+-- A physical card UID maps to at most one record per space.
+CREATE UNIQUE INDEX IF NOT EXISTS idx_member_cards_uid ON public.member_cards (space_id, card_uid);
+CREATE INDEX IF NOT EXISTS idx_member_cards_space ON public.member_cards (space_id);
+CREATE INDEX IF NOT EXISTS idx_member_cards_member ON public.member_cards (member_id);
+
+ALTER TABLE public.member_cards ENABLE ROW LEVEL SECURITY;
+
+DROP POLICY IF EXISTS member_cards_select ON public.member_cards;
+DROP POLICY IF EXISTS member_cards_insert ON public.member_cards;
+DROP POLICY IF EXISTS member_cards_update ON public.member_cards;
+DROP POLICY IF EXISTS member_cards_delete ON public.member_cards;
+CREATE POLICY member_cards_select ON public.member_cards FOR SELECT
+ USING (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
+CREATE POLICY member_cards_insert ON public.member_cards FOR INSERT
+ WITH CHECK (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
+CREATE POLICY member_cards_update ON public.member_cards FOR UPDATE
+ USING (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
+CREATE POLICY member_cards_delete ON public.member_cards FOR DELETE
+ USING (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
+
+DROP TRIGGER IF EXISTS trg_member_cards_touch ON public.member_cards;
+CREATE TRIGGER trg_member_cards_touch
+ BEFORE UPDATE ON public.member_cards
+ FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
+
+CREATE OR REPLACE FUNCTION public.seed_default_role_permissions()
+ RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
+AS $$
+BEGIN
+ INSERT INTO public.space_role_permissions (space_id, subject, permission)
+ SELECT NEW.id, d.subject, d.permission
+ FROM (VALUES
+ ('board','ops.kb.read'),('board','ops.kb.write'),('board','ops.process.read'),
+ ('board','ops.process.write'),('board','ops.secrets.read'),('board','ops.secrets.write'),
+ ('board','ops.arealeads.manage'),('board','members.manage'),('board','payments.manage'),
+ ('board','governance.manage'),('board','forum.moderate'),('board','forms.manage'),
+ ('board','certifications.manage'),('board','certifications.grant'),
+ ('board','classes.manage'),('board','classes.instruct'),
+ ('board','equipment.manage'),
+ ('board','door.manage'),('board','door.operate'),
+ ('board','customize.manage'),('board','settings.manage'),
+ ('treasurer','payments.manage'),('treasurer','ops.kb.read'),('treasurer','ops.process.read'),
+ ('member','ops.kb.read'),('member','ops.process.read'),
+ ('associate','ops.kb.read')
+ ) AS d(subject, permission)
+ ON CONFLICT (space_id, subject, permission) DO NOTHING;
+ RETURN NEW;
+END;
+$$;
+
+INSERT INTO public.space_role_permissions (space_id, subject, permission)
+SELECT id, 'board', 'door.manage' FROM public.spaces
+ON CONFLICT (space_id, subject, permission) DO NOTHING;
+INSERT INTO public.space_role_permissions (space_id, subject, permission)
+SELECT id, 'board', 'door.operate' FROM public.spaces
+ON CONFLICT (space_id, subject, permission) DO NOTHING;
diff --git a/scripts/schema.sql b/scripts/schema.sql
index 455f544..19b2aca 100644
--- a/scripts/schema.sql
+++ b/scripts/schema.sql
@@ -1830,6 +1830,7 @@ BEGIN
('board','certifications.manage'),('board','certifications.grant'),
('board','classes.manage'),('board','classes.instruct'),
('board','equipment.manage'),
+ ('board','door.manage'),('board','door.operate'),
('board','customize.manage'),('board','settings.manage'),
('treasurer','payments.manage'),('treasurer','ops.kb.read'),('treasurer','ops.process.read'),
('member','ops.kb.read'),('member','ops.process.read'),
@@ -2318,3 +2319,58 @@ CREATE TRIGGER trg_equipment_touch
INSERT INTO public.space_role_permissions (space_id, subject, permission)
SELECT id, 'board', 'equipment.manage' FROM public.spaces
ON CONFLICT (space_id, subject, permission) DO NOTHING;
+
+
+-- =============================================================================
+-- 21. Member access cards + door permissions (Door epic, phase 1)
+-- (equivalent to scripts/034_member_cards.sql).
+--
+-- member_cards (card UID is a credential; door.manage-only RLS, no
+-- member SELECT policy -- the masked self-view is a server action).
+-- Permissions door.manage / door.operate (group Access) introduced for
+-- the whole Door epic, seeded to board (above) + backfilled below.
+-- No controller calls in this phase. No anonymous path.
+-- =============================================================================
+
+CREATE TABLE IF NOT EXISTS public.member_cards (
+ id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
+ space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
+ member_id uuid NOT NULL REFERENCES public.space_members(id) ON DELETE CASCADE,
+ card_uid text NOT NULL CHECK (char_length(card_uid) BETWEEN 1 AND 200),
+ card_type text NOT NULL DEFAULT 'rfid' CHECK (card_type IN ('rfid','nfc')),
+ label text,
+ is_active boolean NOT NULL DEFAULT true,
+ created_by uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_member_cards_uid ON public.member_cards (space_id, card_uid);
+CREATE INDEX IF NOT EXISTS idx_member_cards_space ON public.member_cards (space_id);
+CREATE INDEX IF NOT EXISTS idx_member_cards_member ON public.member_cards (member_id);
+
+ALTER TABLE public.member_cards ENABLE ROW LEVEL SECURITY;
+
+DROP POLICY IF EXISTS member_cards_select ON public.member_cards;
+DROP POLICY IF EXISTS member_cards_insert ON public.member_cards;
+DROP POLICY IF EXISTS member_cards_update ON public.member_cards;
+DROP POLICY IF EXISTS member_cards_delete ON public.member_cards;
+CREATE POLICY member_cards_select ON public.member_cards FOR SELECT
+ USING (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
+CREATE POLICY member_cards_insert ON public.member_cards FOR INSERT
+ WITH CHECK (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
+CREATE POLICY member_cards_update ON public.member_cards FOR UPDATE
+ USING (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
+CREATE POLICY member_cards_delete ON public.member_cards FOR DELETE
+ USING (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
+
+DROP TRIGGER IF EXISTS trg_member_cards_touch ON public.member_cards;
+CREATE TRIGGER trg_member_cards_touch
+ BEFORE UPDATE ON public.member_cards
+ FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
+
+INSERT INTO public.space_role_permissions (space_id, subject, permission)
+SELECT id, 'board', 'door.manage' FROM public.spaces
+ON CONFLICT (space_id, subject, permission) DO NOTHING;
+INSERT INTO public.space_role_permissions (space_id, subject, permission)
+SELECT id, 'board', 'door.operate' FROM public.spaces
+ON CONFLICT (space_id, subject, permission) DO NOTHING;
diff --git a/types/database.ts b/types/database.ts
index 49242a3..4f0e225 100644
--- a/types/database.ts
+++ b/types/database.ts
@@ -148,6 +148,12 @@ export type Database = {
Update: { id?: string; equipment_id?: string; space_id?: string; member_id?: string; starts_at?: string; ends_at?: string; status?: string; notes?: string | null; created_by?: string | null; created_at?: string }
Relationships: []
}
+ member_cards: {
+ Row: { id: string; space_id: string; member_id: string; card_uid: string; card_type: string; label: string | null; is_active: boolean; created_by: string | null; created_at: string; updated_at: string }
+ Insert: { id?: string; space_id: string; member_id: string; card_uid: string; card_type?: string; label?: string | null; is_active?: boolean; created_by?: string | null; created_at?: string; updated_at?: string }
+ Update: { id?: string; space_id?: string; member_id?: string; card_uid?: string; card_type?: string; label?: string | null; is_active?: boolean; created_by?: string | null; created_at?: string; updated_at?: string }
+ Relationships: []
+ }
activity_log: {
Row: {
action: string
| |