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
VITE_API_BASE_URL=http://localhost:5000/api
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
- 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.
+202 -20
View File
@@ -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' });
+19 -2
View File
@@ -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 (
@@ -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,
@@ -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<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 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<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
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<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 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;
};
+1
View File
@@ -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:-}
+1
View File
@@ -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:-}
+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:
- `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:
+406 -54
View File
@@ -5,7 +5,9 @@ import { findParrotWeightReference, parrotSpeciesOptions, type ParrotWeightRefer
type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw';
type HouseholdBillingPlan = Exclude<BillingPlan, 'rescue_free'>;
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<string>((resolve, reject) => {
const reader = new FileReader();
@@ -747,6 +813,8 @@ function App() {
const [activeMembership, setActiveMembership] = useState<WorkspaceMember | null>(null);
const [workspaceMembers, setWorkspaceMembers] = useState<WorkspaceMember[]>([]);
const [integrationTokens, setIntegrationTokens] = useState<IntegrationTokenSummary[]>([]);
const [adminSummary, setAdminSummary] = useState<AdminSummary | null>(null);
const [adminRescueWorkspaces, setAdminRescueWorkspaces] = useState<AdminRescueWorkspace[]>([]);
const [birds, setBirds] = useState<Bird[]>([]);
const [selectedBirdId, setSelectedBirdId] = useState<string>('');
const [editingBirdId, setEditingBirdId] = useState<string>('');
@@ -771,6 +839,7 @@ function App() {
const [creatingIntegrationToken, setCreatingIntegrationToken] = useState(false);
const [revokingIntegrationTokenId, setRevokingIntegrationTokenId] = useState('');
const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState('');
const [updatingRescueWorkspaceId, setUpdatingRescueWorkspaceId] = useState<number | null>(null);
const [switchingWorkspaceId, setSwitchingWorkspaceId] = useState<number | null>(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<SettingsSection | null>(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<HTMLFormElement>) => {
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() {
<div>
<p className="eyebrow">FlockPal</p>
<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>
</section>
</main>
@@ -2204,6 +2414,11 @@ function App() {
<button className={`page-tab ${activePage === 'settings' ? 'active' : ''}`} onClick={() => setActivePage('settings')} type="button">
Settings
</button>
{authSession.isAdmin ? (
<button className={`page-tab ${activePage === 'admin' ? 'active' : ''}`} onClick={() => setActivePage('admin')} type="button">
Admin
</button>
) : null}
</div>
{showWorkspaceSwitcher ? (
@@ -2219,7 +2434,7 @@ function App() {
>
<span>{entry.workspace.name}</span>
<small>
{formatBillingPlanName(entry.workspace.billingPlan)} {entry.membership.role}
{formatBillingPlanName(entry.workspace.billingPlan)} {formatWorkspaceRole(entry.membership.role)}
</small>
</button>
))}
@@ -2365,6 +2580,102 @@ function App() {
</section>
) : 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' ? (
<section className={showFlockDetailColumn ? 'dashboard-grid' : 'stack-grid'}>
<aside className="panel bird-list-panel">
@@ -2723,9 +3034,16 @@ function App() {
placeholder="Exam notes, medications, follow-ups, or restrictions"
/>
</label>
<button className="primary-button" type="submit">
Save vet visit
</button>
<div className="button-row wide-field">
<button className="primary-button" type="submit">
{editingVetVisitId ? 'Save vet visit changes' : 'Save vet visit'}
</button>
{editingVetVisitId ? (
<button className="secondary-button" onClick={handleCancelVetVisitEdit} type="button">
Cancel edit
</button>
) : null}
</div>
</form>
<div className="recent-list">
@@ -2737,6 +3055,21 @@ function App() {
{formatDate(visit.visitedOn)} {visit.clinicName}
</span>
<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>
))
) : (
@@ -2761,21 +3094,21 @@ function App() {
<article className="panel form-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Workspace</p>
<h2>Workspace profile and billing</h2>
<p className="eyebrow">Flock</p>
<h2>Flock profile and billing</h2>
</div>
</div>
<p className="muted">
Each workspace 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.
Each flock carries its own billing and collaboration rules. That lets one person keep a personal household flock while also
participating in a rescue flock without mixing billing or bird ownership.
</p>
<form className="form-panel" onSubmit={handleWorkspaceSubmit}>
<label>
Workspace name
Flock name
<input value={workspaceForm.name} onChange={(event) => setWorkspaceForm({ ...workspaceForm, name: event.target.value })} required />
</label>
<label>
Workspace type
Flock type
<select
value={workspaceForm.workspaceType}
onChange={(event) =>
@@ -2789,6 +3122,15 @@ function App() {
<option value="rescue">Rescue</option>
</select>
</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' ? (
<>
<label>
@@ -2815,7 +3157,7 @@ function App() {
) : (
<article className="summary-card">
<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>
)}
<label>
@@ -2828,7 +3170,7 @@ function App() {
/>
</label>
<button className="primary-button" type="submit" disabled={savingWorkspace}>
{savingWorkspace ? 'Saving workspace...' : 'Save workspace settings'}
{savingWorkspace ? 'Saving flock...' : 'Save flock settings'}
</button>
</form>
</article>
@@ -2843,8 +3185,18 @@ function App() {
<div className="summary-grid">
<article className="summary-card">
<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 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">
<strong>{workspace?.billingEmail || authSession.user.email}</strong>
<span>Billing contact for invoices, receipts, and account notices.</span>
@@ -2858,7 +3210,7 @@ function App() {
<span>
{workspace && formatBillingPlanBirdLimit(workspace.billingPlan)
? 'Current bird count against your paid plan allowance.'
: 'Current flock count in this workspace.'}
: 'Current bird count in this flock.'}
</span>
</article>
<article className="summary-card">
@@ -2872,7 +3224,7 @@ function App() {
<div className="panel-header">
<div>
<p className="eyebrow">Collaborators</p>
<h2>Shared workspace access</h2>
<h2>Shared flock access</h2>
</div>
<button
className="secondary-button"
@@ -2888,7 +3240,7 @@ function App() {
{expandedSettingsSection === 'collaborators' ? (
<>
<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.
</p>
<form className="form-panel" onSubmit={handleWorkspaceMemberSubmit}>
@@ -2921,8 +3273,8 @@ function App() {
}
>
<option value="owner">Owner</option>
<option value="manager">Manager</option>
<option value="staff">Staff</option>
<option value="assistant">Assistant</option>
<option value="caregiver">Caregiver</option>
<option value="viewer">Viewer</option>
</select>
</label>
@@ -2937,7 +3289,7 @@ function App() {
<article key={member.id} className="vet-visit-card">
<strong>{member.name}</strong>
<span>
{member.role} {member.email || member.inviteEmail}
{formatWorkspaceRole(member.role)} {member.email || member.inviteEmail}
</span>
<small>{member.acceptedAt ? 'Active access' : 'Invitation pending'}</small>
<button
@@ -2953,7 +3305,7 @@ function App() {
) : (
<article className="vet-visit-card empty-card">
<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>
)}
</div>
@@ -2981,7 +3333,7 @@ function App() {
{expandedSettingsSection === 'integration-tokens' ? (
<>
<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>
<form className="form-panel" onSubmit={handleCreateIntegrationToken}>
<label>
@@ -3056,7 +3408,7 @@ function App() {
) : (
<article className="vet-visit-card empty-card">
<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>
)}
</div>
@@ -3067,7 +3419,7 @@ function App() {
<article className="panel form-panel">
<div className="panel-header">
<div>
<p className="eyebrow">New workspace</p>
<p className="eyebrow">New flock</p>
<h2>Add another flock space</h2>
</div>
<button
@@ -3084,12 +3436,12 @@ function App() {
{expandedSettingsSection === 'new-workspace' ? (
<>
<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.
</p>
<form className="form-panel" onSubmit={handleCreateWorkspace}>
<label>
Workspace name
Flock name
<input
value={workspaceCreateForm.name}
onChange={(event) => setWorkspaceCreateForm({ ...workspaceCreateForm, name: event.target.value })}
@@ -3097,7 +3449,7 @@ function App() {
/>
</label>
<label>
Workspace type
Flock type
<select
value={workspaceCreateForm.workspaceType}
onChange={(event) =>
@@ -3137,7 +3489,7 @@ function App() {
) : (
<article className="summary-card">
<strong>{formatBillingPlanName('rescue_free')}</strong>
<span>No billing is applied to rescue workspaces.</span>
<span>No billing is applied to rescue flocks.</span>
</article>
)}
<label>
@@ -3150,7 +3502,7 @@ function App() {
/>
</label>
<button className="primary-button" type="submit" disabled={creatingWorkspace}>
{creatingWorkspace ? 'Creating workspace...' : 'Create workspace'}
{creatingWorkspace ? 'Creating flock...' : 'Create flock'}
</button>
</form>
</>