feat: team crud

pull/715/head
Travis Fischer 2025-04-29 18:56:28 +07:00
rodzic d02e70ea74
commit 83b8d9e31c
24 zmienionych plików z 855 dodań i 17 usunięć

Wyświetl plik

@ -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)

Wyświetl plik

@ -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))
})
})
}

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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'
}
})
})

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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'
}
})
})

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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))
})

Wyświetl plik

@ -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))
})

Wyświetl plik

@ -19,7 +19,9 @@ export {
arrayContained,
arrayContains,
arrayOverlaps,
asc,
between,
desc,
eq,
exists,
gt,

Wyświetl plik

@ -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
})

Wyświetl plik

@ -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
})

Wyświetl plik

@ -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
}
})
}

Wyświetl plik

@ -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()
// )
// })
// }

Wyświetl plik

@ -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')
}

Wyświetl plik

@ -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}"`
)
}

Wyświetl plik

@ -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}"`
)
}

Wyświetl plik

@ -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}"`
)
}

Wyświetl plik

@ -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`
)
}

Wyświetl plik

@ -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)
}