Fixed delete workflow and added additional profile info

This commit is contained in:
Corey Blais
2026-05-20 17:12:15 -04:00
parent 5db30022eb
commit 0db90aab45
9 changed files with 403 additions and 248 deletions
+31 -1
View File
@@ -224,6 +224,9 @@ const birdSchema = z.object({
name: z.string().trim().min(1).max(120),
tagId: z.string().trim().max(80).optional().or(z.literal('')),
species: z.string().trim().min(1).max(120),
motivators: z.string().trim().max(1000).optional().or(z.literal('')),
demotivators: z.string().trim().max(1000).optional().or(z.literal('')),
favoriteSnack: z.string().trim().max(160).optional().or(z.literal('')),
gender: birdGenderSchema.optional(),
dateOfBirth: dateStringSchema.optional().or(z.literal('')),
gotchaDay: dateStringSchema.optional().or(z.literal('')),
@@ -474,6 +477,9 @@ const normalizeBird = (row: BirdRow) => ({
name: row.name,
tagId: normalizeBandId(row.tag_id),
species: row.species,
motivators: row.motivators,
demotivators: row.demotivators,
favoriteSnack: row.favorite_snack,
gender: row.gender,
dateOfBirth: row.date_of_birth,
gotchaDay: row.gotcha_day,
@@ -790,6 +796,21 @@ const syncWorkspaceStripeBilling = async (workspaceId: number) => {
);
};
const cancelWorkspaceStripeSubscription = async (workspace: WorkspaceRow) => {
if (workspace.workspace_type === 'rescue' || (!workspace.stripe_subscription_id && !workspace.stripe_customer_id)) {
return null;
}
const subscription = await getMostRelevantStripeSubscriptionForWorkspace(workspace);
if (!subscription || subscription.status === 'canceled') {
return null;
}
await getStripeClient().subscriptions.cancel(subscription.id);
return subscription.id;
};
const getStripePriceIdForBillingPlan = (billingPlan: BillingPlan, billingInterval: BillingInterval) => {
if (billingPlan === 'rescue_free') {
throw new Error('Rescue flocks do not use Stripe billing.');
@@ -2347,13 +2368,15 @@ app.delete('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRo
return;
}
const canceledStripeSubscriptionId = await cancelWorkspaceStripeSubscription(req.auth!.workspace);
let nextWorkspaceId = await findAlternateWorkspaceForUser(req.auth!.user.id, req.auth!.workspace.id);
if (!nextWorkspaceId) {
const fallbackWorkspaceId = await getNextWorkspaceId();
const fallbackWorkspace = await createWorkspace({
id: fallbackWorkspaceId,
name: `${req.auth!.user.name}'s Flock`,
name: 'New Flock',
workspaceType: 'standard',
billingEmail: req.auth!.user.email,
billingPlan: 'household_basic',
@@ -2382,6 +2405,7 @@ app.delete('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRo
res.json({
deletedWorkspaceId: req.auth!.workspace.id,
canceledStripeSubscriptionId,
token: req.auth!.token,
session: await buildSessionPayload(updatedAuth),
});
@@ -2492,6 +2516,9 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
name: parsed.data.name,
tagId: normalizeBandId(parsed.data.tagId),
species: parsed.data.species,
motivators: emptyToNull(parsed.data.motivators),
demotivators: emptyToNull(parsed.data.demotivators),
favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
gender: (parsed.data.gender ?? 'unknown') as BirdGender,
dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
gotchaDay: emptyToNull(parsed.data.gotchaDay),
@@ -2613,6 +2640,9 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
name: parsed.data.name,
tagId: normalizeBandId(parsed.data.tagId),
species: parsed.data.species,
motivators: emptyToNull(parsed.data.motivators),
demotivators: emptyToNull(parsed.data.demotivators),
favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
gender: (parsed.data.gender ?? 'unknown') as BirdGender,
dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
gotchaDay: emptyToNull(parsed.data.gotchaDay),
+12 -4
View File
@@ -58,10 +58,6 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
WHERE workspace_type = 'rescue'
AND rescue_verification_status = 'not_required';
INSERT INTO workspaces (id, name, workspace_type, billing_plan)
VALUES (1, 'My Flock', 'standard', 'household_basic')
ON CONFLICT (id) DO NOTHING;
CREATE TABLE IF NOT EXISTS workspace_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
@@ -201,6 +197,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
name VARCHAR(120) NOT NULL,
tag_id VARCHAR(80),
species VARCHAR(120) NOT NULL,
motivators VARCHAR(1000),
demotivators VARCHAR(1000),
favorite_snack VARCHAR(160),
gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
date_of_birth DATE,
gotcha_day DATE,
@@ -217,6 +216,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ALTER TABLE birds
ADD COLUMN IF NOT EXISTS workspace_id INTEGER NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS motivators VARCHAR(1000),
ADD COLUMN IF NOT EXISTS demotivators VARCHAR(1000),
ADD COLUMN IF NOT EXISTS favorite_snack VARCHAR(160),
ADD COLUMN IF NOT EXISTS gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
ADD COLUMN IF NOT EXISTS date_of_birth DATE,
ADD COLUMN IF NOT EXISTS gotcha_day DATE,
@@ -246,6 +248,12 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
END IF;
END $$;
DELETE FROM workspaces
WHERE id = 1
AND name = 'My Flock'
AND NOT EXISTS (SELECT 1 FROM workspace_members WHERE workspace_members.workspace_id = workspaces.id)
AND NOT EXISTS (SELECT 1 FROM birds WHERE birds.workspace_id = workspaces.id);
ALTER TABLE birds
DROP CONSTRAINT IF EXISTS birds_tag_id_key;
@@ -31,6 +31,9 @@ test('createBird returns the inserted bird row', async () => {
name: 'Kiwi',
tag_id: 'A-1',
species: 'Cockatiel',
motivators: 'Step-up practice',
demotivators: 'Vacuum noise',
favorite_snack: 'Millet',
gender: 'female',
date_of_birth: null,
gotcha_day: null,
@@ -50,6 +53,9 @@ test('createBird returns the inserted bird row', async () => {
name: 'Kiwi',
tagId: 'A-1',
species: 'Cockatiel',
motivators: 'Step-up practice',
demotivators: 'Vacuum noise',
favoriteSnack: 'Millet',
gender: 'female',
dateOfBirth: null,
gotchaDay: null,
@@ -62,6 +68,7 @@ test('createBird returns the inserted bird row', async () => {
assert.equal(bird?.name, 'Kiwi');
assert.equal(bird?.workspace_id, 10);
assert.equal(bird?.gender, 'female');
assert.equal(bird?.favorite_snack, 'Millet');
});
test('listWeightsForBird scopes by bird, workspace, and day window', async () => {
+63 -14
View File
@@ -20,6 +20,9 @@ const birdSelectFields = `
birds.name,
birds.tag_id,
birds.species,
birds.motivators,
birds.demotivators,
birds.favorite_snack,
birds.gender,
birds.date_of_birth::text,
birds.gotcha_day::text,
@@ -254,6 +257,9 @@ export const createBird = async ({
name,
tagId,
species,
motivators,
demotivators,
favoriteSnack,
gender,
dateOfBirth,
gotchaDay,
@@ -266,6 +272,9 @@ export const createBird = async ({
name: string;
tagId: string | null;
species: string;
motivators: string | null;
demotivators: string | null;
favoriteSnack: string | null;
gender: BirdGender;
dateOfBirth: string | null;
gotchaDay: string | null;
@@ -275,10 +284,25 @@ export const createBird = async ({
notifyOnGotchaDay: boolean;
}) => {
const result = await db.query<BirdRow>(
`INSERT INTO birds (workspace_id, name, tag_id, species, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`,
[workspaceId, name, tagId, species, gender, dateOfBirth, gotchaDay, chartColor, photoDataUrl, notifyOnDob, notifyOnGotchaDay],
`INSERT INTO birds (workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`,
[
workspaceId,
name,
tagId,
species,
motivators,
demotivators,
favoriteSnack,
gender,
dateOfBirth,
gotchaDay,
chartColor,
photoDataUrl,
notifyOnDob,
notifyOnGotchaDay,
],
);
return result.rows[0] ?? null;
@@ -290,6 +314,9 @@ export const updateBird = async ({
name,
tagId,
species,
motivators,
demotivators,
favoriteSnack,
gender,
dateOfBirth,
gotchaDay,
@@ -303,6 +330,9 @@ export const updateBird = async ({
name: string;
tagId: string | null;
species: string;
motivators: string | null;
demotivators: string | null;
favoriteSnack: string | null;
gender: BirdGender;
dateOfBirth: string | null;
gotchaDay: string | null;
@@ -316,17 +346,20 @@ export const updateBird = async ({
SET name = $2,
tag_id = $3,
species = $4,
gender = $5,
date_of_birth = $6,
gotcha_day = $7,
chart_color = $8,
photo_data_url = $9,
notify_on_dob = $10,
notify_on_gotcha_day = $11
motivators = $5,
demotivators = $6,
favorite_snack = $7,
gender = $8,
date_of_birth = $9,
gotcha_day = $10,
chart_color = $11,
photo_data_url = $12,
notify_on_dob = $13,
notify_on_gotcha_day = $14
WHERE id = $1
AND workspace_id = $12
AND workspace_id = $15
AND memorialized_at IS NULL
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
(
SELECT weight_grams::text
FROM weight_records
@@ -341,7 +374,23 @@ export const updateBird = async ({
ORDER BY recorded_on DESC
LIMIT 1
) AS latest_recorded_on`,
[birdId, name, tagId, species, gender, dateOfBirth, gotchaDay, chartColor, photoDataUrl, notifyOnDob, notifyOnGotchaDay, workspaceId],
[
birdId,
name,
tagId,
species,
motivators,
demotivators,
favoriteSnack,
gender,
dateOfBirth,
gotchaDay,
chartColor,
photoDataUrl,
notifyOnDob,
notifyOnGotchaDay,
workspaceId,
],
);
return result.rows[0] ?? null;
@@ -34,6 +34,36 @@ test('ensurePersonalWorkspaceForUser returns an existing workspace without creat
assert.match(calls[0].text, /FROM workspace_members/);
});
test('ensurePersonalWorkspaceForUser creates a fresh workspace instead of claiming the legacy seed flock', async () => {
const { calls } = mockDb(
{
rowCount: 0,
rows: [],
},
{
rowCount: 1,
rows: [{ next_id: 43 }],
},
{
rowCount: 1,
rows: [],
},
{
rowCount: 1,
rows: [],
},
);
const workspaceId = await ensurePersonalWorkspaceForUser(user);
assert.equal(workspaceId, 43);
assert.equal(calls.length, 4);
assert.match(calls[1].text, /SELECT COALESCE\(MAX\(id\), 0\) \+ 1 AS next_id FROM workspaces/);
assert.match(calls[2].text, /INSERT INTO workspaces/);
assert.match(calls[3].text, /INSERT INTO workspace_members/);
assert.deepEqual(calls[2].params, [43, "Owner's Flock", 'owner@example.com']);
});
test('createWorkspace inserts owner membership and returns the created workspace', async () => {
const { calls } = mockDb(
{ rowCount: 1, rows: [] },
@@ -91,40 +91,14 @@ export const ensurePersonalWorkspaceForUser = async (user: UserRow) => {
return Number(existing.rows[0].workspace_id);
}
const unclaimed = await db.query<{ workspace_id: number }>(
`SELECT workspaces.id AS workspace_id
FROM workspaces
LEFT JOIN workspace_members ON workspace_members.workspace_id = workspaces.id
WHERE workspaces.id = 1
GROUP BY workspaces.id
HAVING COUNT(workspace_members.id) = 0
LIMIT 1`,
const workspaceId = await getNextWorkspaceId();
await db.query(
`INSERT INTO workspaces (id, name, workspace_type, billing_plan, billing_interval, billing_email, subscription_status, rescue_verification_status)
VALUES ($1, $2, 'standard', 'household_basic', 'monthly', $3, 'none', 'not_required')`,
[workspaceId, `${user.name}'s Flock`, user.email],
);
const workspaceId = unclaimed.rowCount ? Number(unclaimed.rows[0].workspace_id) : await getNextWorkspaceId();
if (!unclaimed.rowCount) {
await db.query(
`INSERT INTO workspaces (id, name, workspace_type, billing_plan, billing_interval, billing_email, subscription_status, rescue_verification_status)
VALUES ($1, $2, 'standard', 'household_basic', 'monthly', $3, 'none', 'not_required')`,
[workspaceId, `${user.name}'s Flock`, user.email],
);
} else {
await db.query(
`UPDATE workspaces
SET name = $2,
workspace_type = 'standard',
billing_plan = 'household_basic',
billing_interval = 'monthly',
billing_email = $3,
subscription_status = 'none',
rescue_verification_status = 'not_required',
updated_at = CURRENT_TIMESTAMP
WHERE id = $1`,
[workspaceId, `${user.name}'s Flock`, user.email],
);
}
await db.query(
`INSERT INTO workspace_members (workspace_id, user_id, email, invite_email, name, role, accepted_at)
VALUES ($1, $2, $3, $3, $4, 'owner', CURRENT_TIMESTAMP)
+3
View File
@@ -98,6 +98,9 @@ export type BirdRow = {
name: string;
tag_id: string | null;
species: string;
motivators: string | null;
demotivators: string | null;
favorite_snack: string | null;
gender: BirdGender;
date_of_birth: string | null;
gotcha_day: string | null;