mirror of
https://github.com/nestriness/nestri.git
synced 2026-11-09 17:02:27 +02:00
POST /team makes another team with the caller as owner. The website now serves a team at nestri.io/<slug>, so Team.Slug refuses its top-level pages (home, new, pricing, ...) and a personal team whose generated slug would be one gets a suffix instead.
199 lines
6.0 KiB
TypeScript
199 lines
6.0 KiB
TypeScript
import { Actor } from '@nestri/core/actor';
|
|
import { ErrorCodes, VisibleError } from '@nestri/core/error';
|
|
import { Examples } from '@nestri/core/examples';
|
|
import { Identifier } from '@nestri/core/id';
|
|
import { Team } from '@nestri/core/team/index';
|
|
import { Member } from '@nestri/core/team/member';
|
|
import { Hono } from 'hono';
|
|
import { describeRoute } from 'hono-openapi';
|
|
import { z } from 'zod';
|
|
|
|
import { ErrorResponses, notPublic, Result, validator } from '../utils';
|
|
|
|
/**
|
|
* The teams a caller belongs to: read them, rename one, see who is on it.
|
|
*
|
|
* Every read is membership-scoped in the same way — a team you are not on is
|
|
* a 404, never a 403, so an id cannot be probed for. Renaming takes an owner
|
|
* or an admin, because the slug is the team's address and changing it moves
|
|
* every link somebody saved.
|
|
*/
|
|
export namespace TeamApi {
|
|
const Mine = Team.Info.extend({
|
|
role: Member.Info.shape.role
|
|
}).meta({ ref: 'TeamMembership', description: 'A team, and your role on it' });
|
|
|
|
const Person = z
|
|
.object({
|
|
id: Member.Info.shape.id,
|
|
userId: Member.Info.shape.userId,
|
|
role: Member.Info.shape.role,
|
|
name: z.string(),
|
|
email: z.string().nullable()
|
|
})
|
|
.meta({ ref: 'TeamPerson', description: 'A member of a team, and who they are' });
|
|
|
|
const slugTaken = () =>
|
|
new VisibleError(
|
|
'already_exists',
|
|
ErrorCodes.Validation.ALREADY_EXISTS,
|
|
'That slug is taken',
|
|
'slug'
|
|
);
|
|
|
|
/** The caller's membership of a team, or the 404 that hides whether it exists. */
|
|
async function membership(teamId: string) {
|
|
const member = await Member.findByTeamAndUser({ teamId, userId: Actor.userID });
|
|
const team = member ? await Team.fromID(teamId) : null;
|
|
if (!member || !team) {
|
|
throw new VisibleError(
|
|
'not_found',
|
|
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
|
'No such team, or you are not on it'
|
|
);
|
|
}
|
|
return { member, team };
|
|
}
|
|
|
|
export const route = new Hono()
|
|
.use(notPublic)
|
|
.get(
|
|
'/',
|
|
describeRoute({
|
|
tags: ['Team'],
|
|
summary: 'Your teams',
|
|
description:
|
|
'Every team you are a member of, oldest membership first — so the first is your personal team, the one made when you signed up.',
|
|
responses: {
|
|
200: {
|
|
content: {
|
|
'application/json': {
|
|
schema: Result(
|
|
z.array(Mine).meta({
|
|
description: 'Your teams',
|
|
example: [{ ...Examples.Team, role: 'owner' }]
|
|
})
|
|
)
|
|
}
|
|
},
|
|
description: 'Your teams'
|
|
},
|
|
401: ErrorResponses[401]
|
|
}
|
|
}),
|
|
async (c) => {
|
|
const memberships = await Member.listByUser(Actor.userID);
|
|
const teams = await Promise.all(
|
|
memberships.map(async (m) => {
|
|
const team = await Team.fromID(m.teamId);
|
|
return team ? { ...Team.serialize(team), role: m.role } : null;
|
|
})
|
|
);
|
|
return c.json({ data: teams.filter((t) => t !== null) });
|
|
}
|
|
)
|
|
.post(
|
|
'/',
|
|
describeRoute({
|
|
tags: ['Team'],
|
|
summary: 'Create a team',
|
|
description:
|
|
'A new team with you as its owner. Everyone already has a personal team from sign-up; this is another one, for people or hardware you want kept apart. A taken slug is a 409.',
|
|
responses: {
|
|
200: {
|
|
content: { 'application/json': { schema: Result(Mine) } },
|
|
description: 'The team, with you as owner'
|
|
},
|
|
400: ErrorResponses[400],
|
|
401: ErrorResponses[401],
|
|
409: ErrorResponses[409]
|
|
}
|
|
}),
|
|
validator('json', z.object({ name: Team.Name, slug: Team.Slug })),
|
|
async (c) => {
|
|
const { name, slug } = c.req.valid('json');
|
|
if (await Team.fromSlug(slug)) throw slugTaken();
|
|
const id = Identifier.ascending('team');
|
|
try {
|
|
await Team.create({ id, name, slug });
|
|
} catch (error) {
|
|
// The unique index, when two people race for the same slug.
|
|
const code = (error as { code?: string; cause?: { code?: string } }) ?? {};
|
|
if (code.code === '23505' || code.cause?.code === '23505') throw slugTaken();
|
|
throw error;
|
|
}
|
|
const team = await Team.fromID(id);
|
|
return c.json({ data: { ...Team.serialize(team!), role: 'owner' as const } });
|
|
}
|
|
)
|
|
.patch(
|
|
'/:id',
|
|
describeRoute({
|
|
tags: ['Team'],
|
|
summary: 'Rename a team',
|
|
description:
|
|
'Change the name, the slug, or both. Owners and admins only. A slug another team holds is a 409.',
|
|
responses: {
|
|
200: {
|
|
content: { 'application/json': { schema: Result(Team.Info) } },
|
|
description: 'The team, renamed'
|
|
},
|
|
400: ErrorResponses[400],
|
|
401: ErrorResponses[401],
|
|
403: ErrorResponses[403],
|
|
404: ErrorResponses[404],
|
|
409: ErrorResponses[409]
|
|
}
|
|
}),
|
|
validator(
|
|
'json',
|
|
z
|
|
.object({
|
|
name: Team.Name.optional().meta({ description: 'Display name' }),
|
|
slug: Team.Slug.optional().meta({ description: 'URL-friendly unique slug' })
|
|
})
|
|
.refine((b) => b.name !== undefined || b.slug !== undefined, {
|
|
message: 'Give a name, a slug, or both'
|
|
})
|
|
),
|
|
async (c) => {
|
|
const body = c.req.valid('json');
|
|
const { member, team } = await membership(c.req.param('id'));
|
|
if (member.role !== 'owner' && member.role !== 'admin') {
|
|
throw new VisibleError(
|
|
'forbidden',
|
|
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
|
|
'Only an owner or an admin can rename a team'
|
|
);
|
|
}
|
|
try {
|
|
const renamed = await Team.rename({ id: team.id, ...body });
|
|
return c.json({ data: renamed! });
|
|
} catch (error) {
|
|
if (error instanceof Team.SlugTaken) throw slugTaken();
|
|
throw error;
|
|
}
|
|
}
|
|
)
|
|
.get(
|
|
'/:id/members',
|
|
describeRoute({
|
|
tags: ['Team'],
|
|
summary: 'Who is on a team',
|
|
description: 'Members of a team you are on, oldest first, with their name and address.',
|
|
responses: {
|
|
200: {
|
|
content: { 'application/json': { schema: Result(z.array(Person)) } },
|
|
description: 'The members'
|
|
},
|
|
401: ErrorResponses[401],
|
|
404: ErrorResponses[404]
|
|
}
|
|
}),
|
|
async (c) => {
|
|
const { team } = await membership(c.req.param('id'));
|
|
return c.json({ data: await Member.listPeople(team.id) });
|
|
}
|
|
);
|
|
}
|