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
+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;
};