Files
FlockPal/backend/src/repositories/workspaceRepository.ts
T

376 lines
12 KiB
TypeScript

import { db } from '../db/client.js';
import type { BillingPlan, RescueVerificationStatus, 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<WorkspaceRow>(
`SELECT id, name, workspace_type, billing_email, billing_plan, subscription_status, 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<WorkspaceMemberRow>(
`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;
subscription_status: WorkspaceRow['subscription_status'];
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.subscription_status,
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 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 = 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_email, subscription_status, rescue_verification_status)
VALUES ($1, $2, 'standard', 'household_basic', $3, 'active', '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_email = $3,
subscription_status = 'active',
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)
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 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,
owner,
}: {
id: number;
name: string;
workspaceType: WorkspaceType;
billingEmail: string | null;
billingPlan: BillingPlan;
owner: UserRow;
}) => {
await db.query(
`INSERT INTO workspaces (id, name, workspace_type, billing_email, billing_plan, subscription_status, rescue_verification_status)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
id,
name,
workspaceType,
billingEmail,
billingPlan,
workspaceType === 'rescue' ? 'active' : 'active',
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,
}: {
workspaceId: number;
name: string;
workspaceType: WorkspaceType;
billingEmail: string | null;
billingPlan: BillingPlan;
}) => {
const result = await db.query<WorkspaceRow>(
`UPDATE workspaces
SET name = $2,
workspace_type = $3,
billing_email = $4,
billing_plan = $5,
rescue_verification_status = CASE
WHEN $3 = 'rescue' AND rescue_verification_status = 'not_required' THEN 'pending'
WHEN $3 = 'standard' THEN 'not_required'
ELSE rescue_verification_status
END,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING id, name, workspace_type, billing_email, billing_plan, subscription_status, rescue_verification_status, created_at, updated_at`,
[workspaceId, name, workspaceType, billingEmail, billingPlan],
);
return result.rows[0] ?? null;
};
export const listWorkspaceMembers = async (workspaceId: number) => {
const result = await db.query<WorkspaceMemberRow>(
`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 upsertWorkspaceMember = async ({
workspaceId,
inviteEmail,
name,
role,
existingUser,
}: {
workspaceId: number;
inviteEmail: string;
name: string;
role: WorkspaceMemberRow['role'];
existingUser: UserRow | null;
}) => {
const result = await db.query<WorkspaceMemberRow>(
`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: string, workspaceId: number) => {
const result = await db.query<{ id: string }>(
`DELETE FROM workspace_members
WHERE id = $1
AND workspace_id = $2
AND role <> 'owner'
RETURNING id`,
[memberId, workspaceId],
);
return Boolean(result.rowCount);
};
export const listRescueWorkspacesForAdmin = async () => {
const result = await db.query<
WorkspaceRow & {
owner_email: string | null;
bird_count: number;
member_count: number;
}
>(
`SELECT
workspaces.id,
workspaces.name,
workspaces.workspace_type,
workspaces.billing_email,
workspaces.billing_plan,
workspaces.subscription_status,
workspaces.rescue_verification_status,
workspaces.created_at,
workspaces.updated_at,
owner.invite_email AS owner_email,
COUNT(DISTINCT birds.id)::int AS bird_count,
COUNT(DISTINCT workspace_members.id)::int AS member_count
FROM workspaces
LEFT JOIN workspace_members owner
ON owner.workspace_id = workspaces.id
AND owner.role = 'owner'
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, owner.invite_email
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<WorkspaceRow>(
`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,
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, subscription_status, 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<WorkspaceRow>(
`UPDATE workspaces
SET workspace_type = 'standard',
billing_plan = 'household_basic',
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, subscription_status, rescue_verification_status, created_at, updated_at`,
[workspaceId],
);
return result.rows[0] ?? null;
};
export const getPlatformAdminSummary = async () => {
const result = await db.query<{
total_birds: number;
total_users: number;
total_workspaces: number;
rescue_workspaces: number;
pending_rescues: number;
daily_users: number;
}>(
`SELECT
(SELECT COUNT(*)::int FROM birds) AS total_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 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];
};