diff --git a/db/migrations/20260324071453_team_management.sql b/db/migrations/20260324071453_team_management.sql new file mode 100644 index 0000000..1495d6d --- /dev/null +++ b/db/migrations/20260324071453_team_management.sql @@ -0,0 +1,17 @@ +-- +goose Up + +ALTER TABLE teams ADD COLUMN slug TEXT; +ALTER TABLE teams ADD COLUMN deleted_at TIMESTAMPTZ; + +-- Backfill slugs for existing teams using MD5 of their ID. +-- MD5 returns 32 hex chars; take chars 1-6 and 7-12 to form a 6-6 slug. +UPDATE teams SET slug = LEFT(MD5(id), 6) || '-' || SUBSTRING(MD5(id), 7, 6); + +ALTER TABLE teams ALTER COLUMN slug SET NOT NULL; +CREATE UNIQUE INDEX idx_teams_slug ON teams(slug); + +-- +goose Down + +DROP INDEX idx_teams_slug; +ALTER TABLE teams DROP COLUMN deleted_at; +ALTER TABLE teams DROP COLUMN slug; diff --git a/db/queries/sandboxes.sql b/db/queries/sandboxes.sql index f2a5d51..d897bff 100644 --- a/db/queries/sandboxes.sql +++ b/db/queries/sandboxes.sql @@ -51,3 +51,8 @@ UPDATE sandboxes SET status = $2, last_updated = NOW() WHERE id = ANY($1::text[]); + +-- name: ListActiveSandboxesByTeam :many +SELECT * FROM sandboxes +WHERE team_id = $1 AND status IN ('running', 'paused', 'starting') +ORDER BY created_at DESC; diff --git a/db/queries/teams.sql b/db/queries/teams.sql index 58985ab..935c4dd 100644 --- a/db/queries/teams.sql +++ b/db/queries/teams.sql @@ -1,6 +1,6 @@ -- name: InsertTeam :one -INSERT INTO teams (id, name) -VALUES ($1, $2) +INSERT INTO teams (id, name, slug) +VALUES ($1, $2, $3) RETURNING *; -- name: GetTeam :one @@ -24,3 +24,32 @@ SELECT * FROM teams WHERE is_byoc = TRUE ORDER BY created_at; -- name: GetTeamMembership :one SELECT * FROM users_teams WHERE user_id = $1 AND team_id = $2; + +-- name: UpdateTeamName :exec +UPDATE teams SET name = $2 WHERE id = $1 AND deleted_at IS NULL; + +-- name: SoftDeleteTeam :exec +UPDATE teams SET deleted_at = NOW() WHERE id = $1; + +-- name: GetTeamBySlug :one +SELECT * FROM teams WHERE slug = $1 AND deleted_at IS NULL; + +-- name: GetTeamsForUser :many +SELECT t.id, t.name, t.slug, t.is_byoc, t.created_at, t.deleted_at, ut.role +FROM teams t +JOIN users_teams ut ON ut.team_id = t.id +WHERE ut.user_id = $1 AND t.deleted_at IS NULL +ORDER BY ut.created_at; + +-- name: GetTeamMembers :many +SELECT u.id, u.email, ut.role, ut.created_at AS joined_at +FROM users_teams ut +JOIN users u ON u.id = ut.user_id +WHERE ut.team_id = $1 +ORDER BY ut.created_at; + +-- name: UpdateMemberRole :exec +UPDATE users_teams SET role = $3 WHERE team_id = $1 AND user_id = $2; + +-- name: DeleteTeamMember :exec +DELETE FROM users_teams WHERE team_id = $1 AND user_id = $2; diff --git a/db/queries/users.sql b/db/queries/users.sql index 3c2f4f0..171ef70 100644 --- a/db/queries/users.sql +++ b/db/queries/users.sql @@ -34,3 +34,6 @@ SELECT * FROM admin_permissions WHERE user_id = $1 ORDER BY permission; SELECT EXISTS( SELECT 1 FROM admin_permissions WHERE user_id = $1 AND permission = $2 ) AS has_permission; + +-- name: SearchUsersByEmailPrefix :many +SELECT id, email FROM users WHERE email LIKE $1 || '%' ORDER BY email LIMIT 10; diff --git a/frontend/src/lib/api/team.ts b/frontend/src/lib/api/team.ts new file mode 100644 index 0000000..0fae217 --- /dev/null +++ b/frontend/src/lib/api/team.ts @@ -0,0 +1,83 @@ +import { apiFetch, type ApiResult } from '$lib/api/client'; + +export type TeamMember = { + user_id: string; + email: string; + role: 'owner' | 'admin' | 'member'; + joined_at: string; +}; + +export type TeamInfo = { + id: string; + name: string; + slug: string; + created_at: string; +}; + +export type TeamDetail = { + team: TeamInfo; + members: TeamMember[]; +}; + +export type UserSearchResult = { + user_id: string; + email: string; +}; + +export type TeamWithRole = { + id: string; + name: string; + slug: string; + created_at: string; + role: string; +}; + +export async function listTeams(): Promise> { + return apiFetch('GET', '/api/v1/teams'); +} + +export async function createTeam(name: string): Promise> { + return apiFetch('POST', '/api/v1/teams', { name }); +} + +export async function switchTeam( + teamId: string +): Promise> { + return apiFetch('POST', '/api/v1/auth/switch-team', { team_id: teamId }); +} + +export async function getTeam(id: string): Promise> { + return apiFetch('GET', `/api/v1/teams/${id}`); +} + +export async function updateTeam(id: string, name: string): Promise> { + return apiFetch('PATCH', `/api/v1/teams/${id}`, { name }); +} + +export async function addMember(id: string, email: string): Promise> { + return apiFetch('POST', `/api/v1/teams/${id}/members`, { email }); +} + +export async function removeMember(id: string, userId: string): Promise> { + return apiFetch('DELETE', `/api/v1/teams/${id}/members/${userId}`); +} + +export async function updateMemberRole( + id: string, + userId: string, + role: 'admin' | 'member' +): Promise> { + return apiFetch('PATCH', `/api/v1/teams/${id}/members/${userId}`, { role }); +} + +export async function deleteTeam(id: string): Promise> { + return apiFetch('DELETE', `/api/v1/teams/${id}`); +} + +export async function leaveTeam(id: string): Promise> { + return apiFetch('POST', `/api/v1/teams/${id}/leave`); +} + +export async function searchUsers(email: string): Promise> { + return apiFetch('GET', `/api/v1/users/search?email=${encodeURIComponent(email)}`); +} diff --git a/frontend/src/lib/components/Sidebar.svelte b/frontend/src/lib/components/Sidebar.svelte index 7102b11..b2fe99a 100644 --- a/frontend/src/lib/components/Sidebar.svelte +++ b/frontend/src/lib/components/Sidebar.svelte @@ -1,7 +1,10 @@