From 3a0e30085c0d1a77f7d7432baa94c3a78bb71dfe Mon Sep 17 00:00:00 2001 From: blaisadmin Date: Wed, 15 Apr 2026 23:39:10 -0400 Subject: [PATCH] fixed transfer process --- backend/src/app.ts | 177 ++++++++++------ backend/src/db/schema.ts | 25 +++ .../src/repositories/birdRepository.test.ts | 92 ++++++++- backend/src/repositories/birdRepository.ts | 93 ++++++++- .../repositories/workspaceRepository.test.ts | 36 +++- .../src/repositories/workspaceRepository.ts | 16 ++ backend/src/types.ts | 12 ++ docs/API_REFERENCE.md | 68 +++---- frontend/src/App.tsx | 192 +++++++----------- 9 files changed, 480 insertions(+), 231 deletions(-) diff --git a/backend/src/app.ts b/backend/src/app.ts index ee3b677..1449972 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -27,7 +27,9 @@ import { updateUserName, } from './repositories/authRepository.js'; import { + completePendingBirdTransfersForOwner, createBird, + createPendingBirdTransfer, createVetVisitForBird, createWeightForBird, deleteBird, @@ -54,6 +56,7 @@ import { getMembershipForUser, getNextWorkspaceId, getWorkspaceById, + listOwnedWorkspacesByOwnerEmail, listRescueWorkspacesForAdmin, listMembershipsForUser, listWorkspaceMembers, @@ -156,14 +159,8 @@ const workspaceMemberSchema = z.object({ 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({ - targetWorkspaceId: z.coerce.number().int().positive(), + destinationOwnerEmail: z.string().trim().email().max(255), }); 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: ` +

Hi there,

+

${escapeHtml(sourceWorkspaceName)} wants to transfer ${escapeHtml(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.

+

Accept bird transfer in FlockPal

+

This link expires in 15 minutes and can only be used once.

+ `, + }); + + return { + delivered: true, + previewUrl: null, + }; +}; + const readBearerToken = (authorizationHeader?: string) => { if (!authorizationHeader) { 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) => { 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!); + const receivingWorkspaceId = await ensurePersonalWorkspaceForUser(user!); + const transferCompletion = await completePendingBirdTransfersForOwner(user!.email, receivingWorkspaceId); 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 redirectUrl = new URL(magicLink.redirect_to || frontendBaseUrl); 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 claimWorkspaceInvites(user!); const activeWorkspaceId = await ensurePersonalWorkspaceForUser(user!); + await completePendingBirdTransfersForOwner(user!.email, activeWorkspaceId); const { token } = await createAuthSession(user!.id, activeWorkspaceId); const redirectUrl = new URL(oauthState.redirect_to || frontendBaseUrl); redirectUrl.searchParams.set('auth_token', token); @@ -1410,27 +1425,59 @@ app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, require 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); + const destinationOwnerEmail = normalizeEmail(parsed.data.destinationOwnerEmail); + const sourceBird = await getBirdById(req.params.birdId, req.auth!.workspace.id); - if (!targetMembership) { - res.status(403).json({ error: 'You do not have access to that destination flock.' }); + if (!sourceBird) { + res.status(404).json({ error: 'Bird not found.' }); 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) { res.status(404).json({ error: 'Bird not found.' }); return; } - res.json({ bird: normalizeBird(bird) }); + res.json({ bird: normalizeBird(bird), destinationOwnerEmail, destinationWorkspace: normalizeWorkspace(targetWorkspace) }); } 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.' }); diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 9baa8a7..9887e7b 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -216,6 +216,31 @@ export const ensureSchema = async (database: DatabaseClient = db) => { CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_workspace_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 ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE, diff --git a/backend/src/repositories/birdRepository.test.ts b/backend/src/repositories/birdRepository.test.ts index 8c2cc72..cd4f85a 100644 --- a/backend/src/repositories/birdRepository.test.ts +++ b/backend/src/repositories/birdRepository.test.ts @@ -1,7 +1,14 @@ import assert from 'node:assert/strict'; 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'; 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.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/); +}); diff --git a/backend/src/repositories/birdRepository.ts b/backend/src/repositories/birdRepository.ts index a0f747f..75ca554 100644 --- a/backend/src/repositories/birdRepository.ts +++ b/backend/src/repositories/birdRepository.ts @@ -1,5 +1,5 @@ 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 = ` birds.id, @@ -195,6 +195,97 @@ export const transferBirdToWorkspace = async (birdId: string, sourceWorkspaceId: 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( + `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( + `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) => { 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 a554e43..91741db 100644 --- a/backend/src/repositories/workspaceRepository.test.ts +++ b/backend/src/repositories/workspaceRepository.test.ts @@ -1,7 +1,14 @@ import assert from 'node:assert/strict'; 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 type { UserRow } from '../types.js'; @@ -141,3 +148,30 @@ test('findAlternateWorkspaceForUser returns another workspace when available', a assert.equal(workspaceId, 84); 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/); +}); diff --git a/backend/src/repositories/workspaceRepository.ts b/backend/src/repositories/workspaceRepository.ts index 5345ee0..7fc0394 100644 --- a/backend/src/repositories/workspaceRepository.ts +++ b/backend/src/repositories/workspaceRepository.ts @@ -250,6 +250,22 @@ export const findAlternateWorkspaceForUser = async (userId: string, excludeWorks return result.rows[0] ? Number(result.rows[0].workspace_id) : null; }; +export const listOwnedWorkspacesByOwnerEmail = async (ownerEmail: string, excludeWorkspaceId: number) => { + const result = await db.query( + `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) => { const birdCount = await db.query<{ count: string }>( `SELECT COUNT(*)::text AS count diff --git a/backend/src/types.ts b/backend/src/types.ts index 873fc4a..543c11e 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -106,6 +106,18 @@ export type BirdRow = { 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 = { id: string; bird_id: string; diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index bd72599..2fa2cf9 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -75,7 +75,6 @@ Endpoints that accept either browser session tokens or integration tokens: - `/api/birds` - `/api/birds/:birdId/weights` - `/api/birds/:birdId/vet-visits` -- `/api/transfers/draft` 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 - `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 #### `GET /api/workspaces` @@ -776,35 +741,52 @@ Possible errors: #### `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: ```json { - "targetWorkspaceId": 1002 + "destinationOwnerEmail": "new-owner@example.com" } ``` 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 +- 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 +- pending transfers auto-complete when the recipient signs in; FlockPal creates or uses the recipient's personal flock as the receiving flock +- 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`: ```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: -- `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 +- `400` if the payload is invalid - `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` #### `DELETE /api/birds/:birdId` diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e41c177..8acb861 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -860,15 +860,16 @@ function App() { reason: '', notes: '', }); - const [mergeForm, setMergeForm] = useState({ - birdId: '', - destinationOwnerEmail: '', - notes: '', - }); const [flockTransferForm, setFlockTransferForm] = useState({ 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 [editingVetVisitId, setEditingVetVisitId] = useState(''); const [deletingVetVisitId, setDeletingVetVisitId] = useState(''); @@ -879,10 +880,6 @@ 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], @@ -2127,59 +2124,22 @@ function App() { } }; - const handleMergeSubmit = async (event: React.FormEvent) => { - 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) => { event.preventDefault(); + if (transferringBird) { + return; + } setError(''); + setTransferError(''); + setTransferNotice(null); + setTransferringBird(true); try { const response = await apiFetch(`/birds/${flockTransferForm.birdId}/transfer`, authToken, { method: 'POST', headers: { 'Content-Type': 'application/json' }, 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.')); } - 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'; + 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)); setAllBirdWeights((current) => { const next = { ...current }; @@ -2208,12 +2188,16 @@ function App() { } setFlockTransferForm({ birdId: '', - targetWorkspaceId: '', + destinationOwnerEmail: '', }); - window.alert(`${transferredBirdName} was moved to the selected flock.`); + window.alert(`${transferredBirdName} was transferred to ${flockTransferForm.destinationOwnerEmail}.`); } 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() {

Settings

-

Bird transfer prep

+

Bird transfer

- {!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. -

-
- - -