Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions api/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
34 changes: 34 additions & 0 deletions api/lmdb-store.ts
Original file line number Diff line number Diff line change
@@ -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<Promise<unknown>>()
export const sync = () => Promise.all([...pending])
export const getOne = async <T>(
path: string,
id: string,
): Promise<T | null> => {
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 <T>(
path: string,
params?: { q?: string; limit?: number; from?: number },
): Promise<T> => {
const q = new URLSearchParams(params as unknown as Record<string, string>)
const res = await fetch(`${STORE_URL}/${path}/?${q}`, { headers })
return res.json()
}
140 changes: 75 additions & 65 deletions api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
DeploymentsCollection,
ProjectsCollection,
TeamDef,
TeamsCollection,
User,
UserDef,
UsersCollection,
Expand Down Expand Up @@ -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)
Expand All @@ -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<GoogleGroupItem[]>('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,
) => {
Expand All @@ -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',
})
Expand Down Expand Up @@ -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<GoogleGroupItem[]>('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<GoogleGroupItem[]>('google/group', {
q: `select((.kind == "admin#directory#group" and .id == "${id}") or (.kind == "admin#directory#member" and (._key | split("/")[2]) == "${id}"))`,
})

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get group first: google/group/${id}, .name
get members: google/group/${id}/member { email: .email, .id: .id, name: get("google/user", .id) | .fullName }

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(),
Expand Down Expand Up @@ -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' })
Expand All @@ -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' })
}
}
Expand Down Expand Up @@ -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' })
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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',
})
Expand Down
11 changes: 3 additions & 8 deletions api/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,13 @@ export const UserDef = OBJ({
export type User = Asserted<typeof UserDef>

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<typeof TeamDef>

export const ProjectDef = OBJ({
slug: STR('The unique identifier for the project'),
Expand Down Expand Up @@ -70,10 +69,6 @@ export const UsersCollection = await createCollection<User, 'userEmail'>(
{ name: 'users', primaryKey: 'userEmail' },
)

export const TeamsCollection = await createCollection<Team, 'teamId'>(
{ name: 'teams', primaryKey: 'teamId' },
)

export const ProjectsCollection = await createCollection<
Project,
'slug'
Expand Down
2 changes: 1 addition & 1 deletion tasks/clickhouse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
24 changes: 0 additions & 24 deletions tasks/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ import {
DeploymentsCollection,
type Project,
ProjectsCollection,
type Team,
TeamsCollection,
type User,
UsersCollection,
} from '/api/schema.ts'
Expand Down Expand Up @@ -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<Project, 'createdAt'>[] = [
{
slug: 'website-redesign',
Expand Down Expand Up @@ -82,7 +67,6 @@ const projects: Omit<Project, 'createdAt'>[] = [
async function clearCollection(
collection:
| typeof UsersCollection
| typeof TeamsCollection
| typeof ProjectsCollection
| typeof DeploymentsCollection,
) {
Expand All @@ -98,7 +82,6 @@ async function seed() {

// Clear existing data
await clearCollection(UsersCollection)
await clearCollection(TeamsCollection)
await clearCollection(ProjectsCollection)
await clearCollection(DeploymentsCollection)

Expand All @@ -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()) {
Expand Down
Loading
Loading