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