Public Access
1
0

feat: add workspace account page and mobile app shell

Normalize the UI with shared primitives, add a workspace-backed account surface, and improve authenticated mobile navigation, map behavior, and dashboard browsing.
This commit is contained in:
pguerrerox
2026-05-07 17:40:10 +00:00
parent d4bce92872
commit 1f7737e5cb
24 changed files with 1397 additions and 489 deletions
+198
View File
@@ -0,0 +1,198 @@
import type { Pool, PoolClient } from 'pg';
import type { AccountPageData, AccountWorkspace, AppUser, WorkspaceType, WorkspaceRole } from '../../../shared/types.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);
return {
profile: user,
workspace,
summary,
billing: {
status: 'not_configured',
planName: null,
message: 'Billing is not configured yet. Subscription management will appear here in a future update.',
},
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,
};
}