From 0db90aab45fa3e953e8bd32280aea2e937030d09 Mon Sep 17 00:00:00 2001 From: Corey Blais Date: Wed, 20 May 2026 17:12:15 -0400 Subject: [PATCH] Fixed delete workflow and added additional profile info --- backend/src/app.ts | 32 +- backend/src/db/schema.ts | 16 +- .../src/repositories/birdRepository.test.ts | 7 + backend/src/repositories/birdRepository.ts | 77 ++- .../repositories/workspaceRepository.test.ts | 30 ++ .../src/repositories/workspaceRepository.ts | 38 +- backend/src/types.ts | 3 + frontend/src/App.tsx | 444 ++++++++++-------- frontend/src/index.css | 4 - 9 files changed, 403 insertions(+), 248 deletions(-) diff --git a/backend/src/app.ts b/backend/src/app.ts index 0abf3b4..d070d1e 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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), diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index b16ed63..8e165d0 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -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; diff --git a/backend/src/repositories/birdRepository.test.ts b/backend/src/repositories/birdRepository.test.ts index cd4f85a..80b52f1 100644 --- a/backend/src/repositories/birdRepository.test.ts +++ b/backend/src/repositories/birdRepository.test.ts @@ -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 () => { diff --git a/backend/src/repositories/birdRepository.ts b/backend/src/repositories/birdRepository.ts index 400fcb7..180966d 100644 --- a/backend/src/repositories/birdRepository.ts +++ b/backend/src/repositories/birdRepository.ts @@ -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( - `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; diff --git a/backend/src/repositories/workspaceRepository.test.ts b/backend/src/repositories/workspaceRepository.test.ts index e2e0ce4..486a899 100644 --- a/backend/src/repositories/workspaceRepository.test.ts +++ b/backend/src/repositories/workspaceRepository.test.ts @@ -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: [] }, diff --git a/backend/src/repositories/workspaceRepository.ts b/backend/src/repositories/workspaceRepository.ts index c06183a..4940157 100644 --- a/backend/src/repositories/workspaceRepository.ts +++ b/backend/src/repositories/workspaceRepository.ts @@ -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) diff --git a/backend/src/types.ts b/backend/src/types.ts index 72e9b2b..91fefaa 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -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; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4e7596c..3fa7e43 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -19,6 +19,9 @@ type Bird = { name: string; tagId: string | null; species: string; + motivators: string | null; + demotivators: string | null; + favoriteSnack: string | null; gender: BirdGender; dateOfBirth: string | null; gotchaDay: string | null; @@ -179,6 +182,9 @@ type BirdFormState = { name: string; tagId: string; species: string; + motivators: string; + demotivators: string; + favoriteSnack: string; gender: BirdGender; dateOfBirth: string; gotchaDay: string; @@ -315,6 +321,9 @@ const emptyBirdForm: BirdFormState = { name: '', tagId: '', species: '', + motivators: '', + demotivators: '', + favoriteSnack: '', gender: 'unknown', dateOfBirth: '', gotchaDay: '', @@ -437,6 +446,9 @@ const toBirdForm = (bird: Bird): BirdFormState => ({ name: bird.name, tagId: bird.tagId ?? '', species: bird.species, + motivators: bird.motivators ?? '', + demotivators: bird.demotivators ?? '', + favoriteSnack: bird.favoriteSnack ?? '', gender: bird.gender, dateOfBirth: bird.dateOfBirth ?? '', gotchaDay: bird.gotchaDay ?? '', @@ -2094,7 +2106,7 @@ function App() { } }; - const handleWorkspaceSwitch = async (workspaceId: number) => { + const handleWorkspaceSwitch = async (workspaceId: number, nextActivePage: AppPage = 'overview') => { if (!authToken || workspaceId === workspace?.id) { return; } @@ -2129,7 +2141,7 @@ function App() { setMedications([]); setMedicationAdministrations([]); setAllBirdVetVisits({}); - setActivePage('overview'); + setActivePage(nextActivePage); } catch (switchError) { setError(switchError instanceof Error ? switchError.message : 'Unable to switch flocks.'); } finally { @@ -2279,6 +2291,7 @@ function App() { throw new Error(await readErrorMessage(response, 'Unable to create flock.')); } + const createdData = (await readJsonSafely<{ workspace?: Workspace }>(response)) ?? {}; const workspaceResponse = await apiFetch('/auth/session', authToken); if (!workspaceResponse.ok) { throw new Error(await readErrorMessage(workspaceResponse, 'Flock was created but the session could not be refreshed.')); @@ -2296,6 +2309,11 @@ function App() { ...emptyWorkspaceCreateForm, billingEmail: data.session.user.email, }); + setExpandedSettingsSection(null); + + if (createdData.workspace) { + await handleWorkspaceSwitch(createdData.workspace.id, 'settings'); + } } catch (workspaceError) { setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to create flock.'); } finally { @@ -3205,7 +3223,7 @@ function App() { } const confirmed = window.confirm( - `Delete ${workspace.name}?\n\nThis only works when the flock has no birds. Remove or transfer all birds first.\n\nYou will be switched to another flock or a new personal flock automatically.`, + `Delete ${workspace.name}?\n\nThis only works when the flock has no birds. Remove or transfer all birds first. If this flock has a Stripe subscription, FlockPal will cancel it before deleting the flock.\n\nYou will be switched to another flock or a new empty flock automatically.`, ); if (!confirmed) { @@ -4247,10 +4265,6 @@ function App() {
-
- Name - {selectedBird.name} -
Band ID {selectedBird.tagId || 'Not recorded'} @@ -4267,6 +4281,18 @@ function App() { Species {selectedBird.species}
+
+ Favorite snack + {selectedBird.favoriteSnack || 'Not recorded'} +
+
+ Motivators + {selectedBird.motivators || 'Not recorded'} +
+
+ Demotivates + {selectedBird.demotivators || 'Not recorded'} +
Gender @@ -4559,11 +4585,35 @@ function App() {

Flock

Flock profile

+

- Manage this flock's name and type. Household billing details live in the Billing info card below. + Choose the flock to manage here. Household billing details live in the Billing info card below.

+ {authSession.workspaces.length > 1 ? ( + + ) : null}