diff --git a/.env.example b/.env.example index e2957c5..54a6911 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,4 @@ FRONTEND_URL=http://localhost:3000 BACKEND_URL=http://localhost:5000 VITE_API_BASE_URL=http://localhost:5000/api NODE_ENV=development +ADMIN_EMAILS=corey@blaishome.online diff --git a/README.md b/README.md index 97c9490..a12791f 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,9 @@ FlockPal is a Dockerized TypeScript app for tracking flock health with a clean, - Passwordless authentication only - Magic-link email sign-in - OAuth-ready login flow for Google, Microsoft, and Apple -- Multi-workspace model with `standard` household and `rescue` modes -- Shared workspace member management for both households and rescues -- Separate per-workspace billing plan foundation with `rescue_free`, `household_basic`, `household_plus`, and `household_macaw` +- Multi-flock model with `standard` household and `rescue` modes +- Shared flock member management for both households and rescues +- Separate per-flock billing plan foundation with `rescue_free`, `household_basic`, `household_plus`, and `household_macaw` - Bird profiles with name, tag ID, and species - Bird DOB and gotcha day fields - Daily weight recordings @@ -22,10 +22,10 @@ FlockPal is a Dockerized TypeScript app for tracking flock health with a clean, ## Planned next steps - Medication and care reminders -- Invitation acceptance and onboarding polish for workspace members +- Invitation acceptance and onboarding polish for flock members - Stripe or equivalent billing integration for paid household tiers - Scheduled reminder delivery for birthdays, gotcha days, and care events -- Audit logging for workspace access changes and bird transfers +- Audit logging for flock access changes and bird transfers ## Development @@ -59,13 +59,13 @@ docker compose -f docker-compose.prod.yml up --build -d 3. The production backend runs the compiled Node app from `dist/app.js`. 4. The production frontend is built with Vite and served by Nginx on port `3000`. -## Auth and workspace notes +## Auth and flock notes -- One user can belong to multiple workspaces. -- A rescue member can also keep their own household flock in a separate workspace. -- Billing should attach to the household workspace, not the user account. -- Rescue workspaces stay on the free plan. -- Shared access is controlled by workspace roles like `owner`, `manager`, `staff`, and `viewer`. +- One user can belong to multiple flocks. +- A rescue member can also keep their own household flock separate from the rescue flock. +- Billing should attach to the household flock, not the user account. +- Rescue flocks stay on the free plan. +- Shared access is controlled by flock roles like `owner`, `assistant`, `caregiver`, and `viewer`. - FlockPal no longer stores local passwords. - Authentication now happens through magic links or external identity providers. @@ -96,6 +96,6 @@ Set these if you want magic links delivered by email instead of logged as a prev ## Notes for monetization and security -This starter now includes the account and workspace foundation for monetization, but it still needs production-grade session hardening, invitation verification, billing integration, audit logging, and background reminder delivery before launch. +This starter now includes the account and flock foundation for monetization, but it still needs production-grade session hardening, invitation verification, billing integration, audit logging, and background reminder delivery before launch. -For account design, `standard` vs `rescue` is best treated as a workspace type, not as a user role. If paid plans are added later, a separate `admin account mode` is usually less flexible than workspace roles such as `owner`, `manager`, `staff`, and `viewer`. That lets the same underlying account system work for both households and rescues without splitting product logic into unrelated account classes. +For account design, `standard` vs `rescue` is best treated as a flock type, not as a user role. If paid plans are added later, a separate `admin account mode` is usually less flexible than flock roles such as `owner`, `assistant`, `caregiver`, and `viewer`. That lets the same underlying account system work for both households and rescues without splitting product logic into unrelated account classes. diff --git a/backend/src/app.ts b/backend/src/app.ts index 6e24a7e..9b81c56 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -31,11 +31,13 @@ import { createVetVisitForBird, createWeightForBird, deleteBird, + deleteVetVisitForBird, getBirdById, listBirds, listVetVisitsForBird, listWeightsForBird, updateBird, + updateVetVisitForBird, } from './repositories/birdRepository.js'; import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js'; import { @@ -43,11 +45,14 @@ import { createWorkspace, deleteWorkspaceMember, ensurePersonalWorkspaceForUser, + getPlatformAdminSummary, getMembershipForUser, getNextWorkspaceId, getWorkspaceById, + listRescueWorkspacesForAdmin, listMembershipsForUser, listWorkspaceMembers, + updateRescueVerificationStatus, updateWorkspace, upsertWorkspaceMember, } from './repositories/workspaceRepository.js'; @@ -58,6 +63,7 @@ import type { BirdRow, IntegrationTokenRow, ProviderKey, + RescueVerificationStatus, UserRow, VetVisitRow, WeightRow, @@ -114,10 +120,11 @@ const switchWorkspaceSchema = z.object({ }); const workspaceTypeSchema = z.enum(['standard', 'rescue']); -const workspaceRoleSchema = z.enum(['owner', 'manager', 'staff', 'viewer']); +const workspaceRoleSchema = z.enum(['owner', 'assistant', 'caregiver', 'viewer']); const billingPlanSchema = z.enum(['household_basic', 'household_plus', 'household_macaw']); const integrationTokenScopeSchema = z.enum(['read_only', 'read_write']); const birdGenderSchema = z.enum(['unknown', 'male', 'female']); +const rescueVerificationStatusSchema = z.enum(['pending', 'approved', 'rejected']); const workspaceSchema = z.object({ name: z.string().trim().min(1).max(160), @@ -212,6 +219,12 @@ const smtpUser = process.env.SMTP_USER?.trim() ?? ''; const smtpPass = process.env.SMTP_PASS?.trim() ?? ''; const smtpFromEmail = process.env.SMTP_FROM_EMAIL?.trim() ?? ''; const smtpFromName = process.env.SMTP_FROM_NAME?.trim() || 'FlockPal'; +const adminEmails = new Set( + (process.env.ADMIN_EMAILS ?? '') + .split(',') + .map((email) => normalizeEmail(email)) + .filter(Boolean), +); const mailTransport = smtpHost && smtpFromEmail @@ -245,10 +258,25 @@ const normalizeWorkspace = (row: WorkspaceRow) => ({ workspaceType: row.workspace_type, billingEmail: row.billing_email, billingPlan: row.billing_plan, + subscriptionStatus: row.subscription_status, + rescueVerificationStatus: row.rescue_verification_status, createdAt: row.created_at, updatedAt: row.updated_at, }); +const normalizeAdminRescueWorkspace = ( + row: WorkspaceRow & { + owner_email: string | null; + bird_count: number; + member_count: number; + }, +) => ({ + workspace: normalizeWorkspace(row), + ownerEmail: row.owner_email, + birdCount: Number(row.bird_count ?? 0), + memberCount: Number(row.member_count ?? 0), +}); + const normalizeWorkspaceMember = (row: WorkspaceMemberRow) => ({ id: row.id, workspaceId: row.workspace_id, @@ -375,11 +403,23 @@ const normalizeWorkspaceMembershipList = async (userId: string) => workspace_type: row.workspace_type, billing_email: row.billing_email, billing_plan: row.billing_plan, + subscription_status: row.subscription_status, + rescue_verification_status: row.rescue_verification_status, created_at: row.workspace_created_at, updated_at: row.workspace_updated_at, }), })); +const isAdminUser = (user: UserRow) => adminEmails.has(normalizeEmail(user.email)); + +const subscriptionAllowsWrite = (workspace: WorkspaceRow) => { + if (workspace.workspace_type === 'rescue') { + return workspace.rescue_verification_status === 'approved'; + } + + return workspace.subscription_status === 'active' || workspace.subscription_status === 'trialing'; +}; + const createAuthSession = async (userId: string, activeWorkspaceId: number) => { const token = createSessionToken(); const tokenHash = hashToken(token); @@ -396,6 +436,7 @@ const buildSessionPayload = async (auth: AuthContext) => ({ activeWorkspace: normalizeWorkspace(auth.workspace), activeMembership: normalizeWorkspaceMember(auth.membership), workspaces: await normalizeWorkspaceMembershipList(auth.user.id), + isAdmin: isAdminUser(auth.user), providers: Object.values(oauthProviders).map((provider) => ({ providerKey: provider.providerKey, displayName: provider.displayName, @@ -527,6 +568,36 @@ const requireWriteAccess = (req: Request, res: Response, next: NextFunction) => return; } + if (req.auth?.authType === 'session' && isAdminUser(req.auth.user)) { + next(); + return; + } + + if (req.auth && !subscriptionAllowsWrite(req.auth.workspace)) { + res.status(402).json({ + error: + req.auth.workspace.workspace_type === 'rescue' + ? 'This rescue flock is read-only until FlockPal verifies it.' + : 'This flock is read-only until the subscription is restored.', + code: 'workspace_read_only', + }); + return; + } + + next(); +}; + +const requireAdmin = (req: Request, res: Response, next: NextFunction) => { + if (!req.auth) { + res.status(401).json({ error: 'Authentication required.' }); + return; + } + + if (!isAdminUser(req.auth.user)) { + res.status(403).json({ error: 'Admin access required.' }); + return; + } + next(); }; @@ -608,7 +679,7 @@ app.post('/api/transfers/draft', requireAuth, requireWriteAccess, async (req: Re const bird = await getBirdById(parsed.data.birdId, req.auth!.workspace.id); if (!bird) { - res.status(404).json({ error: 'That bird could not be found in this workspace.' }); + res.status(404).json({ error: 'That bird could not be found in this flock.' }); return; } @@ -703,7 +774,7 @@ app.post('/api/auth/switch-workspace', requireAuth, requireSessionAuth, async (r const parsed = switchWorkspaceSchema.safeParse(req.body); if (!parsed.success) { - res.status(400).json({ error: 'Invalid workspace selection payload', details: parsed.error.flatten() }); + res.status(400).json({ error: 'Invalid flock selection payload', details: parsed.error.flatten() }); return; } @@ -711,7 +782,7 @@ app.post('/api/auth/switch-workspace', requireAuth, requireSessionAuth, async (r const membership = await getMembershipForUser(req.auth!.user.id, parsed.data.workspaceId); if (!membership) { - res.status(403).json({ error: 'You do not have access to that workspace.' }); + res.status(403).json({ error: 'You do not have access to that flock.' }); return; } @@ -888,6 +959,59 @@ const handleOAuthCallback = async (req: Request, res: Response, next: NextFuncti app.get('/api/auth/oauth/:provider/callback', handleOAuthCallback); app.post('/api/auth/oauth/:provider/callback', handleOAuthCallback); +app.get('/api/admin/summary', requireAuth, requireSessionAuth, requireAdmin, async (_req: Request, res: Response, next: NextFunction) => { + try { + const summary = await getPlatformAdminSummary(); + + res.json({ + summary: { + totalBirds: Number(summary?.total_birds ?? 0), + totalUsers: Number(summary?.total_users ?? 0), + totalWorkspaces: Number(summary?.total_workspaces ?? 0), + rescueWorkspaces: Number(summary?.rescue_workspaces ?? 0), + pendingRescues: Number(summary?.pending_rescues ?? 0), + dailyUsers: Number(summary?.daily_users ?? 0), + }, + }); + } catch (error) { + next(error); + } +}); + +app.get('/api/admin/rescue-workspaces', requireAuth, requireSessionAuth, requireAdmin, async (_req: Request, res: Response, next: NextFunction) => { + try { + const rescueWorkspaces = await listRescueWorkspacesForAdmin(); + res.json({ rescueWorkspaces: rescueWorkspaces.map(normalizeAdminRescueWorkspace) }); + } catch (error) { + next(error); + } +}); + +app.patch('/api/admin/rescue-workspaces/:workspaceId', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => { + const parsed = z.object({ rescueVerificationStatus: rescueVerificationStatusSchema }).safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ error: 'Invalid rescue verification payload', details: parsed.error.flatten() }); + return; + } + + try { + const workspace = await updateRescueVerificationStatus( + Number(req.params.workspaceId), + parsed.data.rescueVerificationStatus as RescueVerificationStatus, + ); + + if (!workspace) { + res.status(404).json({ error: 'Rescue flock not found.' }); + return; + } + + res.json({ workspace: normalizeWorkspace(workspace) }); + } catch (error) { + next(error); + } +}); + app.get('/api/integration-tokens', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => { try { const tokens = await listIntegrationTokens(req.auth!.user.id, req.auth!.workspace.id); @@ -897,7 +1021,7 @@ app.get('/api/integration-tokens', requireAuth, requireSessionAuth, async (req: } }); -app.post('/api/integration-tokens', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => { +app.post('/api/integration-tokens', requireAuth, requireSessionAuth, requireWriteAccess, async (req: Request, res: Response, next: NextFunction) => { const parsed = integrationTokenCreateSchema.safeParse(req.body); if (!parsed.success) { @@ -929,7 +1053,7 @@ app.post('/api/integration-tokens', requireAuth, requireSessionAuth, async (req: } }); -app.delete('/api/integration-tokens/:tokenId', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => { +app.delete('/api/integration-tokens/:tokenId', requireAuth, requireSessionAuth, requireWriteAccess, async (req: Request, res: Response, next: NextFunction) => { try { const revoked = await revokeIntegrationToken(req.params.tokenId, req.auth!.user.id, req.auth!.workspace.id); @@ -958,7 +1082,7 @@ app.post('/api/workspaces', requireAuth, requireSessionAuth, async (req: Request const parsed = createWorkspaceSchema.safeParse(req.body); if (!parsed.success) { - res.status(400).json({ error: 'Invalid workspace payload', details: parsed.error.flatten() }); + res.status(400).json({ error: 'Invalid flock payload', details: parsed.error.flatten() }); return; } @@ -984,11 +1108,11 @@ app.get('/api/workspace', requireAuth, async (req: Request, res: Response) => { res.json({ workspace: normalizeWorkspace(req.auth!.workspace) }); }); -app.put('/api/workspace', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'manager']), async (req: Request, res: Response, next: NextFunction) => { +app.put('/api/workspace', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => { const parsed = workspaceSchema.safeParse(req.body); if (!parsed.success) { - res.status(400).json({ error: 'Invalid workspace payload', details: parsed.error.flatten() }); + res.status(400).json({ error: 'Invalid flock payload', details: parsed.error.flatten() }); return; } @@ -1017,11 +1141,11 @@ app.get('/api/workspace/members', requireAuth, async (req: Request, res: Respons } }); -app.post('/api/workspace/members', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'manager']), async (req: Request, res: Response, next: NextFunction) => { +app.post('/api/workspace/members', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => { const parsed = workspaceMemberSchema.safeParse(req.body); if (!parsed.success) { - res.status(400).json({ error: 'Invalid workspace member payload', details: parsed.error.flatten() }); + res.status(400).json({ error: 'Invalid flock member payload', details: parsed.error.flatten() }); return; } @@ -1042,12 +1166,12 @@ app.post('/api/workspace/members', requireAuth, requireWriteAccess, requireWorks } }); -app.delete('/api/workspace/members/:memberId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'manager']), async (req: Request, res: Response, next: NextFunction) => { +app.delete('/api/workspace/members/:memberId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => { try { const deleted = await deleteWorkspaceMember(req.params.memberId, req.auth!.workspace.id); if (!deleted) { - res.status(404).json({ error: 'Workspace member not found or cannot be removed.' }); + res.status(404).json({ error: 'Flock member not found or cannot be removed.' }); return; } @@ -1066,7 +1190,7 @@ app.get('/api/birds', requireAuth, async (req: Request, res: Response, next: Nex } }); -app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'manager', 'staff']), async (req: Request, res: Response, next: NextFunction) => { +app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => { const parsed = birdSchema.safeParse(req.body); if (!parsed.success) { @@ -1092,7 +1216,7 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o res.status(201).json({ bird: normalizeBird(bird!) }); } catch (error) { if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { - res.status(409).json({ error: 'That band/tag ID is already in use in this workspace.' }); + res.status(409).json({ error: 'That band/tag ID is already in use in this flock.' }); return; } @@ -1100,7 +1224,7 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o } }); -app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'manager', 'staff']), async (req: Request, res: Response, next: NextFunction) => { +app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => { const parsed = birdSchema.safeParse(req.body); if (!parsed.success) { @@ -1132,7 +1256,7 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR res.json({ bird: normalizeBird(bird) }); } catch (error) { if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { - res.status(409).json({ error: 'That band/tag ID is already in use in this workspace.' }); + res.status(409).json({ error: 'That band/tag ID is already in use in this flock.' }); return; } @@ -1140,7 +1264,7 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR } }); -app.delete('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'manager', 'staff']), async (req: Request, res: Response, next: NextFunction) => { +app.delete('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => { try { const deleted = await deleteBird(req.params.birdId, req.auth!.workspace.id); @@ -1165,7 +1289,7 @@ app.get('/api/birds/:birdId/weights', requireAuth, async (req: Request, res: Res } }); -app.post('/api/birds/:birdId/weights', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'manager', 'staff']), async (req: Request, res: Response, next: NextFunction) => { +app.post('/api/birds/:birdId/weights', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => { const parsed = weightSchema.safeParse(req.body); if (!parsed.success) { @@ -1202,7 +1326,7 @@ app.get('/api/birds/:birdId/vet-visits', requireAuth, async (req: Request, res: } }); -app.post('/api/birds/:birdId/vet-visits', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'manager', 'staff']), async (req: Request, res: Response, next: NextFunction) => { +app.post('/api/birds/:birdId/vet-visits', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => { const parsed = vetVisitSchema.safeParse(req.body); if (!parsed.success) { @@ -1232,6 +1356,64 @@ app.post('/api/birds/:birdId/vet-visits', requireAuth, requireWriteAccess, requi } }); +app.put('/api/birds/:birdId/vet-visits/:visitId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => { + const parsed = vetVisitSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ error: 'Invalid vet visit payload', details: parsed.error.flatten() }); + return; + } + + try { + const bird = await getBirdById(req.params.birdId, req.auth!.workspace.id); + + if (!bird) { + res.status(404).json({ error: 'Bird not found.' }); + return; + } + + const vetVisit = await updateVetVisitForBird( + req.params.visitId, + req.params.birdId, + parsed.data.visitedOn, + parsed.data.clinicName, + parsed.data.reason, + emptyToNull(parsed.data.notes), + ); + + if (!vetVisit) { + res.status(404).json({ error: 'Vet visit not found.' }); + return; + } + + res.json({ vetVisit: normalizeVetVisit(vetVisit) }); + } catch (error) { + next(error); + } +}); + +app.delete('/api/birds/:birdId/vet-visits/:visitId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => { + try { + const bird = await getBirdById(req.params.birdId, req.auth!.workspace.id); + + if (!bird) { + res.status(404).json({ error: 'Bird not found.' }); + return; + } + + const deleted = await deleteVetVisitForBird(req.params.visitId, req.params.birdId); + + if (!deleted) { + res.status(404).json({ error: 'Vet visit not found.' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } +}); + app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => { console.error(error); res.status(500).json({ error: error instanceof Error ? error.message : 'Internal server error' }); diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 7aeec1e..9baa8a7 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -18,6 +18,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => { workspace_type VARCHAR(16) NOT NULL DEFAULT 'standard', billing_email VARCHAR(255), billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic', + subscription_status VARCHAR(32) NOT NULL DEFAULT 'active', + rescue_verification_status VARCHAR(32) NOT NULL DEFAULT 'not_required', created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -27,7 +29,14 @@ export const ensureSchema = async (database: DatabaseClient = db) => { ALTER TABLE workspaces ADD COLUMN IF NOT EXISTS billing_email VARCHAR(255), - ADD COLUMN IF NOT EXISTS billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic'; + ADD COLUMN IF NOT EXISTS billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic', + ADD COLUMN IF NOT EXISTS subscription_status VARCHAR(32) NOT NULL DEFAULT 'active', + ADD COLUMN IF NOT EXISTS rescue_verification_status VARCHAR(32) NOT NULL DEFAULT 'not_required'; + + UPDATE workspaces + SET rescue_verification_status = 'pending' + WHERE workspace_type = 'rescue' + AND rescue_verification_status = 'not_required'; INSERT INTO workspaces (id, name, workspace_type, billing_plan) VALUES (1, 'My Flock', 'standard', 'household_basic') @@ -39,7 +48,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => { user_id UUID REFERENCES users(id) ON DELETE CASCADE, invite_email VARCHAR(255) NOT NULL, name VARCHAR(160) NOT NULL, - role VARCHAR(16) NOT NULL DEFAULT 'staff', + role VARCHAR(16) NOT NULL DEFAULT 'caregiver', accepted_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -50,6 +59,14 @@ export const ensureSchema = async (database: DatabaseClient = db) => { ADD COLUMN IF NOT EXISTS invite_email VARCHAR(255), ADD COLUMN IF NOT EXISTS accepted_at TIMESTAMPTZ; + UPDATE workspace_members + SET role = CASE + WHEN role = 'manager' THEN 'assistant' + WHEN role = 'staff' THEN 'caregiver' + ELSE role + END + WHERE role IN ('manager', 'staff'); + DO $$ BEGIN IF EXISTS ( diff --git a/backend/src/repositories/authRepository.ts b/backend/src/repositories/authRepository.ts index af09762..920fdd6 100644 --- a/backend/src/repositories/authRepository.ts +++ b/backend/src/repositories/authRepository.ts @@ -8,6 +8,8 @@ import type { MagicLinkTokenRow, OAuthStateRow, ProviderKey, + RescueVerificationStatus, + SubscriptionStatus, UserRow, WorkspaceMemberRow, WorkspaceRole, @@ -36,6 +38,8 @@ const mapSessionAuthRow = ( workspace_workspace_type: WorkspaceType; workspace_billing_email: string | null; workspace_billing_plan: BillingPlan; + workspace_subscription_status: SubscriptionStatus; + workspace_rescue_verification_status: RescueVerificationStatus; workspace_created_at: string; workspace_updated_at: string; membership_id_row: string; @@ -70,6 +74,8 @@ const mapSessionAuthRow = ( workspace_type: row.workspace_workspace_type, billing_email: row.workspace_billing_email, billing_plan: row.workspace_billing_plan, + subscription_status: row.workspace_subscription_status, + rescue_verification_status: row.workspace_rescue_verification_status, created_at: row.workspace_created_at, updated_at: row.workspace_updated_at, }, @@ -113,6 +119,8 @@ const mapIntegrationTokenAuthRow = ( workspace_workspace_type: WorkspaceType; workspace_billing_email: string | null; workspace_billing_plan: BillingPlan; + workspace_subscription_status: SubscriptionStatus; + workspace_rescue_verification_status: RescueVerificationStatus; workspace_created_at: string; workspace_updated_at: string; membership_id_row: string; @@ -147,6 +155,8 @@ const mapIntegrationTokenAuthRow = ( workspace_type: row.workspace_workspace_type, billing_email: row.workspace_billing_email, billing_plan: row.workspace_billing_plan, + subscription_status: row.workspace_subscription_status, + rescue_verification_status: row.workspace_rescue_verification_status, created_at: row.workspace_created_at, updated_at: row.workspace_updated_at, }, @@ -327,6 +337,8 @@ export const resolveAuth = async (tokenHash: string, token: string) => { workspace_workspace_type: WorkspaceType; workspace_billing_email: string | null; workspace_billing_plan: BillingPlan; + workspace_subscription_status: SubscriptionStatus; + workspace_rescue_verification_status: RescueVerificationStatus; workspace_created_at: string; workspace_updated_at: string; membership_id_row: string; @@ -356,6 +368,8 @@ export const resolveAuth = async (tokenHash: string, token: string) => { workspaces.workspace_type AS workspace_workspace_type, workspaces.billing_email AS workspace_billing_email, workspaces.billing_plan AS workspace_billing_plan, + workspaces.subscription_status AS workspace_subscription_status, + workspaces.rescue_verification_status AS workspace_rescue_verification_status, workspaces.created_at AS workspace_created_at, workspaces.updated_at AS workspace_updated_at, workspace_members.id AS membership_id_row, @@ -407,6 +421,8 @@ export const resolveIntegrationTokenAuth = async (tokenHash: string, token: stri workspace_workspace_type: WorkspaceType; workspace_billing_email: string | null; workspace_billing_plan: BillingPlan; + workspace_subscription_status: SubscriptionStatus; + workspace_rescue_verification_status: RescueVerificationStatus; workspace_created_at: string; workspace_updated_at: string; membership_id_row: string; @@ -441,6 +457,8 @@ export const resolveIntegrationTokenAuth = async (tokenHash: string, token: stri workspaces.workspace_type AS workspace_workspace_type, workspaces.billing_email AS workspace_billing_email, workspaces.billing_plan AS workspace_billing_plan, + workspaces.subscription_status AS workspace_subscription_status, + workspaces.rescue_verification_status AS workspace_rescue_verification_status, workspaces.created_at AS workspace_created_at, workspaces.updated_at AS workspace_updated_at, workspace_members.id AS membership_id_row, diff --git a/backend/src/repositories/birdRepository.ts b/backend/src/repositories/birdRepository.ts index ec1b938..5259bf4 100644 --- a/backend/src/repositories/birdRepository.ts +++ b/backend/src/repositories/birdRepository.ts @@ -226,3 +226,38 @@ export const createVetVisitForBird = async (birdId: string, visitedOn: string, c return result.rows[0] ?? null; }; + +export const updateVetVisitForBird = async ( + visitId: string, + birdId: string, + visitedOn: string, + clinicName: string, + reason: string, + notes: string | null, +) => { + const result = await db.query( + `UPDATE vet_visits + SET visited_on = $3, + clinic_name = $4, + reason = $5, + notes = $6 + WHERE id = $1 + AND bird_id = $2 + RETURNING id, bird_id, visited_on::text, clinic_name, reason, notes`, + [visitId, birdId, visitedOn, clinicName, reason, notes], + ); + + return result.rows[0] ?? null; +}; + +export const deleteVetVisitForBird = async (visitId: string, birdId: string) => { + const result = await db.query<{ id: string }>( + `DELETE FROM vet_visits + WHERE id = $1 + AND bird_id = $2 + RETURNING id`, + [visitId, birdId], + ); + + return (result.rowCount ?? 0) > 0; +}; diff --git a/backend/src/repositories/workspaceRepository.ts b/backend/src/repositories/workspaceRepository.ts index 3706794..af58d8e 100644 --- a/backend/src/repositories/workspaceRepository.ts +++ b/backend/src/repositories/workspaceRepository.ts @@ -1,5 +1,5 @@ import { db } from '../db/client.js'; -import type { BillingPlan, UserRow, WorkspaceMemberRow, WorkspaceRow, WorkspaceType } from '../types.js'; +import type { BillingPlan, RescueVerificationStatus, UserRow, WorkspaceMemberRow, WorkspaceRow, WorkspaceType } from '../types.js'; export const getNextWorkspaceId = async () => { const result = await db.query<{ next_id: number }>('SELECT COALESCE(MAX(id), 0) + 1 AS next_id FROM workspaces'); @@ -8,7 +8,7 @@ export const getNextWorkspaceId = async () => { export const getWorkspaceById = async (workspaceId: number) => { const result = await db.query( - `SELECT id, name, workspace_type, billing_email, billing_plan, created_at, updated_at + `SELECT id, name, workspace_type, billing_email, billing_plan, subscription_status, rescue_verification_status, created_at, updated_at FROM workspaces WHERE id = $1`, [workspaceId], @@ -36,6 +36,8 @@ export const listMembershipsForUser = async (userId: string) => { workspace_type: WorkspaceType; billing_email: string | null; billing_plan: BillingPlan; + subscription_status: WorkspaceRow['subscription_status']; + rescue_verification_status: RescueVerificationStatus; workspace_created_at: string; workspace_updated_at: string; } @@ -53,6 +55,8 @@ export const listMembershipsForUser = async (userId: string) => { workspaces.workspace_type, workspaces.billing_email, workspaces.billing_plan, + workspaces.subscription_status, + workspaces.rescue_verification_status, workspaces.created_at AS workspace_created_at, workspaces.updated_at AS workspace_updated_at FROM workspace_members @@ -95,8 +99,8 @@ export const ensurePersonalWorkspaceForUser = async (user: UserRow) => { if (!unclaimed.rowCount) { await db.query( - `INSERT INTO workspaces (id, name, workspace_type, billing_plan, billing_email) - VALUES ($1, $2, 'standard', 'household_basic', $3)`, + `INSERT INTO workspaces (id, name, workspace_type, billing_plan, billing_email, subscription_status, rescue_verification_status) + VALUES ($1, $2, 'standard', 'household_basic', $3, 'active', 'not_required')`, [workspaceId, `${user.name}'s Flock`, user.email], ); } else { @@ -106,6 +110,8 @@ export const ensurePersonalWorkspaceForUser = async (user: UserRow) => { workspace_type = 'standard', billing_plan = 'household_basic', billing_email = $3, + subscription_status = 'active', + rescue_verification_status = 'not_required', updated_at = CURRENT_TIMESTAMP WHERE id = $1`, [workspaceId, `${user.name}'s Flock`, user.email], @@ -154,9 +160,17 @@ export const createWorkspace = async ({ owner: UserRow; }) => { await db.query( - `INSERT INTO workspaces (id, name, workspace_type, billing_email, billing_plan) - VALUES ($1, $2, $3, $4, $5)`, - [id, name, workspaceType, billingEmail, billingPlan], + `INSERT INTO workspaces (id, name, workspace_type, billing_email, billing_plan, subscription_status, rescue_verification_status) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + id, + name, + workspaceType, + billingEmail, + billingPlan, + workspaceType === 'rescue' ? 'active' : 'active', + workspaceType === 'rescue' ? 'pending' : 'not_required', + ], ); await db.query( @@ -187,9 +201,14 @@ export const updateWorkspace = async ({ workspace_type = $3, billing_email = $4, billing_plan = $5, + rescue_verification_status = CASE + WHEN $3 = 'rescue' AND rescue_verification_status = 'not_required' THEN 'pending' + WHEN $3 = 'standard' THEN 'not_required' + ELSE rescue_verification_status + END, updated_at = CURRENT_TIMESTAMP WHERE id = $1 - RETURNING id, name, workspace_type, billing_email, billing_plan, created_at, updated_at`, + RETURNING id, name, workspace_type, billing_email, billing_plan, subscription_status, rescue_verification_status, created_at, updated_at`, [workspaceId, name, workspaceType, billingEmail, billingPlan], ); @@ -249,3 +268,80 @@ export const deleteWorkspaceMember = async (memberId: string, workspaceId: numbe return Boolean(result.rowCount); }; + +export const listRescueWorkspacesForAdmin = async () => { + const result = await db.query< + WorkspaceRow & { + owner_email: string | null; + bird_count: number; + member_count: number; + } + >( + `SELECT + workspaces.id, + workspaces.name, + workspaces.workspace_type, + workspaces.billing_email, + workspaces.billing_plan, + workspaces.subscription_status, + workspaces.rescue_verification_status, + workspaces.created_at, + workspaces.updated_at, + owner.invite_email AS owner_email, + COUNT(DISTINCT birds.id)::int AS bird_count, + COUNT(DISTINCT workspace_members.id)::int AS member_count + FROM workspaces + LEFT JOIN workspace_members owner + ON owner.workspace_id = workspaces.id + AND owner.role = 'owner' + LEFT JOIN birds ON birds.workspace_id = workspaces.id + LEFT JOIN workspace_members ON workspace_members.workspace_id = workspaces.id + WHERE workspaces.workspace_type = 'rescue' + GROUP BY workspaces.id, owner.invite_email + ORDER BY + CASE workspaces.rescue_verification_status + WHEN 'pending' THEN 0 + WHEN 'approved' THEN 1 + WHEN 'rejected' THEN 2 + ELSE 3 + END, + workspaces.created_at DESC`, + ); + + return result.rows; +}; + +export const updateRescueVerificationStatus = async (workspaceId: number, status: RescueVerificationStatus) => { + const result = await db.query( + `UPDATE workspaces + SET rescue_verification_status = $2, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + AND workspace_type = 'rescue' + RETURNING id, name, workspace_type, billing_email, billing_plan, subscription_status, rescue_verification_status, created_at, updated_at`, + [workspaceId, status], + ); + + return result.rows[0] ?? null; +}; + +export const getPlatformAdminSummary = async () => { + const result = await db.query<{ + total_birds: number; + total_users: number; + total_workspaces: number; + rescue_workspaces: number; + pending_rescues: number; + daily_users: number; + }>( + `SELECT + (SELECT COUNT(*)::int FROM birds) AS total_birds, + (SELECT COUNT(*)::int FROM users) AS total_users, + (SELECT COUNT(*)::int FROM workspaces) AS total_workspaces, + (SELECT COUNT(*)::int FROM workspaces WHERE workspace_type = 'rescue') AS rescue_workspaces, + (SELECT COUNT(*)::int FROM workspaces WHERE workspace_type = 'rescue' AND rescue_verification_status = 'pending') AS pending_rescues, + (SELECT COUNT(DISTINCT user_id)::int FROM auth_sessions WHERE created_at >= CURRENT_DATE) AS daily_users`, + ); + + return result.rows[0]; +}; diff --git a/backend/src/types.ts b/backend/src/types.ts index 23cca19..873fc4a 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -1,6 +1,8 @@ export type WorkspaceType = 'standard' | 'rescue'; -export type WorkspaceRole = 'owner' | 'manager' | 'staff' | 'viewer'; +export type WorkspaceRole = 'owner' | 'assistant' | 'caregiver' | 'viewer'; export type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw'; +export type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'none'; +export type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected'; export type ProviderKey = 'google' | 'microsoft' | 'apple'; export type IntegrationTokenScope = 'read_only' | 'read_write'; export type BirdGender = 'unknown' | 'male' | 'female'; @@ -19,6 +21,8 @@ export type WorkspaceRow = { workspace_type: WorkspaceType; billing_email: string | null; billing_plan: BillingPlan; + subscription_status: SubscriptionStatus; + rescue_verification_status: RescueVerificationStatus; created_at: string; updated_at: string; }; diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 5bfbce4..0c88dd5 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -30,6 +30,7 @@ services: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD for production} FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production} BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production} + ADMIN_EMAILS: ${ADMIN_EMAILS:-} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID:-} diff --git a/docker-compose.yml b/docker-compose.yml index 3258123..afda506 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,7 @@ services: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flockpal_dev_password} FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000} BACKEND_URL: ${BACKEND_URL:-http://localhost:5000} + ADMIN_EMAILS: ${ADMIN_EMAILS:-} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID:-} diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index ab7d6fb..d9db1b4 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -147,8 +147,8 @@ Integration tokens use the same bearer-token header format, but they are created Workspace roles used by protected endpoints: - `owner` -- `manager` -- `staff` +- `assistant` +- `caregiver` - `viewer` Role requirements are called out per endpoint below. If the signed-in member lacks permission, the API returns: @@ -250,7 +250,7 @@ Role requirements are called out per endpoint below. If the signed-in member lac - Dates use `YYYY-MM-DD` - `workspaceType` is `standard` or `rescue` -- member `role` is `owner`, `manager`, `staff`, or `viewer` +- member `role` is `owner`, `assistant`, `caregiver`, or `viewer` - bird `gender` is `unknown`, `male`, or `female` - bird `chartColor` must be a `#RRGGBB` hex color - `photoDataUrl` must be a base64 `data:image/...` URL @@ -613,7 +613,7 @@ Response `200`: #### `PUT /api/workspace` -Requires auth with write access and role `owner` or `manager`. Updates the active workspace. +Requires auth with write access and role `owner` or `assistant`. Updates the active workspace. Request body: @@ -648,7 +648,7 @@ Response `200`: #### `POST /api/workspace/members` -Requires auth with write access and role `owner` or `manager`. Invites or upserts a workspace member. +Requires auth with write access and role `owner` or `assistant`. Invites or upserts a workspace member. Request body: @@ -670,7 +670,7 @@ Response `201`: #### `DELETE /api/workspace/members/:memberId` -Requires auth with write access and role `owner` or `manager`. Removes a non-owner member. +Requires auth with write access and role `owner` or `assistant`. Removes a non-owner member. Response `204` with no body. @@ -694,7 +694,7 @@ Response `200`: #### `POST /api/birds` -Requires auth with write access and role `owner`, `manager`, or `staff`. Creates a bird. +Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Creates a bird. Request body: @@ -732,7 +732,7 @@ Possible errors: #### `PUT /api/birds/:birdId` -Requires auth with write access and role `owner`, `manager`, or `staff`. Updates a bird. +Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Updates a bird. Request body matches `POST /api/birds`. @@ -751,7 +751,7 @@ Possible errors: #### `DELETE /api/birds/:birdId` -Requires auth with write access and role `owner`, `manager`, or `staff`. Deletes a bird. +Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Deletes a bird. Response `204` with no body. @@ -779,7 +779,7 @@ Response `200`: #### `POST /api/birds/:birdId/weights` -Requires auth with write access and role `owner`, `manager`, or `staff`. Creates a weight entry. +Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Creates a weight entry. Request body: @@ -820,7 +820,7 @@ Response `200`: #### `POST /api/birds/:birdId/vet-visits` -Requires auth with write access and role `owner`, `manager`, or `staff`. Creates a vet visit. +Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Creates a vet visit. Request body: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2ae6be3..1dbaf15 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,7 +5,9 @@ import { findParrotWeightReference, parrotSpeciesOptions, type ParrotWeightRefer type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw'; type HouseholdBillingPlan = Exclude; type WorkspaceType = 'standard' | 'rescue'; -type WorkspaceRole = 'owner' | 'manager' | 'staff' | 'viewer'; +type WorkspaceRole = 'owner' | 'assistant' | 'caregiver' | 'viewer'; +type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'none'; +type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected'; type IntegrationTokenScope = 'read_only' | 'read_write'; type BirdGender = 'unknown' | 'male' | 'female'; @@ -50,6 +52,8 @@ type Workspace = { workspaceType: WorkspaceType; billingEmail: string | null; billingPlan: BillingPlan; + subscriptionStatus: SubscriptionStatus; + rescueVerificationStatus: RescueVerificationStatus; createdAt: string; updatedAt: string; }; @@ -89,9 +93,26 @@ type AuthSessionPayload = { activeWorkspace: Workspace; activeMembership: WorkspaceMember; workspaces: WorkspaceSummary[]; + isAdmin: boolean; providers: AuthProvider[]; }; +type AdminSummary = { + totalBirds: number; + totalUsers: number; + totalWorkspaces: number; + rescueWorkspaces: number; + pendingRescues: number; + dailyUsers: number; +}; + +type AdminRescueWorkspace = { + workspace: Workspace; + ownerEmail: string | null; + birdCount: number; + memberCount: number; +}; + type IntegrationTokenSummary = { id: string; userId: string; @@ -201,7 +222,7 @@ type PhotoDragState = { startOffsetY: number; }; -type AppPage = 'overview' | 'flock' | 'settings'; +type AppPage = 'overview' | 'flock' | 'settings' | 'admin'; type SettingsSection = 'collaborators' | 'integration-tokens' | 'new-workspace' | 'flock-member' | 'transfer'; const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api'; @@ -229,7 +250,7 @@ const emptyWorkspaceForm: WorkspaceFormState = { const emptyWorkspaceMemberForm: WorkspaceMemberFormState = { name: '', email: '', - role: 'staff', + role: 'caregiver', }; const emptyWorkspaceCreateForm: WorkspaceCreateFormState = { @@ -476,18 +497,18 @@ const formatBillingPlanName = (billingPlan: BillingPlan) => { const formatBillingPlanCapacity = (billingPlan: BillingPlan) => { if (billingPlan === 'rescue_free') { - return 'No billing is applied to rescue workspaces.'; + return 'No billing is applied to rescue flocks.'; } if (billingPlan === 'household_basic') { - return 'Permits up to 4 birds in the workspace.'; + return 'Permits up to 4 birds in the flock.'; } if (billingPlan === 'household_plus') { - return 'Permits 5 to 10 birds in the workspace.'; + return 'Permits 5 to 10 birds in the flock.'; } - return 'Permits 11 or more birds in the workspace.'; + return 'Permits 11 or more birds in the flock.'; }; const formatBillingPlanBirdLimit = (billingPlan: BillingPlan) => { @@ -506,6 +527,51 @@ const formatBillingPlanBirdLimit = (billingPlan: BillingPlan) => { return null; }; +const formatSubscriptionStatus = (status: SubscriptionStatus) => { + if (status === 'trialing') { + return 'Trialing'; + } + if (status === 'past_due') { + return 'Past due'; + } + if (status === 'canceled') { + return 'Canceled'; + } + if (status === 'unpaid') { + return 'Unpaid'; + } + if (status === 'none') { + return 'No subscription'; + } + return 'Active'; +}; + +const formatRescueVerificationStatus = (status: RescueVerificationStatus) => { + if (status === 'approved') { + return 'Approved'; + } + if (status === 'rejected') { + return 'Rejected'; + } + if (status === 'not_required') { + return 'Not required'; + } + return 'Pending verification'; +}; + +const formatWorkspaceRole = (role: WorkspaceRole) => { + if (role === 'owner') { + return 'Owner'; + } + if (role === 'assistant') { + return 'Assistant'; + } + if (role === 'caregiver') { + return 'Caregiver'; + } + return 'Viewer'; +}; + const readFileAsDataUrl = async (file: File) => new Promise((resolve, reject) => { const reader = new FileReader(); @@ -747,6 +813,8 @@ function App() { const [activeMembership, setActiveMembership] = useState(null); const [workspaceMembers, setWorkspaceMembers] = useState([]); const [integrationTokens, setIntegrationTokens] = useState([]); + const [adminSummary, setAdminSummary] = useState(null); + const [adminRescueWorkspaces, setAdminRescueWorkspaces] = useState([]); const [birds, setBirds] = useState([]); const [selectedBirdId, setSelectedBirdId] = useState(''); const [editingBirdId, setEditingBirdId] = useState(''); @@ -771,6 +839,7 @@ function App() { const [creatingIntegrationToken, setCreatingIntegrationToken] = useState(false); const [revokingIntegrationTokenId, setRevokingIntegrationTokenId] = useState(''); const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState(''); + const [updatingRescueWorkspaceId, setUpdatingRescueWorkspaceId] = useState(null); const [switchingWorkspaceId, setSwitchingWorkspaceId] = useState(null); const [showWeightAlertModal, setShowWeightAlertModal] = useState(false); const [speciesPickerOpen, setSpeciesPickerOpen] = useState(false); @@ -795,6 +864,8 @@ function App() { notes: '', }); const [deletingBird, setDeletingBird] = useState(false); + const [editingVetVisitId, setEditingVetVisitId] = useState(''); + const [deletingVetVisitId, setDeletingVetVisitId] = useState(''); const [removingWorkspaceMemberId, setRemovingWorkspaceMemberId] = useState(''); const [expandedSettingsSection, setExpandedSettingsSection] = useState(null); @@ -1056,6 +1127,8 @@ function App() { setActiveMembership(null); setWorkspaceMembers([]); setIntegrationTokens([]); + setAdminSummary(null); + setAdminRescueWorkspaces([]); setBirds([]); setWeights([]); setVetVisits([]); @@ -1192,6 +1265,35 @@ function App() { void loadWorkspaceData(); }, [authToken, workspace?.id]); + useEffect(() => { + if (!authToken || !authSession?.isAdmin || activePage !== 'admin') { + return; + } + + const loadAdminDashboard = async () => { + try { + const [summaryResponse, rescuesResponse] = await Promise.all([ + apiFetch('/admin/summary', authToken), + apiFetch('/admin/rescue-workspaces', authToken), + ]); + + if (!summaryResponse.ok || !rescuesResponse.ok) { + throw new Error('Unable to load admin dashboard.'); + } + + const summaryData = (await readJsonSafely<{ summary?: AdminSummary }>(summaryResponse)) ?? {}; + const rescuesData = (await readJsonSafely<{ rescueWorkspaces?: AdminRescueWorkspace[] }>(rescuesResponse)) ?? {}; + + setAdminSummary(summaryData.summary ?? null); + setAdminRescueWorkspaces(rescuesData.rescueWorkspaces ?? []); + } catch (adminError) { + setError(adminError instanceof Error ? adminError.message : 'Unable to load admin dashboard.'); + } + }; + + void loadAdminDashboard(); + }, [activePage, authSession?.isAdmin, authToken]); + useEffect(() => { if (!selectedBird?.id) { setWeights([]); @@ -1215,6 +1317,8 @@ function App() { setWeights(weightsData.weights ?? []); setVetVisits(visitsData.vetVisits ?? []); + setEditingVetVisitId(''); + setDeletingVetVisitId(''); } catch (loadError) { setError(loadError instanceof Error ? loadError.message : 'Unable to load flock member details.'); } @@ -1355,13 +1459,13 @@ function App() { }); if (!response.ok) { - throw new Error(await readErrorMessage(response, 'Unable to switch workspaces.')); + throw new Error(await readErrorMessage(response, 'Unable to switch flocks.')); } const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(response)) ?? {}; if (!data.session) { - throw new Error('Unable to switch workspaces.'); + throw new Error('Unable to switch flocks.'); } const nextToken = data.token || authToken; @@ -1373,7 +1477,7 @@ function App() { setVetVisits([]); setActivePage('overview'); } catch (switchError) { - setError(switchError instanceof Error ? switchError.message : 'Unable to switch workspaces.'); + setError(switchError instanceof Error ? switchError.message : 'Unable to switch flocks.'); } finally { setSwitchingWorkspaceId(null); } @@ -1447,6 +1551,53 @@ function App() { } }; + const handleRescueVerificationStatusChange = async (workspaceId: number, rescueVerificationStatus: RescueVerificationStatus) => { + if (!authToken) { + return; + } + + setError(''); + setUpdatingRescueWorkspaceId(workspaceId); + + try { + const response = await apiFetch(`/admin/rescue-workspaces/${workspaceId}`, authToken, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ rescueVerificationStatus }), + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to update rescue verification status.')); + } + + const data = (await readJsonSafely<{ workspace?: Workspace }>(response)) ?? {}; + + if (!data.workspace) { + throw new Error('Unable to update rescue verification status.'); + } + + setAdminRescueWorkspaces((current) => + current.map((entry) => (entry.workspace.id === workspaceId ? { ...entry, workspace: data.workspace! } : entry)), + ); + setAdminSummary((current) => + current + ? { + ...current, + pendingRescues: adminRescueWorkspaces.filter((entry) => + entry.workspace.id === workspaceId + ? rescueVerificationStatus === 'pending' + : entry.workspace.rescueVerificationStatus === 'pending', + ).length, + } + : current, + ); + } catch (adminError) { + setError(adminError instanceof Error ? adminError.message : 'Unable to update rescue verification status.'); + } finally { + setUpdatingRescueWorkspaceId(null); + } + }; + const handleCreateWorkspace = async (event: React.FormEvent) => { event.preventDefault(); @@ -1470,17 +1621,17 @@ function App() { }); if (!response.ok) { - throw new Error(await readErrorMessage(response, 'Unable to create workspace.')); + throw new Error(await readErrorMessage(response, 'Unable to create flock.')); } const workspaceResponse = await apiFetch('/auth/session', authToken); if (!workspaceResponse.ok) { - throw new Error(await readErrorMessage(workspaceResponse, 'Workspace was created but the session could not be refreshed.')); + throw new Error(await readErrorMessage(workspaceResponse, 'Flock was created but the session could not be refreshed.')); } const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(workspaceResponse)) ?? {}; if (!data.session) { - throw new Error('Unable to refresh your workspace list.'); + throw new Error('Unable to refresh your flock list.'); } const nextToken = data.token || authToken; @@ -1491,7 +1642,7 @@ function App() { billingEmail: data.session.user.email, }); } catch (workspaceError) { - setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to create workspace.'); + setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to create flock.'); } finally { setCreatingWorkspace(false); } @@ -1832,22 +1983,29 @@ function App() { setError(''); try { - const response = await apiFetch(`/birds/${selectedBird.id}/vet-visits`, authToken, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(vetVisitForm), - }); + const isEditingVetVisit = Boolean(editingVetVisitId); + const response = await apiFetch( + isEditingVetVisit ? `/birds/${selectedBird.id}/vet-visits/${editingVetVisitId}` : `/birds/${selectedBird.id}/vet-visits`, + authToken, + { + method: isEditingVetVisit ? 'PUT' : 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(vetVisitForm), + }, + ); if (!response.ok) { - throw new Error(await readErrorMessage(response, 'Unable to save vet visit.')); + throw new Error(await readErrorMessage(response, `Unable to ${isEditingVetVisit ? 'update' : 'save'} vet visit.`)); } const data = await readJsonSafely<{ vetVisit: VetVisit }>(response); if (!data?.vetVisit) { - throw new Error('Unable to save vet visit.'); + throw new Error(`Unable to ${isEditingVetVisit ? 'update' : 'save'} vet visit.`); } setVetVisits((current) => - [data.vetVisit, ...current].sort((left, right) => right.visitedOn.localeCompare(left.visitedOn)), + (isEditingVetVisit ? current.map((visit) => (visit.id === data.vetVisit.id ? data.vetVisit : visit)) : [data.vetVisit, ...current]).sort( + (left, right) => right.visitedOn.localeCompare(left.visitedOn), + ), ); setVetVisitForm({ visitedOn: new Date().toISOString().slice(0, 10), @@ -1855,11 +2013,61 @@ function App() { reason: '', notes: '', }); + setEditingVetVisitId(''); } catch (submitError) { setError(submitError instanceof Error ? submitError.message : 'Unable to save vet visit.'); } }; + const handleEditVetVisit = (visit: VetVisit) => { + setEditingVetVisitId(visit.id); + setVetVisitForm({ + visitedOn: visit.visitedOn, + clinicName: visit.clinicName, + reason: visit.reason, + notes: visit.notes ?? '', + }); + setError(''); + }; + + const handleCancelVetVisitEdit = () => { + setEditingVetVisitId(''); + setVetVisitForm({ + visitedOn: new Date().toISOString().slice(0, 10), + clinicName: '', + reason: '', + notes: '', + }); + }; + + const handleDeleteVetVisit = async (visitId: string) => { + if (!selectedBird || deletingVetVisitId) { + return; + } + + setDeletingVetVisitId(visitId); + setError(''); + + try { + const response = await apiFetch(`/birds/${selectedBird.id}/vet-visits/${visitId}`, authToken, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to remove vet visit.')); + } + + setVetVisits((current) => current.filter((visit) => visit.id !== visitId)); + if (editingVetVisitId === visitId) { + handleCancelVetVisitEdit(); + } + } catch (removeError) { + setError(removeError instanceof Error ? removeError.message : 'Unable to remove vet visit.'); + } finally { + setDeletingVetVisitId(''); + } + }; + const handleRemoveBird = async () => { if (!selectedBird || deletingBird) { return; @@ -1895,6 +2103,8 @@ function App() { setSelectedBirdId(''); setWeights([]); setVetVisits([]); + setEditingVetVisitId(''); + setDeletingVetVisitId(''); if (editingBirdId === selectedBird.id) { setEditingBirdId(''); @@ -1967,13 +2177,13 @@ function App() { }); if (!response.ok) { - throw new Error(await readErrorMessage(response, 'Unable to save workspace settings.')); + throw new Error(await readErrorMessage(response, 'Unable to save flock settings.')); } const data = (await readJsonSafely<{ workspace?: Workspace }>(response)) ?? {}; if (!data.workspace) { - throw new Error('Unable to save workspace settings.'); + throw new Error('Unable to save flock settings.'); } const savedWorkspace = data.workspace; @@ -1997,7 +2207,7 @@ function App() { billingPlan: isHouseholdPlan(savedWorkspace.billingPlan) ? savedWorkspace.billingPlan : 'household_basic', }); } catch (workspaceError) { - setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to save workspace settings.'); + setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to save flock settings.'); } finally { setSavingWorkspace(false); } @@ -2074,7 +2284,7 @@ function App() {

FlockPal

Loading your flock spaces...

-

Checking your sign-in and workspace access.

+

Checking your sign-in and flock access.

@@ -2204,6 +2414,11 @@ function App() { + {authSession.isAdmin ? ( + + ) : null} {showWorkspaceSwitcher ? ( @@ -2219,7 +2434,7 @@ function App() { > {entry.workspace.name} - {formatBillingPlanName(entry.workspace.billingPlan)} • {entry.membership.role} + {formatBillingPlanName(entry.workspace.billingPlan)} • {formatWorkspaceRole(entry.membership.role)} ))} @@ -2365,6 +2580,102 @@ function App() { ) : null} + {activePage === 'admin' && authSession.isAdmin ? ( +
+
+
+
+

Admin

+

Platform pulse

+
+

Operational counts for the full FlockPal platform.

+
+
+
+ Total birds + {adminSummary?.totalBirds ?? '-'} +
+
+ Daily users + {adminSummary?.dailyUsers ?? '-'} +
+
+ Total users + {adminSummary?.totalUsers ?? '-'} +
+
+ Flocks + {adminSummary?.totalWorkspaces ?? '-'} +
+
+ Rescue flocks + {adminSummary?.rescueWorkspaces ?? '-'} +
+
+ Pending rescues + {adminSummary?.pendingRescues ?? '-'} +
+
+
+ +
+
+
+

Verification

+

Rescue flocks

+
+

Pending rescues are read-only until approved.

+
+
+ {adminRescueWorkspaces.length ? ( + adminRescueWorkspaces.map((entry) => ( +
+ {entry.workspace.name} + + {formatRescueVerificationStatus(entry.workspace.rescueVerificationStatus)} • {entry.birdCount} birds • {entry.memberCount} members + + + Owner {entry.ownerEmail ?? 'unknown'} • Billing {entry.workspace.billingEmail ?? 'not set'} + +
+ + + +
+
+ )) + ) : ( +
+ No rescue flocks yet + New rescue flocks will appear here for verification review. +
+ )} +
+
+
+ ) : null} + {activePage === 'flock' ? (