diff --git a/api/lib/env.ts b/api/lib/env.ts index 8b228c4..40f8f07 100644 --- a/api/lib/env.ts +++ b/api/lib/env.ts @@ -20,3 +20,6 @@ export const CLICKHOUSE_PASSWORD = ENV('CLICKHOUSE_PASSWORD') export const DB_SCHEMA_REFRESH_MS = Number( ENV('DB_SCHEMA_REFRESH_MS', `${24 * 60 * 60 * 1000}`), ) + +export const STORE_URL = ENV('STORE_URL') +export const STORE_SECRET = ENV('STORE_SECRET') diff --git a/api/lmdb-store.ts b/api/lmdb-store.ts new file mode 100644 index 0000000..f2308ed --- /dev/null +++ b/api/lmdb-store.ts @@ -0,0 +1,34 @@ +import { STORE_SECRET, STORE_URL } from '/api/lib/env.ts' + +const headers = { authorization: `Bearer ${STORE_SECRET}` } +export const pending = new Set>() +export const sync = () => Promise.all([...pending]) +export const getOne = async ( + path: string, + id: string, +): Promise => { + const url = `${STORE_URL}/${path}/${encodeURIComponent(String(id))}` + const res = await fetch(url, { headers }) + if (res.status === 404) return null + return res.json() +} + +export const set = (path: string, id: unknown, dt: unknown) => { + const body = typeof dt === 'string' ? dt : JSON.stringify(dt) + const p = fetch( + `${STORE_URL}/${path}/${encodeURIComponent(String(id))}`, + { method: 'POST', body, headers }, + ) + pending.add(p) + p.finally(() => pending.delete(p)) + return 1 +} + +export const get = async ( + path: string, + params?: { q?: string; limit?: number; from?: number }, +): Promise => { + const q = new URLSearchParams(params as unknown as Record) + const res = await fetch(`${STORE_URL}/${path}/?${q}`, { headers }) + return res.json() +} diff --git a/api/routes.ts b/api/routes.ts index 65fa0c0..0162549 100644 --- a/api/routes.ts +++ b/api/routes.ts @@ -7,7 +7,6 @@ import { DeploymentsCollection, ProjectsCollection, TeamDef, - TeamsCollection, User, UserDef, UsersCollection, @@ -39,6 +38,15 @@ import { updateTableData, } from '/api/sql.ts' import { Log } from '@01edu/api/log' +import { get } from './lmdb-store.ts' + +type GoogleGroupItem = { + kind: string + id: string + name: string + email?: string + _key: string +} const withUserSession = async ({ cookies }: RequestContext) => { const session = await decodeSession(cookies.session) @@ -62,12 +70,15 @@ const withDeploymentSession = async (ctx: RequestContext) => { return dep } -const userInTeam = (teamId: string, userEmail?: string) => { +const userInTeam = async (teamId: string, userEmail?: string) => { if (!userEmail) return false - return TeamsCollection.get(teamId)?.teamMembers.includes(userEmail) + const members = await get('google/group', { + q: `select(.kind == "admin#directory#member" and (._key | split("/")[2]) == "${teamId}" and .email == "${userEmail}")`, + }) + return members.length > 0 } -const withDeploymentTableAccess = ( +const withDeploymentTableAccess = async ( ctx: RequestContext & { session: User }, deployment: string, ) => { @@ -83,7 +94,7 @@ const withDeploymentTableAccess = ( const project = ProjectsCollection.get(dep.projectId) if (!project) throw respond.NotFound({ message: 'Project not found' }) if (!project.isPublic && !ctx.session.isAdmin) { - if (!userInTeam(project.teamId, ctx.session.userEmail)) { + if (!(await userInTeam(project.teamId, ctx.session.userEmail))) { throw respond.Forbidden({ message: 'Access to project tables denied', }) @@ -161,68 +172,67 @@ const defs = { }), 'GET/api/teams': route({ authorize: withUserSession, - fn: () => TeamsCollection.values().toArray(), + fn: async () => { + // Optimized query to fetch only groups and members in one call + const allData = await get('google/group', { + q: 'select(.kind == "admin#directory#group" or .kind == "admin#directory#member")', + }) + + const teamsMap = new Map< + string, + { id: string; name: string; members: string[] } + >() + + // First pass: Initialize groups + for (const item of allData) { + if (item.kind === 'admin#directory#group') { + teamsMap.set(item.id, { id: item.id, name: item.name, members: [] }) + } + } + + // Second pass: Associate members + for (const item of allData) { + if (item.kind === 'admin#directory#member') { + const groupId = item._key.split('/')[2] + const team = teamsMap.get(groupId) + if (team && item.email) { + team.members.push(item.email) + } + } + } + return Array.from(teamsMap.values()) + }, output: ARR(TeamDef, 'List of teams'), description: 'Get all teams', }), - 'POST/api/teams': route({ - authorize: withAdminSession, - fn: (_ctx, team) => - TeamsCollection.insert({ - teamId: team.teamId, - teamName: team.teamName, - teamMembers: [], - }), - input: OBJ({ - teamId: STR('The ID of the team'), - teamName: STR('The name of the team'), - }), - output: TeamDef, - description: 'Create a new team', - }), 'GET/api/team': route({ authorize: withUserSession, - fn: (_ctx, { teamId }) => { - const team = TeamsCollection.get(teamId) - if (!team) throw respond.NotFound({ message: 'Team not found' }) - return team + fn: async (_ctx, { id }) => { + // Optimized query to fetch group and its members in one call + const allData = await get('google/group', { + q: `select((.kind == "admin#directory#group" and .id == "${id}") or (.kind == "admin#directory#member" and (._key | split("/")[2]) == "${id}"))`, + }) + + const group = allData.find((item) => + item.kind === 'admin#directory#group' + ) + if (!group) throw respond.NotFound({ message: 'Team not found' }) + + const members = allData + .filter((item) => item.kind === 'admin#directory#member') + .map((item) => item.email) + .filter((e): e is string => !!e) + + return { + id: group.id, + name: group.name, + members, + } }, - input: OBJ({ teamId: STR('The ID of the team') }), + input: OBJ({ id: STR('The ID of the team') }), output: TeamDef, description: 'Get a team by ID', }), - 'PUT/api/team': route({ - authorize: withAdminSession, - fn: (_ctx, input) => - TeamsCollection.update(input.teamId, { - teamName: input.teamName, - teamMembers: input.teamMembers || undefined, - }), - input: OBJ({ - teamId: STR('The ID of the team'), - teamName: STR('The name of the team'), - teamMembers: optional( - ARR( - STR('The user emails of team members'), - 'The list of user emails who are members of the team', - ), - ), - }), - output: TeamDef, - description: 'Update a team by ID', - }), - 'DELETE/api/team': route({ - authorize: withAdminSession, - fn: (_ctx, { teamId }) => { - const team = TeamsCollection.get(teamId) - if (!team) throw respond.NotFound({ message: 'Team not found' }) - TeamsCollection.delete(teamId) - return true - }, - input: OBJ({ teamId: STR('The ID of the team') }), - output: BOOL('Indicates if the team was deleted'), - description: 'Delete a team by ID', - }), 'GET/api/projects': route({ authorize: withUserSession, fn: () => ProjectsCollection.values().toArray(), @@ -427,7 +437,7 @@ const defs = { }), 'POST/api/deployment/logs': route({ authorize: withUserSession, - fn: (ctx, params) => { + fn: async (ctx, params) => { const deployment = DeploymentsCollection.get(params.deployment) if (!deployment) { throw respond.NotFound({ message: 'Deployment not found' }) @@ -440,7 +450,7 @@ const defs = { const project = ProjectsCollection.get(deployment.projectId) if (!project) throw respond.NotFound({ message: 'Project not found' }) if (!project.isPublic && !ctx.session.isAdmin) { - if (!userInTeam(project.teamId, ctx.session.userEmail)) { + if (!(await userInTeam(project.teamId, ctx.session.userEmail))) { throw respond.Forbidden({ message: 'Access to project logs denied' }) } } @@ -476,8 +486,8 @@ const defs = { }), 'POST/api/deployment/table/data': route({ authorize: withUserSession, - fn: (ctx, { deployment, table, ...input }) => { - const dep = withDeploymentTableAccess(ctx, deployment) + fn: async (ctx, { deployment, table, ...input }) => { + const dep = await withDeploymentTableAccess(ctx, deployment) const schema = DatabaseSchemasCollection.get(deployment) if (!schema) throw respond.NotFound({ message: 'Schema not cached yet' }) @@ -529,8 +539,8 @@ const defs = { }), 'POST/api/deployment/table/update': route({ authorize: withUserSession, - fn: (ctx, { deployment, table, pk, data }) => { - const dep = withDeploymentTableAccess(ctx, deployment) + fn: async (ctx, { deployment, table, pk, data }) => { + const dep = await withDeploymentTableAccess(ctx, deployment) return updateTableData(dep, table, pk, data) }, input: OBJ({ @@ -565,7 +575,7 @@ const defs = { const project = ProjectsCollection.get(dep.projectId) if (!project) throw respond.NotFound({ message: 'Project not found' }) if (!project.isPublic && !ctx.session.isAdmin) { - if (!userInTeam(project.teamId, ctx.session.userEmail)) { + if (!(await userInTeam(project.teamId, ctx.session.userEmail))) { throw new respond.ForbiddenError({ message: 'Access to project queries denied', }) diff --git a/api/schema.ts b/api/schema.ts index 7b10848..c86c6c3 100644 --- a/api/schema.ts +++ b/api/schema.ts @@ -18,14 +18,13 @@ export const UserDef = OBJ({ export type User = Asserted export const TeamDef = OBJ({ - teamId: STR('The unique identifier for the team'), - teamName: STR('The name of the team'), - teamMembers: ARR( + id: STR('The unique identifier for the team'), + name: STR('The name of the team'), + members: ARR( STR('The user emails of team members'), 'The list of user emails who are members of the team', ), }, 'The team schema definition') -export type Team = Asserted export const ProjectDef = OBJ({ slug: STR('The unique identifier for the project'), @@ -70,10 +69,6 @@ export const UsersCollection = await createCollection( { name: 'users', primaryKey: 'userEmail' }, ) -export const TeamsCollection = await createCollection( - { name: 'teams', primaryKey: 'teamId' }, -) - export const ProjectsCollection = await createCollection< Project, 'slug' diff --git a/tasks/clickhouse.ts b/tasks/clickhouse.ts index d147dde..41fb39c 100644 --- a/tasks/clickhouse.ts +++ b/tasks/clickhouse.ts @@ -41,6 +41,6 @@ if (import.meta.main) { console.log('logs table is ready') } catch (error) { console.error('Error creating ClickHouse table:', { error }) - Deno.exit(1) + // Deno.exit(1) } } diff --git a/tasks/seed.ts b/tasks/seed.ts index 77dc77e..80ef0e3 100644 --- a/tasks/seed.ts +++ b/tasks/seed.ts @@ -3,8 +3,6 @@ import { DeploymentsCollection, type Project, ProjectsCollection, - type Team, - TeamsCollection, type User, UsersCollection, } from '/api/schema.ts' @@ -42,19 +40,6 @@ const users: User[] = [ }, ] -const teams: Team[] = [ - { - teamId: 'frontend-devs', - teamName: 'Frontend Devs', - teamMembers: ['admin@example.com', 'member1@example.com'], - }, - { - teamId: 'backend-devs', - teamName: 'Backend Devs', - teamMembers: ['admin@example.com', 'member2@example.com'], - }, -] - const projects: Omit[] = [ { slug: 'website-redesign', @@ -82,7 +67,6 @@ const projects: Omit[] = [ async function clearCollection( collection: | typeof UsersCollection - | typeof TeamsCollection | typeof ProjectsCollection | typeof DeploymentsCollection, ) { @@ -98,7 +82,6 @@ async function seed() { // Clear existing data await clearCollection(UsersCollection) - await clearCollection(TeamsCollection) await clearCollection(ProjectsCollection) await clearCollection(DeploymentsCollection) @@ -109,13 +92,6 @@ async function seed() { } console.log('Users seeded.') - // Seed teams - console.log('Seeding teams...') - for (const team of teams) { - await TeamsCollection.insert(team) - } - console.log('Teams seeded.') - // Seed projects console.log('Seeding projects...') for (const [_, project] of projects.entries()) { diff --git a/web/pages/ProjectsPage.tsx b/web/pages/ProjectsPage.tsx index 51195eb..3ce4213 100644 --- a/web/pages/ProjectsPage.tsx +++ b/web/pages/ProjectsPage.tsx @@ -10,6 +10,7 @@ import { Plus, Search, Settings, + Users, } from 'lucide-preact' import { Dialog, DialogModal } from '../components/Dialog.tsx' import { url } from '@01edu/signal-router' @@ -75,40 +76,6 @@ async function saveProject( } } -async function saveTeam(e: Event, teamId?: string) { - e.preventDefault() - const form = e.currentTarget as HTMLFormElement - const formData = new FormData(form) - const name = formData.get('name') as string - - try { - if (teamId) { - const team = teams.data?.find((t) => t.teamId === teamId) - if (!team) throw new Error('Team not found') - await api['PUT/api/team'].fetch({ ...team, teamName: name }) - toast(`Team "${name}" updated.`, 'info') - } else { - const newTeamId = formData.get('teamId') as string - if (!newTeamId || !name) { - toast('Team ID and name are required.', 'error') - return - } - - if (teams.data?.some((t) => t.teamId === newTeamId)) { - toast(`Team ID "${newTeamId}" already exists.`, 'error') - return - } - - await api['POST/api/teams'].fetch({ teamId: newTeamId, teamName: name }) - toast(`Team "${name}" created.`, 'info') - form.reset() - } - teams.fetch() - } catch (err) { - toast(err instanceof Error ? err.message : String(err), 'error') - } -} - function toast(message: string, type: 'info' | 'error' = 'info') { toastSignal.value = { message, type } setTimeout(() => (toastSignal.value = null), 3000) @@ -125,49 +92,6 @@ async function deleteProject(slug: string) { } } -async function deleteTeam(id: string) { - try { - await api['DELETE/api/team'].fetch({ teamId: id }) - toast('Team deleted.', 'info') - teams.fetch() - navigate({ params: { dialog: null, id: null, key: null }, replace: true }) - } catch (err) { - toast(err instanceof Error ? err.message : String(err), 'error') - } -} - -async function addUserToTeam(user: User, team: Team) { - if (team.teamMembers.includes(user.userEmail)) return - const updatedMembers = [...team.teamMembers, user.userEmail] - try { - await api['PUT/api/team'].fetch({ - ...team, - teamMembers: updatedMembers, - }) - toast(`${user.userFullName} added to ${team.teamName}.`) - teams.fetch() - } catch (err) { - toast(err instanceof Error ? err.message : String(err), 'error') - } -} - -async function removeUserFromTeam(user: User, team: Team) { - const updatedMembers = team.teamMembers.filter((email) => - email !== user.userEmail - ) - if (updatedMembers.length === team.teamMembers.length) return - try { - await api['PUT/api/team'].fetch({ - ...team, - teamMembers: updatedMembers, - }) - toast(`${user.userFullName} removed from ${team.teamName}.`) - teams.fetch() - } catch (err) { - toast(err instanceof Error ? err.message : String(err), 'error') - } -} - const FormField = ( { label, children }: { label: string; children: JSX.Element | JSX.Element[] }, ) => ( @@ -205,7 +129,7 @@ const ProjectCard = ( console.log(project) console.log(team) - const isMember = team.teamMembers.includes(user.data?.userEmail || '') + const isMember = team.members.includes(user.data?.userEmail || '') return ( ( { - if ((e.target as HTMLInputElement).checked) { - addUserToTeam(user, team) - } else removeUserFromTeam(user, team) - }} + checked={team.members.includes(user.userEmail)} + // onChange={(e) => { + // if ((e.target as HTMLInputElement).checked) { + // addUserToTeam(user, team) + // } else removeUserFromTeam(user, team) + // }} /> @@ -371,7 +295,7 @@ function ProjectDialog() { > {teams.data?.map((t) => ( - + ))} @@ -405,42 +329,6 @@ function ProjectDialog() { ) } -function TeamSettingsSection({ team }: { team: Team }) { - return ( -
-
saveTeam(e, team.teamId)} - class='space-y-4' - > - - - - -
-
- ) -} - function TeamMembersSection({ team }: { team: Team }) { return (
@@ -463,13 +351,13 @@ function TeamMembersSection({ team }: { team: Team }) { } function TeamProjectsSection({ team }: { team: Team }) { - const teamProjects = projects.data?.filter((p) => p.teamId === team.teamId) || + const teamProjects = projects.data?.filter((p) => p.teamId === team.id) || [] return (
+ Add Project @@ -526,65 +414,59 @@ function TeamsManagementDialog() { return null } - const selectedTeamId = steamid || (teams.data || [])[0]?.teamId - const selectedTeam = teams.data?.find((t) => t.teamId === selectedTeamId) + const selectedTeamId = steamid || (teams.data || [])[0]?.id + const selectedTeam = teams.data?.find((t) => t.id === selectedTeamId) return (