fixed transfer process
This commit is contained in:
+112
-65
@@ -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.' });
|
||||
|
||||
Reference in New Issue
Block a user