From 765d6c61dbc9b35105e24961cdd80827b8444e15 Mon Sep 17 00:00:00 2001 From: blaisadmin Date: Wed, 15 Apr 2026 22:57:34 -0400 Subject: [PATCH] fixing rescue settings --- backend/src/app.ts | 96 ++++++++++ .../src/repositories/birdRepository.test.ts | 33 +++- backend/src/repositories/birdRepository.ts | 27 +++ .../repositories/workspaceRepository.test.ts | 48 ++++- .../src/repositories/workspaceRepository.ts | 44 +++++ docs/API_REFERENCE.md | 58 ++++++ frontend/src/App.tsx | 175 +++++++++++++++++- frontend/src/index.css | 6 + 8 files changed, 476 insertions(+), 11 deletions(-) diff --git a/backend/src/app.ts b/backend/src/app.ts index 94a4e16..ee3b677 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -36,6 +36,7 @@ import { listBirds, listVetVisitsForBird, listWeightsForBird, + transferBirdToWorkspace, updateBird, updateVetVisitForBird, } from './repositories/birdRepository.js'; @@ -45,7 +46,10 @@ import { claimWorkspaceInvites, createWorkspace, deleteWorkspaceMember, + deleteWorkspaceIfEmpty, ensurePersonalWorkspaceForUser, + findAlternateWorkspaceForUser, + getWorkspaceBirdCount, getPlatformAdminSummary, getMembershipForUser, getNextWorkspaceId, @@ -158,6 +162,10 @@ const transferDraftSchema = z.object({ notes: z.string().trim().max(1000).optional().or(z.literal('')), }); +const flockTransferSchema = z.object({ + targetWorkspaceId: z.coerce.number().int().positive(), +}); + const birdSchema = z.object({ name: z.string().trim().min(1).max(120), tagId: z.string().trim().min(1).max(80), @@ -1226,6 +1234,55 @@ app.put('/api/workspace', requireAuth, requireWriteAccess, requireWorkspaceRole( } }); +app.delete('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRole(['owner']), async (req: Request, res: Response, next: NextFunction) => { + try { + if ((await getWorkspaceBirdCount(req.auth!.workspace.id)) > 0) { + res.status(409).json({ error: 'Remove or transfer all birds from this flock before deleting it.' }); + return; + } + + let nextWorkspaceId = await findAlternateWorkspaceForUser(req.auth!.user.id, req.auth!.workspace.id); + + if (!nextWorkspaceId) { + const fallbackWorkspaceId = await getNextWorkspaceId(); + const fallbackWorkspace = await createWorkspace({ + id: fallbackWorkspaceId, + name: `${req.auth!.user.name}'s Flock`, + workspaceType: 'standard', + billingEmail: req.auth!.user.email, + billingPlan: 'household_basic', + owner: req.auth!.user, + }); + nextWorkspaceId = fallbackWorkspace?.id ?? fallbackWorkspaceId; + } + + await updateSessionWorkspace(req.auth!.session.id, nextWorkspaceId); + + const deletion = await deleteWorkspaceIfEmpty(req.auth!.workspace.id); + + if (!deletion.deleted) { + await updateSessionWorkspace(req.auth!.session.id, req.auth!.workspace.id); + + res.status(404).json({ error: 'Flock not found.' }); + return; + } + + const updatedAuth = await resolveSessionAuth(hashToken(req.auth!.token), req.auth!.token); + + if (!updatedAuth) { + throw new Error('Unable to reload session.'); + } + + res.json({ + deletedWorkspaceId: req.auth!.workspace.id, + token: req.auth!.token, + session: await buildSessionPayload(updatedAuth), + }); + } catch (error) { + next(error); + } +}); + app.post( '/api/workspace/rescue-status/cancel', requireAuth, @@ -1345,6 +1402,45 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o } }); +app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, requireSessionAuth, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => { + const parsed = flockTransferSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ error: 'Invalid flock transfer payload', details: parsed.error.flatten() }); + return; + } + + if (parsed.data.targetWorkspaceId === req.auth!.workspace.id) { + res.status(400).json({ error: 'Choose a different destination flock.' }); + return; + } + + try { + const targetMembership = await getMembershipForUser(req.auth!.user.id, parsed.data.targetWorkspaceId); + + if (!targetMembership) { + res.status(403).json({ error: 'You do not have access to that destination flock.' }); + return; + } + + const bird = await transferBirdToWorkspace(req.params.birdId, req.auth!.workspace.id, parsed.data.targetWorkspaceId); + + if (!bird) { + res.status(404).json({ error: 'Bird not found.' }); + return; + } + + res.json({ bird: normalizeBird(bird) }); + } catch (error) { + if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { + res.status(409).json({ error: 'That band/tag ID is already in use in the destination flock.' }); + return; + } + + next(error); + } +}); + app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => { const parsed = birdSchema.safeParse(req.body); diff --git a/backend/src/repositories/birdRepository.test.ts b/backend/src/repositories/birdRepository.test.ts index 1085399..8c2cc72 100644 --- a/backend/src/repositories/birdRepository.test.ts +++ b/backend/src/repositories/birdRepository.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { createBird, getBirdById, listWeightsForBird } from './birdRepository.js'; +import { createBird, getBirdById, listWeightsForBird, transferBirdToWorkspace } from './birdRepository.js'; import { mockDb } from '../test/mockDb.js'; test('getBirdById returns null when the bird does not exist in the workspace', async () => { @@ -70,3 +70,34 @@ test('listWeightsForBird scopes by bird, workspace, and day window', async () => assert.deepEqual(calls[0].params, ['bird-1', 30, 10]); assert.match(calls[0].text, /FROM weight_records/); }); + +test('transferBirdToWorkspace moves the bird to the target workspace', async () => { + const { calls } = mockDb({ + rowCount: 1, + rows: [ + { + id: 'bird-1', + workspace_id: 22, + name: 'Kiwi', + tag_id: 'A-1', + species: 'Cockatiel', + gender: 'female', + date_of_birth: null, + gotcha_day: null, + chart_color: '#cb3a35', + photo_data_url: null, + notify_on_dob: false, + notify_on_gotcha_day: false, + created_at: '2026-04-14T00:00:00.000Z', + latest_weight_grams: '92', + latest_recorded_on: '2026-04-14', + }, + ], + }); + + const bird = await transferBirdToWorkspace('bird-1', 10, 22); + + assert.equal(bird?.workspace_id, 22); + assert.deepEqual(calls[0].params, ['bird-1', 10, 22]); + assert.match(calls[0].text, /UPDATE birds/); +}); diff --git a/backend/src/repositories/birdRepository.ts b/backend/src/repositories/birdRepository.ts index 5259bf4..a0f747f 100644 --- a/backend/src/repositories/birdRepository.ts +++ b/backend/src/repositories/birdRepository.ts @@ -168,6 +168,33 @@ export const deleteBird = async (birdId: string, workspaceId: number) => { return Boolean(result.rowCount); }; +export const transferBirdToWorkspace = async (birdId: string, sourceWorkspaceId: number, targetWorkspaceId: number) => { + const result = await db.query( + `UPDATE birds + SET workspace_id = $3 + WHERE id = $1 + AND workspace_id = $2 + RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, created_at, + ( + SELECT weight_grams::text + FROM weight_records + WHERE bird_id = birds.id + ORDER BY recorded_on DESC + LIMIT 1 + ) AS latest_weight_grams, + ( + SELECT recorded_on::text + FROM weight_records + WHERE bird_id = birds.id + ORDER BY recorded_on DESC + LIMIT 1 + ) AS latest_recorded_on`, + [birdId, sourceWorkspaceId, targetWorkspaceId], + ); + + return result.rows[0] ?? null; +}; + export const listWeightsForBird = async (birdId: string, workspaceId: number, days: number) => { const result = await db.query( `SELECT id, bird_id, weight_grams, recorded_on::text, notes diff --git a/backend/src/repositories/workspaceRepository.test.ts b/backend/src/repositories/workspaceRepository.test.ts index 84cf91b..a554e43 100644 --- a/backend/src/repositories/workspaceRepository.test.ts +++ b/backend/src/repositories/workspaceRepository.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { createWorkspace, ensurePersonalWorkspaceForUser, updateWorkspace } from './workspaceRepository.js'; +import { createWorkspace, deleteWorkspaceIfEmpty, ensurePersonalWorkspaceForUser, findAlternateWorkspaceForUser, updateWorkspace } from './workspaceRepository.js'; import { mockDb } from '../test/mockDb.js'; import type { UserRow } from '../types.js'; @@ -95,3 +95,49 @@ test('updateWorkspace converts an existing household flock to rescue without ins assert.doesNotMatch(calls[0].text, /INSERT INTO workspaces/); assert.deepEqual(calls[0].params, [42, 'Converted Rescue', 'rescue', 'billing@example.com', 'rescue_free']); }); + +test('deleteWorkspaceIfEmpty blocks deletion when birds are still assigned', async () => { + const { calls } = mockDb( + { + rowCount: 1, + rows: [{ count: '2' }], + }, + ); + + const result = await deleteWorkspaceIfEmpty(42); + + assert.deepEqual(result, { deleted: false, reason: 'birds_present' }); + assert.equal(calls.length, 1); + assert.match(calls[0].text, /FROM birds/); +}); + +test('deleteWorkspaceIfEmpty deletes an empty workspace', async () => { + const { calls } = mockDb( + { + rowCount: 1, + rows: [{ count: '0' }], + }, + { + rowCount: 1, + rows: [{ id: 42 }], + }, + ); + + const result = await deleteWorkspaceIfEmpty(42); + + assert.deepEqual(result, { deleted: true }); + assert.equal(calls.length, 2); + assert.match(calls[1].text, /DELETE FROM workspaces/); +}); + +test('findAlternateWorkspaceForUser returns another workspace when available', async () => { + const { calls } = mockDb({ + rowCount: 1, + rows: [{ workspace_id: 84 }], + }); + + const workspaceId = await findAlternateWorkspaceForUser('user-1', 42); + + assert.equal(workspaceId, 84); + assert.deepEqual(calls[0].params, ['user-1', 42]); +}); diff --git a/backend/src/repositories/workspaceRepository.ts b/backend/src/repositories/workspaceRepository.ts index c576e5a..5345ee0 100644 --- a/backend/src/repositories/workspaceRepository.ts +++ b/backend/src/repositories/workspaceRepository.ts @@ -236,6 +236,50 @@ export const listWorkspaceMembers = async (workspaceId: number) => { return result.rows; }; +export const findAlternateWorkspaceForUser = async (userId: string, excludeWorkspaceId: number) => { + const result = await db.query<{ workspace_id: number }>( + `SELECT workspace_id + FROM workspace_members + WHERE user_id = $1 + AND workspace_id <> $2 + ORDER BY created_at ASC + LIMIT 1`, + [userId, excludeWorkspaceId], + ); + + return result.rows[0] ? Number(result.rows[0].workspace_id) : null; +}; + +export const getWorkspaceBirdCount = async (workspaceId: number) => { + const birdCount = await db.query<{ count: string }>( + `SELECT COUNT(*)::text AS count + FROM birds + WHERE workspace_id = $1`, + [workspaceId], + ); + + return Number(birdCount.rows[0]?.count ?? 0); +}; + +export const deleteWorkspaceIfEmpty = async (workspaceId: number) => { + if ((await getWorkspaceBirdCount(workspaceId)) > 0) { + return { deleted: false as const, reason: 'birds_present' as const }; + } + + const deleted = await db.query<{ id: number }>( + `DELETE FROM workspaces + WHERE id = $1 + RETURNING id`, + [workspaceId], + ); + + if (!deleted.rowCount) { + return { deleted: false as const, reason: 'not_found' as const }; + } + + return { deleted: true as const }; +}; + export const upsertWorkspaceMember = async ({ workspaceId, inviteEmail, diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index d9db1b4..bd72599 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -634,6 +634,31 @@ Response `200`: } ``` +#### `DELETE /api/workspace` + +Requires a browser session and role `owner`. Deletes the active flock only when it has no birds. + +Behavior: + +- if the flock still has birds, deletion is blocked +- collaborators, sessions, and integration tokens tied to the flock are removed with it +- the backend switches the user to another existing flock, or creates a new personal flock automatically if needed + +Response `200`: + +```json +{ + "deletedWorkspaceId": 1001, + "token": "raw-session-token", + "session": {} +} +``` + +Possible errors: + +- `409` if birds are still assigned to the flock +- `404` if the flock no longer exists + #### `GET /api/workspace/members` Requires auth. Lists members for the active workspace. Browser sessions and integration tokens can both use this endpoint. @@ -749,6 +774,39 @@ Possible errors: - `404` if the bird does not exist in the active workspace - `409` if the workspace already uses that `tagId` +#### `POST /api/birds/:birdId/transfer` + +Requires a browser session, write access, and role `owner` or `assistant`. Moves a bird from the active flock to another flock the same user can access. + +Request body: + +```json +{ + "targetWorkspaceId": 1002 +} +``` + +Notes: + +- the destination flock must be different from the current flock +- the signed-in user must already be a member of the destination flock +- the bird keeps its existing weight and vet history because the record itself is reassigned + +Response `200`: + +```json +{ + "bird": {} +} +``` + +Possible errors: + +- `400` if the destination flock is the current flock or the payload is invalid +- `403` if the user does not have access to the destination flock +- `404` if the bird is not in the active flock +- `409` if the destination flock already has a bird using the same `tagId` + #### `DELETE /api/birds/:birdId` Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Deletes a bird. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cad3109..e41c177 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -548,7 +548,7 @@ const formatSubscriptionStatus = (status: SubscriptionStatus) => { const formatRescueVerificationStatus = (status: RescueVerificationStatus) => { if (status === 'approved') { - return 'Approved'; + return 'Active'; } if (status === 'rejected') { return 'Rejected'; @@ -837,6 +837,7 @@ function App() { const [cancelingRescueRequest, setCancelingRescueRequest] = useState(false); const [savingWorkspaceMember, setSavingWorkspaceMember] = useState(false); const [creatingWorkspace, setCreatingWorkspace] = useState(false); + const [deletingWorkspace, setDeletingWorkspace] = useState(false); const [creatingIntegrationToken, setCreatingIntegrationToken] = useState(false); const [revokingIntegrationTokenId, setRevokingIntegrationTokenId] = useState(''); const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState(''); @@ -864,6 +865,10 @@ function App() { destinationOwnerEmail: '', notes: '', }); + const [flockTransferForm, setFlockTransferForm] = useState({ + birdId: '', + targetWorkspaceId: '', + }); const [deletingBird, setDeletingBird] = useState(false); const [editingVetVisitId, setEditingVetVisitId] = useState(''); const [deletingVetVisitId, setDeletingVetVisitId] = useState(''); @@ -874,6 +879,10 @@ function App() { () => birds.find((bird) => bird.id === selectedBirdId) ?? null, [birds, selectedBirdId], ); + const transferableWorkspaces = useMemo( + () => authSession?.workspaces.filter((entry) => entry.workspace.id !== workspace?.id) ?? [], + [authSession, workspace?.id], + ); const editingBird = useMemo( () => birds.find((bird) => bird.id === editingBirdId) ?? null, [birds, editingBirdId], @@ -2161,6 +2170,53 @@ function App() { } }; + const handleFlockTransferSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setError(''); + + try { + const response = await apiFetch(`/birds/${flockTransferForm.birdId}/transfer`, authToken, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + targetWorkspaceId: Number(flockTransferForm.targetWorkspaceId), + }), + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to transfer bird to another flock.')); + } + + const data = (await readJsonSafely<{ bird?: Bird }>(response)) ?? {}; + const transferredBirdName = data.bird?.name || birds.find((bird) => bird.id === flockTransferForm.birdId)?.name || 'Bird'; + + setBirds((current) => current.filter((bird) => bird.id !== flockTransferForm.birdId)); + setAllBirdWeights((current) => { + const next = { ...current }; + delete next[flockTransferForm.birdId]; + return next; + }); + setWeights((current) => (selectedBird?.id === flockTransferForm.birdId ? [] : current)); + setVetVisits((current) => (selectedBird?.id === flockTransferForm.birdId ? [] : current)); + if (selectedBird?.id === flockTransferForm.birdId) { + setSelectedBirdId(''); + } + if (editingBirdId === flockTransferForm.birdId) { + setEditingBirdId(''); + setBirdForm(emptyBirdForm); + setBirdPhotoName(''); + } + setFlockTransferForm({ + birdId: '', + targetWorkspaceId: '', + }); + + window.alert(`${transferredBirdName} was moved to the selected flock.`); + } catch (submitError) { + setError(submitError instanceof Error ? submitError.message : 'Unable to transfer bird to another flock.'); + } + }; + const handleWorkspaceSubmit = async (event: React.FormEvent) => { event.preventDefault(); setError(''); @@ -2213,6 +2269,47 @@ function App() { } }; + const handleDeleteWorkspace = async () => { + if (!workspace || !authToken || deletingWorkspace || activeMembership?.role !== 'owner') { + return; + } + + const confirmed = window.confirm( + `Delete ${workspace.name}?\n\nThis only works when the flock has no birds. Remove or transfer all birds first.\n\nYou will be switched to another flock or a new personal flock automatically.`, + ); + + if (!confirmed) { + return; + } + + setError(''); + setDeletingWorkspace(true); + + try { + const response = await apiFetch('/workspace', authToken, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to delete flock.')); + } + + const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(response)) ?? {}; + + if (!data.session) { + throw new Error('Flock was deleted but the session could not be refreshed.'); + } + + const nextToken = data.token || authToken; + persistSessionToken(nextToken); + applySession(data.session, nextToken); + } catch (workspaceError) { + setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to delete flock.'); + } finally { + setDeletingWorkspace(false); + } + }; + const handleCancelRescueRequest = async () => { if (!authToken) { return; @@ -3226,6 +3323,14 @@ function App() { ? 'Convert current flock to rescue' : 'Save flock settings'} + {activeMembership?.role === 'owner' ? ( + + ) : null} + {activeMembership?.role === 'owner' ? ( + Delete is only available when this flock has no birds. Collaborators and tokens are removed with it. + ) : null} @@ -3241,14 +3346,22 @@ function App() { {workspace ? formatBillingPlanName(workspace.billingPlan) : 'No plan yet'} {workspace ? formatBillingPlanCapacity(workspace.billingPlan) : 'Pick a flock plan to see bird capacity.'} -
- {workspace ? formatSubscriptionStatus(workspace.subscriptionStatus) : 'Unknown'} - Flock write access will follow subscription health once billing is connected. -
+ {workspace?.workspaceType !== 'rescue' ? ( +
+ {workspace ? formatSubscriptionStatus(workspace.subscriptionStatus) : 'Unknown'} + Flock write access will follow subscription health once billing is connected. +
+ ) : null} {workspace?.workspaceType === 'rescue' ? (
{formatRescueVerificationStatus(workspace.rescueVerificationStatus)} - Rescue flocks are read-only until an admin approves their verification. + + {workspace.rescueVerificationStatus === 'approved' + ? 'Rescue verification is approved and this flock is fully active.' + : workspace.rescueVerificationStatus === 'rejected' + ? 'This rescue request was rejected. Update the flock or contact support before trying again.' + : 'Rescue flocks are read-only until an admin approves their verification.'} + {workspace.rescueVerificationStatus === 'pending' && (activeMembership?.role === 'owner' || activeMembership?.role === 'assistant') ? ( + {!transferableWorkspaces.length ? ( + Create or join another flock first to use in-app bird transfers. + ) : null} + + +
+ +

+ For future owner-to-owner handoffs outside your own flocks, save a transfer draft below.