Further UI improvements
This commit is contained in:
+117
-23
@@ -13,7 +13,7 @@ dotenv.config();
|
||||
|
||||
type WorkspaceType = 'standard' | 'rescue';
|
||||
type WorkspaceRole = 'owner' | 'manager' | 'staff' | 'viewer';
|
||||
type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus';
|
||||
type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw';
|
||||
type ProviderKey = 'google' | 'microsoft' | 'apple';
|
||||
|
||||
type UserRow = {
|
||||
@@ -177,7 +177,7 @@ const switchWorkspaceSchema = z.object({
|
||||
|
||||
const workspaceTypeSchema = z.enum(['standard', 'rescue']);
|
||||
const workspaceRoleSchema = z.enum(['owner', 'manager', 'staff', 'viewer']);
|
||||
const billingPlanSchema = z.enum(['household_basic', 'household_plus']);
|
||||
const billingPlanSchema = z.enum(['household_basic', 'household_plus', 'household_macaw']);
|
||||
|
||||
const workspaceSchema = z.object({
|
||||
name: z.string().trim().min(1).max(160),
|
||||
@@ -199,6 +199,12 @@ 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 birdSchema = z.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
tagId: z.string().trim().min(1).max(80),
|
||||
@@ -236,11 +242,18 @@ const createRandomId = () => crypto.randomUUID();
|
||||
const createCodeVerifier = () => crypto.randomBytes(32).toString('base64url');
|
||||
const createCodeChallenge = (verifier: string) => crypto.createHash('sha256').update(verifier).digest('base64url');
|
||||
|
||||
const resolveBillingPlan = (workspaceType: WorkspaceType, requestedPlan?: BillingPlan | 'household_basic' | 'household_plus') => {
|
||||
const resolveBillingPlan = (
|
||||
workspaceType: WorkspaceType,
|
||||
requestedPlan?: BillingPlan | 'household_basic' | 'household_plus' | 'household_macaw',
|
||||
) => {
|
||||
if (workspaceType === 'rescue') {
|
||||
return 'rescue_free' as const;
|
||||
}
|
||||
|
||||
if (requestedPlan === 'household_macaw') {
|
||||
return 'household_macaw';
|
||||
}
|
||||
|
||||
return requestedPlan === 'household_plus' ? 'household_plus' : 'household_basic';
|
||||
};
|
||||
|
||||
@@ -825,6 +838,40 @@ const sendMagicLink = async ({
|
||||
};
|
||||
};
|
||||
|
||||
const issueMagicLinkInvite = async ({
|
||||
email,
|
||||
name,
|
||||
redirectTo = frontendBaseUrl,
|
||||
}: {
|
||||
email: string;
|
||||
name: string | null;
|
||||
redirectTo?: string;
|
||||
}) => {
|
||||
await pool.query(
|
||||
`DELETE FROM magic_link_tokens
|
||||
WHERE expires_at <= CURRENT_TIMESTAMP`,
|
||||
);
|
||||
|
||||
const rawToken = createSessionToken();
|
||||
const tokenHash = hashToken(rawToken);
|
||||
const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString();
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO magic_link_tokens (email, name, token_hash, redirect_to, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[email, name, tokenHash, redirectTo, expiresAt],
|
||||
);
|
||||
|
||||
const verifyUrl = new URL(`${backendBaseUrl}/api/auth/magic-link/verify`);
|
||||
verifyUrl.searchParams.set('token', rawToken);
|
||||
|
||||
return sendMagicLink({
|
||||
email,
|
||||
name,
|
||||
magicLinkUrl: verifyUrl.toString(),
|
||||
});
|
||||
};
|
||||
|
||||
const readBearerToken = (authorizationHeader?: string) => {
|
||||
if (!authorizationHeader) {
|
||||
return '';
|
||||
@@ -1041,28 +1088,10 @@ app.post('/api/auth/magic-link/request', async (req: Request, res: Response, nex
|
||||
const redirectTo = parsed.data.redirectTo || frontendBaseUrl;
|
||||
|
||||
try {
|
||||
await pool.query(
|
||||
`DELETE FROM magic_link_tokens
|
||||
WHERE expires_at <= CURRENT_TIMESTAMP`,
|
||||
);
|
||||
|
||||
const rawToken = createSessionToken();
|
||||
const tokenHash = hashToken(rawToken);
|
||||
const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString();
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO magic_link_tokens (email, name, token_hash, redirect_to, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[email, name, tokenHash, redirectTo, expiresAt],
|
||||
);
|
||||
|
||||
const verifyUrl = new URL(`${backendBaseUrl}/api/auth/magic-link/verify`);
|
||||
verifyUrl.searchParams.set('token', rawToken);
|
||||
|
||||
const delivery = await sendMagicLink({
|
||||
const delivery = await issueMagicLinkInvite({
|
||||
email,
|
||||
name,
|
||||
magicLinkUrl: verifyUrl.toString(),
|
||||
redirectTo,
|
||||
});
|
||||
|
||||
res.status(202).json({
|
||||
@@ -1076,6 +1105,71 @@ app.post('/api/auth/magic-link/request', async (req: Request, res: Response, nex
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/transfers/draft', requireAuth, 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 birdResult = await pool.query<BirdRow>(
|
||||
`SELECT ${birdSelectFields}
|
||||
FROM birds
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT weight_grams, recorded_on
|
||||
FROM weight_records
|
||||
WHERE weight_records.bird_id = birds.id
|
||||
ORDER BY recorded_on DESC
|
||||
LIMIT 1
|
||||
) latest ON TRUE
|
||||
WHERE birds.id = $1
|
||||
AND birds.workspace_id = $2`,
|
||||
[parsed.data.birdId, req.auth!.workspace.id],
|
||||
);
|
||||
|
||||
if (!birdResult.rowCount) {
|
||||
res.status(404).json({ error: 'That bird could not be found in this workspace.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const destinationOwnerEmail = normalizeEmail(parsed.data.destinationOwnerEmail);
|
||||
const existingUser = await pool.query<UserRow>(
|
||||
`SELECT id, email, password_hash, name, created_at
|
||||
FROM users
|
||||
WHERE email = $1`,
|
||||
[destinationOwnerEmail],
|
||||
);
|
||||
|
||||
let invitePreviewUrl: string | null = null;
|
||||
let inviteDelivery: 'email' | 'preview' | null = null;
|
||||
|
||||
if (!existingUser.rowCount) {
|
||||
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(birdResult.rows[0]),
|
||||
destinationOwnerEmail,
|
||||
destinationOwnerExists: Boolean(existingUser.rowCount),
|
||||
inviteSent: !existingUser.rowCount,
|
||||
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() : '';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user