mirror of
https://github.com/nestriness/nestri.git
synced 2026-11-03 22:22:39 +02:00
feat(api): create a team; slugs the website serves as pages are reserved
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.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
@@ -32,6 +33,14 @@ export namespace TeamApi {
|
||||
})
|
||||
.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 });
|
||||
@@ -83,6 +92,40 @@ export namespace TeamApi {
|
||||
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({
|
||||
@@ -127,14 +170,7 @@ export namespace TeamApi {
|
||||
const renamed = await Team.rename({ id: team.id, ...body });
|
||||
return c.json({ data: renamed! });
|
||||
} catch (error) {
|
||||
if (error instanceof Team.SlugTaken) {
|
||||
throw new VisibleError(
|
||||
'already_exists',
|
||||
ErrorCodes.Validation.ALREADY_EXISTS,
|
||||
'That slug is taken',
|
||||
'slug'
|
||||
);
|
||||
}
|
||||
if (error instanceof Team.SlugTaken) throw slugTaken();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,3 +124,34 @@ describe('GET /box', () => {
|
||||
expect(((await res.json()) as any).data).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /team', () => {
|
||||
test('creates a second team with you as owner, listed after your personal one', async () => {
|
||||
const me = await person('team-create');
|
||||
const s = slug('second');
|
||||
const res = await app.request('/team', {
|
||||
method: 'POST',
|
||||
headers: me.headers,
|
||||
body: JSON.stringify({ name: 'Second', slug: s })
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(((await res.json()) as any).data).toMatchObject({
|
||||
name: 'Second',
|
||||
slug: s,
|
||||
role: 'owner'
|
||||
});
|
||||
const list = ((await (await app.request('/team', { headers: me.headers })).json()) as any).data;
|
||||
expect(list.map((t: any) => t.slug)).toEqual([expect.any(String), s]);
|
||||
expect(list[0].id).toBe(me.teamId);
|
||||
});
|
||||
|
||||
test('a taken slug is a 409, and a page of the site is a 400', async () => {
|
||||
const me = await person('team-create-taken');
|
||||
const s = slug('dupe');
|
||||
const post = (body: unknown) =>
|
||||
app.request('/team', { method: 'POST', headers: me.headers, body: JSON.stringify(body) });
|
||||
expect((await post({ name: 'A', slug: s })).status).toBe(200);
|
||||
expect((await post({ name: 'B', slug: s })).status).toBe(409);
|
||||
expect((await post({ name: 'C', slug: 'pricing' })).status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -190,7 +190,7 @@ export namespace Team {
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 50);
|
||||
|
||||
const existing = await fromSlug(baseSlug);
|
||||
const existing = RESERVED.has(baseSlug) || (await fromSlug(baseSlug));
|
||||
const slug = existing
|
||||
? `${baseSlug}-${String(Math.floor(Math.random() * 9999)).padStart(4, '0')}`
|
||||
: baseSlug;
|
||||
@@ -237,10 +237,72 @@ export namespace Team {
|
||||
* keeping a second copy of the rule that drifts.
|
||||
*/
|
||||
export const Name = z.string().trim().min(1).max(64);
|
||||
/**
|
||||
* Slugs a team may not have, because the website serves a team at
|
||||
* `nestri.io/<slug>` and these are its own top-level pages, present or
|
||||
* planned. A team called `pricing` would either shadow the page or be
|
||||
* unreachable, and neither is a thing to discover after someone picked it.
|
||||
*/
|
||||
export const RESERVED = new Set([
|
||||
'about',
|
||||
'account',
|
||||
'activity',
|
||||
'admin',
|
||||
'api',
|
||||
'app',
|
||||
'assets',
|
||||
'auth',
|
||||
'billing',
|
||||
'blog',
|
||||
'blogs',
|
||||
'boxes',
|
||||
'careers',
|
||||
'changelog',
|
||||
'collab',
|
||||
'compare',
|
||||
'contact',
|
||||
'dashboard',
|
||||
'docs',
|
||||
'edge',
|
||||
'enterprise',
|
||||
'games',
|
||||
'gaming',
|
||||
'help',
|
||||
'home',
|
||||
'install',
|
||||
'installation',
|
||||
'legal',
|
||||
'login',
|
||||
'logout',
|
||||
'machines',
|
||||
'nesbox',
|
||||
'nesgpu',
|
||||
'nesktop',
|
||||
'neslink',
|
||||
'neslite',
|
||||
'nessh',
|
||||
'nestri',
|
||||
'new',
|
||||
'pricing',
|
||||
'privacy',
|
||||
'products',
|
||||
'security',
|
||||
'settings',
|
||||
'signup',
|
||||
'static',
|
||||
'status',
|
||||
'support',
|
||||
'team',
|
||||
'teams',
|
||||
'terms',
|
||||
'www'
|
||||
]);
|
||||
|
||||
export const Slug = z
|
||||
.string()
|
||||
.max(50)
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'lowercase letters, numbers and single hyphens');
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'lowercase letters, numbers and single hyphens')
|
||||
.refine((s) => !RESERVED.has(s), 'that name is taken by a page on the site');
|
||||
|
||||
/** A slug somebody else's team already has. */
|
||||
export class SlugTaken extends Error {
|
||||
|
||||
Reference in New Issue
Block a user