kopia lustrzana https://github.com/transitive-bullshit/chatgpt-api
feat: team crud
rodzic
d02e70ea74
commit
83b8d9e31c
|
@ -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<AuthenticatedEnv>()
|
|||
|
||||
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)
|
||||
|
|
|
@ -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<AuthenticatedEnv>) {
|
||||
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))
|
||||
})
|
||||
})
|
||||
}
|
|
@ -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<AuthenticatedEnv>) {
|
||||
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))
|
||||
})
|
||||
}
|
|
@ -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<AuthenticatedEnv>) {
|
||||
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))
|
||||
})
|
||||
}
|
|
@ -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<AuthenticatedEnv>) {
|
||||
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))
|
||||
})
|
||||
}
|
|
@ -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<AuthenticatedEnv>
|
||||
) {
|
||||
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))
|
||||
})
|
||||
}
|
|
@ -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<AuthenticatedEnv>
|
||||
) {
|
||||
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))
|
||||
})
|
||||
}
|
|
@ -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'
|
||||
}
|
||||
})
|
||||
})
|
|
@ -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<AuthenticatedEnv>
|
||||
) {
|
||||
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))
|
||||
})
|
||||
}
|
|
@ -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'
|
||||
}
|
||||
})
|
||||
})
|
|
@ -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<AuthenticatedEnv>) {
|
||||
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))
|
||||
})
|
||||
}
|
|
@ -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<AuthenticatedEnv>) {
|
||||
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))
|
||||
})
|
||||
|
|
|
@ -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<AuthenticatedEnv>) {
|
||||
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<AuthenticatedEnv>) {
|
|||
.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))
|
||||
})
|
||||
|
|
|
@ -19,7 +19,9 @@ export {
|
|||
arrayContained,
|
||||
arrayContains,
|
||||
arrayOverlaps,
|
||||
asc,
|
||||
between,
|
||||
desc,
|
||||
eq,
|
||||
exists,
|
||||
gt,
|
||||
|
|
|
@ -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
|
||||
})
|
||||
|
|
|
@ -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
|
||||
})
|
||||
|
|
|
@ -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<T> = Promise<T> | T;
|
||||
type RequestTypes = {
|
||||
body?: ZodRequestBody;
|
||||
params?: ZodType;
|
||||
query?: ZodType;
|
||||
cookies?: ZodType;
|
||||
headers?: ZodType | ZodType[];
|
||||
};
|
||||
type InputTypeBase<R extends RouteConfig, Part extends string, Type extends keyof ValidationTargets> = R['request'] extends RequestTypes ? RequestPart<R, Part> extends ZodType ? {
|
||||
in: {
|
||||
[K in Type]: HasUndefined<ValidationTargets[K]> extends true ? {
|
||||
[K2 in keyof z.input<RequestPart<R, Part>>]?: z.input<RequestPart<R, Part>>[K2];
|
||||
} : {
|
||||
[K2 in keyof z.input<RequestPart<R, Part>>]: z.input<RequestPart<R, Part>>[K2];
|
||||
};
|
||||
};
|
||||
out: {
|
||||
[K in Type]: z.output<RequestPart<R, Part>>;
|
||||
};
|
||||
} : {} : {};
|
||||
|
||||
type InputTypeParam<R extends RouteConfig> = InputTypeBase<R, 'params', 'param'>;
|
||||
type InputTypeQuery<R extends RouteConfig> = InputTypeBase<R, 'query', 'query'>;
|
||||
type InputTypeHeader<R extends RouteConfig> = InputTypeBase<R, 'headers', 'header'>;
|
||||
type InputTypeCookie<R extends RouteConfig> = InputTypeBase<R, 'cookies', 'cookie'>;
|
||||
|
||||
type o: <R extends RouteConfig, I extends Input = InputTypeParam<R> & InputTypeQuery<R> & InputTypeHeader<R> & InputTypeCookie<R> & InputTypeForm<R> & InputTypeJson<R>, P extends string = ConvertPathType<R["path"]>>({ middleware: routeMiddleware, hide, ...route }: R, handler: Handler<R["middleware"] extends MiddlewareHandler[] | MiddlewareHandler ? RouteMiddlewareParam<R>["env"] & E : E, P, I, R extends {
|
||||
responses: {
|
||||
[statusCode: number]: {
|
||||
content: {
|
||||
[mediaType: string]: ZodMediaTypeObject;
|
||||
};
|
||||
};
|
||||
};
|
||||
} ? MaybePromise<RouteConfigToTypedResponse<R>> : MaybePromise<RouteConfigToTypedResponse<R>> | MaybePromise<Response>>) => OpenAPIHono<E, S & ToSchema<R["method"], MergePath<BasePath, P>, I, RouteConfigToTypedResponse<R>>, 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<Parameters<typeof createRoute>[0], 'responses'>,
|
||||
'request' | 'responses' | 'security'
|
||||
>,
|
||||
'path' | 'operationId' | 'tags' | 'description'
|
||||
>
|
||||
>
|
||||
|
||||
export function createOpenAPIHonoRoute<TAuthenticated extends boolean>(
|
||||
opts: CreateOpenAPIHonoRouteOpts<TAuthenticated>
|
||||
) {
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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<TTableConfig>,
|
||||
// T extends AnyZodObject
|
||||
// >(table: TTable, schema: T) {
|
||||
// return z.object({
|
||||
// where: z.record(
|
||||
// z.enum(Object.keys(table._.columns) as [string, ...string[]]),
|
||||
// z.string()
|
||||
// )
|
||||
// })
|
||||
// }
|
||||
|
|
|
@ -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')
|
||||
}
|
|
@ -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}"`
|
||||
)
|
||||
}
|
|
@ -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}"`
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import type { AuthenticatedContext } from './types'
|
||||
import { assert } from './utils'
|
||||
|
||||
export function acl<
|
||||
TModel extends Record<string, unknown> & { id: string },
|
||||
export async function acl<
|
||||
TModel extends Record<string, unknown>,
|
||||
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}"`
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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`
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
Ładowanie…
Reference in New Issue