import { db } from '../db/client.js'; import type { BillingInterval, BillingPlan, RescueVerificationStatus, SubscriptionStatus, UserRow, WorkspaceMemberRow, WorkspaceRow, WorkspaceType } from '../types.js'; export const getNextWorkspaceId = async () => { const result = await db.query<{ next_id: number }>('SELECT COALESCE(MAX(id), 0) + 1 AS next_id FROM workspaces'); return Number(result.rows[0]?.next_id ?? 1); }; export const getWorkspaceById = async (workspaceId: number) => { const result = await db.query( `SELECT id, name, workspace_type, billing_email, billing_plan, billing_interval, subscription_status, stripe_customer_id, stripe_subscription_id, rescue_verification_status, created_at, updated_at FROM workspaces WHERE id = $1`, [workspaceId], ); return result.rows[0] ?? null; }; export const getMembershipForUser = async (userId: string, workspaceId: number) => { const result = await db.query( `SELECT id, workspace_id, user_id, COALESCE(invite_email, email) AS invite_email, name, role, accepted_at::text, created_at FROM workspace_members WHERE workspace_id = $1 AND user_id = $2`, [workspaceId, userId], ); return result.rows[0] ?? null; }; export const listMembershipsForUser = async (userId: string) => { const result = await db.query< WorkspaceMemberRow & { workspace_name: string; workspace_type: WorkspaceType; billing_email: string | null; billing_plan: BillingPlan; billing_interval: BillingInterval; subscription_status: WorkspaceRow['subscription_status']; stripe_customer_id: string | null; stripe_subscription_id: string | null; rescue_verification_status: RescueVerificationStatus; workspace_created_at: string; workspace_updated_at: string; } >( `SELECT workspace_members.id, workspace_members.workspace_id, workspace_members.user_id, COALESCE(workspace_members.invite_email, workspace_members.email) AS invite_email, workspace_members.name, workspace_members.role, workspace_members.accepted_at::text, workspace_members.created_at, workspaces.name AS workspace_name, workspaces.workspace_type, workspaces.billing_email, workspaces.billing_plan, workspaces.billing_interval, workspaces.subscription_status, workspaces.stripe_customer_id, workspaces.stripe_subscription_id, workspaces.rescue_verification_status, workspaces.created_at AS workspace_created_at, workspaces.updated_at AS workspace_updated_at FROM workspace_members INNER JOIN workspaces ON workspaces.id = workspace_members.workspace_id WHERE workspace_members.user_id = $1 ORDER BY workspaces.created_at ASC`, [userId], ); return result.rows; }; export const ensurePersonalWorkspaceForUser = async (user: UserRow) => { const existing = await db.query<{ workspace_id: number }>( `SELECT workspace_id FROM workspace_members INNER JOIN workspaces ON workspaces.id = workspace_members.workspace_id WHERE workspace_members.user_id = $1 AND workspaces.workspace_type = 'standard' ORDER BY workspaces.created_at ASC LIMIT 1`, [user.id], ); if (existing.rowCount) { return Number(existing.rows[0].workspace_id); } 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], ); 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) ON CONFLICT (workspace_id, invite_email) DO UPDATE SET user_id = EXCLUDED.user_id, email = EXCLUDED.email, name = EXCLUDED.name, role = 'owner', accepted_at = CURRENT_TIMESTAMP`, [workspaceId, user.id, user.email, user.name], ); return workspaceId; }; export const ensureDefaultWorkspaceForUser = async (user: UserRow) => { const existing = await db.query<{ workspace_id: number }>( `SELECT workspace_id FROM workspace_members INNER JOIN workspaces ON workspaces.id = workspace_members.workspace_id WHERE workspace_members.user_id = $1 ORDER BY workspaces.created_at ASC LIMIT 1`, [user.id], ); if (existing.rowCount) { return Number(existing.rows[0].workspace_id); } return ensurePersonalWorkspaceForUser(user); }; export const claimWorkspaceInvites = async (user: UserRow) => { await db.query( `UPDATE workspace_members SET user_id = $1, accepted_at = CURRENT_TIMESTAMP WHERE LOWER(COALESCE(invite_email, email)) = LOWER($2) AND user_id IS NULL`, [user.id, user.email], ); }; export const createWorkspace = async ({ id, name, workspaceType, billingEmail, billingPlan, billingInterval, owner, }: { id: number; name: string; workspaceType: WorkspaceType; billingEmail: string | null; billingPlan: BillingPlan; billingInterval: BillingInterval; owner: UserRow; }) => { await db.query( `INSERT INTO workspaces (id, name, workspace_type, billing_email, billing_plan, billing_interval, subscription_status, rescue_verification_status) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [ id, name, workspaceType, billingEmail, billingPlan, billingInterval, workspaceType === 'rescue' ? 'active' : 'none', workspaceType === 'rescue' ? 'pending' : 'not_required', ], ); 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)`, [id, owner.id, owner.email, owner.name], ); return getWorkspaceById(id); }; export const updateWorkspace = async ({ workspaceId, name, workspaceType, billingEmail, billingPlan, billingInterval, }: { workspaceId: number; name: string; workspaceType: WorkspaceType; billingEmail: string | null; billingPlan: BillingPlan; billingInterval: BillingInterval; }) => { const result = await db.query( `WITH input AS ( SELECT $1::integer AS workspace_id, $2::varchar AS name, $3::varchar AS workspace_type, $4::varchar AS billing_email, $5::varchar AS billing_plan, $6::varchar AS billing_interval ) UPDATE workspaces SET name = input.name, workspace_type = input.workspace_type, billing_email = input.billing_email, billing_plan = input.billing_plan, billing_interval = input.billing_interval, rescue_verification_status = CASE WHEN input.workspace_type = 'rescue' AND workspaces.rescue_verification_status = 'not_required' THEN 'pending' WHEN input.workspace_type = 'standard' THEN 'not_required' ELSE workspaces.rescue_verification_status END, updated_at = CURRENT_TIMESTAMP FROM input WHERE workspaces.id = input.workspace_id RETURNING workspaces.id, workspaces.name, workspaces.workspace_type, workspaces.billing_email, workspaces.billing_plan, workspaces.billing_interval, workspaces.subscription_status, workspaces.stripe_customer_id, workspaces.stripe_subscription_id, workspaces.rescue_verification_status, workspaces.created_at, workspaces.updated_at`, [workspaceId, name, workspaceType, billingEmail, billingPlan, billingInterval], ); return result.rows[0] ?? null; }; export const listWorkspaceMembers = async (workspaceId: number) => { const result = await db.query( `SELECT id, workspace_id, user_id, COALESCE(invite_email, email) AS invite_email, name, role, accepted_at::text, created_at FROM workspace_members WHERE workspace_id = $1 ORDER BY created_at ASC`, [workspaceId], ); return result.rows; }; export const listWorkspaceNotificationEmails = async (workspaceId: number) => { const result = await db.query<{ email: string }>( `SELECT DISTINCT LOWER(TRIM(email)) AS email FROM ( SELECT COALESCE(invite_email, email) AS email FROM workspace_members WHERE workspace_id = $1 UNION SELECT billing_email AS email FROM workspaces WHERE id = $1 ) contact_emails WHERE email IS NOT NULL AND TRIM(email) <> '' ORDER BY email ASC`, [workspaceId], ); return result.rows.map((row) => row.email); }; export const findAlternateWorkspaceForUser = async (userId: string, excludeWorkspaceId: number) => { const result = await db.query<{ workspace_id: number }>( `SELECT workspace_id FROM workspace_members WHERE user_id = $1 AND workspace_id <> $2 ORDER BY created_at ASC LIMIT 1`, [userId, excludeWorkspaceId], ); return result.rows[0] ? Number(result.rows[0].workspace_id) : null; }; export const listOwnedWorkspacesByOwnerEmail = async (ownerEmail: string, excludeWorkspaceId: number) => { const result = await db.query( `SELECT workspaces.id, workspaces.name, workspaces.workspace_type, workspaces.billing_email, workspaces.billing_plan, workspaces.billing_interval, workspaces.subscription_status, workspaces.stripe_customer_id, workspaces.stripe_subscription_id, 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 FROM birds WHERE workspace_id = $1 AND memorialized_at IS NULL`, [workspaceId], ); return Number(birdCount.rows[0]?.count ?? 0); }; export const getWorkspaceTotalBirdCount = async (workspaceId: number) => { const birdCount = await db.query<{ count: string }>( `SELECT COUNT(*)::text AS count FROM birds WHERE workspace_id = $1`, [workspaceId], ); return Number(birdCount.rows[0]?.count ?? 0); }; export const deleteWorkspaceIfEmpty = async (workspaceId: number) => { if ((await getWorkspaceTotalBirdCount(workspaceId)) > 0) { return { deleted: false as const, reason: 'birds_present' as const }; } const deleted = await db.query<{ id: number }>( `DELETE FROM workspaces WHERE id = $1 RETURNING id`, [workspaceId], ); if (!deleted.rowCount) { return { deleted: false as const, reason: 'not_found' as const }; } return { deleted: true as const }; }; export const upsertWorkspaceMember = async ({ workspaceId, inviteEmail, name, role, existingUser, }: { workspaceId: number; inviteEmail: string; name: string; role: WorkspaceMemberRow['role']; existingUser: UserRow | null; }) => { const result = await db.query( `INSERT INTO workspace_members (workspace_id, user_id, email, invite_email, name, role, accepted_at) VALUES ($1, $2, $3, $3, $4, $5, $6) ON CONFLICT (workspace_id, invite_email) DO UPDATE SET name = EXCLUDED.name, role = EXCLUDED.role, email = EXCLUDED.email, user_id = COALESCE(workspace_members.user_id, EXCLUDED.user_id), accepted_at = COALESCE(workspace_members.accepted_at, EXCLUDED.accepted_at) RETURNING id, workspace_id, user_id, COALESCE(invite_email, email) AS invite_email, name, role, accepted_at::text, created_at`, [workspaceId, existingUser?.id ?? null, inviteEmail, name, role, existingUser ? new Date().toISOString() : null], ); return result.rows[0] ?? null; }; export const deleteWorkspaceMember = async ({ memberId, workspaceId, requesterMemberId, requesterIsBillingOwner, }: { memberId: string; workspaceId: number; requesterMemberId: string; requesterIsBillingOwner: boolean; }) => { const result = await db.query<{ id: string }>( `DELETE FROM workspace_members WHERE id = $1 AND workspace_id = $2 AND ( role <> 'owner' OR ( $3 = TRUE AND id <> $4 ) ) RETURNING id`, [memberId, workspaceId, requesterIsBillingOwner, requesterMemberId], ); return Boolean(result.rowCount); }; export const updateWorkspaceMemberRole = async ({ memberId, workspaceId, role, requesterMemberId, requesterIsBillingOwner, requesterRole, billingEmail, }: { memberId: string; workspaceId: number; role: WorkspaceMemberRow['role']; requesterMemberId: string; requesterIsBillingOwner: boolean; requesterRole: WorkspaceMemberRow['role']; billingEmail: string; }) => { const result = await db.query( `UPDATE workspace_members SET role = $3 WHERE id = $1 AND workspace_id = $2 AND ( $3 <> 'owner' OR $4 = TRUE ) AND ( role <> 'owner' OR ( id <> $5 AND ( $4 = TRUE OR ( $7 = 'owner' AND LOWER(BTRIM(COALESCE(invite_email, email))) <> LOWER(BTRIM($6)) ) ) ) ) RETURNING id, workspace_id, user_id, COALESCE(invite_email, email) AS invite_email, name, role, accepted_at::text, created_at`, [memberId, workspaceId, role, requesterIsBillingOwner, requesterMemberId, billingEmail, requesterRole], ); return result.rows[0] ?? null; }; export const listRescueWorkspacesForAdmin = async () => { const result = await db.query< WorkspaceRow & { bird_count: number; member_count: number; } >( `SELECT workspaces.id, workspaces.name, workspaces.workspace_type, workspaces.billing_email, workspaces.billing_plan, workspaces.billing_interval, workspaces.subscription_status, workspaces.stripe_customer_id, workspaces.stripe_subscription_id, workspaces.rescue_verification_status, workspaces.created_at, workspaces.updated_at, COUNT(DISTINCT birds.id)::int AS bird_count, COUNT(DISTINCT workspace_members.id)::int AS member_count FROM workspaces LEFT JOIN birds ON birds.workspace_id = workspaces.id LEFT JOIN workspace_members ON workspace_members.workspace_id = workspaces.id WHERE workspaces.workspace_type = 'rescue' GROUP BY workspaces.id ORDER BY CASE workspaces.rescue_verification_status WHEN 'pending' THEN 0 WHEN 'approved' THEN 1 WHEN 'rejected' THEN 2 ELSE 3 END, workspaces.created_at DESC`, ); return result.rows; }; export const updateRescueVerificationStatus = async (workspaceId: number, status: RescueVerificationStatus) => { const result = await db.query( `UPDATE workspaces SET workspace_type = CASE WHEN $2 = 'rejected' THEN 'standard' ELSE workspace_type END, billing_plan = CASE WHEN $2 = 'rejected' THEN 'household_basic' ELSE billing_plan END, billing_interval = CASE WHEN $2 = 'rejected' THEN 'monthly' ELSE billing_interval END, subscription_status = CASE WHEN $2 = 'rejected' THEN 'none' ELSE subscription_status END, rescue_verification_status = CASE WHEN $2 = 'rejected' THEN 'not_required' ELSE $2 END, updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND workspace_type = 'rescue' RETURNING id, name, workspace_type, billing_email, billing_plan, billing_interval, subscription_status, stripe_customer_id, stripe_subscription_id, rescue_verification_status, created_at, updated_at`, [workspaceId, status], ); return result.rows[0] ?? null; }; export const cancelRescueVerificationRequest = async (workspaceId: number) => { const result = await db.query( `UPDATE workspaces SET workspace_type = 'standard', billing_plan = 'household_basic', billing_interval = 'monthly', subscription_status = 'none', rescue_verification_status = 'not_required', updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND workspace_type = 'rescue' AND rescue_verification_status = 'pending' RETURNING id, name, workspace_type, billing_email, billing_plan, billing_interval, subscription_status, stripe_customer_id, stripe_subscription_id, rescue_verification_status, created_at, updated_at`, [workspaceId], ); return result.rows[0] ?? null; }; export const setWorkspaceStripeCustomerId = async (workspaceId: number, stripeCustomerId: string) => { const result = await db.query( `UPDATE workspaces SET stripe_customer_id = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING id, name, workspace_type, billing_email, billing_plan, billing_interval, subscription_status, stripe_customer_id, stripe_subscription_id, rescue_verification_status, created_at, updated_at`, [workspaceId, stripeCustomerId], ); return result.rows[0] ?? null; }; export const setWorkspaceStripeSubscription = async ({ workspaceId, stripeCustomerId, stripeSubscriptionId, subscriptionStatus, billingPlan, billingInterval, }: { workspaceId: number; stripeCustomerId: string | null; stripeSubscriptionId: string; subscriptionStatus: SubscriptionStatus; billingPlan?: BillingPlan | null; billingInterval?: BillingInterval | null; }) => { const result = await db.query( `UPDATE workspaces SET stripe_customer_id = COALESCE($2, stripe_customer_id), stripe_subscription_id = $3, subscription_status = $4, billing_plan = COALESCE($5, billing_plan), billing_interval = COALESCE($6, billing_interval), updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING id, name, workspace_type, billing_email, billing_plan, billing_interval, subscription_status, stripe_customer_id, stripe_subscription_id, rescue_verification_status, created_at, updated_at`, [workspaceId, stripeCustomerId, stripeSubscriptionId, subscriptionStatus, billingPlan ?? null, billingInterval ?? null], ); return result.rows[0] ?? null; }; export const setWorkspaceSubscriptionStatusByStripeSubscriptionId = async ( stripeSubscriptionId: string, subscriptionStatus: SubscriptionStatus, billingPlan?: BillingPlan | null, billingInterval?: BillingInterval | null, ) => { const result = await db.query( `UPDATE workspaces SET subscription_status = $2, billing_plan = COALESCE($3, billing_plan), billing_interval = COALESCE($4, billing_interval), updated_at = CURRENT_TIMESTAMP WHERE stripe_subscription_id = $1 RETURNING id, name, workspace_type, billing_email, billing_plan, billing_interval, subscription_status, stripe_customer_id, stripe_subscription_id, rescue_verification_status, created_at, updated_at`, [stripeSubscriptionId, subscriptionStatus, billingPlan ?? null, billingInterval ?? null], ); return result.rows[0] ?? null; }; export const getPlatformAdminSummary = async () => { const result = await db.query<{ total_birds: number; memorialized_birds: number; total_users: number; total_workspaces: number; rescue_workspaces: number; rescue_birds: number; pending_rescues: number; daily_users: number; }>( `SELECT (SELECT COUNT(*)::int FROM birds) AS total_birds, (SELECT COUNT(*)::int FROM birds WHERE memorialized_at IS NOT NULL) AS memorialized_birds, (SELECT COUNT(*)::int FROM users) AS total_users, (SELECT COUNT(*)::int FROM workspaces) AS total_workspaces, (SELECT COUNT(*)::int FROM workspaces WHERE workspace_type = 'rescue') AS rescue_workspaces, (SELECT COUNT(*)::int FROM birds INNER JOIN workspaces ON workspaces.id = birds.workspace_id WHERE workspaces.workspace_type = 'rescue') AS rescue_birds, (SELECT COUNT(*)::int FROM workspaces WHERE workspace_type = 'rescue' AND rescue_verification_status = 'pending') AS pending_rescues, (SELECT COUNT(DISTINCT user_id)::int FROM auth_sessions WHERE created_at >= CURRENT_DATE) AS daily_users`, ); return result.rows[0]; };