94b8c357b4
- add workspace-scoped billing storage, usage tracking, and add-on catalog - enforce plan entitlements for search and deep research routes - expand pricing and account UI around billing state, usage, and upgrades
197 lines
5.3 KiB
TypeScript
197 lines
5.3 KiB
TypeScript
import type { Pool, PoolClient } from 'pg';
|
|
import type { AccountPageData, AccountWorkspace, AppUser, WorkspaceType, WorkspaceRole } from '../../../shared/types.js';
|
|
import { getWorkspaceBillingState } from '../billing/service.js';
|
|
|
|
type DbClient = Pool | PoolClient;
|
|
|
|
type WorkspaceRow = {
|
|
id: string;
|
|
name: string;
|
|
workspace_type: WorkspaceType;
|
|
role: WorkspaceRole;
|
|
member_count: string;
|
|
};
|
|
|
|
type SummaryRow = {
|
|
total_search_jobs: string;
|
|
total_deep_research_batches: string;
|
|
total_businesses: string;
|
|
};
|
|
|
|
export function buildDefaultWorkspaceName(user: { displayName?: string | null; email: string }) {
|
|
const baseName = user.displayName?.trim() || user.email.split('@')[0] || 'User';
|
|
return `${baseName}'s Workspace`;
|
|
}
|
|
|
|
export async function createDefaultWorkspaceForUser(
|
|
db: DbClient,
|
|
user: { id: string; email: string; displayName?: string | null },
|
|
) {
|
|
const workspaceResult = await db.query<{ id: string }>(
|
|
`
|
|
insert into public.workspaces (name, workspace_type)
|
|
values ($1, 'personal')
|
|
returning id
|
|
`,
|
|
[buildDefaultWorkspaceName(user)],
|
|
);
|
|
|
|
const workspaceId = workspaceResult.rows[0].id;
|
|
|
|
await db.query(
|
|
`
|
|
insert into public.workspace_memberships (workspace_id, user_id, role)
|
|
values ($1, $2, 'owner')
|
|
`,
|
|
[workspaceId, user.id],
|
|
);
|
|
|
|
return workspaceId;
|
|
}
|
|
|
|
export async function ensureWorkspaceForUser(
|
|
db: DbClient,
|
|
user: { id: string; email: string; displayName?: string | null },
|
|
) {
|
|
const existingWorkspace = await getPrimaryWorkspaceForUser(db, user.id);
|
|
if (existingWorkspace) {
|
|
return existingWorkspace;
|
|
}
|
|
|
|
await createDefaultWorkspaceForUser(db, user);
|
|
return getPrimaryWorkspaceForUser(db, user.id);
|
|
}
|
|
|
|
export async function getPrimaryWorkspaceForUser(db: DbClient, userId: string): Promise<AccountWorkspace | null> {
|
|
const result = await db.query<WorkspaceRow>(
|
|
`
|
|
select w.id, w.name, w.workspace_type, wm.role,
|
|
(
|
|
select count(*)::text
|
|
from public.workspace_memberships member
|
|
where member.workspace_id = w.id
|
|
) as member_count
|
|
from public.workspace_memberships wm
|
|
join public.workspaces w on w.id = wm.workspace_id
|
|
where wm.user_id = $1
|
|
order by wm.created_at asc
|
|
limit 1
|
|
`,
|
|
[userId],
|
|
);
|
|
|
|
if (result.rowCount === 0) {
|
|
return null;
|
|
}
|
|
|
|
return mapWorkspaceRow(result.rows[0]);
|
|
}
|
|
|
|
export async function getAccountSummaryForUser(db: DbClient, userId: string) {
|
|
const result = await db.query<SummaryRow>(
|
|
`
|
|
select
|
|
(select count(*)::text from public.search_jobs where user_id = $1) as total_search_jobs,
|
|
(select count(*)::text from public.deep_research_batches where user_id = $1) as total_deep_research_batches,
|
|
(select count(*)::text from public.businesses where user_id = $1) as total_businesses
|
|
`,
|
|
[userId],
|
|
);
|
|
|
|
const row = result.rows[0];
|
|
|
|
return {
|
|
totalSearchJobs: Number(row.total_search_jobs),
|
|
totalDeepResearchBatches: Number(row.total_deep_research_batches),
|
|
totalBusinesses: Number(row.total_businesses),
|
|
};
|
|
}
|
|
|
|
export async function updateUserProfile(
|
|
db: DbClient,
|
|
userId: string,
|
|
input: { displayName?: string; avatarUrl?: string | null },
|
|
): Promise<AppUser> {
|
|
const result = await db.query<{
|
|
id: string;
|
|
email: string;
|
|
display_name: string | null;
|
|
avatar_url: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}>(
|
|
`
|
|
update public.users
|
|
set
|
|
display_name = coalesce($2, display_name),
|
|
avatar_url = case when $3::boolean then nullif($4, '') else avatar_url end
|
|
where id = $1
|
|
returning id, email, display_name, avatar_url, created_at, updated_at
|
|
`,
|
|
[userId, input.displayName?.trim() || null, Object.prototype.hasOwnProperty.call(input, 'avatarUrl'), input.avatarUrl?.trim() || null],
|
|
);
|
|
|
|
return mapUserRow(result.rows[0]);
|
|
}
|
|
|
|
export async function updateWorkspaceName(db: DbClient, workspaceId: string, name: string) {
|
|
await db.query(
|
|
`
|
|
update public.workspaces
|
|
set name = $2
|
|
where id = $1
|
|
`,
|
|
[workspaceId, name.trim()],
|
|
);
|
|
}
|
|
|
|
export async function buildAccountPageData(db: DbClient, user: AppUser): Promise<AccountPageData> {
|
|
const workspace = await ensureWorkspaceForUser(db, user);
|
|
|
|
if (!workspace) {
|
|
throw new Error('Failed to load workspace.');
|
|
}
|
|
|
|
const summary = await getAccountSummaryForUser(db, user.id);
|
|
const billing = await getWorkspaceBillingState(db, workspace.id);
|
|
|
|
return {
|
|
profile: user,
|
|
workspace,
|
|
summary,
|
|
billing,
|
|
team: {
|
|
canManageMembers: workspace.role === 'owner' || workspace.role === 'admin',
|
|
message: 'Workspace member management is coming soon.',
|
|
},
|
|
};
|
|
}
|
|
|
|
function mapWorkspaceRow(row: WorkspaceRow): AccountWorkspace {
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
workspaceType: row.workspace_type,
|
|
role: row.role,
|
|
memberCount: Number(row.member_count),
|
|
};
|
|
}
|
|
|
|
function mapUserRow(row: {
|
|
id: string;
|
|
email: string;
|
|
display_name: string | null;
|
|
avatar_url: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}): AppUser {
|
|
return {
|
|
id: row.id,
|
|
email: row.email,
|
|
displayName: row.display_name || row.email.split('@')[0] || 'User',
|
|
avatarUrl: row.avatar_url,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
};
|
|
}
|