fixing rescue settings
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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<BirdRow>(
|
||||
`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<WeightRow>(
|
||||
`SELECT id, bird_id, weight_grams, recorded_on::text, notes
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user