fixed transfer process

This commit is contained in:
blaisadmin
2026-04-15 23:39:10 -04:00
parent 765d6c61db
commit 3a0e30085c
9 changed files with 480 additions and 231 deletions
+112 -65
View File
@@ -27,7 +27,9 @@ import {
updateUserName, updateUserName,
} from './repositories/authRepository.js'; } from './repositories/authRepository.js';
import { import {
completePendingBirdTransfersForOwner,
createBird, createBird,
createPendingBirdTransfer,
createVetVisitForBird, createVetVisitForBird,
createWeightForBird, createWeightForBird,
deleteBird, deleteBird,
@@ -54,6 +56,7 @@ import {
getMembershipForUser, getMembershipForUser,
getNextWorkspaceId, getNextWorkspaceId,
getWorkspaceById, getWorkspaceById,
listOwnedWorkspacesByOwnerEmail,
listRescueWorkspacesForAdmin, listRescueWorkspacesForAdmin,
listMembershipsForUser, listMembershipsForUser,
listWorkspaceMembers, listWorkspaceMembers,
@@ -156,14 +159,8 @@ const workspaceMemberSchema = z.object({
role: workspaceRoleSchema, role: workspaceRoleSchema,
}); });
const transferDraftSchema = z.object({
birdId: z.string().uuid(),
destinationOwnerEmail: z.string().trim().email().max(255),
notes: z.string().trim().max(1000).optional().or(z.literal('')),
});
const flockTransferSchema = z.object({ const flockTransferSchema = z.object({
targetWorkspaceId: z.coerce.number().int().positive(), destinationOwnerEmail: z.string().trim().email().max(255),
}); });
const birdSchema = z.object({ const birdSchema = z.object({
@@ -594,6 +591,68 @@ const issueMagicLinkInvite = async ({
}); });
}; };
const issueBirdTransferInvite = async ({
email,
birdName,
sourceWorkspaceName,
redirectTo = frontendBaseUrl,
}: {
email: string;
birdName: string;
sourceWorkspaceName: string;
redirectTo?: string;
}) => {
await deleteExpiredMagicLinkTokens();
const rawToken = createSessionToken();
const tokenHash = hashToken(rawToken);
const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString();
await createMagicLinkToken(email, null, tokenHash, redirectTo, expiresAt);
const verifyUrl = new URL(`${backendBaseUrl}/api/auth/magic-link/verify`);
verifyUrl.searchParams.set('token', rawToken);
const magicLinkUrl = verifyUrl.toString();
const subject = `${sourceWorkspaceName} sent you a bird transfer in FlockPal`;
const text = [
'Hi there,',
'',
`${sourceWorkspaceName} wants to transfer ${birdName} to your FlockPal account.`,
'Use this secure invite link to sign in or create your account. FlockPal will automatically create your receiving flock and complete any pending bird transfers for this email.',
magicLinkUrl,
'',
'This link expires in 15 minutes and can only be used once.',
].join('\n');
if (!mailTransport) {
console.log(`Bird transfer invite for ${email}: ${magicLinkUrl}`);
return {
delivered: false,
previewUrl: magicLinkUrl,
};
}
await mailTransport.sendMail({
from: smtpFromName ? `"${smtpFromName}" <${smtpFromEmail}>` : smtpFromEmail,
to: email,
subject,
text,
html: `
<p>Hi there,</p>
<p><strong>${escapeHtml(sourceWorkspaceName)}</strong> wants to transfer <strong>${escapeHtml(birdName)}</strong> to your FlockPal account.</p>
<p>Use this secure invite link to sign in or create your account. FlockPal will automatically create your receiving flock and complete any pending bird transfers for this email.</p>
<p><a href="${magicLinkUrl}">Accept bird transfer in FlockPal</a></p>
<p>This link expires in 15 minutes and can only be used once.</p>
`,
});
return {
delivered: true,
previewUrl: null,
};
};
const readBearerToken = (authorizationHeader?: string) => { const readBearerToken = (authorizationHeader?: string) => {
if (!authorizationHeader) { if (!authorizationHeader) {
return ''; return '';
@@ -747,53 +806,6 @@ app.post('/api/auth/magic-link/request', async (req: Request, res: Response, nex
} }
}); });
app.post('/api/transfers/draft', requireAuth, requireWriteAccess, async (req: Request, res: Response, next: NextFunction) => {
const parsed = transferDraftSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid transfer draft payload', details: parsed.error.flatten() });
return;
}
try {
const bird = await getBirdById(parsed.data.birdId, req.auth!.workspace.id);
if (!bird) {
res.status(404).json({ error: 'That bird could not be found in this flock.' });
return;
}
const destinationOwnerEmail = normalizeEmail(parsed.data.destinationOwnerEmail);
const existingUser = await findUserByEmail(destinationOwnerEmail);
let invitePreviewUrl: string | null = null;
let inviteDelivery: 'email' | 'preview' | null = null;
if (!existingUser) {
const delivery = await issueMagicLinkInvite({
email: destinationOwnerEmail,
name: null,
redirectTo: frontendBaseUrl,
});
invitePreviewUrl = delivery.previewUrl;
inviteDelivery = delivery.delivered ? 'email' : 'preview';
}
res.status(201).json({
ok: true,
bird: normalizeBird(bird),
destinationOwnerEmail,
destinationOwnerExists: Boolean(existingUser),
inviteSent: !existingUser,
invitePreviewUrl,
inviteDelivery,
});
} catch (error) {
next(error);
}
});
app.get('/api/auth/magic-link/verify', async (req: Request, res: Response, next: NextFunction) => { app.get('/api/auth/magic-link/verify', async (req: Request, res: Response, next: NextFunction) => {
const rawToken = typeof req.query.token === 'string' ? req.query.token.trim() : ''; const rawToken = typeof req.query.token === 'string' ? req.query.token.trim() : '';
@@ -819,8 +831,10 @@ app.get('/api/auth/magic-link/verify', async (req: Request, res: Response, next:
} }
await claimWorkspaceInvites(user!); await claimWorkspaceInvites(user!);
const receivingWorkspaceId = await ensurePersonalWorkspaceForUser(user!);
const transferCompletion = await completePendingBirdTransfersForOwner(user!.email, receivingWorkspaceId);
const memberships = await normalizeWorkspaceMembershipList(user!.id); const memberships = await normalizeWorkspaceMembershipList(user!.id);
const activeWorkspaceId = memberships[0]?.workspace.id ?? (await ensurePersonalWorkspaceForUser(user!)); const activeWorkspaceId = transferCompletion.completed > 0 ? receivingWorkspaceId : memberships[0]?.workspace.id ?? receivingWorkspaceId;
const { token } = await createAuthSession(user!.id, activeWorkspaceId); const { token } = await createAuthSession(user!.id, activeWorkspaceId);
const redirectUrl = new URL(magicLink.redirect_to || frontendBaseUrl); const redirectUrl = new URL(magicLink.redirect_to || frontendBaseUrl);
redirectUrl.searchParams.set('auth_token', token); redirectUrl.searchParams.set('auth_token', token);
@@ -1026,6 +1040,7 @@ const handleOAuthCallback = async (req: Request, res: Response, next: NextFuncti
await linkAuthAccount(user!.id, providerKey, providerSubject, email); await linkAuthAccount(user!.id, providerKey, providerSubject, email);
await claimWorkspaceInvites(user!); await claimWorkspaceInvites(user!);
const activeWorkspaceId = await ensurePersonalWorkspaceForUser(user!); const activeWorkspaceId = await ensurePersonalWorkspaceForUser(user!);
await completePendingBirdTransfersForOwner(user!.email, activeWorkspaceId);
const { token } = await createAuthSession(user!.id, activeWorkspaceId); const { token } = await createAuthSession(user!.id, activeWorkspaceId);
const redirectUrl = new URL(oauthState.redirect_to || frontendBaseUrl); const redirectUrl = new URL(oauthState.redirect_to || frontendBaseUrl);
redirectUrl.searchParams.set('auth_token', token); redirectUrl.searchParams.set('auth_token', token);
@@ -1410,27 +1425,59 @@ app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, require
return; return;
} }
if (parsed.data.targetWorkspaceId === req.auth!.workspace.id) {
res.status(400).json({ error: 'Choose a different destination flock.' });
return;
}
try { try {
const targetMembership = await getMembershipForUser(req.auth!.user.id, parsed.data.targetWorkspaceId); const destinationOwnerEmail = normalizeEmail(parsed.data.destinationOwnerEmail);
const sourceBird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
if (!targetMembership) { if (!sourceBird) {
res.status(403).json({ error: 'You do not have access to that destination flock.' }); res.status(404).json({ error: 'Bird not found.' });
return; return;
} }
const bird = await transferBirdToWorkspace(req.params.birdId, req.auth!.workspace.id, parsed.data.targetWorkspaceId); const targetWorkspaces = await listOwnedWorkspacesByOwnerEmail(destinationOwnerEmail, req.auth!.workspace.id);
if (!targetWorkspaces.length) {
await createPendingBirdTransfer({
birdId: sourceBird.id,
sourceWorkspaceId: req.auth!.workspace.id,
destinationOwnerEmail,
requestedByUserId: req.auth!.user.id,
});
const delivery = await issueBirdTransferInvite({
email: destinationOwnerEmail,
birdName: sourceBird.name,
sourceWorkspaceName: req.auth!.workspace.name,
redirectTo: frontendBaseUrl,
});
res.status(202).json({
ok: true,
bird: normalizeBird(sourceBird),
destinationOwnerEmail,
inviteSent: true,
invitePreviewUrl: delivery.previewUrl,
inviteDelivery: delivery.delivered ? 'email' : 'preview',
message:
'A bird transfer invite was sent. The bird will stay in this flock until the recipient signs in, then FlockPal will automatically move it to their receiving flock.',
});
return;
}
if (targetWorkspaces.length > 1) {
res.status(409).json({ error: 'That owner email has more than one flock. Ask the receiving owner to use a unique owner email before transferring.' });
return;
}
const targetWorkspace = targetWorkspaces[0];
const bird = await transferBirdToWorkspace(req.params.birdId, req.auth!.workspace.id, targetWorkspace.id);
if (!bird) { if (!bird) {
res.status(404).json({ error: 'Bird not found.' }); res.status(404).json({ error: 'Bird not found.' });
return; return;
} }
res.json({ bird: normalizeBird(bird) }); res.json({ bird: normalizeBird(bird), destinationOwnerEmail, destinationWorkspace: normalizeWorkspace(targetWorkspace) });
} 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 the destination flock.' }); res.status(409).json({ error: 'That band/tag ID is already in use in the destination flock.' });
+25
View File
@@ -216,6 +216,31 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_workspace_tag_id CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_workspace_tag_id
ON birds (workspace_id, tag_id); ON birds (workspace_id, tag_id);
CREATE TABLE IF NOT EXISTS pending_bird_transfers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
source_workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
destination_owner_email VARCHAR(255) NOT NULL,
requested_by_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
completed_at TIMESTAMPTZ,
completed_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE pending_bird_transfers
ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS completed_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS last_error TEXT;
CREATE INDEX IF NOT EXISTS idx_pending_bird_transfers_destination_email
ON pending_bird_transfers (LOWER(destination_owner_email), created_at DESC)
WHERE completed_at IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_bird_transfers_open_bird
ON pending_bird_transfers (bird_id)
WHERE completed_at IS NULL;
CREATE TABLE IF NOT EXISTS weight_records ( CREATE TABLE IF NOT EXISTS weight_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE, bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
@@ -1,7 +1,14 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { createBird, getBirdById, listWeightsForBird, transferBirdToWorkspace } from './birdRepository.js'; import {
completePendingBirdTransfersForOwner,
createBird,
createPendingBirdTransfer,
getBirdById,
listWeightsForBird,
transferBirdToWorkspace,
} from './birdRepository.js';
import { mockDb } from '../test/mockDb.js'; import { mockDb } from '../test/mockDb.js';
test('getBirdById returns null when the bird does not exist in the workspace', async () => { test('getBirdById returns null when the bird does not exist in the workspace', async () => {
@@ -101,3 +108,86 @@ test('transferBirdToWorkspace moves the bird to the target workspace', async ()
assert.deepEqual(calls[0].params, ['bird-1', 10, 22]); assert.deepEqual(calls[0].params, ['bird-1', 10, 22]);
assert.match(calls[0].text, /UPDATE birds/); assert.match(calls[0].text, /UPDATE birds/);
}); });
test('createPendingBirdTransfer stores an open transfer for auto-completion', async () => {
const { calls } = mockDb({
rowCount: 1,
rows: [
{
id: 'transfer-1',
bird_id: 'bird-1',
source_workspace_id: 10,
destination_owner_email: 'receiver@example.com',
requested_by_user_id: 'user-1',
completed_at: null,
completed_workspace_id: null,
last_error: null,
created_at: '2026-04-15T00:00:00.000Z',
},
],
});
const transfer = await createPendingBirdTransfer({
birdId: 'bird-1',
sourceWorkspaceId: 10,
destinationOwnerEmail: 'receiver@example.com',
requestedByUserId: 'user-1',
});
assert.equal(transfer?.id, 'transfer-1');
assert.deepEqual(calls[0].params, ['bird-1', 10, 'receiver@example.com', 'user-1']);
assert.match(calls[0].text, /INSERT INTO pending_bird_transfers/);
assert.match(calls[0].text, /ON CONFLICT/);
});
test('completePendingBirdTransfersForOwner moves pending birds and marks completion', async () => {
const { calls } = mockDb(
{
rowCount: 1,
rows: [
{
id: 'transfer-1',
bird_id: 'bird-1',
source_workspace_id: 10,
destination_owner_email: 'receiver@example.com',
requested_by_user_id: 'user-1',
completed_at: null,
completed_workspace_id: null,
last_error: null,
created_at: '2026-04-15T00:00:00.000Z',
},
],
},
{
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',
},
],
},
{ rowCount: 1, rows: [] },
);
const result = await completePendingBirdTransfersForOwner('receiver@example.com', 22);
assert.deepEqual(result, { completed: 1, failed: 0 });
assert.deepEqual(calls[0].params, ['receiver@example.com']);
assert.deepEqual(calls[1].params, ['bird-1', 10, 22]);
assert.deepEqual(calls[2].params, ['transfer-1', 22]);
assert.match(calls[2].text, /completed_at = CURRENT_TIMESTAMP/);
});
+92 -1
View File
@@ -1,5 +1,5 @@
import { db } from '../db/client.js'; import { db } from '../db/client.js';
import type { BirdGender, BirdRow, VetVisitRow, WeightRow } from '../types.js'; import type { BirdGender, BirdRow, PendingBirdTransferRow, VetVisitRow, WeightRow } from '../types.js';
const birdSelectFields = ` const birdSelectFields = `
birds.id, birds.id,
@@ -195,6 +195,97 @@ export const transferBirdToWorkspace = async (birdId: string, sourceWorkspaceId:
return result.rows[0] ?? null; return result.rows[0] ?? null;
}; };
export const createPendingBirdTransfer = async ({
birdId,
sourceWorkspaceId,
destinationOwnerEmail,
requestedByUserId,
}: {
birdId: string;
sourceWorkspaceId: number;
destinationOwnerEmail: string;
requestedByUserId: string;
}) => {
const result = await db.query<PendingBirdTransferRow>(
`INSERT INTO pending_bird_transfers (bird_id, source_workspace_id, destination_owner_email, requested_by_user_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (bird_id) WHERE completed_at IS NULL DO UPDATE
SET destination_owner_email = EXCLUDED.destination_owner_email,
requested_by_user_id = EXCLUDED.requested_by_user_id,
last_error = NULL,
created_at = CURRENT_TIMESTAMP
RETURNING id, bird_id, source_workspace_id, destination_owner_email, requested_by_user_id, completed_at::text, completed_workspace_id, last_error, created_at`,
[birdId, sourceWorkspaceId, destinationOwnerEmail, requestedByUserId],
);
return result.rows[0] ?? null;
};
export const listPendingBirdTransfersForOwnerEmail = async (ownerEmail: string) => {
const result = await db.query<PendingBirdTransferRow>(
`SELECT id, bird_id, source_workspace_id, destination_owner_email, requested_by_user_id, completed_at::text, completed_workspace_id, last_error, created_at
FROM pending_bird_transfers
WHERE LOWER(destination_owner_email) = LOWER($1)
AND completed_at IS NULL
ORDER BY created_at ASC`,
[ownerEmail],
);
return result.rows;
};
export const markPendingBirdTransferCompleted = async (transferId: string, completedWorkspaceId: number) => {
await db.query(
`UPDATE pending_bird_transfers
SET completed_at = CURRENT_TIMESTAMP,
completed_workspace_id = $2,
last_error = NULL
WHERE id = $1`,
[transferId, completedWorkspaceId],
);
};
export const markPendingBirdTransferFailed = async (transferId: string, lastError: string) => {
await db.query(
`UPDATE pending_bird_transfers
SET last_error = $2
WHERE id = $1`,
[transferId, lastError],
);
};
export const completePendingBirdTransfersForOwner = async (ownerEmail: string, targetWorkspaceId: number) => {
const transfers = await listPendingBirdTransfersForOwnerEmail(ownerEmail);
let completed = 0;
let failed = 0;
for (const transfer of transfers) {
try {
const bird = await transferBirdToWorkspace(transfer.bird_id, transfer.source_workspace_id, targetWorkspaceId);
if (!bird) {
failed += 1;
await markPendingBirdTransferFailed(transfer.id, 'Bird is no longer available in the source flock.');
continue;
}
await markPendingBirdTransferCompleted(transfer.id, targetWorkspaceId);
completed += 1;
} catch (error) {
failed += 1;
const message =
typeof error === 'object' && error && 'code' in error && error.code === '23505'
? 'The receiving flock already has a bird using the same band/tag ID.'
: error instanceof Error
? error.message
: 'Unable to complete pending bird transfer.';
await markPendingBirdTransferFailed(transfer.id, message);
}
}
return { completed, failed };
};
export const listWeightsForBird = async (birdId: string, workspaceId: number, days: number) => { export const listWeightsForBird = async (birdId: string, workspaceId: number, days: number) => {
const result = await db.query<WeightRow>( const result = await db.query<WeightRow>(
`SELECT id, bird_id, weight_grams, recorded_on::text, notes `SELECT id, bird_id, weight_grams, recorded_on::text, notes
@@ -1,7 +1,14 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { createWorkspace, deleteWorkspaceIfEmpty, ensurePersonalWorkspaceForUser, findAlternateWorkspaceForUser, updateWorkspace } from './workspaceRepository.js'; import {
createWorkspace,
deleteWorkspaceIfEmpty,
ensurePersonalWorkspaceForUser,
findAlternateWorkspaceForUser,
listOwnedWorkspacesByOwnerEmail,
updateWorkspace,
} from './workspaceRepository.js';
import { mockDb } from '../test/mockDb.js'; import { mockDb } from '../test/mockDb.js';
import type { UserRow } from '../types.js'; import type { UserRow } from '../types.js';
@@ -141,3 +148,30 @@ test('findAlternateWorkspaceForUser returns another workspace when available', a
assert.equal(workspaceId, 84); assert.equal(workspaceId, 84);
assert.deepEqual(calls[0].params, ['user-1', 42]); assert.deepEqual(calls[0].params, ['user-1', 42]);
}); });
test('listOwnedWorkspacesByOwnerEmail resolves accepted owner flocks by email', async () => {
const { calls } = mockDb({
rowCount: 1,
rows: [
{
id: 84,
name: 'Receiving Flock',
workspace_type: 'standard',
billing_email: 'receiver@example.com',
billing_plan: 'household_basic',
subscription_status: 'active',
rescue_verification_status: 'not_required',
created_at: '2026-04-14T00:00:00.000Z',
updated_at: '2026-04-14T00:00:00.000Z',
},
],
});
const workspaces = await listOwnedWorkspacesByOwnerEmail('Receiver@Example.com', 42);
assert.equal(workspaces[0]?.id, 84);
assert.deepEqual(calls[0].params, ['Receiver@Example.com', 42]);
assert.match(calls[0].text, /workspace_members\.role = 'owner'/);
assert.match(calls[0].text, /accepted_at IS NOT NULL/);
assert.match(calls[0].text, /workspaces\.id <> \$2/);
});
@@ -250,6 +250,22 @@ export const findAlternateWorkspaceForUser = async (userId: string, excludeWorks
return result.rows[0] ? Number(result.rows[0].workspace_id) : null; return result.rows[0] ? Number(result.rows[0].workspace_id) : null;
}; };
export const listOwnedWorkspacesByOwnerEmail = async (ownerEmail: string, excludeWorkspaceId: number) => {
const result = await db.query<WorkspaceRow>(
`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
FROM workspace_members
INNER JOIN workspaces ON workspaces.id = workspace_members.workspace_id
WHERE LOWER(COALESCE(workspace_members.invite_email, workspace_members.email)) = LOWER($1)
AND workspace_members.role = 'owner'
AND workspace_members.accepted_at IS NOT NULL
AND workspaces.id <> $2
ORDER BY workspaces.created_at ASC`,
[ownerEmail, excludeWorkspaceId],
);
return result.rows;
};
export const getWorkspaceBirdCount = async (workspaceId: number) => { export const getWorkspaceBirdCount = async (workspaceId: number) => {
const birdCount = await db.query<{ count: string }>( const birdCount = await db.query<{ count: string }>(
`SELECT COUNT(*)::text AS count `SELECT COUNT(*)::text AS count
+12
View File
@@ -106,6 +106,18 @@ export type BirdRow = {
latest_recorded_on: string | null; latest_recorded_on: string | null;
}; };
export type PendingBirdTransferRow = {
id: string;
bird_id: string;
source_workspace_id: number;
destination_owner_email: string;
requested_by_user_id: string;
completed_at: string | null;
completed_workspace_id: number | null;
last_error: string | null;
created_at: string;
};
export type WeightRow = { export type WeightRow = {
id: string; id: string;
bird_id: string; bird_id: string;
+25 -43
View File
@@ -75,7 +75,6 @@ Endpoints that accept either browser session tokens or integration tokens:
- `/api/birds` - `/api/birds`
- `/api/birds/:birdId/weights` - `/api/birds/:birdId/weights`
- `/api/birds/:birdId/vet-visits` - `/api/birds/:birdId/vet-visits`
- `/api/transfers/draft`
Read-only integration tokens can call read endpoints, but cannot call write endpoints. Read-only integration tokens can call read endpoints, but cannot call write endpoints.
@@ -522,40 +521,6 @@ Responses:
- `400` for missing or expired OAuth state - `400` for missing or expired OAuth state
- `404` for unknown provider - `404` for unknown provider
### Transfers
#### `POST /api/transfers/draft`
Requires auth with write access. Prepares a bird transfer to another owner email and optionally triggers a magic-link invite for that email when no user exists yet.
Request body:
```json
{
"birdId": "uuid",
"destinationOwnerEmail": "new-owner@example.com",
"notes": "Optional draft note"
}
```
Response `201`:
```json
{
"ok": true,
"bird": {},
"destinationOwnerEmail": "new-owner@example.com",
"destinationOwnerExists": false,
"inviteSent": true,
"invitePreviewUrl": "http://localhost:5000/api/auth/magic-link/verify?token=...",
"inviteDelivery": "preview"
}
```
Possible errors:
- `404` if the bird is not in the active workspace
### Workspaces ### Workspaces
#### `GET /api/workspaces` #### `GET /api/workspaces`
@@ -776,35 +741,52 @@ Possible errors:
#### `POST /api/birds/:birdId/transfer` #### `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. Requires a browser session, write access, and role `owner` or `assistant`. Transfers a bird from the active flock to a flock owned by the provided receiving owner email. The sender does not need access to the receiving flock.
Request body: Request body:
```json ```json
{ {
"targetWorkspaceId": 1002 "destinationOwnerEmail": "new-owner@example.com"
} }
``` ```
Notes: Notes:
- the destination flock must be different from the current flock - if the receiving owner email does not currently own a receiving flock, FlockPal creates a pending transfer, sends a bird-transfer invite, and returns `202`; the bird stays in the sender's flock until the recipient signs in
- the signed-in user must already be a member of the destination flock - pending transfers auto-complete when the recipient signs in; FlockPal creates or uses the recipient's personal flock as the receiving flock
- the bird keeps its existing weight and vet history because the record itself is reassigned - immediate transfer requires the receiving owner email to match an accepted `owner` of exactly one other flock
- when transferred, the bird keeps its existing weight and vet history because the record itself is reassigned
Response `200`: Response `200`:
```json ```json
{ {
"bird": {} "bird": {},
"destinationOwnerEmail": "new-owner@example.com",
"destinationWorkspace": {}
}
```
Response `202` when the receiving email does not currently own a receiving flock:
```json
{
"ok": true,
"bird": {},
"destinationOwnerEmail": "new-owner@example.com",
"inviteSent": true,
"invitePreviewUrl": "http://localhost:5000/api/auth/magic-link/verify?token=...",
"inviteDelivery": "preview",
"message": "A bird transfer invite was sent. The bird will stay in this flock until the recipient signs in, then FlockPal will automatically move it to their receiving flock."
} }
``` ```
Possible errors: Possible errors:
- `400` if the destination flock is the current flock or the payload is invalid - `400` if 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 - `404` if the bird is not in the active flock
- `409` if that owner email owns more than one receiving flock
- `409` if the destination flock already has a bird using the same `tagId` - `409` if the destination flock already has a bird using the same `tagId`
#### `DELETE /api/birds/:birdId` #### `DELETE /api/birds/:birdId`
+72 -120
View File
@@ -860,15 +860,16 @@ function App() {
reason: '', reason: '',
notes: '', notes: '',
}); });
const [mergeForm, setMergeForm] = useState({
birdId: '',
destinationOwnerEmail: '',
notes: '',
});
const [flockTransferForm, setFlockTransferForm] = useState({ const [flockTransferForm, setFlockTransferForm] = useState({
birdId: '', birdId: '',
targetWorkspaceId: '', destinationOwnerEmail: '',
}); });
const [transferringBird, setTransferringBird] = useState(false);
const [transferError, setTransferError] = useState('');
const [transferNotice, setTransferNotice] = useState<{
message: string;
previewUrl?: string | null;
} | null>(null);
const [deletingBird, setDeletingBird] = useState(false); const [deletingBird, setDeletingBird] = useState(false);
const [editingVetVisitId, setEditingVetVisitId] = useState(''); const [editingVetVisitId, setEditingVetVisitId] = useState('');
const [deletingVetVisitId, setDeletingVetVisitId] = useState(''); const [deletingVetVisitId, setDeletingVetVisitId] = useState('');
@@ -879,10 +880,6 @@ function App() {
() => birds.find((bird) => bird.id === selectedBirdId) ?? null, () => birds.find((bird) => bird.id === selectedBirdId) ?? null,
[birds, selectedBirdId], [birds, selectedBirdId],
); );
const transferableWorkspaces = useMemo(
() => authSession?.workspaces.filter((entry) => entry.workspace.id !== workspace?.id) ?? [],
[authSession, workspace?.id],
);
const editingBird = useMemo( const editingBird = useMemo(
() => birds.find((bird) => bird.id === editingBirdId) ?? null, () => birds.find((bird) => bird.id === editingBirdId) ?? null,
[birds, editingBirdId], [birds, editingBirdId],
@@ -2127,59 +2124,22 @@ function App() {
} }
}; };
const handleMergeSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError('');
try {
const response = await apiFetch('/transfers/draft', authToken, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(mergeForm),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to save transfer draft.'));
}
const data =
(await readJsonSafely<{
bird?: Bird;
destinationOwnerExists?: boolean;
inviteSent?: boolean;
}>(response)) ?? {};
const transferBirdName = data.bird?.name || birds.find((bird) => bird.id === mergeForm.birdId)?.name || 'bird';
const inviteCopy = data.inviteSent
? `\n\nA FlockPal invite was also sent to ${mergeForm.destinationOwnerEmail} because that email does not have an account yet.`
: data.destinationOwnerExists
? `\n\n${mergeForm.destinationOwnerEmail} already has a FlockPal account, so no invite was needed.`
: '';
window.alert(
`Transfer prep saved for ${transferBirdName}.${inviteCopy}\n\nThis is currently a planning workflow only. Later this page can turn into a real account-to-account transfer flow using verified bird identity and ownership checks.`,
);
setMergeForm({
birdId: '',
destinationOwnerEmail: '',
notes: '',
});
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : 'Unable to save transfer draft.');
}
};
const handleFlockTransferSubmit = async (event: React.FormEvent<HTMLFormElement>) => { const handleFlockTransferSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
if (transferringBird) {
return;
}
setError(''); setError('');
setTransferError('');
setTransferNotice(null);
setTransferringBird(true);
try { try {
const response = await apiFetch(`/birds/${flockTransferForm.birdId}/transfer`, authToken, { const response = await apiFetch(`/birds/${flockTransferForm.birdId}/transfer`, authToken, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
targetWorkspaceId: Number(flockTransferForm.targetWorkspaceId), destinationOwnerEmail: flockTransferForm.destinationOwnerEmail,
}), }),
}); });
@@ -2187,9 +2147,29 @@ function App() {
throw new Error(await readErrorMessage(response, 'Unable to transfer bird to another flock.')); throw new Error(await readErrorMessage(response, 'Unable to transfer bird to another flock.'));
} }
const data = (await readJsonSafely<{ bird?: Bird }>(response)) ?? {}; const data =
(await readJsonSafely<{
bird?: Bird;
inviteSent?: boolean;
invitePreviewUrl?: string | null;
message?: string;
}>(response)) ?? {};
const transferredBirdName = data.bird?.name || birds.find((bird) => bird.id === flockTransferForm.birdId)?.name || 'Bird'; const transferredBirdName = data.bird?.name || birds.find((bird) => bird.id === flockTransferForm.birdId)?.name || 'Bird';
if (data.inviteSent) {
setTransferNotice({
message:
data.message ??
`A bird transfer invite was sent to ${flockTransferForm.destinationOwnerEmail}. The transfer will complete automatically after they sign in.`,
previewUrl: data.invitePreviewUrl,
});
setFlockTransferForm({
birdId: '',
destinationOwnerEmail: '',
});
return;
}
setBirds((current) => current.filter((bird) => bird.id !== flockTransferForm.birdId)); setBirds((current) => current.filter((bird) => bird.id !== flockTransferForm.birdId));
setAllBirdWeights((current) => { setAllBirdWeights((current) => {
const next = { ...current }; const next = { ...current };
@@ -2208,12 +2188,16 @@ function App() {
} }
setFlockTransferForm({ setFlockTransferForm({
birdId: '', birdId: '',
targetWorkspaceId: '', destinationOwnerEmail: '',
}); });
window.alert(`${transferredBirdName} was moved to the selected flock.`); window.alert(`${transferredBirdName} was transferred to ${flockTransferForm.destinationOwnerEmail}.`);
} catch (submitError) { } catch (submitError) {
setError(submitError instanceof Error ? submitError.message : 'Unable to transfer bird to another flock.'); const message = submitError instanceof Error ? submitError.message : 'Unable to transfer bird to another flock.';
setTransferError(message);
setError(message);
} finally {
setTransferringBird(false);
} }
}; };
@@ -3955,7 +3939,7 @@ function App() {
<div className="panel-header"> <div className="panel-header">
<div> <div>
<p className="eyebrow">Settings</p> <p className="eyebrow">Settings</p>
<h2>Bird transfer prep</h2> <h2>Bird transfer</h2>
</div> </div>
<button <button
className="secondary-button" className="secondary-button"
@@ -3971,15 +3955,19 @@ function App() {
{expandedSettingsSection === 'transfer' ? ( {expandedSettingsSection === 'transfer' ? (
<> <>
<p className="muted"> <p className="muted">
Move a bird to another flock you already belong to. This keeps the bird record, weight history, and vet visits attached while Transfer a bird to another flock by entering the receiving flock owner's email. This keeps the bird record, weight history, and
changing which flock owns it. vet visits attached while changing which flock owns it.
</p> </p>
<form className="form-panel" onSubmit={handleFlockTransferSubmit}> <form className="form-panel" onSubmit={handleFlockTransferSubmit}>
<label> <label>
Bird to move Bird to move
<select <select
value={flockTransferForm.birdId} value={flockTransferForm.birdId}
onChange={(event) => setFlockTransferForm({ ...flockTransferForm, birdId: event.target.value })} onChange={(event) => {
setFlockTransferForm({ ...flockTransferForm, birdId: event.target.value });
setTransferError('');
setTransferNotice(null);
}}
required required
> >
<option value="">Select a bird from this flock</option> <option value="">Select a bird from this flock</option>
@@ -3991,70 +3979,34 @@ function App() {
</select> </select>
</label> </label>
<label> <label>
Destination flock Receiving flock owner email
<select
value={flockTransferForm.targetWorkspaceId}
onChange={(event) => setFlockTransferForm({ ...flockTransferForm, targetWorkspaceId: event.target.value })}
required
>
<option value="">Select another flock you can access</option>
{transferableWorkspaces.map((entry) => (
<option key={entry.workspace.id} value={String(entry.workspace.id)}>
{entry.workspace.name} {formatWorkspaceRole(entry.membership.role)}
</option>
))}
</select>
</label>
<button className="primary-button" type="submit" disabled={!transferableWorkspaces.length}>
Move bird to another flock
</button>
{!transferableWorkspaces.length ? (
<small className="muted">Create or join another flock first to use in-app bird transfers.</small>
) : null}
</form>
<div className="form-divider" />
<p className="muted">
For future owner-to-owner handoffs outside your own flocks, save a transfer draft below.
</p>
<form className="form-panel" onSubmit={handleMergeSubmit}>
<label>
Bird for external transfer prep
<select
value={mergeForm.birdId}
onChange={(event) => setMergeForm({ ...mergeForm, birdId: event.target.value })}
required
>
<option value="">Select a bird from this flock</option>
{birds.map((bird) => (
<option key={bird.id} value={bird.id}>
{bird.name} {bird.species} Band {bird.tagId}
</option>
))}
</select>
</label>
<label>
Destination owner email
<input <input
type="email" type="email"
value={mergeForm.destinationOwnerEmail} value={flockTransferForm.destinationOwnerEmail}
onChange={(event) => setMergeForm({ ...mergeForm, destinationOwnerEmail: event.target.value })} onChange={(event) => {
setFlockTransferForm({ ...flockTransferForm, destinationOwnerEmail: event.target.value });
setTransferError('');
setTransferNotice(null);
}}
placeholder="owner@example.com"
required required
/> />
</label> </label>
<label> <button className="primary-button" type="submit" disabled={transferringBird}>
Transfer notes {transferringBird ? 'Transferring bird...' : 'Transfer bird'}
<textarea
rows={4}
value={mergeForm.notes}
onChange={(event) => setMergeForm({ ...mergeForm, notes: event.target.value })}
placeholder="Optional context for rescue release, adoption, or household transfer"
/>
</label>
<button className="primary-button" type="submit">
Save transfer draft
</button> </button>
{transferError ? (
<p className="error-banner" role="alert">
{transferError}
</p>
) : null}
{transferNotice ? (
<article className="summary-card" role="status">
<strong>Pending transfer invite sent</strong>
<span>{transferNotice.message}</span>
{transferNotice.previewUrl ? <a href={transferNotice.previewUrl}>Open invite link</a> : null}
</article>
) : null}
</form> </form>
</> </>
) : null} ) : null}