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:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import cors from '@fastify/cors';
|
||||
import { getEnv } from './config/env.js';
|
||||
import { deepResearchRoutes } from './routes/deep-research.js';
|
||||
import { authRoutes } from './routes/auth.js';
|
||||
import { accountRoutes } from './routes/account.js';
|
||||
import { healthRoutes } from './routes/health.js';
|
||||
import { searchJobRoutes } from './routes/search-jobs.js';
|
||||
|
||||
@@ -48,6 +49,7 @@ export async function buildApp() {
|
||||
|
||||
await app.register(healthRoutes, { prefix: '/api' });
|
||||
await app.register(authRoutes, { prefix: '/api' });
|
||||
await app.register(accountRoutes, { prefix: '/api' });
|
||||
await app.register(searchJobRoutes, { prefix: '/api' });
|
||||
await app.register(deepResearchRoutes, { prefix: '/api' });
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { FastifyPluginAsync } from 'fastify';
|
||||
import { ZodError, z } from 'zod';
|
||||
import { requireAuth } from '../auth/middleware.js';
|
||||
import { getDbPool } from '../db/pool.js';
|
||||
import { buildAccountPageData, ensureWorkspaceForUser, updateUserProfile, updateWorkspaceName } from '../account/repository.js';
|
||||
|
||||
const updateAccountSchema = z.object({
|
||||
displayName: z.string().trim().min(1).max(120).optional(),
|
||||
avatarUrl: z.string().trim().url().nullable().optional().or(z.literal('')),
|
||||
workspaceName: z.string().trim().min(1).max(160).optional(),
|
||||
});
|
||||
|
||||
export const accountRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/account/me', { preHandler: requireAuth }, async (request, reply) => {
|
||||
try {
|
||||
const account = await buildAccountPageData(getDbPool(), request.authUser!);
|
||||
return { account };
|
||||
} catch (error) {
|
||||
request.log.error(error);
|
||||
return reply.code(500).send({ error: 'Failed to load account page.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/account/me', { preHandler: requireAuth }, async (request, reply) => {
|
||||
try {
|
||||
const payload = updateAccountSchema.parse(request.body);
|
||||
const db = getDbPool();
|
||||
const workspace = await ensureWorkspaceForUser(db, request.authUser!);
|
||||
|
||||
if (!workspace) {
|
||||
return reply.code(500).send({ error: 'Failed to load workspace.' });
|
||||
}
|
||||
|
||||
if (payload.workspaceName && workspace.role !== 'owner' && workspace.role !== 'admin') {
|
||||
return reply.code(403).send({ error: 'You do not have permission to update this workspace.' });
|
||||
}
|
||||
|
||||
const profile = await updateUserProfile(db, request.authUser!.id, {
|
||||
displayName: payload.displayName,
|
||||
avatarUrl: payload.avatarUrl === '' ? null : payload.avatarUrl,
|
||||
});
|
||||
|
||||
if (payload.workspaceName) {
|
||||
await updateWorkspaceName(db, workspace.id, payload.workspaceName);
|
||||
}
|
||||
|
||||
const account = await buildAccountPageData(db, profile);
|
||||
return { account };
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid account payload.' });
|
||||
}
|
||||
|
||||
request.log.error(error);
|
||||
return reply.code(500).send({ error: 'Failed to update account.' });
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { ZodError, z } from 'zod';
|
||||
import { hashPassword, verifyPassword } from '../auth/passwords.js';
|
||||
import { clearSessionCookie, createSession, deleteSessionById, deleteSessionByToken, getSessionTokenFromRequest, getSessionUserByToken, setSessionCookie, } from '../auth/sessions.js';
|
||||
import { createUser, getUserByEmail, toAppUser } from '../auth/users.js';
|
||||
import { createDefaultWorkspaceForUser } from '../account/repository.js';
|
||||
import { getDbPool } from '../db/pool.js';
|
||||
|
||||
const signUpSchema = z.object({
|
||||
@@ -50,13 +51,27 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(payload.password);
|
||||
const user = await createUser(db, {
|
||||
email: payload.email,
|
||||
passwordHash,
|
||||
displayName: payload.displayName,
|
||||
});
|
||||
const client = await db.connect();
|
||||
let user;
|
||||
let session;
|
||||
|
||||
try {
|
||||
await client.query('begin');
|
||||
user = await createUser(client, {
|
||||
email: payload.email,
|
||||
passwordHash,
|
||||
displayName: payload.displayName,
|
||||
});
|
||||
await createDefaultWorkspaceForUser(client, user);
|
||||
session = await createSession(client, user.id, getRequestMetadata(request));
|
||||
await client.query('commit');
|
||||
} catch (error) {
|
||||
await client.query('rollback');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
const session = await createSession(db, user.id, getRequestMetadata(request));
|
||||
setSessionCookie(reply, session.token, session.expiresAt);
|
||||
|
||||
return reply.code(201).send({ user: { ...toAppUser(user), sessionId: session.sessionId } });
|
||||
|
||||
Reference in New Issue
Block a user