Added admin mode, read only status for inactive accounts, and resuce verification

This commit is contained in:
Corey Blais
2026-04-15 16:33:07 -04:00
parent 43c32a5efc
commit 784a911dc2
12 changed files with 816 additions and 109 deletions
+1
View File
@@ -5,3 +5,4 @@ FRONTEND_URL=http://localhost:3000
BACKEND_URL=http://localhost:5000 BACKEND_URL=http://localhost:5000
VITE_API_BASE_URL=http://localhost:5000/api VITE_API_BASE_URL=http://localhost:5000/api
NODE_ENV=development NODE_ENV=development
ADMIN_EMAILS=corey@blaishome.online
+13 -13
View File
@@ -7,9 +7,9 @@ FlockPal is a Dockerized TypeScript app for tracking flock health with a clean,
- Passwordless authentication only - Passwordless authentication only
- Magic-link email sign-in - Magic-link email sign-in
- OAuth-ready login flow for Google, Microsoft, and Apple - OAuth-ready login flow for Google, Microsoft, and Apple
- Multi-workspace model with `standard` household and `rescue` modes - Multi-flock model with `standard` household and `rescue` modes
- Shared workspace member management for both households and rescues - Shared flock member management for both households and rescues
- Separate per-workspace billing plan foundation with `rescue_free`, `household_basic`, `household_plus`, and `household_macaw` - 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 profiles with name, tag ID, and species
- Bird DOB and gotcha day fields - Bird DOB and gotcha day fields
- Daily weight recordings - Daily weight recordings
@@ -22,10 +22,10 @@ FlockPal is a Dockerized TypeScript app for tracking flock health with a clean,
## Planned next steps ## Planned next steps
- Medication and care reminders - 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 - Stripe or equivalent billing integration for paid household tiers
- Scheduled reminder delivery for birthdays, gotcha days, and care events - 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 ## 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`. 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`. 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. - One user can belong to multiple flocks.
- A rescue member can also keep their own household flock in a separate workspace. - A rescue member can also keep their own household flock separate from the rescue flock.
- Billing should attach to the household workspace, not the user account. - Billing should attach to the household flock, not the user account.
- Rescue workspaces stay on the free plan. - Rescue flocks stay on the free plan.
- Shared access is controlled by workspace roles like `owner`, `manager`, `staff`, and `viewer`. - Shared access is controlled by flock roles like `owner`, `assistant`, `caregiver`, and `viewer`.
- FlockPal no longer stores local passwords. - FlockPal no longer stores local passwords.
- Authentication now happens through magic links or external identity providers. - 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 ## 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.
+202 -20
View File
@@ -31,11 +31,13 @@ import {
createVetVisitForBird, createVetVisitForBird,
createWeightForBird, createWeightForBird,
deleteBird, deleteBird,
deleteVetVisitForBird,
getBirdById, getBirdById,
listBirds, listBirds,
listVetVisitsForBird, listVetVisitsForBird,
listWeightsForBird, listWeightsForBird,
updateBird, updateBird,
updateVetVisitForBird,
} from './repositories/birdRepository.js'; } from './repositories/birdRepository.js';
import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js'; import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js';
import { import {
@@ -43,11 +45,14 @@ import {
createWorkspace, createWorkspace,
deleteWorkspaceMember, deleteWorkspaceMember,
ensurePersonalWorkspaceForUser, ensurePersonalWorkspaceForUser,
getPlatformAdminSummary,
getMembershipForUser, getMembershipForUser,
getNextWorkspaceId, getNextWorkspaceId,
getWorkspaceById, getWorkspaceById,
listRescueWorkspacesForAdmin,
listMembershipsForUser, listMembershipsForUser,
listWorkspaceMembers, listWorkspaceMembers,
updateRescueVerificationStatus,
updateWorkspace, updateWorkspace,
upsertWorkspaceMember, upsertWorkspaceMember,
} from './repositories/workspaceRepository.js'; } from './repositories/workspaceRepository.js';
@@ -58,6 +63,7 @@ import type {
BirdRow, BirdRow,
IntegrationTokenRow, IntegrationTokenRow,
ProviderKey, ProviderKey,
RescueVerificationStatus,
UserRow, UserRow,
VetVisitRow, VetVisitRow,
WeightRow, WeightRow,
@@ -114,10 +120,11 @@ const switchWorkspaceSchema = z.object({
}); });
const workspaceTypeSchema = z.enum(['standard', 'rescue']); 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 billingPlanSchema = z.enum(['household_basic', 'household_plus', 'household_macaw']);
const integrationTokenScopeSchema = z.enum(['read_only', 'read_write']); const integrationTokenScopeSchema = z.enum(['read_only', 'read_write']);
const birdGenderSchema = z.enum(['unknown', 'male', 'female']); const birdGenderSchema = z.enum(['unknown', 'male', 'female']);
const rescueVerificationStatusSchema = z.enum(['pending', 'approved', 'rejected']);
const workspaceSchema = z.object({ const workspaceSchema = z.object({
name: z.string().trim().min(1).max(160), 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 smtpPass = process.env.SMTP_PASS?.trim() ?? '';
const smtpFromEmail = process.env.SMTP_FROM_EMAIL?.trim() ?? ''; const smtpFromEmail = process.env.SMTP_FROM_EMAIL?.trim() ?? '';
const smtpFromName = process.env.SMTP_FROM_NAME?.trim() || 'FlockPal'; 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 = const mailTransport =
smtpHost && smtpFromEmail smtpHost && smtpFromEmail
@@ -245,10 +258,25 @@ const normalizeWorkspace = (row: WorkspaceRow) => ({
workspaceType: row.workspace_type, workspaceType: row.workspace_type,
billingEmail: row.billing_email, billingEmail: row.billing_email,
billingPlan: row.billing_plan, billingPlan: row.billing_plan,
subscriptionStatus: row.subscription_status,
rescueVerificationStatus: row.rescue_verification_status,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_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) => ({ const normalizeWorkspaceMember = (row: WorkspaceMemberRow) => ({
id: row.id, id: row.id,
workspaceId: row.workspace_id, workspaceId: row.workspace_id,
@@ -375,11 +403,23 @@ const normalizeWorkspaceMembershipList = async (userId: string) =>
workspace_type: row.workspace_type, workspace_type: row.workspace_type,
billing_email: row.billing_email, billing_email: row.billing_email,
billing_plan: row.billing_plan, billing_plan: row.billing_plan,
subscription_status: row.subscription_status,
rescue_verification_status: row.rescue_verification_status,
created_at: row.workspace_created_at, created_at: row.workspace_created_at,
updated_at: row.workspace_updated_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 createAuthSession = async (userId: string, activeWorkspaceId: number) => {
const token = createSessionToken(); const token = createSessionToken();
const tokenHash = hashToken(token); const tokenHash = hashToken(token);
@@ -396,6 +436,7 @@ const buildSessionPayload = async (auth: AuthContext) => ({
activeWorkspace: normalizeWorkspace(auth.workspace), activeWorkspace: normalizeWorkspace(auth.workspace),
activeMembership: normalizeWorkspaceMember(auth.membership), activeMembership: normalizeWorkspaceMember(auth.membership),
workspaces: await normalizeWorkspaceMembershipList(auth.user.id), workspaces: await normalizeWorkspaceMembershipList(auth.user.id),
isAdmin: isAdminUser(auth.user),
providers: Object.values(oauthProviders).map((provider) => ({ providers: Object.values(oauthProviders).map((provider) => ({
providerKey: provider.providerKey, providerKey: provider.providerKey,
displayName: provider.displayName, displayName: provider.displayName,
@@ -527,6 +568,36 @@ const requireWriteAccess = (req: Request, res: Response, next: NextFunction) =>
return; 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(); 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); const bird = await getBirdById(parsed.data.birdId, req.auth!.workspace.id);
if (!bird) { 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; return;
} }
@@ -703,7 +774,7 @@ app.post('/api/auth/switch-workspace', requireAuth, requireSessionAuth, async (r
const parsed = switchWorkspaceSchema.safeParse(req.body); const parsed = switchWorkspaceSchema.safeParse(req.body);
if (!parsed.success) { 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; 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); const membership = await getMembershipForUser(req.auth!.user.id, parsed.data.workspaceId);
if (!membership) { 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; return;
} }
@@ -888,6 +959,59 @@ const handleOAuthCallback = async (req: Request, res: Response, next: NextFuncti
app.get('/api/auth/oauth/:provider/callback', handleOAuthCallback); app.get('/api/auth/oauth/:provider/callback', handleOAuthCallback);
app.post('/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) => { app.get('/api/integration-tokens', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => {
try { try {
const tokens = await listIntegrationTokens(req.auth!.user.id, req.auth!.workspace.id); 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); const parsed = integrationTokenCreateSchema.safeParse(req.body);
if (!parsed.success) { 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 { try {
const revoked = await revokeIntegrationToken(req.params.tokenId, req.auth!.user.id, req.auth!.workspace.id); 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); const parsed = createWorkspaceSchema.safeParse(req.body);
if (!parsed.success) { 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; return;
} }
@@ -984,11 +1108,11 @@ app.get('/api/workspace', requireAuth, async (req: Request, res: Response) => {
res.json({ workspace: normalizeWorkspace(req.auth!.workspace) }); 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); const parsed = workspaceSchema.safeParse(req.body);
if (!parsed.success) { 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; 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); const parsed = workspaceMemberSchema.safeParse(req.body);
if (!parsed.success) { 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; 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 { try {
const deleted = await deleteWorkspaceMember(req.params.memberId, req.auth!.workspace.id); const deleted = await deleteWorkspaceMember(req.params.memberId, req.auth!.workspace.id);
if (!deleted) { 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; 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); const parsed = birdSchema.safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
@@ -1092,7 +1216,7 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
res.status(201).json({ bird: normalizeBird(bird!) }); res.status(201).json({ bird: normalizeBird(bird!) });
} catch (error) { } catch (error) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { 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; 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); const parsed = birdSchema.safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
@@ -1132,7 +1256,7 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
res.json({ bird: normalizeBird(bird) }); res.json({ bird: normalizeBird(bird) });
} catch (error) { } catch (error) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { 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; 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 { try {
const deleted = await deleteBird(req.params.birdId, req.auth!.workspace.id); 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); const parsed = weightSchema.safeParse(req.body);
if (!parsed.success) { 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); const parsed = vetVisitSchema.safeParse(req.body);
if (!parsed.success) { 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) => { app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
console.error(error); console.error(error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Internal server error' }); res.status(500).json({ error: error instanceof Error ? error.message : 'Internal server error' });
+19 -2
View File
@@ -18,6 +18,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
workspace_type VARCHAR(16) NOT NULL DEFAULT 'standard', workspace_type VARCHAR(16) NOT NULL DEFAULT 'standard',
billing_email VARCHAR(255), billing_email VARCHAR(255),
billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic', 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, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_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 ALTER TABLE workspaces
ADD COLUMN IF NOT EXISTS billing_email VARCHAR(255), 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) INSERT INTO workspaces (id, name, workspace_type, billing_plan)
VALUES (1, 'My Flock', 'standard', 'household_basic') 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, user_id UUID REFERENCES users(id) ON DELETE CASCADE,
invite_email VARCHAR(255) NOT NULL, invite_email VARCHAR(255) NOT NULL,
name VARCHAR(160) NOT NULL, name VARCHAR(160) NOT NULL,
role VARCHAR(16) NOT NULL DEFAULT 'staff', role VARCHAR(16) NOT NULL DEFAULT 'caregiver',
accepted_at TIMESTAMPTZ, accepted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP 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 invite_email VARCHAR(255),
ADD COLUMN IF NOT EXISTS accepted_at TIMESTAMPTZ; 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 $$ DO $$
BEGIN BEGIN
IF EXISTS ( IF EXISTS (
@@ -8,6 +8,8 @@ import type {
MagicLinkTokenRow, MagicLinkTokenRow,
OAuthStateRow, OAuthStateRow,
ProviderKey, ProviderKey,
RescueVerificationStatus,
SubscriptionStatus,
UserRow, UserRow,
WorkspaceMemberRow, WorkspaceMemberRow,
WorkspaceRole, WorkspaceRole,
@@ -36,6 +38,8 @@ const mapSessionAuthRow = (
workspace_workspace_type: WorkspaceType; workspace_workspace_type: WorkspaceType;
workspace_billing_email: string | null; workspace_billing_email: string | null;
workspace_billing_plan: BillingPlan; workspace_billing_plan: BillingPlan;
workspace_subscription_status: SubscriptionStatus;
workspace_rescue_verification_status: RescueVerificationStatus;
workspace_created_at: string; workspace_created_at: string;
workspace_updated_at: string; workspace_updated_at: string;
membership_id_row: string; membership_id_row: string;
@@ -70,6 +74,8 @@ const mapSessionAuthRow = (
workspace_type: row.workspace_workspace_type, workspace_type: row.workspace_workspace_type,
billing_email: row.workspace_billing_email, billing_email: row.workspace_billing_email,
billing_plan: row.workspace_billing_plan, 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, created_at: row.workspace_created_at,
updated_at: row.workspace_updated_at, updated_at: row.workspace_updated_at,
}, },
@@ -113,6 +119,8 @@ const mapIntegrationTokenAuthRow = (
workspace_workspace_type: WorkspaceType; workspace_workspace_type: WorkspaceType;
workspace_billing_email: string | null; workspace_billing_email: string | null;
workspace_billing_plan: BillingPlan; workspace_billing_plan: BillingPlan;
workspace_subscription_status: SubscriptionStatus;
workspace_rescue_verification_status: RescueVerificationStatus;
workspace_created_at: string; workspace_created_at: string;
workspace_updated_at: string; workspace_updated_at: string;
membership_id_row: string; membership_id_row: string;
@@ -147,6 +155,8 @@ const mapIntegrationTokenAuthRow = (
workspace_type: row.workspace_workspace_type, workspace_type: row.workspace_workspace_type,
billing_email: row.workspace_billing_email, billing_email: row.workspace_billing_email,
billing_plan: row.workspace_billing_plan, 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, created_at: row.workspace_created_at,
updated_at: row.workspace_updated_at, updated_at: row.workspace_updated_at,
}, },
@@ -327,6 +337,8 @@ export const resolveAuth = async (tokenHash: string, token: string) => {
workspace_workspace_type: WorkspaceType; workspace_workspace_type: WorkspaceType;
workspace_billing_email: string | null; workspace_billing_email: string | null;
workspace_billing_plan: BillingPlan; workspace_billing_plan: BillingPlan;
workspace_subscription_status: SubscriptionStatus;
workspace_rescue_verification_status: RescueVerificationStatus;
workspace_created_at: string; workspace_created_at: string;
workspace_updated_at: string; workspace_updated_at: string;
membership_id_row: 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.workspace_type AS workspace_workspace_type,
workspaces.billing_email AS workspace_billing_email, workspaces.billing_email AS workspace_billing_email,
workspaces.billing_plan AS workspace_billing_plan, 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.created_at AS workspace_created_at,
workspaces.updated_at AS workspace_updated_at, workspaces.updated_at AS workspace_updated_at,
workspace_members.id AS membership_id_row, workspace_members.id AS membership_id_row,
@@ -407,6 +421,8 @@ export const resolveIntegrationTokenAuth = async (tokenHash: string, token: stri
workspace_workspace_type: WorkspaceType; workspace_workspace_type: WorkspaceType;
workspace_billing_email: string | null; workspace_billing_email: string | null;
workspace_billing_plan: BillingPlan; workspace_billing_plan: BillingPlan;
workspace_subscription_status: SubscriptionStatus;
workspace_rescue_verification_status: RescueVerificationStatus;
workspace_created_at: string; workspace_created_at: string;
workspace_updated_at: string; workspace_updated_at: string;
membership_id_row: 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.workspace_type AS workspace_workspace_type,
workspaces.billing_email AS workspace_billing_email, workspaces.billing_email AS workspace_billing_email,
workspaces.billing_plan AS workspace_billing_plan, 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.created_at AS workspace_created_at,
workspaces.updated_at AS workspace_updated_at, workspaces.updated_at AS workspace_updated_at,
workspace_members.id AS membership_id_row, workspace_members.id AS membership_id_row,
@@ -226,3 +226,38 @@ export const createVetVisitForBird = async (birdId: string, visitedOn: string, c
return result.rows[0] ?? null; 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<VetVisitRow>(
`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;
};
+104 -8
View File
@@ -1,5 +1,5 @@
import { db } from '../db/client.js'; 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 () => { export const getNextWorkspaceId = async () => {
const result = await db.query<{ next_id: number }>('SELECT COALESCE(MAX(id), 0) + 1 AS next_id FROM workspaces'); 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) => { export const getWorkspaceById = async (workspaceId: number) => {
const result = await db.query<WorkspaceRow>( const result = await db.query<WorkspaceRow>(
`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 FROM workspaces
WHERE id = $1`, WHERE id = $1`,
[workspaceId], [workspaceId],
@@ -36,6 +36,8 @@ export const listMembershipsForUser = async (userId: string) => {
workspace_type: WorkspaceType; workspace_type: WorkspaceType;
billing_email: string | null; billing_email: string | null;
billing_plan: BillingPlan; billing_plan: BillingPlan;
subscription_status: WorkspaceRow['subscription_status'];
rescue_verification_status: RescueVerificationStatus;
workspace_created_at: string; workspace_created_at: string;
workspace_updated_at: string; workspace_updated_at: string;
} }
@@ -53,6 +55,8 @@ export const listMembershipsForUser = async (userId: string) => {
workspaces.workspace_type, workspaces.workspace_type,
workspaces.billing_email, workspaces.billing_email,
workspaces.billing_plan, workspaces.billing_plan,
workspaces.subscription_status,
workspaces.rescue_verification_status,
workspaces.created_at AS workspace_created_at, workspaces.created_at AS workspace_created_at,
workspaces.updated_at AS workspace_updated_at workspaces.updated_at AS workspace_updated_at
FROM workspace_members FROM workspace_members
@@ -95,8 +99,8 @@ export const ensurePersonalWorkspaceForUser = async (user: UserRow) => {
if (!unclaimed.rowCount) { if (!unclaimed.rowCount) {
await db.query( await db.query(
`INSERT INTO workspaces (id, name, workspace_type, billing_plan, billing_email) `INSERT INTO workspaces (id, name, workspace_type, billing_plan, billing_email, subscription_status, rescue_verification_status)
VALUES ($1, $2, 'standard', 'household_basic', $3)`, VALUES ($1, $2, 'standard', 'household_basic', $3, 'active', 'not_required')`,
[workspaceId, `${user.name}'s Flock`, user.email], [workspaceId, `${user.name}'s Flock`, user.email],
); );
} else { } else {
@@ -106,6 +110,8 @@ export const ensurePersonalWorkspaceForUser = async (user: UserRow) => {
workspace_type = 'standard', workspace_type = 'standard',
billing_plan = 'household_basic', billing_plan = 'household_basic',
billing_email = $3, billing_email = $3,
subscription_status = 'active',
rescue_verification_status = 'not_required',
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = $1`, WHERE id = $1`,
[workspaceId, `${user.name}'s Flock`, user.email], [workspaceId, `${user.name}'s Flock`, user.email],
@@ -154,9 +160,17 @@ export const createWorkspace = async ({
owner: UserRow; owner: UserRow;
}) => { }) => {
await db.query( await db.query(
`INSERT INTO workspaces (id, name, workspace_type, billing_email, billing_plan) `INSERT INTO workspaces (id, name, workspace_type, billing_email, billing_plan, subscription_status, rescue_verification_status)
VALUES ($1, $2, $3, $4, $5)`, VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[id, name, workspaceType, billingEmail, billingPlan], [
id,
name,
workspaceType,
billingEmail,
billingPlan,
workspaceType === 'rescue' ? 'active' : 'active',
workspaceType === 'rescue' ? 'pending' : 'not_required',
],
); );
await db.query( await db.query(
@@ -187,9 +201,14 @@ export const updateWorkspace = async ({
workspace_type = $3, workspace_type = $3,
billing_email = $4, billing_email = $4,
billing_plan = $5, 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 updated_at = CURRENT_TIMESTAMP
WHERE id = $1 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], [workspaceId, name, workspaceType, billingEmail, billingPlan],
); );
@@ -249,3 +268,80 @@ export const deleteWorkspaceMember = async (memberId: string, workspaceId: numbe
return Boolean(result.rowCount); 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<WorkspaceRow>(
`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];
};
+5 -1
View File
@@ -1,6 +1,8 @@
export type WorkspaceType = 'standard' | 'rescue'; 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 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 ProviderKey = 'google' | 'microsoft' | 'apple';
export type IntegrationTokenScope = 'read_only' | 'read_write'; export type IntegrationTokenScope = 'read_only' | 'read_write';
export type BirdGender = 'unknown' | 'male' | 'female'; export type BirdGender = 'unknown' | 'male' | 'female';
@@ -19,6 +21,8 @@ export type WorkspaceRow = {
workspace_type: WorkspaceType; workspace_type: WorkspaceType;
billing_email: string | null; billing_email: string | null;
billing_plan: BillingPlan; billing_plan: BillingPlan;
subscription_status: SubscriptionStatus;
rescue_verification_status: RescueVerificationStatus;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}; };
+1
View File
@@ -30,6 +30,7 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD for production} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD for production}
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production} FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production} BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID:-} MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID:-}
+1
View File
@@ -29,6 +29,7 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flockpal_dev_password} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flockpal_dev_password}
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000} FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000} BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID:-} MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID:-}
+11 -11
View File
@@ -147,8 +147,8 @@ Integration tokens use the same bearer-token header format, but they are created
Workspace roles used by protected endpoints: Workspace roles used by protected endpoints:
- `owner` - `owner`
- `manager` - `assistant`
- `staff` - `caregiver`
- `viewer` - `viewer`
Role requirements are called out per endpoint below. If the signed-in member lacks permission, the API returns: 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` - Dates use `YYYY-MM-DD`
- `workspaceType` is `standard` or `rescue` - `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 `gender` is `unknown`, `male`, or `female`
- bird `chartColor` must be a `#RRGGBB` hex color - bird `chartColor` must be a `#RRGGBB` hex color
- `photoDataUrl` must be a base64 `data:image/...` URL - `photoDataUrl` must be a base64 `data:image/...` URL
@@ -613,7 +613,7 @@ Response `200`:
#### `PUT /api/workspace` #### `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: Request body:
@@ -648,7 +648,7 @@ Response `200`:
#### `POST /api/workspace/members` #### `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: Request body:
@@ -670,7 +670,7 @@ Response `201`:
#### `DELETE /api/workspace/members/:memberId` #### `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. Response `204` with no body.
@@ -694,7 +694,7 @@ Response `200`:
#### `POST /api/birds` #### `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: Request body:
@@ -732,7 +732,7 @@ Possible errors:
#### `PUT /api/birds/:birdId` #### `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`. Request body matches `POST /api/birds`.
@@ -751,7 +751,7 @@ Possible errors:
#### `DELETE /api/birds/:birdId` #### `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. Response `204` with no body.
@@ -779,7 +779,7 @@ Response `200`:
#### `POST /api/birds/:birdId/weights` #### `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: Request body:
@@ -820,7 +820,7 @@ Response `200`:
#### `POST /api/birds/:birdId/vet-visits` #### `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: Request body:
+406 -54
View File
@@ -5,7 +5,9 @@ import { findParrotWeightReference, parrotSpeciesOptions, type ParrotWeightRefer
type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw'; type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw';
type HouseholdBillingPlan = Exclude<BillingPlan, 'rescue_free'>; type HouseholdBillingPlan = Exclude<BillingPlan, 'rescue_free'>;
type WorkspaceType = 'standard' | 'rescue'; 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 IntegrationTokenScope = 'read_only' | 'read_write';
type BirdGender = 'unknown' | 'male' | 'female'; type BirdGender = 'unknown' | 'male' | 'female';
@@ -50,6 +52,8 @@ type Workspace = {
workspaceType: WorkspaceType; workspaceType: WorkspaceType;
billingEmail: string | null; billingEmail: string | null;
billingPlan: BillingPlan; billingPlan: BillingPlan;
subscriptionStatus: SubscriptionStatus;
rescueVerificationStatus: RescueVerificationStatus;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
}; };
@@ -89,9 +93,26 @@ type AuthSessionPayload = {
activeWorkspace: Workspace; activeWorkspace: Workspace;
activeMembership: WorkspaceMember; activeMembership: WorkspaceMember;
workspaces: WorkspaceSummary[]; workspaces: WorkspaceSummary[];
isAdmin: boolean;
providers: AuthProvider[]; 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 = { type IntegrationTokenSummary = {
id: string; id: string;
userId: string; userId: string;
@@ -201,7 +222,7 @@ type PhotoDragState = {
startOffsetY: number; startOffsetY: number;
}; };
type AppPage = 'overview' | 'flock' | 'settings'; type AppPage = 'overview' | 'flock' | 'settings' | 'admin';
type SettingsSection = 'collaborators' | 'integration-tokens' | 'new-workspace' | 'flock-member' | 'transfer'; type SettingsSection = 'collaborators' | 'integration-tokens' | 'new-workspace' | 'flock-member' | 'transfer';
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api'; const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api';
@@ -229,7 +250,7 @@ const emptyWorkspaceForm: WorkspaceFormState = {
const emptyWorkspaceMemberForm: WorkspaceMemberFormState = { const emptyWorkspaceMemberForm: WorkspaceMemberFormState = {
name: '', name: '',
email: '', email: '',
role: 'staff', role: 'caregiver',
}; };
const emptyWorkspaceCreateForm: WorkspaceCreateFormState = { const emptyWorkspaceCreateForm: WorkspaceCreateFormState = {
@@ -476,18 +497,18 @@ const formatBillingPlanName = (billingPlan: BillingPlan) => {
const formatBillingPlanCapacity = (billingPlan: BillingPlan) => { const formatBillingPlanCapacity = (billingPlan: BillingPlan) => {
if (billingPlan === 'rescue_free') { if (billingPlan === 'rescue_free') {
return 'No billing is applied to rescue workspaces.'; return 'No billing is applied to rescue flocks.';
} }
if (billingPlan === 'household_basic') { 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') { 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) => { const formatBillingPlanBirdLimit = (billingPlan: BillingPlan) => {
@@ -506,6 +527,51 @@ const formatBillingPlanBirdLimit = (billingPlan: BillingPlan) => {
return null; 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) => const readFileAsDataUrl = async (file: File) =>
new Promise<string>((resolve, reject) => { new Promise<string>((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
@@ -747,6 +813,8 @@ function App() {
const [activeMembership, setActiveMembership] = useState<WorkspaceMember | null>(null); const [activeMembership, setActiveMembership] = useState<WorkspaceMember | null>(null);
const [workspaceMembers, setWorkspaceMembers] = useState<WorkspaceMember[]>([]); const [workspaceMembers, setWorkspaceMembers] = useState<WorkspaceMember[]>([]);
const [integrationTokens, setIntegrationTokens] = useState<IntegrationTokenSummary[]>([]); const [integrationTokens, setIntegrationTokens] = useState<IntegrationTokenSummary[]>([]);
const [adminSummary, setAdminSummary] = useState<AdminSummary | null>(null);
const [adminRescueWorkspaces, setAdminRescueWorkspaces] = useState<AdminRescueWorkspace[]>([]);
const [birds, setBirds] = useState<Bird[]>([]); const [birds, setBirds] = useState<Bird[]>([]);
const [selectedBirdId, setSelectedBirdId] = useState<string>(''); const [selectedBirdId, setSelectedBirdId] = useState<string>('');
const [editingBirdId, setEditingBirdId] = useState<string>(''); const [editingBirdId, setEditingBirdId] = useState<string>('');
@@ -771,6 +839,7 @@ function App() {
const [creatingIntegrationToken, setCreatingIntegrationToken] = useState(false); const [creatingIntegrationToken, setCreatingIntegrationToken] = useState(false);
const [revokingIntegrationTokenId, setRevokingIntegrationTokenId] = useState(''); const [revokingIntegrationTokenId, setRevokingIntegrationTokenId] = useState('');
const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState(''); const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState('');
const [updatingRescueWorkspaceId, setUpdatingRescueWorkspaceId] = useState<number | null>(null);
const [switchingWorkspaceId, setSwitchingWorkspaceId] = useState<number | null>(null); const [switchingWorkspaceId, setSwitchingWorkspaceId] = useState<number | null>(null);
const [showWeightAlertModal, setShowWeightAlertModal] = useState(false); const [showWeightAlertModal, setShowWeightAlertModal] = useState(false);
const [speciesPickerOpen, setSpeciesPickerOpen] = useState(false); const [speciesPickerOpen, setSpeciesPickerOpen] = useState(false);
@@ -795,6 +864,8 @@ function App() {
notes: '', notes: '',
}); });
const [deletingBird, setDeletingBird] = useState(false); const [deletingBird, setDeletingBird] = useState(false);
const [editingVetVisitId, setEditingVetVisitId] = useState('');
const [deletingVetVisitId, setDeletingVetVisitId] = useState('');
const [removingWorkspaceMemberId, setRemovingWorkspaceMemberId] = useState(''); const [removingWorkspaceMemberId, setRemovingWorkspaceMemberId] = useState('');
const [expandedSettingsSection, setExpandedSettingsSection] = useState<SettingsSection | null>(null); const [expandedSettingsSection, setExpandedSettingsSection] = useState<SettingsSection | null>(null);
@@ -1056,6 +1127,8 @@ function App() {
setActiveMembership(null); setActiveMembership(null);
setWorkspaceMembers([]); setWorkspaceMembers([]);
setIntegrationTokens([]); setIntegrationTokens([]);
setAdminSummary(null);
setAdminRescueWorkspaces([]);
setBirds([]); setBirds([]);
setWeights([]); setWeights([]);
setVetVisits([]); setVetVisits([]);
@@ -1192,6 +1265,35 @@ function App() {
void loadWorkspaceData(); void loadWorkspaceData();
}, [authToken, workspace?.id]); }, [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(() => { useEffect(() => {
if (!selectedBird?.id) { if (!selectedBird?.id) {
setWeights([]); setWeights([]);
@@ -1215,6 +1317,8 @@ function App() {
setWeights(weightsData.weights ?? []); setWeights(weightsData.weights ?? []);
setVetVisits(visitsData.vetVisits ?? []); setVetVisits(visitsData.vetVisits ?? []);
setEditingVetVisitId('');
setDeletingVetVisitId('');
} catch (loadError) { } catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Unable to load flock member details.'); setError(loadError instanceof Error ? loadError.message : 'Unable to load flock member details.');
} }
@@ -1355,13 +1459,13 @@ function App() {
}); });
if (!response.ok) { 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)) ?? {}; const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(response)) ?? {};
if (!data.session) { if (!data.session) {
throw new Error('Unable to switch workspaces.'); throw new Error('Unable to switch flocks.');
} }
const nextToken = data.token || authToken; const nextToken = data.token || authToken;
@@ -1373,7 +1477,7 @@ function App() {
setVetVisits([]); setVetVisits([]);
setActivePage('overview'); setActivePage('overview');
} catch (switchError) { } catch (switchError) {
setError(switchError instanceof Error ? switchError.message : 'Unable to switch workspaces.'); setError(switchError instanceof Error ? switchError.message : 'Unable to switch flocks.');
} finally { } finally {
setSwitchingWorkspaceId(null); 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<HTMLFormElement>) => { const handleCreateWorkspace = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
@@ -1470,17 +1621,17 @@ function App() {
}); });
if (!response.ok) { 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); const workspaceResponse = await apiFetch('/auth/session', authToken);
if (!workspaceResponse.ok) { 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)) ?? {}; const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(workspaceResponse)) ?? {};
if (!data.session) { 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; const nextToken = data.token || authToken;
@@ -1491,7 +1642,7 @@ function App() {
billingEmail: data.session.user.email, billingEmail: data.session.user.email,
}); });
} catch (workspaceError) { } catch (workspaceError) {
setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to create workspace.'); setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to create flock.');
} finally { } finally {
setCreatingWorkspace(false); setCreatingWorkspace(false);
} }
@@ -1832,22 +1983,29 @@ function App() {
setError(''); setError('');
try { try {
const response = await apiFetch(`/birds/${selectedBird.id}/vet-visits`, authToken, { const isEditingVetVisit = Boolean(editingVetVisitId);
method: 'POST', const response = await apiFetch(
headers: { 'Content-Type': 'application/json' }, isEditingVetVisit ? `/birds/${selectedBird.id}/vet-visits/${editingVetVisitId}` : `/birds/${selectedBird.id}/vet-visits`,
body: JSON.stringify(vetVisitForm), authToken,
}); {
method: isEditingVetVisit ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(vetVisitForm),
},
);
if (!response.ok) { 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); const data = await readJsonSafely<{ vetVisit: VetVisit }>(response);
if (!data?.vetVisit) { if (!data?.vetVisit) {
throw new Error('Unable to save vet visit.'); throw new Error(`Unable to ${isEditingVetVisit ? 'update' : 'save'} vet visit.`);
} }
setVetVisits((current) => 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({ setVetVisitForm({
visitedOn: new Date().toISOString().slice(0, 10), visitedOn: new Date().toISOString().slice(0, 10),
@@ -1855,11 +2013,61 @@ function App() {
reason: '', reason: '',
notes: '', notes: '',
}); });
setEditingVetVisitId('');
} catch (submitError) { } catch (submitError) {
setError(submitError instanceof Error ? submitError.message : 'Unable to save vet visit.'); 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 () => { const handleRemoveBird = async () => {
if (!selectedBird || deletingBird) { if (!selectedBird || deletingBird) {
return; return;
@@ -1895,6 +2103,8 @@ function App() {
setSelectedBirdId(''); setSelectedBirdId('');
setWeights([]); setWeights([]);
setVetVisits([]); setVetVisits([]);
setEditingVetVisitId('');
setDeletingVetVisitId('');
if (editingBirdId === selectedBird.id) { if (editingBirdId === selectedBird.id) {
setEditingBirdId(''); setEditingBirdId('');
@@ -1967,13 +2177,13 @@ function App() {
}); });
if (!response.ok) { 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)) ?? {}; const data = (await readJsonSafely<{ workspace?: Workspace }>(response)) ?? {};
if (!data.workspace) { if (!data.workspace) {
throw new Error('Unable to save workspace settings.'); throw new Error('Unable to save flock settings.');
} }
const savedWorkspace = data.workspace; const savedWorkspace = data.workspace;
@@ -1997,7 +2207,7 @@ function App() {
billingPlan: isHouseholdPlan(savedWorkspace.billingPlan) ? savedWorkspace.billingPlan : 'household_basic', billingPlan: isHouseholdPlan(savedWorkspace.billingPlan) ? savedWorkspace.billingPlan : 'household_basic',
}); });
} catch (workspaceError) { } 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 { } finally {
setSavingWorkspace(false); setSavingWorkspace(false);
} }
@@ -2074,7 +2284,7 @@ function App() {
<div> <div>
<p className="eyebrow">FlockPal</p> <p className="eyebrow">FlockPal</p>
<h1>Loading your flock spaces...</h1> <h1>Loading your flock spaces...</h1>
<p className="muted">Checking your sign-in and workspace access.</p> <p className="muted">Checking your sign-in and flock access.</p>
</div> </div>
</section> </section>
</main> </main>
@@ -2204,6 +2414,11 @@ function App() {
<button className={`page-tab ${activePage === 'settings' ? 'active' : ''}`} onClick={() => setActivePage('settings')} type="button"> <button className={`page-tab ${activePage === 'settings' ? 'active' : ''}`} onClick={() => setActivePage('settings')} type="button">
Settings Settings
</button> </button>
{authSession.isAdmin ? (
<button className={`page-tab ${activePage === 'admin' ? 'active' : ''}`} onClick={() => setActivePage('admin')} type="button">
Admin
</button>
) : null}
</div> </div>
{showWorkspaceSwitcher ? ( {showWorkspaceSwitcher ? (
@@ -2219,7 +2434,7 @@ function App() {
> >
<span>{entry.workspace.name}</span> <span>{entry.workspace.name}</span>
<small> <small>
{formatBillingPlanName(entry.workspace.billingPlan)} {entry.membership.role} {formatBillingPlanName(entry.workspace.billingPlan)} {formatWorkspaceRole(entry.membership.role)}
</small> </small>
</button> </button>
))} ))}
@@ -2365,6 +2580,102 @@ function App() {
</section> </section>
) : null} ) : null}
{activePage === 'admin' && authSession.isAdmin ? (
<section className="stack-grid">
<article className="panel">
<div className="panel-header">
<div>
<p className="eyebrow">Admin</p>
<h2>Platform pulse</h2>
</div>
<p className="muted">Operational counts for the full FlockPal platform.</p>
</div>
<div className="summary-grid">
<article className="summary-card">
<span>Total birds</span>
<strong>{adminSummary?.totalBirds ?? '-'}</strong>
</article>
<article className="summary-card">
<span>Daily users</span>
<strong>{adminSummary?.dailyUsers ?? '-'}</strong>
</article>
<article className="summary-card">
<span>Total users</span>
<strong>{adminSummary?.totalUsers ?? '-'}</strong>
</article>
<article className="summary-card">
<span>Flocks</span>
<strong>{adminSummary?.totalWorkspaces ?? '-'}</strong>
</article>
<article className="summary-card">
<span>Rescue flocks</span>
<strong>{adminSummary?.rescueWorkspaces ?? '-'}</strong>
</article>
<article className="summary-card">
<span>Pending rescues</span>
<strong>{adminSummary?.pendingRescues ?? '-'}</strong>
</article>
</div>
</article>
<article className="panel">
<div className="panel-header">
<div>
<p className="eyebrow">Verification</p>
<h2>Rescue flocks</h2>
</div>
<p className="muted">Pending rescues are read-only until approved.</p>
</div>
<div className="recent-list">
{adminRescueWorkspaces.length ? (
adminRescueWorkspaces.map((entry) => (
<article key={entry.workspace.id} className="vet-visit-card">
<strong>{entry.workspace.name}</strong>
<span>
{formatRescueVerificationStatus(entry.workspace.rescueVerificationStatus)} {entry.birdCount} birds {entry.memberCount} members
</span>
<small>
Owner {entry.ownerEmail ?? 'unknown'} Billing {entry.workspace.billingEmail ?? 'not set'}
</small>
<div className="button-row">
<button
className="secondary-button"
onClick={() => handleRescueVerificationStatusChange(entry.workspace.id, 'approved')}
type="button"
disabled={updatingRescueWorkspaceId === entry.workspace.id || entry.workspace.rescueVerificationStatus === 'approved'}
>
Approve
</button>
<button
className="secondary-button"
onClick={() => handleRescueVerificationStatusChange(entry.workspace.id, 'pending')}
type="button"
disabled={updatingRescueWorkspaceId === entry.workspace.id || entry.workspace.rescueVerificationStatus === 'pending'}
>
Mark pending
</button>
<button
className="secondary-button"
onClick={() => handleRescueVerificationStatusChange(entry.workspace.id, 'rejected')}
type="button"
disabled={updatingRescueWorkspaceId === entry.workspace.id || entry.workspace.rescueVerificationStatus === 'rejected'}
>
Reject
</button>
</div>
</article>
))
) : (
<article className="vet-visit-card empty-card">
<strong>No rescue flocks yet</strong>
<small>New rescue flocks will appear here for verification review.</small>
</article>
)}
</div>
</article>
</section>
) : null}
{activePage === 'flock' ? ( {activePage === 'flock' ? (
<section className={showFlockDetailColumn ? 'dashboard-grid' : 'stack-grid'}> <section className={showFlockDetailColumn ? 'dashboard-grid' : 'stack-grid'}>
<aside className="panel bird-list-panel"> <aside className="panel bird-list-panel">
@@ -2723,9 +3034,16 @@ function App() {
placeholder="Exam notes, medications, follow-ups, or restrictions" placeholder="Exam notes, medications, follow-ups, or restrictions"
/> />
</label> </label>
<button className="primary-button" type="submit"> <div className="button-row wide-field">
Save vet visit <button className="primary-button" type="submit">
</button> {editingVetVisitId ? 'Save vet visit changes' : 'Save vet visit'}
</button>
{editingVetVisitId ? (
<button className="secondary-button" onClick={handleCancelVetVisitEdit} type="button">
Cancel edit
</button>
) : null}
</div>
</form> </form>
<div className="recent-list"> <div className="recent-list">
@@ -2737,6 +3055,21 @@ function App() {
{formatDate(visit.visitedOn)} {visit.clinicName} {formatDate(visit.visitedOn)} {visit.clinicName}
</span> </span>
<small>{visit.notes || 'No notes recorded.'}</small> <small>{visit.notes || 'No notes recorded.'}</small>
<div className="button-row">
<button className="secondary-button" onClick={() => handleEditVetVisit(visit)} type="button">
Edit
</button>
{editingVetVisitId === visit.id ? (
<button
className="secondary-button"
onClick={() => handleDeleteVetVisit(visit.id)}
type="button"
disabled={deletingVetVisitId === visit.id}
>
{deletingVetVisitId === visit.id ? 'Deleting...' : 'Delete'}
</button>
) : null}
</div>
</article> </article>
)) ))
) : ( ) : (
@@ -2761,21 +3094,21 @@ function App() {
<article className="panel form-panel"> <article className="panel form-panel">
<div className="panel-header"> <div className="panel-header">
<div> <div>
<p className="eyebrow">Workspace</p> <p className="eyebrow">Flock</p>
<h2>Workspace profile and billing</h2> <h2>Flock profile and billing</h2>
</div> </div>
</div> </div>
<p className="muted"> <p className="muted">
Each workspace carries its own billing and collaboration rules. That lets one person keep a personal household flock while also Each flock carries its own billing and collaboration rules. That lets one person keep a personal household flock while also
participating in a rescue workspace without mixing billing or bird ownership. participating in a rescue flock without mixing billing or bird ownership.
</p> </p>
<form className="form-panel" onSubmit={handleWorkspaceSubmit}> <form className="form-panel" onSubmit={handleWorkspaceSubmit}>
<label> <label>
Workspace name Flock name
<input value={workspaceForm.name} onChange={(event) => setWorkspaceForm({ ...workspaceForm, name: event.target.value })} required /> <input value={workspaceForm.name} onChange={(event) => setWorkspaceForm({ ...workspaceForm, name: event.target.value })} required />
</label> </label>
<label> <label>
Workspace type Flock type
<select <select
value={workspaceForm.workspaceType} value={workspaceForm.workspaceType}
onChange={(event) => onChange={(event) =>
@@ -2789,6 +3122,15 @@ function App() {
<option value="rescue">Rescue</option> <option value="rescue">Rescue</option>
</select> </select>
</label> </label>
{workspace?.workspaceType === 'standard' && workspaceForm.workspaceType === 'rescue' ? (
<article className="summary-card summary-alert-card">
<strong>Approval required before edits continue</strong>
<span>
Changing this household flock to a rescue flock will make it read-only until FlockPal approves the rescue verification.
Monitor the email address used to sign up for any follow-up details needed to approve rescue status.
</span>
</article>
) : null}
{workspaceForm.workspaceType === 'standard' ? ( {workspaceForm.workspaceType === 'standard' ? (
<> <>
<label> <label>
@@ -2815,7 +3157,7 @@ function App() {
) : ( ) : (
<article className="summary-card"> <article className="summary-card">
<strong>{formatBillingPlanName('rescue_free')}</strong> <strong>{formatBillingPlanName('rescue_free')}</strong>
<span>Rescue workspaces stay free while still supporting shared team access.</span> <span>Rescue flocks stay free while still supporting shared team access.</span>
</article> </article>
)} )}
<label> <label>
@@ -2828,7 +3170,7 @@ function App() {
/> />
</label> </label>
<button className="primary-button" type="submit" disabled={savingWorkspace}> <button className="primary-button" type="submit" disabled={savingWorkspace}>
{savingWorkspace ? 'Saving workspace...' : 'Save workspace settings'} {savingWorkspace ? 'Saving flock...' : 'Save flock settings'}
</button> </button>
</form> </form>
</article> </article>
@@ -2843,8 +3185,18 @@ function App() {
<div className="summary-grid"> <div className="summary-grid">
<article className="summary-card"> <article className="summary-card">
<strong>{workspace ? formatBillingPlanName(workspace.billingPlan) : 'No plan yet'}</strong> <strong>{workspace ? formatBillingPlanName(workspace.billingPlan) : 'No plan yet'}</strong>
<span>{workspace ? formatBillingPlanCapacity(workspace.billingPlan) : 'Pick a workspace plan to see bird capacity.'}</span> <span>{workspace ? formatBillingPlanCapacity(workspace.billingPlan) : 'Pick a flock plan to see bird capacity.'}</span>
</article> </article>
<article className="summary-card">
<strong>{workspace ? formatSubscriptionStatus(workspace.subscriptionStatus) : 'Unknown'}</strong>
<span>Flock write access will follow subscription health once billing is connected.</span>
</article>
{workspace?.workspaceType === 'rescue' ? (
<article className="summary-card">
<strong>{formatRescueVerificationStatus(workspace.rescueVerificationStatus)}</strong>
<span>Rescue flocks are read-only until an admin approves their verification.</span>
</article>
) : null}
<article className="summary-card"> <article className="summary-card">
<strong>{workspace?.billingEmail || authSession.user.email}</strong> <strong>{workspace?.billingEmail || authSession.user.email}</strong>
<span>Billing contact for invoices, receipts, and account notices.</span> <span>Billing contact for invoices, receipts, and account notices.</span>
@@ -2858,7 +3210,7 @@ function App() {
<span> <span>
{workspace && formatBillingPlanBirdLimit(workspace.billingPlan) {workspace && formatBillingPlanBirdLimit(workspace.billingPlan)
? 'Current bird count against your paid plan allowance.' ? 'Current bird count against your paid plan allowance.'
: 'Current flock count in this workspace.'} : 'Current bird count in this flock.'}
</span> </span>
</article> </article>
<article className="summary-card"> <article className="summary-card">
@@ -2872,7 +3224,7 @@ function App() {
<div className="panel-header"> <div className="panel-header">
<div> <div>
<p className="eyebrow">Collaborators</p> <p className="eyebrow">Collaborators</p>
<h2>Shared workspace access</h2> <h2>Shared flock access</h2>
</div> </div>
<button <button
className="secondary-button" className="secondary-button"
@@ -2888,7 +3240,7 @@ function App() {
{expandedSettingsSection === 'collaborators' ? ( {expandedSettingsSection === 'collaborators' ? (
<> <>
<p className="muted"> <p className="muted">
Invite other people to help manage this flock. Rescue workspaces support teams, and household workspaces can also support Invite other people to help manage this flock. Rescue flocks support teams, and household flocks can also support
co-caregivers without changing who owns the billing. co-caregivers without changing who owns the billing.
</p> </p>
<form className="form-panel" onSubmit={handleWorkspaceMemberSubmit}> <form className="form-panel" onSubmit={handleWorkspaceMemberSubmit}>
@@ -2921,8 +3273,8 @@ function App() {
} }
> >
<option value="owner">Owner</option> <option value="owner">Owner</option>
<option value="manager">Manager</option> <option value="assistant">Assistant</option>
<option value="staff">Staff</option> <option value="caregiver">Caregiver</option>
<option value="viewer">Viewer</option> <option value="viewer">Viewer</option>
</select> </select>
</label> </label>
@@ -2937,7 +3289,7 @@ function App() {
<article key={member.id} className="vet-visit-card"> <article key={member.id} className="vet-visit-card">
<strong>{member.name}</strong> <strong>{member.name}</strong>
<span> <span>
{member.role} {member.email || member.inviteEmail} {formatWorkspaceRole(member.role)} {member.email || member.inviteEmail}
</span> </span>
<small>{member.acceptedAt ? 'Active access' : 'Invitation pending'}</small> <small>{member.acceptedAt ? 'Active access' : 'Invitation pending'}</small>
<button <button
@@ -2953,7 +3305,7 @@ function App() {
) : ( ) : (
<article className="vet-visit-card empty-card"> <article className="vet-visit-card empty-card">
<strong>No collaborators yet</strong> <strong>No collaborators yet</strong>
<small>Add the people who should be able to help care for birds in this workspace.</small> <small>Add the people who should be able to help care for birds in this flock.</small>
</article> </article>
)} )}
</div> </div>
@@ -2981,7 +3333,7 @@ function App() {
{expandedSettingsSection === 'integration-tokens' ? ( {expandedSettingsSection === 'integration-tokens' ? (
<> <>
<p className="muted"> <p className="muted">
Create a workspace-scoped token for automations like n8n. The secret is shown only once, so store it in your automation tool when it appears. Create a flock-scoped token for automations like n8n. The secret is shown only once, so store it in your automation tool when it appears.
</p> </p>
<form className="form-panel" onSubmit={handleCreateIntegrationToken}> <form className="form-panel" onSubmit={handleCreateIntegrationToken}>
<label> <label>
@@ -3056,7 +3408,7 @@ function App() {
) : ( ) : (
<article className="vet-visit-card empty-card"> <article className="vet-visit-card empty-card">
<strong>No integration tokens yet</strong> <strong>No integration tokens yet</strong>
<small>Create one for n8n, scripts, or other personal automations tied to this workspace.</small> <small>Create one for n8n, scripts, or other personal automations tied to this flock.</small>
</article> </article>
)} )}
</div> </div>
@@ -3067,7 +3419,7 @@ function App() {
<article className="panel form-panel"> <article className="panel form-panel">
<div className="panel-header"> <div className="panel-header">
<div> <div>
<p className="eyebrow">New workspace</p> <p className="eyebrow">New flock</p>
<h2>Add another flock space</h2> <h2>Add another flock space</h2>
</div> </div>
<button <button
@@ -3084,12 +3436,12 @@ function App() {
{expandedSettingsSection === 'new-workspace' ? ( {expandedSettingsSection === 'new-workspace' ? (
<> <>
<p className="muted"> <p className="muted">
This is the key piece for someone who helps with a rescue but also keeps their own birds at home. Each workspace stays separate This is the key piece for someone who helps with a rescue but also keeps their own birds at home. Each flock stays separate
for access and billing. for access and billing.
</p> </p>
<form className="form-panel" onSubmit={handleCreateWorkspace}> <form className="form-panel" onSubmit={handleCreateWorkspace}>
<label> <label>
Workspace name Flock name
<input <input
value={workspaceCreateForm.name} value={workspaceCreateForm.name}
onChange={(event) => setWorkspaceCreateForm({ ...workspaceCreateForm, name: event.target.value })} onChange={(event) => setWorkspaceCreateForm({ ...workspaceCreateForm, name: event.target.value })}
@@ -3097,7 +3449,7 @@ function App() {
/> />
</label> </label>
<label> <label>
Workspace type Flock type
<select <select
value={workspaceCreateForm.workspaceType} value={workspaceCreateForm.workspaceType}
onChange={(event) => onChange={(event) =>
@@ -3137,7 +3489,7 @@ function App() {
) : ( ) : (
<article className="summary-card"> <article className="summary-card">
<strong>{formatBillingPlanName('rescue_free')}</strong> <strong>{formatBillingPlanName('rescue_free')}</strong>
<span>No billing is applied to rescue workspaces.</span> <span>No billing is applied to rescue flocks.</span>
</article> </article>
)} )}
<label> <label>
@@ -3150,7 +3502,7 @@ function App() {
/> />
</label> </label>
<button className="primary-button" type="submit" disabled={creatingWorkspace}> <button className="primary-button" type="submit" disabled={creatingWorkspace}>
{creatingWorkspace ? 'Creating workspace...' : 'Create workspace'} {creatingWorkspace ? 'Creating flock...' : 'Create flock'}
</button> </button>
</form> </form>
</> </>