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
|
BACKEND_URL=http://localhost:5000
|
||||||
VITE_API_BASE_URL=http://localhost:5000/api
|
VITE_API_BASE_URL=http://localhost:5000/api
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
ADMIN_EMAILS=corey@blaishome.online
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ FlockPal is a Dockerized TypeScript app for tracking flock health with a clean,
|
|||||||
- Passwordless authentication only
|
- Passwordless authentication only
|
||||||
- Magic-link email sign-in
|
- Magic-link email sign-in
|
||||||
- OAuth-ready login flow for Google, Microsoft, and Apple
|
- OAuth-ready login flow for Google, Microsoft, and Apple
|
||||||
- Multi-workspace model with `standard` household and `rescue` modes
|
- Multi-flock model with `standard` household and `rescue` modes
|
||||||
- Shared workspace member management for both households and rescues
|
- Shared flock member management for both households and rescues
|
||||||
- Separate per-workspace billing plan foundation with `rescue_free`, `household_basic`, `household_plus`, and `household_macaw`
|
- Separate per-flock billing plan foundation with `rescue_free`, `household_basic`, `household_plus`, and `household_macaw`
|
||||||
- Bird profiles with name, tag ID, and species
|
- Bird profiles with name, tag ID, and species
|
||||||
- Bird DOB and gotcha day fields
|
- Bird DOB and gotcha day fields
|
||||||
- Daily weight recordings
|
- Daily weight recordings
|
||||||
@@ -22,10 +22,10 @@ FlockPal is a Dockerized TypeScript app for tracking flock health with a clean,
|
|||||||
## Planned next steps
|
## Planned next steps
|
||||||
|
|
||||||
- Medication and care reminders
|
- Medication and care reminders
|
||||||
- Invitation acceptance and onboarding polish for workspace members
|
- Invitation acceptance and onboarding polish for flock members
|
||||||
- Stripe or equivalent billing integration for paid household tiers
|
- Stripe or equivalent billing integration for paid household tiers
|
||||||
- Scheduled reminder delivery for birthdays, gotcha days, and care events
|
- Scheduled reminder delivery for birthdays, gotcha days, and care events
|
||||||
- Audit logging for workspace access changes and bird transfers
|
- Audit logging for flock access changes and bird transfers
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -59,13 +59,13 @@ docker compose -f docker-compose.prod.yml up --build -d
|
|||||||
3. The production backend runs the compiled Node app from `dist/app.js`.
|
3. The production backend runs the compiled Node app from `dist/app.js`.
|
||||||
4. The production frontend is built with Vite and served by Nginx on port `3000`.
|
4. The production frontend is built with Vite and served by Nginx on port `3000`.
|
||||||
|
|
||||||
## Auth and workspace notes
|
## Auth and flock notes
|
||||||
|
|
||||||
- One user can belong to multiple workspaces.
|
- One user can belong to multiple flocks.
|
||||||
- A rescue member can also keep their own household flock in a separate workspace.
|
- A rescue member can also keep their own household flock separate from the rescue flock.
|
||||||
- Billing should attach to the household workspace, not the user account.
|
- Billing should attach to the household flock, not the user account.
|
||||||
- Rescue workspaces stay on the free plan.
|
- Rescue flocks stay on the free plan.
|
||||||
- Shared access is controlled by workspace roles like `owner`, `manager`, `staff`, and `viewer`.
|
- Shared access is controlled by flock roles like `owner`, `assistant`, `caregiver`, and `viewer`.
|
||||||
- FlockPal no longer stores local passwords.
|
- FlockPal no longer stores local passwords.
|
||||||
- Authentication now happens through magic links or external identity providers.
|
- Authentication now happens through magic links or external identity providers.
|
||||||
|
|
||||||
@@ -96,6 +96,6 @@ Set these if you want magic links delivered by email instead of logged as a prev
|
|||||||
|
|
||||||
## Notes for monetization and security
|
## Notes for monetization and security
|
||||||
|
|
||||||
This starter now includes the account and workspace foundation for monetization, but it still needs production-grade session hardening, invitation verification, billing integration, audit logging, and background reminder delivery before launch.
|
This starter now includes the account and flock foundation for monetization, but it still needs production-grade session hardening, invitation verification, billing integration, audit logging, and background reminder delivery before launch.
|
||||||
|
|
||||||
For account design, `standard` vs `rescue` is best treated as a workspace type, not as a user role. If paid plans are added later, a separate `admin account mode` is usually less flexible than workspace roles such as `owner`, `manager`, `staff`, and `viewer`. That lets the same underlying account system work for both households and rescues without splitting product logic into unrelated account classes.
|
For account design, `standard` vs `rescue` is best treated as a flock type, not as a user role. If paid plans are added later, a separate `admin account mode` is usually less flexible than flock roles such as `owner`, `assistant`, `caregiver`, and `viewer`. That lets the same underlying account system work for both households and rescues without splitting product logic into unrelated account classes.
|
||||||
|
|||||||
+202
-20
@@ -31,11 +31,13 @@ import {
|
|||||||
createVetVisitForBird,
|
createVetVisitForBird,
|
||||||
createWeightForBird,
|
createWeightForBird,
|
||||||
deleteBird,
|
deleteBird,
|
||||||
|
deleteVetVisitForBird,
|
||||||
getBirdById,
|
getBirdById,
|
||||||
listBirds,
|
listBirds,
|
||||||
listVetVisitsForBird,
|
listVetVisitsForBird,
|
||||||
listWeightsForBird,
|
listWeightsForBird,
|
||||||
updateBird,
|
updateBird,
|
||||||
|
updateVetVisitForBird,
|
||||||
} from './repositories/birdRepository.js';
|
} from './repositories/birdRepository.js';
|
||||||
import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js';
|
import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js';
|
||||||
import {
|
import {
|
||||||
@@ -43,11 +45,14 @@ import {
|
|||||||
createWorkspace,
|
createWorkspace,
|
||||||
deleteWorkspaceMember,
|
deleteWorkspaceMember,
|
||||||
ensurePersonalWorkspaceForUser,
|
ensurePersonalWorkspaceForUser,
|
||||||
|
getPlatformAdminSummary,
|
||||||
getMembershipForUser,
|
getMembershipForUser,
|
||||||
getNextWorkspaceId,
|
getNextWorkspaceId,
|
||||||
getWorkspaceById,
|
getWorkspaceById,
|
||||||
|
listRescueWorkspacesForAdmin,
|
||||||
listMembershipsForUser,
|
listMembershipsForUser,
|
||||||
listWorkspaceMembers,
|
listWorkspaceMembers,
|
||||||
|
updateRescueVerificationStatus,
|
||||||
updateWorkspace,
|
updateWorkspace,
|
||||||
upsertWorkspaceMember,
|
upsertWorkspaceMember,
|
||||||
} from './repositories/workspaceRepository.js';
|
} from './repositories/workspaceRepository.js';
|
||||||
@@ -58,6 +63,7 @@ import type {
|
|||||||
BirdRow,
|
BirdRow,
|
||||||
IntegrationTokenRow,
|
IntegrationTokenRow,
|
||||||
ProviderKey,
|
ProviderKey,
|
||||||
|
RescueVerificationStatus,
|
||||||
UserRow,
|
UserRow,
|
||||||
VetVisitRow,
|
VetVisitRow,
|
||||||
WeightRow,
|
WeightRow,
|
||||||
@@ -114,10 +120,11 @@ const switchWorkspaceSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const workspaceTypeSchema = z.enum(['standard', 'rescue']);
|
const workspaceTypeSchema = z.enum(['standard', 'rescue']);
|
||||||
const workspaceRoleSchema = z.enum(['owner', 'manager', 'staff', 'viewer']);
|
const workspaceRoleSchema = z.enum(['owner', 'assistant', 'caregiver', 'viewer']);
|
||||||
const billingPlanSchema = z.enum(['household_basic', 'household_plus', 'household_macaw']);
|
const billingPlanSchema = z.enum(['household_basic', 'household_plus', 'household_macaw']);
|
||||||
const integrationTokenScopeSchema = z.enum(['read_only', 'read_write']);
|
const integrationTokenScopeSchema = z.enum(['read_only', 'read_write']);
|
||||||
const birdGenderSchema = z.enum(['unknown', 'male', 'female']);
|
const birdGenderSchema = z.enum(['unknown', 'male', 'female']);
|
||||||
|
const rescueVerificationStatusSchema = z.enum(['pending', 'approved', 'rejected']);
|
||||||
|
|
||||||
const workspaceSchema = z.object({
|
const workspaceSchema = z.object({
|
||||||
name: z.string().trim().min(1).max(160),
|
name: z.string().trim().min(1).max(160),
|
||||||
@@ -212,6 +219,12 @@ const smtpUser = process.env.SMTP_USER?.trim() ?? '';
|
|||||||
const smtpPass = process.env.SMTP_PASS?.trim() ?? '';
|
const smtpPass = process.env.SMTP_PASS?.trim() ?? '';
|
||||||
const smtpFromEmail = process.env.SMTP_FROM_EMAIL?.trim() ?? '';
|
const smtpFromEmail = process.env.SMTP_FROM_EMAIL?.trim() ?? '';
|
||||||
const smtpFromName = process.env.SMTP_FROM_NAME?.trim() || 'FlockPal';
|
const smtpFromName = process.env.SMTP_FROM_NAME?.trim() || 'FlockPal';
|
||||||
|
const adminEmails = new Set(
|
||||||
|
(process.env.ADMIN_EMAILS ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((email) => normalizeEmail(email))
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
|
||||||
const mailTransport =
|
const mailTransport =
|
||||||
smtpHost && smtpFromEmail
|
smtpHost && smtpFromEmail
|
||||||
@@ -245,10 +258,25 @@ const normalizeWorkspace = (row: WorkspaceRow) => ({
|
|||||||
workspaceType: row.workspace_type,
|
workspaceType: row.workspace_type,
|
||||||
billingEmail: row.billing_email,
|
billingEmail: row.billing_email,
|
||||||
billingPlan: row.billing_plan,
|
billingPlan: row.billing_plan,
|
||||||
|
subscriptionStatus: row.subscription_status,
|
||||||
|
rescueVerificationStatus: row.rescue_verification_status,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const normalizeAdminRescueWorkspace = (
|
||||||
|
row: WorkspaceRow & {
|
||||||
|
owner_email: string | null;
|
||||||
|
bird_count: number;
|
||||||
|
member_count: number;
|
||||||
|
},
|
||||||
|
) => ({
|
||||||
|
workspace: normalizeWorkspace(row),
|
||||||
|
ownerEmail: row.owner_email,
|
||||||
|
birdCount: Number(row.bird_count ?? 0),
|
||||||
|
memberCount: Number(row.member_count ?? 0),
|
||||||
|
});
|
||||||
|
|
||||||
const normalizeWorkspaceMember = (row: WorkspaceMemberRow) => ({
|
const normalizeWorkspaceMember = (row: WorkspaceMemberRow) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
workspaceId: row.workspace_id,
|
workspaceId: row.workspace_id,
|
||||||
@@ -375,11 +403,23 @@ const normalizeWorkspaceMembershipList = async (userId: string) =>
|
|||||||
workspace_type: row.workspace_type,
|
workspace_type: row.workspace_type,
|
||||||
billing_email: row.billing_email,
|
billing_email: row.billing_email,
|
||||||
billing_plan: row.billing_plan,
|
billing_plan: row.billing_plan,
|
||||||
|
subscription_status: row.subscription_status,
|
||||||
|
rescue_verification_status: row.rescue_verification_status,
|
||||||
created_at: row.workspace_created_at,
|
created_at: row.workspace_created_at,
|
||||||
updated_at: row.workspace_updated_at,
|
updated_at: row.workspace_updated_at,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const isAdminUser = (user: UserRow) => adminEmails.has(normalizeEmail(user.email));
|
||||||
|
|
||||||
|
const subscriptionAllowsWrite = (workspace: WorkspaceRow) => {
|
||||||
|
if (workspace.workspace_type === 'rescue') {
|
||||||
|
return workspace.rescue_verification_status === 'approved';
|
||||||
|
}
|
||||||
|
|
||||||
|
return workspace.subscription_status === 'active' || workspace.subscription_status === 'trialing';
|
||||||
|
};
|
||||||
|
|
||||||
const createAuthSession = async (userId: string, activeWorkspaceId: number) => {
|
const createAuthSession = async (userId: string, activeWorkspaceId: number) => {
|
||||||
const token = createSessionToken();
|
const token = createSessionToken();
|
||||||
const tokenHash = hashToken(token);
|
const tokenHash = hashToken(token);
|
||||||
@@ -396,6 +436,7 @@ const buildSessionPayload = async (auth: AuthContext) => ({
|
|||||||
activeWorkspace: normalizeWorkspace(auth.workspace),
|
activeWorkspace: normalizeWorkspace(auth.workspace),
|
||||||
activeMembership: normalizeWorkspaceMember(auth.membership),
|
activeMembership: normalizeWorkspaceMember(auth.membership),
|
||||||
workspaces: await normalizeWorkspaceMembershipList(auth.user.id),
|
workspaces: await normalizeWorkspaceMembershipList(auth.user.id),
|
||||||
|
isAdmin: isAdminUser(auth.user),
|
||||||
providers: Object.values(oauthProviders).map((provider) => ({
|
providers: Object.values(oauthProviders).map((provider) => ({
|
||||||
providerKey: provider.providerKey,
|
providerKey: provider.providerKey,
|
||||||
displayName: provider.displayName,
|
displayName: provider.displayName,
|
||||||
@@ -527,6 +568,36 @@ const requireWriteAccess = (req: Request, res: Response, next: NextFunction) =>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.auth?.authType === 'session' && isAdminUser(req.auth.user)) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.auth && !subscriptionAllowsWrite(req.auth.workspace)) {
|
||||||
|
res.status(402).json({
|
||||||
|
error:
|
||||||
|
req.auth.workspace.workspace_type === 'rescue'
|
||||||
|
? 'This rescue flock is read-only until FlockPal verifies it.'
|
||||||
|
: 'This flock is read-only until the subscription is restored.',
|
||||||
|
code: 'workspace_read_only',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
const requireAdmin = (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
if (!req.auth) {
|
||||||
|
res.status(401).json({ error: 'Authentication required.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAdminUser(req.auth.user)) {
|
||||||
|
res.status(403).json({ error: 'Admin access required.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -608,7 +679,7 @@ app.post('/api/transfers/draft', requireAuth, requireWriteAccess, async (req: Re
|
|||||||
const bird = await getBirdById(parsed.data.birdId, req.auth!.workspace.id);
|
const bird = await getBirdById(parsed.data.birdId, req.auth!.workspace.id);
|
||||||
|
|
||||||
if (!bird) {
|
if (!bird) {
|
||||||
res.status(404).json({ error: 'That bird could not be found in this workspace.' });
|
res.status(404).json({ error: 'That bird could not be found in this flock.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -703,7 +774,7 @@ app.post('/api/auth/switch-workspace', requireAuth, requireSessionAuth, async (r
|
|||||||
const parsed = switchWorkspaceSchema.safeParse(req.body);
|
const parsed = switchWorkspaceSchema.safeParse(req.body);
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
res.status(400).json({ error: 'Invalid workspace selection payload', details: parsed.error.flatten() });
|
res.status(400).json({ error: 'Invalid flock selection payload', details: parsed.error.flatten() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -711,7 +782,7 @@ app.post('/api/auth/switch-workspace', requireAuth, requireSessionAuth, async (r
|
|||||||
const membership = await getMembershipForUser(req.auth!.user.id, parsed.data.workspaceId);
|
const membership = await getMembershipForUser(req.auth!.user.id, parsed.data.workspaceId);
|
||||||
|
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
res.status(403).json({ error: 'You do not have access to that workspace.' });
|
res.status(403).json({ error: 'You do not have access to that flock.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -888,6 +959,59 @@ const handleOAuthCallback = async (req: Request, res: Response, next: NextFuncti
|
|||||||
app.get('/api/auth/oauth/:provider/callback', handleOAuthCallback);
|
app.get('/api/auth/oauth/:provider/callback', handleOAuthCallback);
|
||||||
app.post('/api/auth/oauth/:provider/callback', handleOAuthCallback);
|
app.post('/api/auth/oauth/:provider/callback', handleOAuthCallback);
|
||||||
|
|
||||||
|
app.get('/api/admin/summary', requireAuth, requireSessionAuth, requireAdmin, async (_req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const summary = await getPlatformAdminSummary();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
summary: {
|
||||||
|
totalBirds: Number(summary?.total_birds ?? 0),
|
||||||
|
totalUsers: Number(summary?.total_users ?? 0),
|
||||||
|
totalWorkspaces: Number(summary?.total_workspaces ?? 0),
|
||||||
|
rescueWorkspaces: Number(summary?.rescue_workspaces ?? 0),
|
||||||
|
pendingRescues: Number(summary?.pending_rescues ?? 0),
|
||||||
|
dailyUsers: Number(summary?.daily_users ?? 0),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/rescue-workspaces', requireAuth, requireSessionAuth, requireAdmin, async (_req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const rescueWorkspaces = await listRescueWorkspacesForAdmin();
|
||||||
|
res.json({ rescueWorkspaces: rescueWorkspaces.map(normalizeAdminRescueWorkspace) });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/admin/rescue-workspaces/:workspaceId', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const parsed = z.object({ rescueVerificationStatus: rescueVerificationStatusSchema }).safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Invalid rescue verification payload', details: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const workspace = await updateRescueVerificationStatus(
|
||||||
|
Number(req.params.workspaceId),
|
||||||
|
parsed.data.rescueVerificationStatus as RescueVerificationStatus,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
res.status(404).json({ error: 'Rescue flock not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ workspace: normalizeWorkspace(workspace) });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/integration-tokens', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => {
|
app.get('/api/integration-tokens', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const tokens = await listIntegrationTokens(req.auth!.user.id, req.auth!.workspace.id);
|
const tokens = await listIntegrationTokens(req.auth!.user.id, req.auth!.workspace.id);
|
||||||
@@ -897,7 +1021,7 @@ app.get('/api/integration-tokens', requireAuth, requireSessionAuth, async (req:
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/integration-tokens', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => {
|
app.post('/api/integration-tokens', requireAuth, requireSessionAuth, requireWriteAccess, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const parsed = integrationTokenCreateSchema.safeParse(req.body);
|
const parsed = integrationTokenCreateSchema.safeParse(req.body);
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -929,7 +1053,7 @@ app.post('/api/integration-tokens', requireAuth, requireSessionAuth, async (req:
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/integration-tokens/:tokenId', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => {
|
app.delete('/api/integration-tokens/:tokenId', requireAuth, requireSessionAuth, requireWriteAccess, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const revoked = await revokeIntegrationToken(req.params.tokenId, req.auth!.user.id, req.auth!.workspace.id);
|
const revoked = await revokeIntegrationToken(req.params.tokenId, req.auth!.user.id, req.auth!.workspace.id);
|
||||||
|
|
||||||
@@ -958,7 +1082,7 @@ app.post('/api/workspaces', requireAuth, requireSessionAuth, async (req: Request
|
|||||||
const parsed = createWorkspaceSchema.safeParse(req.body);
|
const parsed = createWorkspaceSchema.safeParse(req.body);
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
res.status(400).json({ error: 'Invalid workspace payload', details: parsed.error.flatten() });
|
res.status(400).json({ error: 'Invalid flock payload', details: parsed.error.flatten() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -984,11 +1108,11 @@ app.get('/api/workspace', requireAuth, async (req: Request, res: Response) => {
|
|||||||
res.json({ workspace: normalizeWorkspace(req.auth!.workspace) });
|
res.json({ workspace: normalizeWorkspace(req.auth!.workspace) });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/workspace', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'manager']), async (req: Request, res: Response, next: NextFunction) => {
|
app.put('/api/workspace', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const parsed = workspaceSchema.safeParse(req.body);
|
const parsed = workspaceSchema.safeParse(req.body);
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
res.status(400).json({ error: 'Invalid workspace payload', details: parsed.error.flatten() });
|
res.status(400).json({ error: 'Invalid flock payload', details: parsed.error.flatten() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1017,11 +1141,11 @@ app.get('/api/workspace/members', requireAuth, async (req: Request, res: Respons
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/workspace/members', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'manager']), async (req: Request, res: Response, next: NextFunction) => {
|
app.post('/api/workspace/members', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const parsed = workspaceMemberSchema.safeParse(req.body);
|
const parsed = workspaceMemberSchema.safeParse(req.body);
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
res.status(400).json({ error: 'Invalid workspace member payload', details: parsed.error.flatten() });
|
res.status(400).json({ error: 'Invalid flock member payload', details: parsed.error.flatten() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1042,12 +1166,12 @@ app.post('/api/workspace/members', requireAuth, requireWriteAccess, requireWorks
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/workspace/members/:memberId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'manager']), async (req: Request, res: Response, next: NextFunction) => {
|
app.delete('/api/workspace/members/:memberId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const deleted = await deleteWorkspaceMember(req.params.memberId, req.auth!.workspace.id);
|
const deleted = await deleteWorkspaceMember(req.params.memberId, req.auth!.workspace.id);
|
||||||
|
|
||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
res.status(404).json({ error: 'Workspace member not found or cannot be removed.' });
|
res.status(404).json({ error: 'Flock member not found or cannot be removed.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1066,7 +1190,7 @@ app.get('/api/birds', requireAuth, async (req: Request, res: Response, next: Nex
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'manager', 'staff']), async (req: Request, res: Response, next: NextFunction) => {
|
app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const parsed = birdSchema.safeParse(req.body);
|
const parsed = birdSchema.safeParse(req.body);
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -1092,7 +1216,7 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
|
|||||||
res.status(201).json({ bird: normalizeBird(bird!) });
|
res.status(201).json({ bird: normalizeBird(bird!) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
||||||
res.status(409).json({ error: 'That band/tag ID is already in use in this workspace.' });
|
res.status(409).json({ error: 'That band/tag ID is already in use in this flock.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1100,7 +1224,7 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'manager', 'staff']), async (req: Request, res: Response, next: NextFunction) => {
|
app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const parsed = birdSchema.safeParse(req.body);
|
const parsed = birdSchema.safeParse(req.body);
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -1132,7 +1256,7 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
|
|||||||
res.json({ bird: normalizeBird(bird) });
|
res.json({ bird: normalizeBird(bird) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
||||||
res.status(409).json({ error: 'That band/tag ID is already in use in this workspace.' });
|
res.status(409).json({ error: 'That band/tag ID is already in use in this flock.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1140,7 +1264,7 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'manager', 'staff']), async (req: Request, res: Response, next: NextFunction) => {
|
app.delete('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const deleted = await deleteBird(req.params.birdId, req.auth!.workspace.id);
|
const deleted = await deleteBird(req.params.birdId, req.auth!.workspace.id);
|
||||||
|
|
||||||
@@ -1165,7 +1289,7 @@ app.get('/api/birds/:birdId/weights', requireAuth, async (req: Request, res: Res
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/birds/:birdId/weights', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'manager', 'staff']), async (req: Request, res: Response, next: NextFunction) => {
|
app.post('/api/birds/:birdId/weights', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const parsed = weightSchema.safeParse(req.body);
|
const parsed = weightSchema.safeParse(req.body);
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -1202,7 +1326,7 @@ app.get('/api/birds/:birdId/vet-visits', requireAuth, async (req: Request, res:
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/birds/:birdId/vet-visits', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'manager', 'staff']), async (req: Request, res: Response, next: NextFunction) => {
|
app.post('/api/birds/:birdId/vet-visits', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const parsed = vetVisitSchema.safeParse(req.body);
|
const parsed = vetVisitSchema.safeParse(req.body);
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -1232,6 +1356,64 @@ app.post('/api/birds/:birdId/vet-visits', requireAuth, requireWriteAccess, requi
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.put('/api/birds/:birdId/vet-visits/:visitId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const parsed = vetVisitSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Invalid vet visit payload', details: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
|
||||||
|
|
||||||
|
if (!bird) {
|
||||||
|
res.status(404).json({ error: 'Bird not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vetVisit = await updateVetVisitForBird(
|
||||||
|
req.params.visitId,
|
||||||
|
req.params.birdId,
|
||||||
|
parsed.data.visitedOn,
|
||||||
|
parsed.data.clinicName,
|
||||||
|
parsed.data.reason,
|
||||||
|
emptyToNull(parsed.data.notes),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!vetVisit) {
|
||||||
|
res.status(404).json({ error: 'Vet visit not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ vetVisit: normalizeVetVisit(vetVisit) });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/birds/:birdId/vet-visits/:visitId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const bird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
|
||||||
|
|
||||||
|
if (!bird) {
|
||||||
|
res.status(404).json({ error: 'Bird not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await deleteVetVisitForBird(req.params.visitId, req.params.birdId);
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
res.status(404).json({ error: 'Vet visit not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
res.status(500).json({ error: error instanceof Error ? error.message : 'Internal server error' });
|
res.status(500).json({ error: error instanceof Error ? error.message : 'Internal server error' });
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
workspace_type VARCHAR(16) NOT NULL DEFAULT 'standard',
|
workspace_type VARCHAR(16) NOT NULL DEFAULT 'standard',
|
||||||
billing_email VARCHAR(255),
|
billing_email VARCHAR(255),
|
||||||
billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic',
|
billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic',
|
||||||
|
subscription_status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||||
|
rescue_verification_status VARCHAR(32) NOT NULL DEFAULT 'not_required',
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -27,7 +29,14 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
|
|
||||||
ALTER TABLE workspaces
|
ALTER TABLE workspaces
|
||||||
ADD COLUMN IF NOT EXISTS billing_email VARCHAR(255),
|
ADD COLUMN IF NOT EXISTS billing_email VARCHAR(255),
|
||||||
ADD COLUMN IF NOT EXISTS billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic';
|
ADD COLUMN IF NOT EXISTS billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic',
|
||||||
|
ADD COLUMN IF NOT EXISTS subscription_status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||||
|
ADD COLUMN IF NOT EXISTS rescue_verification_status VARCHAR(32) NOT NULL DEFAULT 'not_required';
|
||||||
|
|
||||||
|
UPDATE workspaces
|
||||||
|
SET rescue_verification_status = 'pending'
|
||||||
|
WHERE workspace_type = 'rescue'
|
||||||
|
AND rescue_verification_status = 'not_required';
|
||||||
|
|
||||||
INSERT INTO workspaces (id, name, workspace_type, billing_plan)
|
INSERT INTO workspaces (id, name, workspace_type, billing_plan)
|
||||||
VALUES (1, 'My Flock', 'standard', 'household_basic')
|
VALUES (1, 'My Flock', 'standard', 'household_basic')
|
||||||
@@ -39,7 +48,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
invite_email VARCHAR(255) NOT NULL,
|
invite_email VARCHAR(255) NOT NULL,
|
||||||
name VARCHAR(160) NOT NULL,
|
name VARCHAR(160) NOT NULL,
|
||||||
role VARCHAR(16) NOT NULL DEFAULT 'staff',
|
role VARCHAR(16) NOT NULL DEFAULT 'caregiver',
|
||||||
accepted_at TIMESTAMPTZ,
|
accepted_at TIMESTAMPTZ,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -50,6 +59,14 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
ADD COLUMN IF NOT EXISTS invite_email VARCHAR(255),
|
ADD COLUMN IF NOT EXISTS invite_email VARCHAR(255),
|
||||||
ADD COLUMN IF NOT EXISTS accepted_at TIMESTAMPTZ;
|
ADD COLUMN IF NOT EXISTS accepted_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
UPDATE workspace_members
|
||||||
|
SET role = CASE
|
||||||
|
WHEN role = 'manager' THEN 'assistant'
|
||||||
|
WHEN role = 'staff' THEN 'caregiver'
|
||||||
|
ELSE role
|
||||||
|
END
|
||||||
|
WHERE role IN ('manager', 'staff');
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF EXISTS (
|
IF EXISTS (
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import type {
|
|||||||
MagicLinkTokenRow,
|
MagicLinkTokenRow,
|
||||||
OAuthStateRow,
|
OAuthStateRow,
|
||||||
ProviderKey,
|
ProviderKey,
|
||||||
|
RescueVerificationStatus,
|
||||||
|
SubscriptionStatus,
|
||||||
UserRow,
|
UserRow,
|
||||||
WorkspaceMemberRow,
|
WorkspaceMemberRow,
|
||||||
WorkspaceRole,
|
WorkspaceRole,
|
||||||
@@ -36,6 +38,8 @@ const mapSessionAuthRow = (
|
|||||||
workspace_workspace_type: WorkspaceType;
|
workspace_workspace_type: WorkspaceType;
|
||||||
workspace_billing_email: string | null;
|
workspace_billing_email: string | null;
|
||||||
workspace_billing_plan: BillingPlan;
|
workspace_billing_plan: BillingPlan;
|
||||||
|
workspace_subscription_status: SubscriptionStatus;
|
||||||
|
workspace_rescue_verification_status: RescueVerificationStatus;
|
||||||
workspace_created_at: string;
|
workspace_created_at: string;
|
||||||
workspace_updated_at: string;
|
workspace_updated_at: string;
|
||||||
membership_id_row: string;
|
membership_id_row: string;
|
||||||
@@ -70,6 +74,8 @@ const mapSessionAuthRow = (
|
|||||||
workspace_type: row.workspace_workspace_type,
|
workspace_type: row.workspace_workspace_type,
|
||||||
billing_email: row.workspace_billing_email,
|
billing_email: row.workspace_billing_email,
|
||||||
billing_plan: row.workspace_billing_plan,
|
billing_plan: row.workspace_billing_plan,
|
||||||
|
subscription_status: row.workspace_subscription_status,
|
||||||
|
rescue_verification_status: row.workspace_rescue_verification_status,
|
||||||
created_at: row.workspace_created_at,
|
created_at: row.workspace_created_at,
|
||||||
updated_at: row.workspace_updated_at,
|
updated_at: row.workspace_updated_at,
|
||||||
},
|
},
|
||||||
@@ -113,6 +119,8 @@ const mapIntegrationTokenAuthRow = (
|
|||||||
workspace_workspace_type: WorkspaceType;
|
workspace_workspace_type: WorkspaceType;
|
||||||
workspace_billing_email: string | null;
|
workspace_billing_email: string | null;
|
||||||
workspace_billing_plan: BillingPlan;
|
workspace_billing_plan: BillingPlan;
|
||||||
|
workspace_subscription_status: SubscriptionStatus;
|
||||||
|
workspace_rescue_verification_status: RescueVerificationStatus;
|
||||||
workspace_created_at: string;
|
workspace_created_at: string;
|
||||||
workspace_updated_at: string;
|
workspace_updated_at: string;
|
||||||
membership_id_row: string;
|
membership_id_row: string;
|
||||||
@@ -147,6 +155,8 @@ const mapIntegrationTokenAuthRow = (
|
|||||||
workspace_type: row.workspace_workspace_type,
|
workspace_type: row.workspace_workspace_type,
|
||||||
billing_email: row.workspace_billing_email,
|
billing_email: row.workspace_billing_email,
|
||||||
billing_plan: row.workspace_billing_plan,
|
billing_plan: row.workspace_billing_plan,
|
||||||
|
subscription_status: row.workspace_subscription_status,
|
||||||
|
rescue_verification_status: row.workspace_rescue_verification_status,
|
||||||
created_at: row.workspace_created_at,
|
created_at: row.workspace_created_at,
|
||||||
updated_at: row.workspace_updated_at,
|
updated_at: row.workspace_updated_at,
|
||||||
},
|
},
|
||||||
@@ -327,6 +337,8 @@ export const resolveAuth = async (tokenHash: string, token: string) => {
|
|||||||
workspace_workspace_type: WorkspaceType;
|
workspace_workspace_type: WorkspaceType;
|
||||||
workspace_billing_email: string | null;
|
workspace_billing_email: string | null;
|
||||||
workspace_billing_plan: BillingPlan;
|
workspace_billing_plan: BillingPlan;
|
||||||
|
workspace_subscription_status: SubscriptionStatus;
|
||||||
|
workspace_rescue_verification_status: RescueVerificationStatus;
|
||||||
workspace_created_at: string;
|
workspace_created_at: string;
|
||||||
workspace_updated_at: string;
|
workspace_updated_at: string;
|
||||||
membership_id_row: string;
|
membership_id_row: string;
|
||||||
@@ -356,6 +368,8 @@ export const resolveAuth = async (tokenHash: string, token: string) => {
|
|||||||
workspaces.workspace_type AS workspace_workspace_type,
|
workspaces.workspace_type AS workspace_workspace_type,
|
||||||
workspaces.billing_email AS workspace_billing_email,
|
workspaces.billing_email AS workspace_billing_email,
|
||||||
workspaces.billing_plan AS workspace_billing_plan,
|
workspaces.billing_plan AS workspace_billing_plan,
|
||||||
|
workspaces.subscription_status AS workspace_subscription_status,
|
||||||
|
workspaces.rescue_verification_status AS workspace_rescue_verification_status,
|
||||||
workspaces.created_at AS workspace_created_at,
|
workspaces.created_at AS workspace_created_at,
|
||||||
workspaces.updated_at AS workspace_updated_at,
|
workspaces.updated_at AS workspace_updated_at,
|
||||||
workspace_members.id AS membership_id_row,
|
workspace_members.id AS membership_id_row,
|
||||||
@@ -407,6 +421,8 @@ export const resolveIntegrationTokenAuth = async (tokenHash: string, token: stri
|
|||||||
workspace_workspace_type: WorkspaceType;
|
workspace_workspace_type: WorkspaceType;
|
||||||
workspace_billing_email: string | null;
|
workspace_billing_email: string | null;
|
||||||
workspace_billing_plan: BillingPlan;
|
workspace_billing_plan: BillingPlan;
|
||||||
|
workspace_subscription_status: SubscriptionStatus;
|
||||||
|
workspace_rescue_verification_status: RescueVerificationStatus;
|
||||||
workspace_created_at: string;
|
workspace_created_at: string;
|
||||||
workspace_updated_at: string;
|
workspace_updated_at: string;
|
||||||
membership_id_row: string;
|
membership_id_row: string;
|
||||||
@@ -441,6 +457,8 @@ export const resolveIntegrationTokenAuth = async (tokenHash: string, token: stri
|
|||||||
workspaces.workspace_type AS workspace_workspace_type,
|
workspaces.workspace_type AS workspace_workspace_type,
|
||||||
workspaces.billing_email AS workspace_billing_email,
|
workspaces.billing_email AS workspace_billing_email,
|
||||||
workspaces.billing_plan AS workspace_billing_plan,
|
workspaces.billing_plan AS workspace_billing_plan,
|
||||||
|
workspaces.subscription_status AS workspace_subscription_status,
|
||||||
|
workspaces.rescue_verification_status AS workspace_rescue_verification_status,
|
||||||
workspaces.created_at AS workspace_created_at,
|
workspaces.created_at AS workspace_created_at,
|
||||||
workspaces.updated_at AS workspace_updated_at,
|
workspaces.updated_at AS workspace_updated_at,
|
||||||
workspace_members.id AS membership_id_row,
|
workspace_members.id AS membership_id_row,
|
||||||
|
|||||||
@@ -226,3 +226,38 @@ export const createVetVisitForBird = async (birdId: string, visitedOn: string, c
|
|||||||
|
|
||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const updateVetVisitForBird = async (
|
||||||
|
visitId: string,
|
||||||
|
birdId: string,
|
||||||
|
visitedOn: string,
|
||||||
|
clinicName: string,
|
||||||
|
reason: string,
|
||||||
|
notes: string | null,
|
||||||
|
) => {
|
||||||
|
const result = await db.query<VetVisitRow>(
|
||||||
|
`UPDATE vet_visits
|
||||||
|
SET visited_on = $3,
|
||||||
|
clinic_name = $4,
|
||||||
|
reason = $5,
|
||||||
|
notes = $6
|
||||||
|
WHERE id = $1
|
||||||
|
AND bird_id = $2
|
||||||
|
RETURNING id, bird_id, visited_on::text, clinic_name, reason, notes`,
|
||||||
|
[visitId, birdId, visitedOn, clinicName, reason, notes],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteVetVisitForBird = async (visitId: string, birdId: string) => {
|
||||||
|
const result = await db.query<{ id: string }>(
|
||||||
|
`DELETE FROM vet_visits
|
||||||
|
WHERE id = $1
|
||||||
|
AND bird_id = $2
|
||||||
|
RETURNING id`,
|
||||||
|
[visitId, birdId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (result.rowCount ?? 0) > 0;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { db } from '../db/client.js';
|
import { db } from '../db/client.js';
|
||||||
import type { BillingPlan, UserRow, WorkspaceMemberRow, WorkspaceRow, WorkspaceType } from '../types.js';
|
import type { BillingPlan, RescueVerificationStatus, UserRow, WorkspaceMemberRow, WorkspaceRow, WorkspaceType } from '../types.js';
|
||||||
|
|
||||||
export const getNextWorkspaceId = async () => {
|
export const getNextWorkspaceId = async () => {
|
||||||
const result = await db.query<{ next_id: number }>('SELECT COALESCE(MAX(id), 0) + 1 AS next_id FROM workspaces');
|
const result = await db.query<{ next_id: number }>('SELECT COALESCE(MAX(id), 0) + 1 AS next_id FROM workspaces');
|
||||||
@@ -8,7 +8,7 @@ export const getNextWorkspaceId = async () => {
|
|||||||
|
|
||||||
export const getWorkspaceById = async (workspaceId: number) => {
|
export const getWorkspaceById = async (workspaceId: number) => {
|
||||||
const result = await db.query<WorkspaceRow>(
|
const result = await db.query<WorkspaceRow>(
|
||||||
`SELECT id, name, workspace_type, billing_email, billing_plan, created_at, updated_at
|
`SELECT id, name, workspace_type, billing_email, billing_plan, subscription_status, rescue_verification_status, created_at, updated_at
|
||||||
FROM workspaces
|
FROM workspaces
|
||||||
WHERE id = $1`,
|
WHERE id = $1`,
|
||||||
[workspaceId],
|
[workspaceId],
|
||||||
@@ -36,6 +36,8 @@ export const listMembershipsForUser = async (userId: string) => {
|
|||||||
workspace_type: WorkspaceType;
|
workspace_type: WorkspaceType;
|
||||||
billing_email: string | null;
|
billing_email: string | null;
|
||||||
billing_plan: BillingPlan;
|
billing_plan: BillingPlan;
|
||||||
|
subscription_status: WorkspaceRow['subscription_status'];
|
||||||
|
rescue_verification_status: RescueVerificationStatus;
|
||||||
workspace_created_at: string;
|
workspace_created_at: string;
|
||||||
workspace_updated_at: string;
|
workspace_updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -53,6 +55,8 @@ export const listMembershipsForUser = async (userId: string) => {
|
|||||||
workspaces.workspace_type,
|
workspaces.workspace_type,
|
||||||
workspaces.billing_email,
|
workspaces.billing_email,
|
||||||
workspaces.billing_plan,
|
workspaces.billing_plan,
|
||||||
|
workspaces.subscription_status,
|
||||||
|
workspaces.rescue_verification_status,
|
||||||
workspaces.created_at AS workspace_created_at,
|
workspaces.created_at AS workspace_created_at,
|
||||||
workspaces.updated_at AS workspace_updated_at
|
workspaces.updated_at AS workspace_updated_at
|
||||||
FROM workspace_members
|
FROM workspace_members
|
||||||
@@ -95,8 +99,8 @@ export const ensurePersonalWorkspaceForUser = async (user: UserRow) => {
|
|||||||
|
|
||||||
if (!unclaimed.rowCount) {
|
if (!unclaimed.rowCount) {
|
||||||
await db.query(
|
await db.query(
|
||||||
`INSERT INTO workspaces (id, name, workspace_type, billing_plan, billing_email)
|
`INSERT INTO workspaces (id, name, workspace_type, billing_plan, billing_email, subscription_status, rescue_verification_status)
|
||||||
VALUES ($1, $2, 'standard', 'household_basic', $3)`,
|
VALUES ($1, $2, 'standard', 'household_basic', $3, 'active', 'not_required')`,
|
||||||
[workspaceId, `${user.name}'s Flock`, user.email],
|
[workspaceId, `${user.name}'s Flock`, user.email],
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -106,6 +110,8 @@ export const ensurePersonalWorkspaceForUser = async (user: UserRow) => {
|
|||||||
workspace_type = 'standard',
|
workspace_type = 'standard',
|
||||||
billing_plan = 'household_basic',
|
billing_plan = 'household_basic',
|
||||||
billing_email = $3,
|
billing_email = $3,
|
||||||
|
subscription_status = 'active',
|
||||||
|
rescue_verification_status = 'not_required',
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $1`,
|
WHERE id = $1`,
|
||||||
[workspaceId, `${user.name}'s Flock`, user.email],
|
[workspaceId, `${user.name}'s Flock`, user.email],
|
||||||
@@ -154,9 +160,17 @@ export const createWorkspace = async ({
|
|||||||
owner: UserRow;
|
owner: UserRow;
|
||||||
}) => {
|
}) => {
|
||||||
await db.query(
|
await db.query(
|
||||||
`INSERT INTO workspaces (id, name, workspace_type, billing_email, billing_plan)
|
`INSERT INTO workspaces (id, name, workspace_type, billing_email, billing_plan, subscription_status, rescue_verification_status)
|
||||||
VALUES ($1, $2, $3, $4, $5)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||||
[id, name, workspaceType, billingEmail, billingPlan],
|
[
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
workspaceType,
|
||||||
|
billingEmail,
|
||||||
|
billingPlan,
|
||||||
|
workspaceType === 'rescue' ? 'active' : 'active',
|
||||||
|
workspaceType === 'rescue' ? 'pending' : 'not_required',
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
await db.query(
|
await db.query(
|
||||||
@@ -187,9 +201,14 @@ export const updateWorkspace = async ({
|
|||||||
workspace_type = $3,
|
workspace_type = $3,
|
||||||
billing_email = $4,
|
billing_email = $4,
|
||||||
billing_plan = $5,
|
billing_plan = $5,
|
||||||
|
rescue_verification_status = CASE
|
||||||
|
WHEN $3 = 'rescue' AND rescue_verification_status = 'not_required' THEN 'pending'
|
||||||
|
WHEN $3 = 'standard' THEN 'not_required'
|
||||||
|
ELSE rescue_verification_status
|
||||||
|
END,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, name, workspace_type, billing_email, billing_plan, created_at, updated_at`,
|
RETURNING id, name, workspace_type, billing_email, billing_plan, subscription_status, rescue_verification_status, created_at, updated_at`,
|
||||||
[workspaceId, name, workspaceType, billingEmail, billingPlan],
|
[workspaceId, name, workspaceType, billingEmail, billingPlan],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -249,3 +268,80 @@ export const deleteWorkspaceMember = async (memberId: string, workspaceId: numbe
|
|||||||
|
|
||||||
return Boolean(result.rowCount);
|
return Boolean(result.rowCount);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const listRescueWorkspacesForAdmin = async () => {
|
||||||
|
const result = await db.query<
|
||||||
|
WorkspaceRow & {
|
||||||
|
owner_email: string | null;
|
||||||
|
bird_count: number;
|
||||||
|
member_count: number;
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
`SELECT
|
||||||
|
workspaces.id,
|
||||||
|
workspaces.name,
|
||||||
|
workspaces.workspace_type,
|
||||||
|
workspaces.billing_email,
|
||||||
|
workspaces.billing_plan,
|
||||||
|
workspaces.subscription_status,
|
||||||
|
workspaces.rescue_verification_status,
|
||||||
|
workspaces.created_at,
|
||||||
|
workspaces.updated_at,
|
||||||
|
owner.invite_email AS owner_email,
|
||||||
|
COUNT(DISTINCT birds.id)::int AS bird_count,
|
||||||
|
COUNT(DISTINCT workspace_members.id)::int AS member_count
|
||||||
|
FROM workspaces
|
||||||
|
LEFT JOIN workspace_members owner
|
||||||
|
ON owner.workspace_id = workspaces.id
|
||||||
|
AND owner.role = 'owner'
|
||||||
|
LEFT JOIN birds ON birds.workspace_id = workspaces.id
|
||||||
|
LEFT JOIN workspace_members ON workspace_members.workspace_id = workspaces.id
|
||||||
|
WHERE workspaces.workspace_type = 'rescue'
|
||||||
|
GROUP BY workspaces.id, owner.invite_email
|
||||||
|
ORDER BY
|
||||||
|
CASE workspaces.rescue_verification_status
|
||||||
|
WHEN 'pending' THEN 0
|
||||||
|
WHEN 'approved' THEN 1
|
||||||
|
WHEN 'rejected' THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END,
|
||||||
|
workspaces.created_at DESC`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateRescueVerificationStatus = async (workspaceId: number, status: RescueVerificationStatus) => {
|
||||||
|
const result = await db.query<WorkspaceRow>(
|
||||||
|
`UPDATE workspaces
|
||||||
|
SET rescue_verification_status = $2,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $1
|
||||||
|
AND workspace_type = 'rescue'
|
||||||
|
RETURNING id, name, workspace_type, billing_email, billing_plan, subscription_status, rescue_verification_status, created_at, updated_at`,
|
||||||
|
[workspaceId, status],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPlatformAdminSummary = async () => {
|
||||||
|
const result = await db.query<{
|
||||||
|
total_birds: number;
|
||||||
|
total_users: number;
|
||||||
|
total_workspaces: number;
|
||||||
|
rescue_workspaces: number;
|
||||||
|
pending_rescues: number;
|
||||||
|
daily_users: number;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
(SELECT COUNT(*)::int FROM birds) AS total_birds,
|
||||||
|
(SELECT COUNT(*)::int FROM users) AS total_users,
|
||||||
|
(SELECT COUNT(*)::int FROM workspaces) AS total_workspaces,
|
||||||
|
(SELECT COUNT(*)::int FROM workspaces WHERE workspace_type = 'rescue') AS rescue_workspaces,
|
||||||
|
(SELECT COUNT(*)::int FROM workspaces WHERE workspace_type = 'rescue' AND rescue_verification_status = 'pending') AS pending_rescues,
|
||||||
|
(SELECT COUNT(DISTINCT user_id)::int FROM auth_sessions WHERE created_at >= CURRENT_DATE) AS daily_users`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
export type WorkspaceType = 'standard' | 'rescue';
|
export type WorkspaceType = 'standard' | 'rescue';
|
||||||
export type WorkspaceRole = 'owner' | 'manager' | 'staff' | 'viewer';
|
export type WorkspaceRole = 'owner' | 'assistant' | 'caregiver' | 'viewer';
|
||||||
export type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw';
|
export type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw';
|
||||||
|
export type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'none';
|
||||||
|
export type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected';
|
||||||
export type ProviderKey = 'google' | 'microsoft' | 'apple';
|
export type ProviderKey = 'google' | 'microsoft' | 'apple';
|
||||||
export type IntegrationTokenScope = 'read_only' | 'read_write';
|
export type IntegrationTokenScope = 'read_only' | 'read_write';
|
||||||
export type BirdGender = 'unknown' | 'male' | 'female';
|
export type BirdGender = 'unknown' | 'male' | 'female';
|
||||||
@@ -19,6 +21,8 @@ export type WorkspaceRow = {
|
|||||||
workspace_type: WorkspaceType;
|
workspace_type: WorkspaceType;
|
||||||
billing_email: string | null;
|
billing_email: string | null;
|
||||||
billing_plan: BillingPlan;
|
billing_plan: BillingPlan;
|
||||||
|
subscription_status: SubscriptionStatus;
|
||||||
|
rescue_verification_status: RescueVerificationStatus;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ services:
|
|||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD for production}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD for production}
|
||||||
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
|
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
|
||||||
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
|
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
|
||||||
|
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||||
MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID:-}
|
MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID:-}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ services:
|
|||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flockpal_dev_password}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flockpal_dev_password}
|
||||||
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
|
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
|
||||||
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
|
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
|
||||||
|
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||||
MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID:-}
|
MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID:-}
|
||||||
|
|||||||
+11
-11
@@ -147,8 +147,8 @@ Integration tokens use the same bearer-token header format, but they are created
|
|||||||
Workspace roles used by protected endpoints:
|
Workspace roles used by protected endpoints:
|
||||||
|
|
||||||
- `owner`
|
- `owner`
|
||||||
- `manager`
|
- `assistant`
|
||||||
- `staff`
|
- `caregiver`
|
||||||
- `viewer`
|
- `viewer`
|
||||||
|
|
||||||
Role requirements are called out per endpoint below. If the signed-in member lacks permission, the API returns:
|
Role requirements are called out per endpoint below. If the signed-in member lacks permission, the API returns:
|
||||||
@@ -250,7 +250,7 @@ Role requirements are called out per endpoint below. If the signed-in member lac
|
|||||||
|
|
||||||
- Dates use `YYYY-MM-DD`
|
- Dates use `YYYY-MM-DD`
|
||||||
- `workspaceType` is `standard` or `rescue`
|
- `workspaceType` is `standard` or `rescue`
|
||||||
- member `role` is `owner`, `manager`, `staff`, or `viewer`
|
- member `role` is `owner`, `assistant`, `caregiver`, or `viewer`
|
||||||
- bird `gender` is `unknown`, `male`, or `female`
|
- bird `gender` is `unknown`, `male`, or `female`
|
||||||
- bird `chartColor` must be a `#RRGGBB` hex color
|
- bird `chartColor` must be a `#RRGGBB` hex color
|
||||||
- `photoDataUrl` must be a base64 `data:image/...` URL
|
- `photoDataUrl` must be a base64 `data:image/...` URL
|
||||||
@@ -613,7 +613,7 @@ Response `200`:
|
|||||||
|
|
||||||
#### `PUT /api/workspace`
|
#### `PUT /api/workspace`
|
||||||
|
|
||||||
Requires auth with write access and role `owner` or `manager`. Updates the active workspace.
|
Requires auth with write access and role `owner` or `assistant`. Updates the active workspace.
|
||||||
|
|
||||||
Request body:
|
Request body:
|
||||||
|
|
||||||
@@ -648,7 +648,7 @@ Response `200`:
|
|||||||
|
|
||||||
#### `POST /api/workspace/members`
|
#### `POST /api/workspace/members`
|
||||||
|
|
||||||
Requires auth with write access and role `owner` or `manager`. Invites or upserts a workspace member.
|
Requires auth with write access and role `owner` or `assistant`. Invites or upserts a workspace member.
|
||||||
|
|
||||||
Request body:
|
Request body:
|
||||||
|
|
||||||
@@ -670,7 +670,7 @@ Response `201`:
|
|||||||
|
|
||||||
#### `DELETE /api/workspace/members/:memberId`
|
#### `DELETE /api/workspace/members/:memberId`
|
||||||
|
|
||||||
Requires auth with write access and role `owner` or `manager`. Removes a non-owner member.
|
Requires auth with write access and role `owner` or `assistant`. Removes a non-owner member.
|
||||||
|
|
||||||
Response `204` with no body.
|
Response `204` with no body.
|
||||||
|
|
||||||
@@ -694,7 +694,7 @@ Response `200`:
|
|||||||
|
|
||||||
#### `POST /api/birds`
|
#### `POST /api/birds`
|
||||||
|
|
||||||
Requires auth with write access and role `owner`, `manager`, or `staff`. Creates a bird.
|
Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Creates a bird.
|
||||||
|
|
||||||
Request body:
|
Request body:
|
||||||
|
|
||||||
@@ -732,7 +732,7 @@ Possible errors:
|
|||||||
|
|
||||||
#### `PUT /api/birds/:birdId`
|
#### `PUT /api/birds/:birdId`
|
||||||
|
|
||||||
Requires auth with write access and role `owner`, `manager`, or `staff`. Updates a bird.
|
Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Updates a bird.
|
||||||
|
|
||||||
Request body matches `POST /api/birds`.
|
Request body matches `POST /api/birds`.
|
||||||
|
|
||||||
@@ -751,7 +751,7 @@ Possible errors:
|
|||||||
|
|
||||||
#### `DELETE /api/birds/:birdId`
|
#### `DELETE /api/birds/:birdId`
|
||||||
|
|
||||||
Requires auth with write access and role `owner`, `manager`, or `staff`. Deletes a bird.
|
Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Deletes a bird.
|
||||||
|
|
||||||
Response `204` with no body.
|
Response `204` with no body.
|
||||||
|
|
||||||
@@ -779,7 +779,7 @@ Response `200`:
|
|||||||
|
|
||||||
#### `POST /api/birds/:birdId/weights`
|
#### `POST /api/birds/:birdId/weights`
|
||||||
|
|
||||||
Requires auth with write access and role `owner`, `manager`, or `staff`. Creates a weight entry.
|
Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Creates a weight entry.
|
||||||
|
|
||||||
Request body:
|
Request body:
|
||||||
|
|
||||||
@@ -820,7 +820,7 @@ Response `200`:
|
|||||||
|
|
||||||
#### `POST /api/birds/:birdId/vet-visits`
|
#### `POST /api/birds/:birdId/vet-visits`
|
||||||
|
|
||||||
Requires auth with write access and role `owner`, `manager`, or `staff`. Creates a vet visit.
|
Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Creates a vet visit.
|
||||||
|
|
||||||
Request body:
|
Request body:
|
||||||
|
|
||||||
|
|||||||
+406
-54
@@ -5,7 +5,9 @@ import { findParrotWeightReference, parrotSpeciesOptions, type ParrotWeightRefer
|
|||||||
type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw';
|
type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw';
|
||||||
type HouseholdBillingPlan = Exclude<BillingPlan, 'rescue_free'>;
|
type HouseholdBillingPlan = Exclude<BillingPlan, 'rescue_free'>;
|
||||||
type WorkspaceType = 'standard' | 'rescue';
|
type WorkspaceType = 'standard' | 'rescue';
|
||||||
type WorkspaceRole = 'owner' | 'manager' | 'staff' | 'viewer';
|
type WorkspaceRole = 'owner' | 'assistant' | 'caregiver' | 'viewer';
|
||||||
|
type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'none';
|
||||||
|
type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected';
|
||||||
type IntegrationTokenScope = 'read_only' | 'read_write';
|
type IntegrationTokenScope = 'read_only' | 'read_write';
|
||||||
type BirdGender = 'unknown' | 'male' | 'female';
|
type BirdGender = 'unknown' | 'male' | 'female';
|
||||||
|
|
||||||
@@ -50,6 +52,8 @@ type Workspace = {
|
|||||||
workspaceType: WorkspaceType;
|
workspaceType: WorkspaceType;
|
||||||
billingEmail: string | null;
|
billingEmail: string | null;
|
||||||
billingPlan: BillingPlan;
|
billingPlan: BillingPlan;
|
||||||
|
subscriptionStatus: SubscriptionStatus;
|
||||||
|
rescueVerificationStatus: RescueVerificationStatus;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
@@ -89,9 +93,26 @@ type AuthSessionPayload = {
|
|||||||
activeWorkspace: Workspace;
|
activeWorkspace: Workspace;
|
||||||
activeMembership: WorkspaceMember;
|
activeMembership: WorkspaceMember;
|
||||||
workspaces: WorkspaceSummary[];
|
workspaces: WorkspaceSummary[];
|
||||||
|
isAdmin: boolean;
|
||||||
providers: AuthProvider[];
|
providers: AuthProvider[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AdminSummary = {
|
||||||
|
totalBirds: number;
|
||||||
|
totalUsers: number;
|
||||||
|
totalWorkspaces: number;
|
||||||
|
rescueWorkspaces: number;
|
||||||
|
pendingRescues: number;
|
||||||
|
dailyUsers: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AdminRescueWorkspace = {
|
||||||
|
workspace: Workspace;
|
||||||
|
ownerEmail: string | null;
|
||||||
|
birdCount: number;
|
||||||
|
memberCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
type IntegrationTokenSummary = {
|
type IntegrationTokenSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -201,7 +222,7 @@ type PhotoDragState = {
|
|||||||
startOffsetY: number;
|
startOffsetY: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AppPage = 'overview' | 'flock' | 'settings';
|
type AppPage = 'overview' | 'flock' | 'settings' | 'admin';
|
||||||
type SettingsSection = 'collaborators' | 'integration-tokens' | 'new-workspace' | 'flock-member' | 'transfer';
|
type SettingsSection = 'collaborators' | 'integration-tokens' | 'new-workspace' | 'flock-member' | 'transfer';
|
||||||
|
|
||||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api';
|
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api';
|
||||||
@@ -229,7 +250,7 @@ const emptyWorkspaceForm: WorkspaceFormState = {
|
|||||||
const emptyWorkspaceMemberForm: WorkspaceMemberFormState = {
|
const emptyWorkspaceMemberForm: WorkspaceMemberFormState = {
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
role: 'staff',
|
role: 'caregiver',
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyWorkspaceCreateForm: WorkspaceCreateFormState = {
|
const emptyWorkspaceCreateForm: WorkspaceCreateFormState = {
|
||||||
@@ -476,18 +497,18 @@ const formatBillingPlanName = (billingPlan: BillingPlan) => {
|
|||||||
|
|
||||||
const formatBillingPlanCapacity = (billingPlan: BillingPlan) => {
|
const formatBillingPlanCapacity = (billingPlan: BillingPlan) => {
|
||||||
if (billingPlan === 'rescue_free') {
|
if (billingPlan === 'rescue_free') {
|
||||||
return 'No billing is applied to rescue workspaces.';
|
return 'No billing is applied to rescue flocks.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (billingPlan === 'household_basic') {
|
if (billingPlan === 'household_basic') {
|
||||||
return 'Permits up to 4 birds in the workspace.';
|
return 'Permits up to 4 birds in the flock.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (billingPlan === 'household_plus') {
|
if (billingPlan === 'household_plus') {
|
||||||
return 'Permits 5 to 10 birds in the workspace.';
|
return 'Permits 5 to 10 birds in the flock.';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Permits 11 or more birds in the workspace.';
|
return 'Permits 11 or more birds in the flock.';
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatBillingPlanBirdLimit = (billingPlan: BillingPlan) => {
|
const formatBillingPlanBirdLimit = (billingPlan: BillingPlan) => {
|
||||||
@@ -506,6 +527,51 @@ const formatBillingPlanBirdLimit = (billingPlan: BillingPlan) => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatSubscriptionStatus = (status: SubscriptionStatus) => {
|
||||||
|
if (status === 'trialing') {
|
||||||
|
return 'Trialing';
|
||||||
|
}
|
||||||
|
if (status === 'past_due') {
|
||||||
|
return 'Past due';
|
||||||
|
}
|
||||||
|
if (status === 'canceled') {
|
||||||
|
return 'Canceled';
|
||||||
|
}
|
||||||
|
if (status === 'unpaid') {
|
||||||
|
return 'Unpaid';
|
||||||
|
}
|
||||||
|
if (status === 'none') {
|
||||||
|
return 'No subscription';
|
||||||
|
}
|
||||||
|
return 'Active';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatRescueVerificationStatus = (status: RescueVerificationStatus) => {
|
||||||
|
if (status === 'approved') {
|
||||||
|
return 'Approved';
|
||||||
|
}
|
||||||
|
if (status === 'rejected') {
|
||||||
|
return 'Rejected';
|
||||||
|
}
|
||||||
|
if (status === 'not_required') {
|
||||||
|
return 'Not required';
|
||||||
|
}
|
||||||
|
return 'Pending verification';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatWorkspaceRole = (role: WorkspaceRole) => {
|
||||||
|
if (role === 'owner') {
|
||||||
|
return 'Owner';
|
||||||
|
}
|
||||||
|
if (role === 'assistant') {
|
||||||
|
return 'Assistant';
|
||||||
|
}
|
||||||
|
if (role === 'caregiver') {
|
||||||
|
return 'Caregiver';
|
||||||
|
}
|
||||||
|
return 'Viewer';
|
||||||
|
};
|
||||||
|
|
||||||
const readFileAsDataUrl = async (file: File) =>
|
const readFileAsDataUrl = async (file: File) =>
|
||||||
new Promise<string>((resolve, reject) => {
|
new Promise<string>((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
@@ -747,6 +813,8 @@ function App() {
|
|||||||
const [activeMembership, setActiveMembership] = useState<WorkspaceMember | null>(null);
|
const [activeMembership, setActiveMembership] = useState<WorkspaceMember | null>(null);
|
||||||
const [workspaceMembers, setWorkspaceMembers] = useState<WorkspaceMember[]>([]);
|
const [workspaceMembers, setWorkspaceMembers] = useState<WorkspaceMember[]>([]);
|
||||||
const [integrationTokens, setIntegrationTokens] = useState<IntegrationTokenSummary[]>([]);
|
const [integrationTokens, setIntegrationTokens] = useState<IntegrationTokenSummary[]>([]);
|
||||||
|
const [adminSummary, setAdminSummary] = useState<AdminSummary | null>(null);
|
||||||
|
const [adminRescueWorkspaces, setAdminRescueWorkspaces] = useState<AdminRescueWorkspace[]>([]);
|
||||||
const [birds, setBirds] = useState<Bird[]>([]);
|
const [birds, setBirds] = useState<Bird[]>([]);
|
||||||
const [selectedBirdId, setSelectedBirdId] = useState<string>('');
|
const [selectedBirdId, setSelectedBirdId] = useState<string>('');
|
||||||
const [editingBirdId, setEditingBirdId] = useState<string>('');
|
const [editingBirdId, setEditingBirdId] = useState<string>('');
|
||||||
@@ -771,6 +839,7 @@ function App() {
|
|||||||
const [creatingIntegrationToken, setCreatingIntegrationToken] = useState(false);
|
const [creatingIntegrationToken, setCreatingIntegrationToken] = useState(false);
|
||||||
const [revokingIntegrationTokenId, setRevokingIntegrationTokenId] = useState('');
|
const [revokingIntegrationTokenId, setRevokingIntegrationTokenId] = useState('');
|
||||||
const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState('');
|
const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState('');
|
||||||
|
const [updatingRescueWorkspaceId, setUpdatingRescueWorkspaceId] = useState<number | null>(null);
|
||||||
const [switchingWorkspaceId, setSwitchingWorkspaceId] = useState<number | null>(null);
|
const [switchingWorkspaceId, setSwitchingWorkspaceId] = useState<number | null>(null);
|
||||||
const [showWeightAlertModal, setShowWeightAlertModal] = useState(false);
|
const [showWeightAlertModal, setShowWeightAlertModal] = useState(false);
|
||||||
const [speciesPickerOpen, setSpeciesPickerOpen] = useState(false);
|
const [speciesPickerOpen, setSpeciesPickerOpen] = useState(false);
|
||||||
@@ -795,6 +864,8 @@ function App() {
|
|||||||
notes: '',
|
notes: '',
|
||||||
});
|
});
|
||||||
const [deletingBird, setDeletingBird] = useState(false);
|
const [deletingBird, setDeletingBird] = useState(false);
|
||||||
|
const [editingVetVisitId, setEditingVetVisitId] = useState('');
|
||||||
|
const [deletingVetVisitId, setDeletingVetVisitId] = useState('');
|
||||||
const [removingWorkspaceMemberId, setRemovingWorkspaceMemberId] = useState('');
|
const [removingWorkspaceMemberId, setRemovingWorkspaceMemberId] = useState('');
|
||||||
const [expandedSettingsSection, setExpandedSettingsSection] = useState<SettingsSection | null>(null);
|
const [expandedSettingsSection, setExpandedSettingsSection] = useState<SettingsSection | null>(null);
|
||||||
|
|
||||||
@@ -1056,6 +1127,8 @@ function App() {
|
|||||||
setActiveMembership(null);
|
setActiveMembership(null);
|
||||||
setWorkspaceMembers([]);
|
setWorkspaceMembers([]);
|
||||||
setIntegrationTokens([]);
|
setIntegrationTokens([]);
|
||||||
|
setAdminSummary(null);
|
||||||
|
setAdminRescueWorkspaces([]);
|
||||||
setBirds([]);
|
setBirds([]);
|
||||||
setWeights([]);
|
setWeights([]);
|
||||||
setVetVisits([]);
|
setVetVisits([]);
|
||||||
@@ -1192,6 +1265,35 @@ function App() {
|
|||||||
void loadWorkspaceData();
|
void loadWorkspaceData();
|
||||||
}, [authToken, workspace?.id]);
|
}, [authToken, workspace?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authToken || !authSession?.isAdmin || activePage !== 'admin') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadAdminDashboard = async () => {
|
||||||
|
try {
|
||||||
|
const [summaryResponse, rescuesResponse] = await Promise.all([
|
||||||
|
apiFetch('/admin/summary', authToken),
|
||||||
|
apiFetch('/admin/rescue-workspaces', authToken),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!summaryResponse.ok || !rescuesResponse.ok) {
|
||||||
|
throw new Error('Unable to load admin dashboard.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryData = (await readJsonSafely<{ summary?: AdminSummary }>(summaryResponse)) ?? {};
|
||||||
|
const rescuesData = (await readJsonSafely<{ rescueWorkspaces?: AdminRescueWorkspace[] }>(rescuesResponse)) ?? {};
|
||||||
|
|
||||||
|
setAdminSummary(summaryData.summary ?? null);
|
||||||
|
setAdminRescueWorkspaces(rescuesData.rescueWorkspaces ?? []);
|
||||||
|
} catch (adminError) {
|
||||||
|
setError(adminError instanceof Error ? adminError.message : 'Unable to load admin dashboard.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadAdminDashboard();
|
||||||
|
}, [activePage, authSession?.isAdmin, authToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedBird?.id) {
|
if (!selectedBird?.id) {
|
||||||
setWeights([]);
|
setWeights([]);
|
||||||
@@ -1215,6 +1317,8 @@ function App() {
|
|||||||
|
|
||||||
setWeights(weightsData.weights ?? []);
|
setWeights(weightsData.weights ?? []);
|
||||||
setVetVisits(visitsData.vetVisits ?? []);
|
setVetVisits(visitsData.vetVisits ?? []);
|
||||||
|
setEditingVetVisitId('');
|
||||||
|
setDeletingVetVisitId('');
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
setError(loadError instanceof Error ? loadError.message : 'Unable to load flock member details.');
|
setError(loadError instanceof Error ? loadError.message : 'Unable to load flock member details.');
|
||||||
}
|
}
|
||||||
@@ -1355,13 +1459,13 @@ function App() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(await readErrorMessage(response, 'Unable to switch workspaces.'));
|
throw new Error(await readErrorMessage(response, 'Unable to switch flocks.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(response)) ?? {};
|
const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(response)) ?? {};
|
||||||
|
|
||||||
if (!data.session) {
|
if (!data.session) {
|
||||||
throw new Error('Unable to switch workspaces.');
|
throw new Error('Unable to switch flocks.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextToken = data.token || authToken;
|
const nextToken = data.token || authToken;
|
||||||
@@ -1373,7 +1477,7 @@ function App() {
|
|||||||
setVetVisits([]);
|
setVetVisits([]);
|
||||||
setActivePage('overview');
|
setActivePage('overview');
|
||||||
} catch (switchError) {
|
} catch (switchError) {
|
||||||
setError(switchError instanceof Error ? switchError.message : 'Unable to switch workspaces.');
|
setError(switchError instanceof Error ? switchError.message : 'Unable to switch flocks.');
|
||||||
} finally {
|
} finally {
|
||||||
setSwitchingWorkspaceId(null);
|
setSwitchingWorkspaceId(null);
|
||||||
}
|
}
|
||||||
@@ -1447,6 +1551,53 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRescueVerificationStatusChange = async (workspaceId: number, rescueVerificationStatus: RescueVerificationStatus) => {
|
||||||
|
if (!authToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError('');
|
||||||
|
setUpdatingRescueWorkspaceId(workspaceId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`/admin/rescue-workspaces/${workspaceId}`, authToken, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ rescueVerificationStatus }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response, 'Unable to update rescue verification status.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await readJsonSafely<{ workspace?: Workspace }>(response)) ?? {};
|
||||||
|
|
||||||
|
if (!data.workspace) {
|
||||||
|
throw new Error('Unable to update rescue verification status.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setAdminRescueWorkspaces((current) =>
|
||||||
|
current.map((entry) => (entry.workspace.id === workspaceId ? { ...entry, workspace: data.workspace! } : entry)),
|
||||||
|
);
|
||||||
|
setAdminSummary((current) =>
|
||||||
|
current
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
pendingRescues: adminRescueWorkspaces.filter((entry) =>
|
||||||
|
entry.workspace.id === workspaceId
|
||||||
|
? rescueVerificationStatus === 'pending'
|
||||||
|
: entry.workspace.rescueVerificationStatus === 'pending',
|
||||||
|
).length,
|
||||||
|
}
|
||||||
|
: current,
|
||||||
|
);
|
||||||
|
} catch (adminError) {
|
||||||
|
setError(adminError instanceof Error ? adminError.message : 'Unable to update rescue verification status.');
|
||||||
|
} finally {
|
||||||
|
setUpdatingRescueWorkspaceId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateWorkspace = async (event: React.FormEvent<HTMLFormElement>) => {
|
const handleCreateWorkspace = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
@@ -1470,17 +1621,17 @@ function App() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(await readErrorMessage(response, 'Unable to create workspace.'));
|
throw new Error(await readErrorMessage(response, 'Unable to create flock.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspaceResponse = await apiFetch('/auth/session', authToken);
|
const workspaceResponse = await apiFetch('/auth/session', authToken);
|
||||||
if (!workspaceResponse.ok) {
|
if (!workspaceResponse.ok) {
|
||||||
throw new Error(await readErrorMessage(workspaceResponse, 'Workspace was created but the session could not be refreshed.'));
|
throw new Error(await readErrorMessage(workspaceResponse, 'Flock was created but the session could not be refreshed.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(workspaceResponse)) ?? {};
|
const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(workspaceResponse)) ?? {};
|
||||||
if (!data.session) {
|
if (!data.session) {
|
||||||
throw new Error('Unable to refresh your workspace list.');
|
throw new Error('Unable to refresh your flock list.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextToken = data.token || authToken;
|
const nextToken = data.token || authToken;
|
||||||
@@ -1491,7 +1642,7 @@ function App() {
|
|||||||
billingEmail: data.session.user.email,
|
billingEmail: data.session.user.email,
|
||||||
});
|
});
|
||||||
} catch (workspaceError) {
|
} catch (workspaceError) {
|
||||||
setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to create workspace.');
|
setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to create flock.');
|
||||||
} finally {
|
} finally {
|
||||||
setCreatingWorkspace(false);
|
setCreatingWorkspace(false);
|
||||||
}
|
}
|
||||||
@@ -1832,22 +1983,29 @@ function App() {
|
|||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch(`/birds/${selectedBird.id}/vet-visits`, authToken, {
|
const isEditingVetVisit = Boolean(editingVetVisitId);
|
||||||
method: 'POST',
|
const response = await apiFetch(
|
||||||
headers: { 'Content-Type': 'application/json' },
|
isEditingVetVisit ? `/birds/${selectedBird.id}/vet-visits/${editingVetVisitId}` : `/birds/${selectedBird.id}/vet-visits`,
|
||||||
body: JSON.stringify(vetVisitForm),
|
authToken,
|
||||||
});
|
{
|
||||||
|
method: isEditingVetVisit ? 'PUT' : 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(vetVisitForm),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(await readErrorMessage(response, 'Unable to save vet visit.'));
|
throw new Error(await readErrorMessage(response, `Unable to ${isEditingVetVisit ? 'update' : 'save'} vet visit.`));
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await readJsonSafely<{ vetVisit: VetVisit }>(response);
|
const data = await readJsonSafely<{ vetVisit: VetVisit }>(response);
|
||||||
if (!data?.vetVisit) {
|
if (!data?.vetVisit) {
|
||||||
throw new Error('Unable to save vet visit.');
|
throw new Error(`Unable to ${isEditingVetVisit ? 'update' : 'save'} vet visit.`);
|
||||||
}
|
}
|
||||||
setVetVisits((current) =>
|
setVetVisits((current) =>
|
||||||
[data.vetVisit, ...current].sort((left, right) => right.visitedOn.localeCompare(left.visitedOn)),
|
(isEditingVetVisit ? current.map((visit) => (visit.id === data.vetVisit.id ? data.vetVisit : visit)) : [data.vetVisit, ...current]).sort(
|
||||||
|
(left, right) => right.visitedOn.localeCompare(left.visitedOn),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
setVetVisitForm({
|
setVetVisitForm({
|
||||||
visitedOn: new Date().toISOString().slice(0, 10),
|
visitedOn: new Date().toISOString().slice(0, 10),
|
||||||
@@ -1855,11 +2013,61 @@ function App() {
|
|||||||
reason: '',
|
reason: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
});
|
});
|
||||||
|
setEditingVetVisitId('');
|
||||||
} catch (submitError) {
|
} catch (submitError) {
|
||||||
setError(submitError instanceof Error ? submitError.message : 'Unable to save vet visit.');
|
setError(submitError instanceof Error ? submitError.message : 'Unable to save vet visit.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditVetVisit = (visit: VetVisit) => {
|
||||||
|
setEditingVetVisitId(visit.id);
|
||||||
|
setVetVisitForm({
|
||||||
|
visitedOn: visit.visitedOn,
|
||||||
|
clinicName: visit.clinicName,
|
||||||
|
reason: visit.reason,
|
||||||
|
notes: visit.notes ?? '',
|
||||||
|
});
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelVetVisitEdit = () => {
|
||||||
|
setEditingVetVisitId('');
|
||||||
|
setVetVisitForm({
|
||||||
|
visitedOn: new Date().toISOString().slice(0, 10),
|
||||||
|
clinicName: '',
|
||||||
|
reason: '',
|
||||||
|
notes: '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteVetVisit = async (visitId: string) => {
|
||||||
|
if (!selectedBird || deletingVetVisitId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeletingVetVisitId(visitId);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`/birds/${selectedBird.id}/vet-visits/${visitId}`, authToken, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response, 'Unable to remove vet visit.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
setVetVisits((current) => current.filter((visit) => visit.id !== visitId));
|
||||||
|
if (editingVetVisitId === visitId) {
|
||||||
|
handleCancelVetVisitEdit();
|
||||||
|
}
|
||||||
|
} catch (removeError) {
|
||||||
|
setError(removeError instanceof Error ? removeError.message : 'Unable to remove vet visit.');
|
||||||
|
} finally {
|
||||||
|
setDeletingVetVisitId('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleRemoveBird = async () => {
|
const handleRemoveBird = async () => {
|
||||||
if (!selectedBird || deletingBird) {
|
if (!selectedBird || deletingBird) {
|
||||||
return;
|
return;
|
||||||
@@ -1895,6 +2103,8 @@ function App() {
|
|||||||
setSelectedBirdId('');
|
setSelectedBirdId('');
|
||||||
setWeights([]);
|
setWeights([]);
|
||||||
setVetVisits([]);
|
setVetVisits([]);
|
||||||
|
setEditingVetVisitId('');
|
||||||
|
setDeletingVetVisitId('');
|
||||||
|
|
||||||
if (editingBirdId === selectedBird.id) {
|
if (editingBirdId === selectedBird.id) {
|
||||||
setEditingBirdId('');
|
setEditingBirdId('');
|
||||||
@@ -1967,13 +2177,13 @@ function App() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(await readErrorMessage(response, 'Unable to save workspace settings.'));
|
throw new Error(await readErrorMessage(response, 'Unable to save flock settings.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await readJsonSafely<{ workspace?: Workspace }>(response)) ?? {};
|
const data = (await readJsonSafely<{ workspace?: Workspace }>(response)) ?? {};
|
||||||
|
|
||||||
if (!data.workspace) {
|
if (!data.workspace) {
|
||||||
throw new Error('Unable to save workspace settings.');
|
throw new Error('Unable to save flock settings.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedWorkspace = data.workspace;
|
const savedWorkspace = data.workspace;
|
||||||
@@ -1997,7 +2207,7 @@ function App() {
|
|||||||
billingPlan: isHouseholdPlan(savedWorkspace.billingPlan) ? savedWorkspace.billingPlan : 'household_basic',
|
billingPlan: isHouseholdPlan(savedWorkspace.billingPlan) ? savedWorkspace.billingPlan : 'household_basic',
|
||||||
});
|
});
|
||||||
} catch (workspaceError) {
|
} catch (workspaceError) {
|
||||||
setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to save workspace settings.');
|
setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to save flock settings.');
|
||||||
} finally {
|
} finally {
|
||||||
setSavingWorkspace(false);
|
setSavingWorkspace(false);
|
||||||
}
|
}
|
||||||
@@ -2074,7 +2284,7 @@ function App() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">FlockPal</p>
|
<p className="eyebrow">FlockPal</p>
|
||||||
<h1>Loading your flock spaces...</h1>
|
<h1>Loading your flock spaces...</h1>
|
||||||
<p className="muted">Checking your sign-in and workspace access.</p>
|
<p className="muted">Checking your sign-in and flock access.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -2204,6 +2414,11 @@ function App() {
|
|||||||
<button className={`page-tab ${activePage === 'settings' ? 'active' : ''}`} onClick={() => setActivePage('settings')} type="button">
|
<button className={`page-tab ${activePage === 'settings' ? 'active' : ''}`} onClick={() => setActivePage('settings')} type="button">
|
||||||
Settings
|
Settings
|
||||||
</button>
|
</button>
|
||||||
|
{authSession.isAdmin ? (
|
||||||
|
<button className={`page-tab ${activePage === 'admin' ? 'active' : ''}`} onClick={() => setActivePage('admin')} type="button">
|
||||||
|
Admin
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showWorkspaceSwitcher ? (
|
{showWorkspaceSwitcher ? (
|
||||||
@@ -2219,7 +2434,7 @@ function App() {
|
|||||||
>
|
>
|
||||||
<span>{entry.workspace.name}</span>
|
<span>{entry.workspace.name}</span>
|
||||||
<small>
|
<small>
|
||||||
{formatBillingPlanName(entry.workspace.billingPlan)} • {entry.membership.role}
|
{formatBillingPlanName(entry.workspace.billingPlan)} • {formatWorkspaceRole(entry.membership.role)}
|
||||||
</small>
|
</small>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -2365,6 +2580,102 @@ function App() {
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{activePage === 'admin' && authSession.isAdmin ? (
|
||||||
|
<section className="stack-grid">
|
||||||
|
<article className="panel">
|
||||||
|
<div className="panel-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Admin</p>
|
||||||
|
<h2>Platform pulse</h2>
|
||||||
|
</div>
|
||||||
|
<p className="muted">Operational counts for the full FlockPal platform.</p>
|
||||||
|
</div>
|
||||||
|
<div className="summary-grid">
|
||||||
|
<article className="summary-card">
|
||||||
|
<span>Total birds</span>
|
||||||
|
<strong>{adminSummary?.totalBirds ?? '-'}</strong>
|
||||||
|
</article>
|
||||||
|
<article className="summary-card">
|
||||||
|
<span>Daily users</span>
|
||||||
|
<strong>{adminSummary?.dailyUsers ?? '-'}</strong>
|
||||||
|
</article>
|
||||||
|
<article className="summary-card">
|
||||||
|
<span>Total users</span>
|
||||||
|
<strong>{adminSummary?.totalUsers ?? '-'}</strong>
|
||||||
|
</article>
|
||||||
|
<article className="summary-card">
|
||||||
|
<span>Flocks</span>
|
||||||
|
<strong>{adminSummary?.totalWorkspaces ?? '-'}</strong>
|
||||||
|
</article>
|
||||||
|
<article className="summary-card">
|
||||||
|
<span>Rescue flocks</span>
|
||||||
|
<strong>{adminSummary?.rescueWorkspaces ?? '-'}</strong>
|
||||||
|
</article>
|
||||||
|
<article className="summary-card">
|
||||||
|
<span>Pending rescues</span>
|
||||||
|
<strong>{adminSummary?.pendingRescues ?? '-'}</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="panel">
|
||||||
|
<div className="panel-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Verification</p>
|
||||||
|
<h2>Rescue flocks</h2>
|
||||||
|
</div>
|
||||||
|
<p className="muted">Pending rescues are read-only until approved.</p>
|
||||||
|
</div>
|
||||||
|
<div className="recent-list">
|
||||||
|
{adminRescueWorkspaces.length ? (
|
||||||
|
adminRescueWorkspaces.map((entry) => (
|
||||||
|
<article key={entry.workspace.id} className="vet-visit-card">
|
||||||
|
<strong>{entry.workspace.name}</strong>
|
||||||
|
<span>
|
||||||
|
{formatRescueVerificationStatus(entry.workspace.rescueVerificationStatus)} • {entry.birdCount} birds • {entry.memberCount} members
|
||||||
|
</span>
|
||||||
|
<small>
|
||||||
|
Owner {entry.ownerEmail ?? 'unknown'} • Billing {entry.workspace.billingEmail ?? 'not set'}
|
||||||
|
</small>
|
||||||
|
<div className="button-row">
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={() => handleRescueVerificationStatusChange(entry.workspace.id, 'approved')}
|
||||||
|
type="button"
|
||||||
|
disabled={updatingRescueWorkspaceId === entry.workspace.id || entry.workspace.rescueVerificationStatus === 'approved'}
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={() => handleRescueVerificationStatusChange(entry.workspace.id, 'pending')}
|
||||||
|
type="button"
|
||||||
|
disabled={updatingRescueWorkspaceId === entry.workspace.id || entry.workspace.rescueVerificationStatus === 'pending'}
|
||||||
|
>
|
||||||
|
Mark pending
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={() => handleRescueVerificationStatusChange(entry.workspace.id, 'rejected')}
|
||||||
|
type="button"
|
||||||
|
disabled={updatingRescueWorkspaceId === entry.workspace.id || entry.workspace.rescueVerificationStatus === 'rejected'}
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<article className="vet-visit-card empty-card">
|
||||||
|
<strong>No rescue flocks yet</strong>
|
||||||
|
<small>New rescue flocks will appear here for verification review.</small>
|
||||||
|
</article>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{activePage === 'flock' ? (
|
{activePage === 'flock' ? (
|
||||||
<section className={showFlockDetailColumn ? 'dashboard-grid' : 'stack-grid'}>
|
<section className={showFlockDetailColumn ? 'dashboard-grid' : 'stack-grid'}>
|
||||||
<aside className="panel bird-list-panel">
|
<aside className="panel bird-list-panel">
|
||||||
@@ -2723,9 +3034,16 @@ function App() {
|
|||||||
placeholder="Exam notes, medications, follow-ups, or restrictions"
|
placeholder="Exam notes, medications, follow-ups, or restrictions"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button className="primary-button" type="submit">
|
<div className="button-row wide-field">
|
||||||
Save vet visit
|
<button className="primary-button" type="submit">
|
||||||
</button>
|
{editingVetVisitId ? 'Save vet visit changes' : 'Save vet visit'}
|
||||||
|
</button>
|
||||||
|
{editingVetVisitId ? (
|
||||||
|
<button className="secondary-button" onClick={handleCancelVetVisitEdit} type="button">
|
||||||
|
Cancel edit
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="recent-list">
|
<div className="recent-list">
|
||||||
@@ -2737,6 +3055,21 @@ function App() {
|
|||||||
{formatDate(visit.visitedOn)} • {visit.clinicName}
|
{formatDate(visit.visitedOn)} • {visit.clinicName}
|
||||||
</span>
|
</span>
|
||||||
<small>{visit.notes || 'No notes recorded.'}</small>
|
<small>{visit.notes || 'No notes recorded.'}</small>
|
||||||
|
<div className="button-row">
|
||||||
|
<button className="secondary-button" onClick={() => handleEditVetVisit(visit)} type="button">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
{editingVetVisitId === visit.id ? (
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={() => handleDeleteVetVisit(visit.id)}
|
||||||
|
type="button"
|
||||||
|
disabled={deletingVetVisitId === visit.id}
|
||||||
|
>
|
||||||
|
{deletingVetVisitId === visit.id ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
@@ -2761,21 +3094,21 @@ function App() {
|
|||||||
<article className="panel form-panel">
|
<article className="panel form-panel">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Workspace</p>
|
<p className="eyebrow">Flock</p>
|
||||||
<h2>Workspace profile and billing</h2>
|
<h2>Flock profile and billing</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Each workspace carries its own billing and collaboration rules. That lets one person keep a personal household flock while also
|
Each flock carries its own billing and collaboration rules. That lets one person keep a personal household flock while also
|
||||||
participating in a rescue workspace without mixing billing or bird ownership.
|
participating in a rescue flock without mixing billing or bird ownership.
|
||||||
</p>
|
</p>
|
||||||
<form className="form-panel" onSubmit={handleWorkspaceSubmit}>
|
<form className="form-panel" onSubmit={handleWorkspaceSubmit}>
|
||||||
<label>
|
<label>
|
||||||
Workspace name
|
Flock name
|
||||||
<input value={workspaceForm.name} onChange={(event) => setWorkspaceForm({ ...workspaceForm, name: event.target.value })} required />
|
<input value={workspaceForm.name} onChange={(event) => setWorkspaceForm({ ...workspaceForm, name: event.target.value })} required />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Workspace type
|
Flock type
|
||||||
<select
|
<select
|
||||||
value={workspaceForm.workspaceType}
|
value={workspaceForm.workspaceType}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -2789,6 +3122,15 @@ function App() {
|
|||||||
<option value="rescue">Rescue</option>
|
<option value="rescue">Rescue</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
{workspace?.workspaceType === 'standard' && workspaceForm.workspaceType === 'rescue' ? (
|
||||||
|
<article className="summary-card summary-alert-card">
|
||||||
|
<strong>Approval required before edits continue</strong>
|
||||||
|
<span>
|
||||||
|
Changing this household flock to a rescue flock will make it read-only until FlockPal approves the rescue verification.
|
||||||
|
Monitor the email address used to sign up for any follow-up details needed to approve rescue status.
|
||||||
|
</span>
|
||||||
|
</article>
|
||||||
|
) : null}
|
||||||
{workspaceForm.workspaceType === 'standard' ? (
|
{workspaceForm.workspaceType === 'standard' ? (
|
||||||
<>
|
<>
|
||||||
<label>
|
<label>
|
||||||
@@ -2815,7 +3157,7 @@ function App() {
|
|||||||
) : (
|
) : (
|
||||||
<article className="summary-card">
|
<article className="summary-card">
|
||||||
<strong>{formatBillingPlanName('rescue_free')}</strong>
|
<strong>{formatBillingPlanName('rescue_free')}</strong>
|
||||||
<span>Rescue workspaces stay free while still supporting shared team access.</span>
|
<span>Rescue flocks stay free while still supporting shared team access.</span>
|
||||||
</article>
|
</article>
|
||||||
)}
|
)}
|
||||||
<label>
|
<label>
|
||||||
@@ -2828,7 +3170,7 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button className="primary-button" type="submit" disabled={savingWorkspace}>
|
<button className="primary-button" type="submit" disabled={savingWorkspace}>
|
||||||
{savingWorkspace ? 'Saving workspace...' : 'Save workspace settings'}
|
{savingWorkspace ? 'Saving flock...' : 'Save flock settings'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
@@ -2843,8 +3185,18 @@ function App() {
|
|||||||
<div className="summary-grid">
|
<div className="summary-grid">
|
||||||
<article className="summary-card">
|
<article className="summary-card">
|
||||||
<strong>{workspace ? formatBillingPlanName(workspace.billingPlan) : 'No plan yet'}</strong>
|
<strong>{workspace ? formatBillingPlanName(workspace.billingPlan) : 'No plan yet'}</strong>
|
||||||
<span>{workspace ? formatBillingPlanCapacity(workspace.billingPlan) : 'Pick a workspace plan to see bird capacity.'}</span>
|
<span>{workspace ? formatBillingPlanCapacity(workspace.billingPlan) : 'Pick a flock plan to see bird capacity.'}</span>
|
||||||
</article>
|
</article>
|
||||||
|
<article className="summary-card">
|
||||||
|
<strong>{workspace ? formatSubscriptionStatus(workspace.subscriptionStatus) : 'Unknown'}</strong>
|
||||||
|
<span>Flock write access will follow subscription health once billing is connected.</span>
|
||||||
|
</article>
|
||||||
|
{workspace?.workspaceType === 'rescue' ? (
|
||||||
|
<article className="summary-card">
|
||||||
|
<strong>{formatRescueVerificationStatus(workspace.rescueVerificationStatus)}</strong>
|
||||||
|
<span>Rescue flocks are read-only until an admin approves their verification.</span>
|
||||||
|
</article>
|
||||||
|
) : null}
|
||||||
<article className="summary-card">
|
<article className="summary-card">
|
||||||
<strong>{workspace?.billingEmail || authSession.user.email}</strong>
|
<strong>{workspace?.billingEmail || authSession.user.email}</strong>
|
||||||
<span>Billing contact for invoices, receipts, and account notices.</span>
|
<span>Billing contact for invoices, receipts, and account notices.</span>
|
||||||
@@ -2858,7 +3210,7 @@ function App() {
|
|||||||
<span>
|
<span>
|
||||||
{workspace && formatBillingPlanBirdLimit(workspace.billingPlan)
|
{workspace && formatBillingPlanBirdLimit(workspace.billingPlan)
|
||||||
? 'Current bird count against your paid plan allowance.'
|
? 'Current bird count against your paid plan allowance.'
|
||||||
: 'Current flock count in this workspace.'}
|
: 'Current bird count in this flock.'}
|
||||||
</span>
|
</span>
|
||||||
</article>
|
</article>
|
||||||
<article className="summary-card">
|
<article className="summary-card">
|
||||||
@@ -2872,7 +3224,7 @@ function App() {
|
|||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Collaborators</p>
|
<p className="eyebrow">Collaborators</p>
|
||||||
<h2>Shared workspace access</h2>
|
<h2>Shared flock access</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
@@ -2888,7 +3240,7 @@ function App() {
|
|||||||
{expandedSettingsSection === 'collaborators' ? (
|
{expandedSettingsSection === 'collaborators' ? (
|
||||||
<>
|
<>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Invite other people to help manage this flock. Rescue workspaces support teams, and household workspaces can also support
|
Invite other people to help manage this flock. Rescue flocks support teams, and household flocks can also support
|
||||||
co-caregivers without changing who owns the billing.
|
co-caregivers without changing who owns the billing.
|
||||||
</p>
|
</p>
|
||||||
<form className="form-panel" onSubmit={handleWorkspaceMemberSubmit}>
|
<form className="form-panel" onSubmit={handleWorkspaceMemberSubmit}>
|
||||||
@@ -2921,8 +3273,8 @@ function App() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="owner">Owner</option>
|
<option value="owner">Owner</option>
|
||||||
<option value="manager">Manager</option>
|
<option value="assistant">Assistant</option>
|
||||||
<option value="staff">Staff</option>
|
<option value="caregiver">Caregiver</option>
|
||||||
<option value="viewer">Viewer</option>
|
<option value="viewer">Viewer</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
@@ -2937,7 +3289,7 @@ function App() {
|
|||||||
<article key={member.id} className="vet-visit-card">
|
<article key={member.id} className="vet-visit-card">
|
||||||
<strong>{member.name}</strong>
|
<strong>{member.name}</strong>
|
||||||
<span>
|
<span>
|
||||||
{member.role} • {member.email || member.inviteEmail}
|
{formatWorkspaceRole(member.role)} • {member.email || member.inviteEmail}
|
||||||
</span>
|
</span>
|
||||||
<small>{member.acceptedAt ? 'Active access' : 'Invitation pending'}</small>
|
<small>{member.acceptedAt ? 'Active access' : 'Invitation pending'}</small>
|
||||||
<button
|
<button
|
||||||
@@ -2953,7 +3305,7 @@ function App() {
|
|||||||
) : (
|
) : (
|
||||||
<article className="vet-visit-card empty-card">
|
<article className="vet-visit-card empty-card">
|
||||||
<strong>No collaborators yet</strong>
|
<strong>No collaborators yet</strong>
|
||||||
<small>Add the people who should be able to help care for birds in this workspace.</small>
|
<small>Add the people who should be able to help care for birds in this flock.</small>
|
||||||
</article>
|
</article>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2981,7 +3333,7 @@ function App() {
|
|||||||
{expandedSettingsSection === 'integration-tokens' ? (
|
{expandedSettingsSection === 'integration-tokens' ? (
|
||||||
<>
|
<>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Create a workspace-scoped token for automations like n8n. The secret is shown only once, so store it in your automation tool when it appears.
|
Create a flock-scoped token for automations like n8n. The secret is shown only once, so store it in your automation tool when it appears.
|
||||||
</p>
|
</p>
|
||||||
<form className="form-panel" onSubmit={handleCreateIntegrationToken}>
|
<form className="form-panel" onSubmit={handleCreateIntegrationToken}>
|
||||||
<label>
|
<label>
|
||||||
@@ -3056,7 +3408,7 @@ function App() {
|
|||||||
) : (
|
) : (
|
||||||
<article className="vet-visit-card empty-card">
|
<article className="vet-visit-card empty-card">
|
||||||
<strong>No integration tokens yet</strong>
|
<strong>No integration tokens yet</strong>
|
||||||
<small>Create one for n8n, scripts, or other personal automations tied to this workspace.</small>
|
<small>Create one for n8n, scripts, or other personal automations tied to this flock.</small>
|
||||||
</article>
|
</article>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -3067,7 +3419,7 @@ function App() {
|
|||||||
<article className="panel form-panel">
|
<article className="panel form-panel">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">New workspace</p>
|
<p className="eyebrow">New flock</p>
|
||||||
<h2>Add another flock space</h2>
|
<h2>Add another flock space</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -3084,12 +3436,12 @@ function App() {
|
|||||||
{expandedSettingsSection === 'new-workspace' ? (
|
{expandedSettingsSection === 'new-workspace' ? (
|
||||||
<>
|
<>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
This is the key piece for someone who helps with a rescue but also keeps their own birds at home. Each workspace stays separate
|
This is the key piece for someone who helps with a rescue but also keeps their own birds at home. Each flock stays separate
|
||||||
for access and billing.
|
for access and billing.
|
||||||
</p>
|
</p>
|
||||||
<form className="form-panel" onSubmit={handleCreateWorkspace}>
|
<form className="form-panel" onSubmit={handleCreateWorkspace}>
|
||||||
<label>
|
<label>
|
||||||
Workspace name
|
Flock name
|
||||||
<input
|
<input
|
||||||
value={workspaceCreateForm.name}
|
value={workspaceCreateForm.name}
|
||||||
onChange={(event) => setWorkspaceCreateForm({ ...workspaceCreateForm, name: event.target.value })}
|
onChange={(event) => setWorkspaceCreateForm({ ...workspaceCreateForm, name: event.target.value })}
|
||||||
@@ -3097,7 +3449,7 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Workspace type
|
Flock type
|
||||||
<select
|
<select
|
||||||
value={workspaceCreateForm.workspaceType}
|
value={workspaceCreateForm.workspaceType}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -3137,7 +3489,7 @@ function App() {
|
|||||||
) : (
|
) : (
|
||||||
<article className="summary-card">
|
<article className="summary-card">
|
||||||
<strong>{formatBillingPlanName('rescue_free')}</strong>
|
<strong>{formatBillingPlanName('rescue_free')}</strong>
|
||||||
<span>No billing is applied to rescue workspaces.</span>
|
<span>No billing is applied to rescue flocks.</span>
|
||||||
</article>
|
</article>
|
||||||
)}
|
)}
|
||||||
<label>
|
<label>
|
||||||
@@ -3150,7 +3502,7 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button className="primary-button" type="submit" disabled={creatingWorkspace}>
|
<button className="primary-button" type="submit" disabled={creatingWorkspace}>
|
||||||
{creatingWorkspace ? 'Creating workspace...' : 'Create workspace'}
|
{creatingWorkspace ? 'Creating flock...' : 'Create flock'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user