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,
} 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: `
<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) => {
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.' });
+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
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,
@@ -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/);
});
+92 -1
View File
@@ -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<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) => {
const result = await db.query<WeightRow>(
`SELECT id, bird_id, weight_grams, recorded_on::text, notes
@@ -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/);
});
@@ -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<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) => {
const birdCount = await db.query<{ count: string }>(
`SELECT COUNT(*)::text AS count
+12
View File
@@ -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;