Added admin mode, read only status for inactive accounts, and resuce verification
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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' });
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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];
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user