From 83b8d9e31cf27a6473814c7f36d8492673891f36 Mon Sep 17 00:00:00 2001 From: Travis Fischer Date: Tue, 29 Apr 2025 18:56:28 +0700 Subject: [PATCH] feat: team crud --- apps/api/src/api-v1/index.ts | 23 +++- apps/api/src/api-v1/teams/create-team.ts | 72 +++++++++++ apps/api/src/api-v1/teams/delete-team.ts | 47 ++++++++ apps/api/src/api-v1/teams/get-team.ts | 46 +++++++ apps/api/src/api-v1/teams/list-teams.ts | 56 +++++++++ .../teams/members/create-team-member.ts | 82 +++++++++++++ .../teams/members/delete-team-member.ts | 62 ++++++++++ apps/api/src/api-v1/teams/members/schemas.ts | 23 ++++ .../teams/members/update-team-member.ts | 72 +++++++++++ apps/api/src/api-v1/teams/schemas.ts | 13 ++ apps/api/src/api-v1/teams/update-team.ts | 57 +++++++++ apps/api/src/api-v1/users/get-user.ts | 3 +- apps/api/src/api-v1/users/update-user.ts | 3 +- apps/api/src/db/index.ts | 2 + apps/api/src/db/schema/team-member.ts | 18 ++- apps/api/src/db/schema/team.ts | 18 ++- apps/api/src/db/schema/temp | 113 ++++++++++++++++++ apps/api/src/db/schemas.ts | 29 +++++ apps/api/src/lib/acl-admin.ts | 8 ++ apps/api/src/lib/acl-team-admin.ts | 52 ++++++++ apps/api/src/lib/acl-team-member.ts | 36 +++++- apps/api/src/lib/acl.ts | 10 +- apps/api/src/lib/ensure-unique-team-slug.ts | 23 ++++ packages/validators/src/validators.ts | 4 + 24 files changed, 855 insertions(+), 17 deletions(-) create mode 100644 apps/api/src/api-v1/teams/create-team.ts create mode 100644 apps/api/src/api-v1/teams/delete-team.ts create mode 100644 apps/api/src/api-v1/teams/get-team.ts create mode 100644 apps/api/src/api-v1/teams/list-teams.ts create mode 100644 apps/api/src/api-v1/teams/members/create-team-member.ts create mode 100644 apps/api/src/api-v1/teams/members/delete-team-member.ts create mode 100644 apps/api/src/api-v1/teams/members/schemas.ts create mode 100644 apps/api/src/api-v1/teams/members/update-team-member.ts create mode 100644 apps/api/src/api-v1/teams/schemas.ts create mode 100644 apps/api/src/api-v1/teams/update-team.ts create mode 100644 apps/api/src/lib/acl-admin.ts create mode 100644 apps/api/src/lib/acl-team-admin.ts create mode 100644 apps/api/src/lib/ensure-unique-team-slug.ts diff --git a/apps/api/src/api-v1/index.ts b/apps/api/src/api-v1/index.ts index 18ad5734..a766ee2a 100644 --- a/apps/api/src/api-v1/index.ts +++ b/apps/api/src/api-v1/index.ts @@ -4,6 +4,14 @@ import type { AuthenticatedEnv } from '@/lib/types' import * as middleware from '@/lib/middleware' import { registerHealthCheck } from './health-check' +import { registerV1TeamsCreateTeam } from './teams/create-team' +import { registerV1TeamsDeleteTeam } from './teams/delete-team' +import { registerV1TeamsGetTeam } from './teams/get-team' +import { registerV1TeamsListTeams } from './teams/list-teams' +import { registerV1TeamsMembersCreateTeamMember } from './teams/members/create-team-member' +import { registerV1TeamsMembersDeleteTeamMember } from './teams/members/delete-team-member' +import { registerV1TeamsMembersUpdateTeamMember } from './teams/members/update-team-member' +import { registerV1TeamsUpdateTeam } from './teams/update-team' import { registerV1UsersGetUser } from './users/get-user' import { registerV1UsersUpdateUser } from './users/update-user' @@ -14,10 +22,23 @@ const pri = new OpenAPIHono() registerHealthCheck(pub) -// users crud +// Users crud registerV1UsersGetUser(pri) registerV1UsersUpdateUser(pri) +// Teams crud +registerV1TeamsCreateTeam(pri) +registerV1TeamsListTeams(pri) +registerV1TeamsGetTeam(pri) +registerV1TeamsDeleteTeam(pri) +registerV1TeamsUpdateTeam(pri) + +// Team members crud +registerV1TeamsMembersCreateTeamMember(pri) +registerV1TeamsMembersUpdateTeamMember(pri) +registerV1TeamsMembersDeleteTeamMember(pri) + +// Setup routes and middleware apiV1.route('/', pub) apiV1.use(middleware.authenticate) apiV1.use(middleware.team) diff --git a/apps/api/src/api-v1/teams/create-team.ts b/apps/api/src/api-v1/teams/create-team.ts new file mode 100644 index 00000000..1a9d389e --- /dev/null +++ b/apps/api/src/api-v1/teams/create-team.ts @@ -0,0 +1,72 @@ +import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' + +import type { AuthenticatedEnv } from '@/lib/types' +import { db, schema } from '@/db' +import { ensureUniqueTeamSlug } from '@/lib/ensure-unique-team-slug' +import { assert, parseZodSchema } from '@/lib/utils' + +const route = createRoute({ + description: 'Creates a team.', + tags: ['teams'], + operationId: 'createTeam', + method: 'post', + path: 'teams', + security: [{ bearerAuth: [] }], + request: { + body: { + required: true, + content: { + 'application/json': { + schema: schema.teamInsertSchema + } + } + } + }, + responses: { + 200: { + description: 'The created team', + content: { + 'application/json': { + schema: schema.teamSelectSchema + } + } + } + // TODO + // ...openApiErrorResponses + } +}) + +export function registerV1TeamsCreateTeam(app: OpenAPIHono) { + return app.openapi(route, async (c) => { + const user = c.get('user') + const body = c.req.valid('json') + + await ensureUniqueTeamSlug(body.slug) + + return db.transaction(async (tx) => { + const [team] = await tx + .insert(schema.teams) + .values({ + ...body, + ownerId: user.id + }) + .returning() + assert(team, 404, `Failed to create team "${body.slug}"`) + + const [teamMember] = await tx.insert(schema.teamMembers).values({ + userId: user.id, + teamId: team.id, + teamSlug: team.slug, + role: 'admin', + confirmed: true + }) + assert( + teamMember, + 404, + `Failed to create team member owner for team "${body.slug}"` + ) + + return c.json(parseZodSchema(schema.teamSelectSchema, team)) + }) + }) +} diff --git a/apps/api/src/api-v1/teams/delete-team.ts b/apps/api/src/api-v1/teams/delete-team.ts new file mode 100644 index 00000000..241c1d46 --- /dev/null +++ b/apps/api/src/api-v1/teams/delete-team.ts @@ -0,0 +1,47 @@ +import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' + +import type { AuthenticatedEnv } from '@/lib/types' +import { db, eq, schema } from '@/db' +import { aclTeamAdmin } from '@/lib/acl-team-admin' +import { assert, parseZodSchema } from '@/lib/utils' + +import { TeamSlugParamsSchema } from './schemas' + +const route = createRoute({ + description: 'Deletes a team by slug.', + tags: ['teams'], + operationId: 'deleteTeam', + method: 'delete', + path: 'teams/{team}', + security: [{ bearerAuth: [] }], + request: { + params: TeamSlugParamsSchema + }, + responses: { + 200: { + description: 'The team that was deleted', + content: { + 'application/json': { + schema: schema.teamSelectSchema + } + } + } + // TODO + // ...openApiErrorResponses + } +}) + +export function registerV1TeamsDeleteTeam(app: OpenAPIHono) { + return app.openapi(route, async (c) => { + const { team: teamSlug } = c.req.valid('param') + await aclTeamAdmin(c, { teamSlug }) + + const [team] = await db + .delete(schema.teams) + .where(eq(schema.teams.slug, teamSlug)) + .returning() + assert(team, 404, `Team not found "${teamSlug}"`) + + return c.json(parseZodSchema(schema.teamSelectSchema, team)) + }) +} diff --git a/apps/api/src/api-v1/teams/get-team.ts b/apps/api/src/api-v1/teams/get-team.ts new file mode 100644 index 00000000..9b023aef --- /dev/null +++ b/apps/api/src/api-v1/teams/get-team.ts @@ -0,0 +1,46 @@ +import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' + +import type { AuthenticatedEnv } from '@/lib/types' +import { db, eq, schema } from '@/db' +import { aclTeamMember } from '@/lib/acl-team-member' +import { assert, parseZodSchema } from '@/lib/utils' + +import { TeamSlugParamsSchema } from './schemas' + +const route = createRoute({ + description: 'Gets a team by slug.', + tags: ['teams'], + operationId: 'getTeam', + method: 'get', + path: 'teams/{team}', + security: [{ bearerAuth: [] }], + request: { + params: TeamSlugParamsSchema + }, + responses: { + 200: { + description: 'A team object', + content: { + 'application/json': { + schema: schema.teamSelectSchema + } + } + } + // TODO + // ...openApiErrorResponses + } +}) + +export function registerV1TeamsGetTeam(app: OpenAPIHono) { + return app.openapi(route, async (c) => { + const { team: teamSlug } = c.req.valid('param') + await aclTeamMember(c, { teamSlug }) + + const team = await db.query.teams.findFirst({ + where: eq(schema.teams.slug, teamSlug) + }) + assert(team, 404, `Team not found "${teamSlug}"`) + + return c.json(parseZodSchema(schema.teamSelectSchema, team)) + }) +} diff --git a/apps/api/src/api-v1/teams/list-teams.ts b/apps/api/src/api-v1/teams/list-teams.ts new file mode 100644 index 00000000..4bd00fb8 --- /dev/null +++ b/apps/api/src/api-v1/teams/list-teams.ts @@ -0,0 +1,56 @@ +import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi' + +import type { AuthenticatedEnv } from '@/lib/types' +import { db, eq, paginationSchema, schema } from '@/db' +import { parseZodSchema } from '@/lib/utils' + +const route = createRoute({ + description: 'Lists all teams the authenticated user belongs to.', + tags: ['teams'], + operationId: 'listTeams', + method: 'get', + path: 'teams', + security: [{ bearerAuth: [] }], + request: { + query: paginationSchema + }, + responses: { + 200: { + description: 'A list of teams', + content: { + 'application/json': { + schema: z.array(schema.teamSelectSchema) + } + } + } + // TODO + // ...openApiErrorResponses + } +}) + +export function registerV1TeamsListTeams(app: OpenAPIHono) { + return app.openapi(route, async (c) => { + const { + offset = 0, + limit = 10, + sort = 'desc', + sortBy = 'createdAt' + } = c.req.valid('query') + + // schema.teamMembers._.columns + + const teamMembers = await db.query.teamMembers.findMany({ + where: eq(schema.teamMembers.userId, c.get('user').id), + with: { + team: true + }, + orderBy: (teamMembers, { asc, desc }) => [ + sort === 'desc' ? desc(teamMembers[sortBy]) : asc(teamMembers[sortBy]) + ], + offset, + limit + }) + + return c.json(parseZodSchema(z.array(schema.teamSelectSchema), teamMembers)) + }) +} diff --git a/apps/api/src/api-v1/teams/members/create-team-member.ts b/apps/api/src/api-v1/teams/members/create-team-member.ts new file mode 100644 index 00000000..a5aa5153 --- /dev/null +++ b/apps/api/src/api-v1/teams/members/create-team-member.ts @@ -0,0 +1,82 @@ +import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' + +import type { AuthenticatedEnv } from '@/lib/types' +import { and, db, eq, schema } from '@/db' +import { aclTeamAdmin } from '@/lib/acl-team-admin' +import { assert, parseZodSchema } from '@/lib/utils' + +import { TeamSlugParamsSchema } from '../schemas' + +const route = createRoute({ + description: 'Creates a team member.', + tags: ['teams'], + operationId: 'createTeamMember', + method: 'post', + path: 'teams/{team}/members', + security: [{ bearerAuth: [] }], + request: { + params: TeamSlugParamsSchema, + body: { + required: true, + content: { + 'application/json': { + schema: schema.teamMemberInsertSchema + } + } + } + }, + responses: { + 200: { + description: 'The created team member', + content: { + 'application/json': { + schema: schema.teamMemberSelectSchema + } + } + } + // TODO + // ...openApiErrorResponses + } +}) + +export function registerV1TeamsMembersCreateTeamMember( + app: OpenAPIHono +) { + return app.openapi(route, async (c) => { + const { team: teamSlug } = c.req.valid('param') + const body = c.req.valid('json') + await aclTeamAdmin(c, { teamSlug }) + + const team = await db.query.teams.findFirst({ + where: eq(schema.teams.slug, teamSlug) + }) + assert(team, 404, `Team not found "${teamSlug}"`) + + const existingTeamMember = await db.query.teamMembers.findFirst({ + where: and( + eq(schema.teamMembers.teamSlug, teamSlug), + eq(schema.teamMembers.userId, body.userId) + ) + }) + assert( + existingTeamMember, + 409, + `User "${body.userId}" is already a member of team "${teamSlug}"` + ) + + const [teamMember] = await db.insert(schema.teamMembers).values({ + ...body, + teamSlug, + teamId: team.id + }) + assert( + teamMember, + 400, + `Failed to create team member "${body.userId}"for team "${teamSlug}"` + ) + + // TODO: send team invite email + + return c.json(parseZodSchema(schema.teamMemberSelectSchema, teamMember)) + }) +} diff --git a/apps/api/src/api-v1/teams/members/delete-team-member.ts b/apps/api/src/api-v1/teams/members/delete-team-member.ts new file mode 100644 index 00000000..ccb6e8a9 --- /dev/null +++ b/apps/api/src/api-v1/teams/members/delete-team-member.ts @@ -0,0 +1,62 @@ +import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' + +import type { AuthenticatedEnv } from '@/lib/types' +import { and, db, eq, schema } from '@/db' +import { aclTeamAdmin } from '@/lib/acl-team-admin' +import { aclTeamMember } from '@/lib/acl-team-member' +import { assert, parseZodSchema } from '@/lib/utils' + +import { TeamSlugParamsSchema } from '../schemas' +import { TeamMemberUserIdParamsSchema } from './schemas' + +const route = createRoute({ + description: 'Deletes a team member.', + tags: ['teams'], + operationId: 'deleteTeamMember', + method: 'delete', + path: 'teams/{team}/members/{userId}', + security: [{ bearerAuth: [] }], + request: { + params: TeamSlugParamsSchema.merge(TeamMemberUserIdParamsSchema) + }, + responses: { + 200: { + description: 'The deleted team member', + content: { + 'application/json': { + schema: schema.teamMemberSelectSchema + } + } + } + // TODO + // ...openApiErrorResponses + } +}) + +export function registerV1TeamsMembersDeleteTeamMember( + app: OpenAPIHono +) { + return app.openapi(route, async (c) => { + const { team: teamSlug, userId } = c.req.valid('param') + + await aclTeamAdmin(c, { teamSlug }) + await aclTeamMember(c, { teamSlug, userId }) + + const [teamMember] = await db + .delete(schema.teamMembers) + .where( + and( + eq(schema.teamMembers.teamSlug, teamSlug), + eq(schema.teamMembers.userId, userId) + ) + ) + .returning() + assert( + teamMember, + 404, + `Team member "${userId}" for team "${teamSlug}" not found` + ) + + return c.json(parseZodSchema(schema.teamMemberSelectSchema, teamMember)) + }) +} diff --git a/apps/api/src/api-v1/teams/members/schemas.ts b/apps/api/src/api-v1/teams/members/schemas.ts new file mode 100644 index 00000000..003a0837 --- /dev/null +++ b/apps/api/src/api-v1/teams/members/schemas.ts @@ -0,0 +1,23 @@ +import { z } from '@hono/zod-openapi' + +import { teamSlugSchema } from '@/db' + +export const TeamSlugParamsSchema = z.object({ + team: teamSlugSchema.openapi({ + param: { + description: 'Team slug', + name: 'team', + in: 'path' + } + }) +}) + +export const TeamMemberUserIdParamsSchema = z.object({ + userId: z.string().openapi({ + param: { + description: 'Team member user id', + name: 'userId', + in: 'path' + } + }) +}) diff --git a/apps/api/src/api-v1/teams/members/update-team-member.ts b/apps/api/src/api-v1/teams/members/update-team-member.ts new file mode 100644 index 00000000..f57e8c05 --- /dev/null +++ b/apps/api/src/api-v1/teams/members/update-team-member.ts @@ -0,0 +1,72 @@ +import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' + +import type { AuthenticatedEnv } from '@/lib/types' +import { and, db, eq, schema } from '@/db' +import { aclTeamAdmin } from '@/lib/acl-team-admin' +import { aclTeamMember } from '@/lib/acl-team-member' +import { assert, parseZodSchema } from '@/lib/utils' + +import { TeamSlugParamsSchema } from '../schemas' +import { TeamMemberUserIdParamsSchema } from './schemas' + +const route = createRoute({ + description: 'Updates a team member.', + tags: ['teams'], + operationId: 'updateTeamMember', + method: 'put', + path: 'teams/{team}/members/{userId}', + security: [{ bearerAuth: [] }], + request: { + params: TeamSlugParamsSchema.merge(TeamMemberUserIdParamsSchema), + body: { + required: true, + content: { + 'application/json': { + schema: schema.teamMemberUpdateSchema + } + } + } + }, + responses: { + 200: { + description: 'The updated team member', + content: { + 'application/json': { + schema: schema.teamMemberSelectSchema + } + } + } + // TODO + // ...openApiErrorResponses + } +}) + +export function registerV1TeamsMembersUpdateTeamMember( + app: OpenAPIHono +) { + return app.openapi(route, async (c) => { + const { team: teamSlug, userId } = c.req.valid('param') + const body = c.req.valid('json') + + await aclTeamAdmin(c, { teamSlug }) + await aclTeamMember(c, { teamSlug, userId }) + + const [teamMember] = await db + .update(schema.teamMembers) + .set(body) + .where( + and( + eq(schema.teamMembers.teamSlug, teamSlug), + eq(schema.teamMembers.userId, userId) + ) + ) + .returning() + assert( + teamMember, + 400, + `Failed to update team member "${userId}" for team "${teamSlug}"` + ) + + return c.json(parseZodSchema(schema.teamMemberSelectSchema, teamMember)) + }) +} diff --git a/apps/api/src/api-v1/teams/schemas.ts b/apps/api/src/api-v1/teams/schemas.ts new file mode 100644 index 00000000..952613b9 --- /dev/null +++ b/apps/api/src/api-v1/teams/schemas.ts @@ -0,0 +1,13 @@ +import { z } from '@hono/zod-openapi' + +import { teamSlugSchema } from '@/db' + +export const TeamSlugParamsSchema = z.object({ + team: teamSlugSchema.openapi({ + param: { + description: 'Team slug', + name: 'team', + in: 'path' + } + }) +}) diff --git a/apps/api/src/api-v1/teams/update-team.ts b/apps/api/src/api-v1/teams/update-team.ts new file mode 100644 index 00000000..3b19d340 --- /dev/null +++ b/apps/api/src/api-v1/teams/update-team.ts @@ -0,0 +1,57 @@ +import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' + +import type { AuthenticatedEnv } from '@/lib/types' +import { db, eq, schema } from '@/db' +import { aclTeamAdmin } from '@/lib/acl-team-admin' +import { assert, parseZodSchema } from '@/lib/utils' + +import { TeamSlugParamsSchema } from './schemas' + +const route = createRoute({ + description: 'Updates a team.', + tags: ['teams'], + operationId: 'updateTeam', + method: 'put', + path: 'teams/{team}', + security: [{ bearerAuth: [] }], + request: { + params: TeamSlugParamsSchema, + body: { + required: true, + content: { + 'application/json': { + schema: schema.teamUpdateSchema + } + } + } + }, + responses: { + 200: { + description: 'The updated team', + content: { + 'application/json': { + schema: schema.teamSelectSchema + } + } + } + // TODO + // ...openApiErrorResponses + } +}) + +export function registerV1TeamsUpdateTeam(app: OpenAPIHono) { + return app.openapi(route, async (c) => { + const { team: teamSlug } = c.req.valid('param') + const body = c.req.valid('json') + await aclTeamAdmin(c, { teamSlug }) + + const [team] = await db + .update(schema.teams) + .set(body) + .where(eq(schema.teams.slug, teamSlug)) + .returning() + assert(team, 404, `Failed to update team "${teamSlug}"`) + + return c.json(parseZodSchema(schema.teamSelectSchema, team)) + }) +} diff --git a/apps/api/src/api-v1/users/get-user.ts b/apps/api/src/api-v1/users/get-user.ts index ece784ee..fd106cea 100644 --- a/apps/api/src/api-v1/users/get-user.ts +++ b/apps/api/src/api-v1/users/get-user.ts @@ -15,6 +15,7 @@ const ParamsSchema = z.object({ }) const route = createRoute({ + description: 'Gets a user by ID.', tags: ['users'], operationId: 'getUser', method: 'get', @@ -40,12 +41,12 @@ const route = createRoute({ export function registerV1UsersGetUser(app: OpenAPIHono) { return app.openapi(route, async (c) => { const { userId } = c.req.valid('param') + await acl(c, { userId }, { label: 'User' }) const user = await db.query.users.findFirst({ where: eq(schema.users.id, userId) }) assert(user, 404, `User not found "${userId}"`) - acl(c, user, { label: 'User', userField: 'id' }) return c.json(parseZodSchema(schema.userSelectSchema, user)) }) diff --git a/apps/api/src/api-v1/users/update-user.ts b/apps/api/src/api-v1/users/update-user.ts index 967f2deb..1ab16e5b 100644 --- a/apps/api/src/api-v1/users/update-user.ts +++ b/apps/api/src/api-v1/users/update-user.ts @@ -15,6 +15,7 @@ const ParamsSchema = z.object({ }) const route = createRoute({ + description: 'Updates a user', tags: ['users'], operationId: 'updateUser', method: 'put', @@ -48,6 +49,7 @@ const route = createRoute({ export function registerV1UsersUpdateUser(app: OpenAPIHono) { return app.openapi(route, async (c) => { const { userId } = c.req.valid('param') + await acl(c, { userId }, { label: 'User' }) const body = c.req.valid('json') const [user] = await db @@ -56,7 +58,6 @@ export function registerV1UsersUpdateUser(app: OpenAPIHono) { .where(eq(schema.users.id, userId)) .returning() assert(user, 404, `User not found "${userId}"`) - acl(c, user, { label: 'User', userField: 'id' }) return c.json(parseZodSchema(schema.userSelectSchema, user)) }) diff --git a/apps/api/src/db/index.ts b/apps/api/src/db/index.ts index 66440182..550723cc 100644 --- a/apps/api/src/db/index.ts +++ b/apps/api/src/db/index.ts @@ -19,7 +19,9 @@ export { arrayContained, arrayContains, arrayOverlaps, + asc, between, + desc, eq, exists, gt, diff --git a/apps/api/src/db/schema/team-member.ts b/apps/api/src/db/schema/team-member.ts index c70ac795..c070870e 100644 --- a/apps/api/src/db/schema/team-member.ts +++ b/apps/api/src/db/schema/team-member.ts @@ -3,13 +3,16 @@ import { boolean, index, pgTable, - primaryKey + primaryKey, + text } from '@fisch0920/drizzle-orm/pg-core' import { teams } from './team' import { users } from './user' import { + createInsertSchema, createSelectSchema, + createUpdateSchema, cuid, teamMemberRoleEnum, timestamp, @@ -24,6 +27,9 @@ export const teamMembers = pgTable( userId: cuid() .notNull() .references(() => users.id, { onDelete: 'cascade' }), + teamSlug: text() + .notNull() + .references(() => teams.slug, { onDelete: 'cascade' }), teamId: cuid() .notNull() .references(() => teams.id, { onDelete: 'cascade' }), @@ -36,6 +42,7 @@ export const teamMembers = pgTable( primaryKey({ columns: [table.userId, table.teamId] }), index('team_member_user_idx').on(table.userId), index('team_member_team_idx').on(table.teamId), + index('team_member_slug_idx').on(table.teamSlug), index('team_member_createdAt_idx').on(table.createdAt), index('team_member_updatedAt_idx').on(table.updatedAt) ] @@ -52,5 +59,14 @@ export const teamMembersRelations = relations(teamMembers, ({ one }) => ({ }) })) +export const teamMemberInsertSchema = createInsertSchema(teamMembers).pick({ + userId: true, + role: true +}) + export const teamMemberSelectSchema = createSelectSchema(teamMembers).openapi('TeamMember') + +export const teamMemberUpdateSchema = createUpdateSchema(teamMembers).pick({ + role: true +}) diff --git a/apps/api/src/db/schema/team.ts b/apps/api/src/db/schema/team.ts index 8dc11875..204e7d97 100644 --- a/apps/api/src/db/schema/team.ts +++ b/apps/api/src/db/schema/team.ts @@ -1,3 +1,4 @@ +import { validators } from '@agentic/validators' import { relations } from '@fisch0920/drizzle-orm' import { index, @@ -44,7 +45,18 @@ export const teamsRelations = relations(teams, ({ one, many }) => ({ })) export const teamInsertSchema = createInsertSchema(teams, { - slug: (schema) => schema.min(3).max(20) // TODO -}) + slug: (schema) => + schema.refine((slug) => validators.team(slug), { + message: 'Invalid team slug' + }) +}).omit({ id: true, createdAt: true, updatedAt: true, ownerId: true }) + export const teamSelectSchema = createSelectSchema(teams).openapi('Team') -export const teamUpdateSchema = createUpdateSchema(teams).omit({ slug: true }) + +export const teamUpdateSchema = createUpdateSchema(teams).omit({ + id: true, + createdAt: true, + updatedAt: true, + ownerId: true, + slug: true +}) diff --git a/apps/api/src/db/schema/temp b/apps/api/src/db/schema/temp index 0edba428..2f2bfacb 100644 --- a/apps/api/src/db/schema/temp +++ b/apps/api/src/db/schema/temp @@ -111,3 +111,116 @@ export const optionalCuid = optional('cuid') export const optionalStripeId = optional('stripeId') export const optionalProjectId = optional('projectId') export const optionalDeploymentId = optional('deploymentId') + +// --- + + +type MaybePromise = Promise | T; +type RequestTypes = { + body?: ZodRequestBody; + params?: ZodType; + query?: ZodType; + cookies?: ZodType; + headers?: ZodType | ZodType[]; +}; +type InputTypeBase = R['request'] extends RequestTypes ? RequestPart extends ZodType ? { + in: { + [K in Type]: HasUndefined extends true ? { + [K2 in keyof z.input>]?: z.input>[K2]; + } : { + [K2 in keyof z.input>]: z.input>[K2]; + }; + }; + out: { + [K in Type]: z.output>; + }; +} : {} : {}; + +type InputTypeParam = InputTypeBase; +type InputTypeQuery = InputTypeBase; +type InputTypeHeader = InputTypeBase; +type InputTypeCookie = InputTypeBase; + +type o: & InputTypeQuery & InputTypeHeader & InputTypeCookie & InputTypeForm & InputTypeJson, P extends string = ConvertPathType>({ middleware: routeMiddleware, hide, ...route }: R, handler: Handler["env"] & E : E, P, I, R extends { + responses: { + [statusCode: number]: { + content: { + [mediaType: string]: ZodMediaTypeObject; + }; + }; + }; +} ? MaybePromise> : MaybePromise> | MaybePromise>) => OpenAPIHono, I, RouteConfigToTypedResponse>, BasePath>; + + +// --- + +import type { SetOptional, SetRequired, Simplify } from 'type-fest' +import type { AnyZodObject } from 'zod' +import { createRoute, type RouteConfig } from '@hono/zod-openapi' + +export type CreateOpenAPIHonoRouteOpts< + TAuthenticated extends boolean = true, + TMethod extends RouteConfig['method'] = RouteConfig['method'] +> = Simplify< + { + authenticated: TAuthenticated + paramsSchema?: AnyZodObject + bodySchema?: AnyZodObject + responseSchema: AnyZodObject + method: TMethod + } & SetRequired< + Omit< + SetOptional[0], 'responses'>, + 'request' | 'responses' | 'security' + >, + 'path' | 'operationId' | 'tags' | 'description' + > +> + +export function createOpenAPIHonoRoute( + opts: CreateOpenAPIHonoRouteOpts +) { + const { + authenticated, + paramsSchema, + bodySchema, + responseSchema, + ...createRouteOpts + } = opts + + return createRoute({ + ...createRouteOpts, + security: authenticated + ? [ + { + bearerAuth: [] + } + ] + : [], + request: { + params: paramsSchema!, + body: bodySchema + ? { + required: true, + content: { + 'application/json': { + schema: bodySchema + } + } + } + : undefined + }, + responses: { + 200: { + description: responseSchema.shape.description, + content: { + 'application/json': { + schema: responseSchema + } + } + } + // TODO + // ...openApiErrorResponses + } + }) +} diff --git a/apps/api/src/db/schemas.ts b/apps/api/src/db/schemas.ts index 88787edd..06f890a0 100644 --- a/apps/api/src/db/schemas.ts +++ b/apps/api/src/db/schemas.ts @@ -21,3 +21,32 @@ export const deploymentIdSchema = z .refine((id) => validators.deployment(id), { message: 'Invalid deployment id' }) + +export const teamSlugSchema = z + .string() + .refine((slug) => validators.team(slug), { + message: 'Invalid team slug' + }) + +export const paginationSchema = z.object({ + offset: z.number().int().nonnegative().default(0).optional(), + limit: z.number().int().positive().max(100).default(10).optional(), + sort: z.enum(['asc', 'desc']).default('desc').optional(), + sortBy: z.enum(['createdAt', 'updatedAt']).default('createdAt').optional() +}) + +// import type { PgTable, TableConfig } from '@fisch0920/drizzle-orm/pg-core' +// import type { AnyZodObject } from 'zod' +// +// export function createWhereFilterSchema< +// TTableConfig extends TableConfig, +// TTable extends PgTable, +// T extends AnyZodObject +// >(table: TTable, schema: T) { +// return z.object({ +// where: z.record( +// z.enum(Object.keys(table._.columns) as [string, ...string[]]), +// z.string() +// ) +// }) +// } diff --git a/apps/api/src/lib/acl-admin.ts b/apps/api/src/lib/acl-admin.ts new file mode 100644 index 00000000..01c5ea1c --- /dev/null +++ b/apps/api/src/lib/acl-admin.ts @@ -0,0 +1,8 @@ +import type { AuthenticatedContext } from './types' +import { assert } from './utils' + +export async function aclAdmin(ctx: AuthenticatedContext) { + const user = ctx.get('user') + assert(user, 401, 'Authentication required') + assert(user.role === 'admin', 403, 'Access denied') +} diff --git a/apps/api/src/lib/acl-team-admin.ts b/apps/api/src/lib/acl-team-admin.ts new file mode 100644 index 00000000..49ec5eab --- /dev/null +++ b/apps/api/src/lib/acl-team-admin.ts @@ -0,0 +1,52 @@ +import { and, db, eq, schema, type TeamMember } from '@/db' + +import type { AuthenticatedContext } from './types' +import { assert } from './utils' + +export async function aclTeamAdmin( + ctx: AuthenticatedContext, + { + teamSlug, + teamMember + }: { + teamSlug: string + teamMember?: TeamMember + } +) { + const user = ctx.get('user') + assert(user, 401, 'Authentication required') + + if (user.role === 'admin') { + // TODO: Allow admins to access all team resources + return + } + + if (!teamMember) { + teamMember = await db.query.teamMembers.findFirst({ + where: and( + eq(schema.teamMembers.teamSlug, teamSlug), + eq(schema.teamMembers.userId, user.id) + ) + }) + } + + assert(teamMember, 403, `User does not have access to team "${teamSlug}"`) + + assert( + teamMember.role === 'admin', + 403, + `User does not have "admin" role for team "${teamSlug}"` + ) + + assert( + teamMember.userId === user.id, + 403, + `User does not have access to team "${teamSlug}"` + ) + + assert( + teamMember.confirmed, + 403, + `User has not confirmed their invitation to team "${teamSlug}"` + ) +} diff --git a/apps/api/src/lib/acl-team-member.ts b/apps/api/src/lib/acl-team-member.ts index d8be992f..29f2c7b4 100644 --- a/apps/api/src/lib/acl-team-member.ts +++ b/apps/api/src/lib/acl-team-member.ts @@ -1,24 +1,50 @@ -import type { TeamMemberWithTeam } from '@/db' +import { and, db, eq, schema, type TeamMember } from '@/db' import type { AuthenticatedContext } from './types' import { assert } from './utils' export async function aclTeamMember( ctx: AuthenticatedContext, - teamMember: TeamMemberWithTeam + { + teamSlug, + teamMember, + userId + }: { + teamSlug: string + teamMember?: TeamMember + userId?: string + } ) { const user = ctx.get('user') assert(user, 401, 'Authentication required') + if (user.role === 'admin') { + // TODO: Allow admins to access all team resources + return + } + + userId ??= user.id + + if (!teamMember) { + teamMember = await db.query.teamMembers.findFirst({ + where: and( + eq(schema.teamMembers.teamSlug, teamSlug), + eq(schema.teamMembers.userId, userId) + ) + }) + } + + assert(teamMember, 403, `User does not have access to team "${teamSlug}"`) + assert( - teamMember.userId === user.id, + teamMember.userId === userId, 403, - `User does not have access to team "${teamMember.team.slug}"` + `User does not have access to team "${teamSlug}"` ) assert( teamMember.confirmed, 403, - `User has not confirmed their invitation to team "${teamMember.team.slug}"` + `User has not confirmed their invitation to team "${teamSlug}"` ) } diff --git a/apps/api/src/lib/acl.ts b/apps/api/src/lib/acl.ts index 63124d57..deeb3aeb 100644 --- a/apps/api/src/lib/acl.ts +++ b/apps/api/src/lib/acl.ts @@ -1,8 +1,8 @@ import type { AuthenticatedContext } from './types' import { assert } from './utils' -export function acl< - TModel extends Record & { id: string }, +export async function acl< + TModel extends Record, TUserField extends keyof TModel = 'user', TTeamField extends keyof TModel = 'team' >( @@ -10,8 +10,8 @@ export function acl< model: TModel, { label, - userField = 'user' as TUserField, - teamField = 'team' as TTeamField + userField = 'userId' as TUserField, + teamField = 'teamId' as TTeamField }: { label: string userField?: TUserField @@ -34,6 +34,6 @@ export function acl< assert( isAuthUserOwner || isAuthUserAdmin || hasTeamAccess, 403, - `User does not have access to ${label} "${model.id}"` + `User does not have access to ${label} "${model.id ?? userFieldValue}"` ) } diff --git a/apps/api/src/lib/ensure-unique-team-slug.ts b/apps/api/src/lib/ensure-unique-team-slug.ts new file mode 100644 index 00000000..87a824a4 --- /dev/null +++ b/apps/api/src/lib/ensure-unique-team-slug.ts @@ -0,0 +1,23 @@ +import { db, eq, schema } from '@/db' + +import { assert } from './utils' + +export async function ensureUniqueTeamSlug(slug: string) { + slug = slug.toLocaleLowerCase() + + const [existingTeam, existingUser] = await Promise.all([ + db.query.teams.findFirst({ + where: eq(schema.teams.slug, slug) + }), + + db.query.users.findFirst({ + where: eq(schema.users.username, slug) + }) + ]) + + assert( + !existingUser && !existingTeam, + 409, + `Team slug [${slug}] is not available` + ) +} diff --git a/packages/validators/src/validators.ts b/packages/validators/src/validators.ts index 704ab69c..5ebe74b5 100644 --- a/packages/validators/src/validators.ts +++ b/packages/validators/src/validators.ts @@ -24,6 +24,10 @@ export function username(value: string): boolean { return !!value && usernameRe.test(value) } +export function team(value: string): boolean { + return username(value) +} + export function password(value: string): boolean { return !!value && passwordRe.test(value) }