mirror of
https://github.com/nestriness/nestri.git
synced 2026-11-03 22:22:39 +02:00
Merge branch 'feat/team-and-box-reads' into dev
This commit is contained in:
@@ -11,6 +11,7 @@ import { type ContentfulStatusCode } from 'hono/utils/http-status';
|
||||
import { auth } from './middleware/auth.js';
|
||||
import { AccessTokenApi } from './routes/access-token.js';
|
||||
import { BillingApi } from './routes/billing.js';
|
||||
import { BoxApi } from './routes/box.js';
|
||||
import { EnrolmentApi } from './routes/enrolment.js';
|
||||
import { GameApi } from './routes/game.js';
|
||||
import { IndexApi } from './routes/index.js';
|
||||
@@ -20,6 +21,7 @@ import { MachineApi } from './routes/machine.js';
|
||||
import { OrganisationApi } from './routes/organisation.js';
|
||||
import { SessionApi } from './routes/session.js';
|
||||
import { SteamApi } from './routes/steam.js';
|
||||
import { TeamApi } from './routes/team.js';
|
||||
import { UserApi } from './routes/user.js';
|
||||
import { WaitlistApi } from './routes/waitlist.js';
|
||||
|
||||
@@ -51,6 +53,8 @@ const routes = app
|
||||
.route('/machine', MachineApi.route)
|
||||
.route('/machine', SessionApi.machineRoute)
|
||||
.route('/machine', EnrolmentApi.route)
|
||||
.route('/team', TeamApi.route)
|
||||
.route('/box', BoxApi.route)
|
||||
.route('/session', SessionApi.route)
|
||||
.route('/access-token', AccessTokenApi.route)
|
||||
.route('/waitlist', WaitlistApi.route)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Actor } from '@nestri/core/actor';
|
||||
import { Box } from '@nestri/core/box/index';
|
||||
import { Examples } from '@nestri/core/examples';
|
||||
import { Hono } from 'hono';
|
||||
import { describeRoute } from 'hono-openapi';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ErrorResponses, notPublic, Result } from '../utils';
|
||||
|
||||
/**
|
||||
* The caller's boxes. Read-only: a box is created by placement and its state
|
||||
* is whatever its host last reported, so the list is the whole of what a
|
||||
* person can ask here for now.
|
||||
*/
|
||||
export namespace BoxApi {
|
||||
export const route = new Hono().use(notPublic).get(
|
||||
'/',
|
||||
describeRoute({
|
||||
tags: ['Box'],
|
||||
summary: 'Your boxes',
|
||||
description:
|
||||
'Every box you own, oldest first, with its state and — for one that stopped — why, as its host reported it.',
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: Result(
|
||||
z.array(Box.Info).meta({ description: 'Your boxes', example: [Examples.Box] })
|
||||
)
|
||||
}
|
||||
},
|
||||
description: 'Your boxes'
|
||||
},
|
||||
401: ErrorResponses[401]
|
||||
}
|
||||
}),
|
||||
async (c) => c.json({ data: await Box.listByUser(Actor.userID) })
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
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) });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
|
||||
import { AccessToken } from '@nestri/core/access-token/index';
|
||||
import { Fixtures } from '@nestri/core/db/fixtures';
|
||||
import { testDb } from '@nestri/core/db/test';
|
||||
import { Identifier } from '@nestri/core/id';
|
||||
import { Member } from '@nestri/core/team/member';
|
||||
|
||||
import { app } from '../app/index';
|
||||
import './setup';
|
||||
|
||||
const sql = testDb();
|
||||
const createdUserIds: string[] = [];
|
||||
|
||||
async function person(label: string) {
|
||||
const owner = await Fixtures.owner(label);
|
||||
createdUserIds.push(owner.userId);
|
||||
const pat = await AccessToken.create({
|
||||
id: Identifier.ascending('accessToken'),
|
||||
ownerUserId: owner.userId,
|
||||
teamId: null,
|
||||
name: label
|
||||
});
|
||||
return {
|
||||
...owner,
|
||||
headers: { authorization: `Bearer ${pat.token}`, 'content-type': 'application/json' }
|
||||
};
|
||||
}
|
||||
|
||||
function rename(who: { headers: Record<string, string> }, teamId: string, body: unknown) {
|
||||
return app.request(`/team/${teamId}`, {
|
||||
method: 'PATCH',
|
||||
headers: who.headers,
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
|
||||
/** Unique per run, so a slug from a previous run never collides. */
|
||||
const slug = (s: string) => `${s}-${Identifier.ascending('team').slice(-8).toLowerCase()}`;
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdUserIds.length > 0) {
|
||||
await sql`delete from "user" where id in ${sql(createdUserIds)}`;
|
||||
}
|
||||
});
|
||||
|
||||
describe('GET /team', () => {
|
||||
test('lists your personal team, with your role on it', async () => {
|
||||
const me = await person('team-list');
|
||||
const res = await app.request('/team', { headers: me.headers });
|
||||
expect(res.status).toBe(200);
|
||||
const { data } = (await res.json()) as any;
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data[0]).toMatchObject({ id: me.teamId, role: 'owner' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /team/:id', () => {
|
||||
test('an owner renames name and slug', async () => {
|
||||
const me = await person('team-rename');
|
||||
const s = slug('renamed');
|
||||
const res = await rename(me, me.teamId, { name: 'Renamed', slug: s });
|
||||
expect(res.status).toBe(200);
|
||||
expect(((await res.json()) as any).data).toMatchObject({ name: 'Renamed', slug: s });
|
||||
});
|
||||
|
||||
test("someone else's team is a 404, not a 403", async () => {
|
||||
const me = await person('team-probe');
|
||||
const them = await person('team-probed');
|
||||
const res = await rename(me, them.teamId, { name: 'Mine now' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
test('a plain member cannot rename', async () => {
|
||||
const owner = await person('team-owner');
|
||||
const member = await person('team-member');
|
||||
await Member.create({
|
||||
id: Identifier.ascending('teamMember'),
|
||||
teamId: owner.teamId,
|
||||
userId: member.userId,
|
||||
role: 'member'
|
||||
});
|
||||
const res = await rename(member, owner.teamId, { name: 'Coup' });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
test('a taken slug is a 409 on the slug', async () => {
|
||||
const a = await person('team-slug-a');
|
||||
const b = await person('team-slug-b');
|
||||
const s = slug('taken');
|
||||
expect((await rename(a, a.teamId, { slug: s })).status).toBe(200);
|
||||
const res = await rename(b, b.teamId, { slug: s });
|
||||
expect(res.status).toBe(409);
|
||||
expect(((await res.json()) as any).param).toBe('slug');
|
||||
});
|
||||
|
||||
test('a malformed slug is refused before it reaches the database', async () => {
|
||||
const me = await person('team-bad-slug');
|
||||
expect((await rename(me, me.teamId, { slug: 'Not A Slug' })).status).toBe(400);
|
||||
expect((await rename(me, me.teamId, {})).status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /team/:id/members', () => {
|
||||
test('names the people on your team, and nobody can list a team they are not on', async () => {
|
||||
const me = await person('team-people');
|
||||
const other = await person('team-people-other');
|
||||
const res = await app.request(`/team/${me.teamId}/members`, { headers: me.headers });
|
||||
expect(res.status).toBe(200);
|
||||
const { data } = (await res.json()) as any;
|
||||
expect(data).toEqual([
|
||||
expect.objectContaining({ userId: me.userId, role: 'owner', name: 'team-people' })
|
||||
]);
|
||||
const refused = await app.request(`/team/${other.teamId}/members`, { headers: me.headers });
|
||||
expect(refused.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /box', () => {
|
||||
test('a person with no boxes gets an empty list', async () => {
|
||||
const me = await person('box-none');
|
||||
const res = await app.request('/box', { headers: me.headers });
|
||||
expect(res.status).toBe(200);
|
||||
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;
|
||||
@@ -230,6 +230,127 @@ export namespace Team {
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* What a team's name and slug may be. The slug is the team's address, so
|
||||
* it is what `createPersonal` makes: lowercase letters, digits and single
|
||||
* hyphens. Exported so a client can check before it asks, rather than
|
||||
* 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')
|
||||
.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 {
|
||||
constructor(public slug: string) {
|
||||
super(`The slug ${slug} is taken`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change a team's name, its slug, or both.
|
||||
*
|
||||
* Who may do this is the caller's business (the route checks the role);
|
||||
* this only refuses a slug that is taken. The check before the write gives
|
||||
* the ordinary answer, and the unique index is what actually holds when two
|
||||
* people race for the same one.
|
||||
*/
|
||||
export const rename = fn(
|
||||
z.object({ id: Info.shape.id, name: Name.optional(), slug: Slug.optional() }),
|
||||
async (input) => {
|
||||
if (input.slug) {
|
||||
const holder = await fromSlug(input.slug);
|
||||
if (holder && holder.id !== input.id) throw new SlugTaken(input.slug);
|
||||
}
|
||||
try {
|
||||
return await Database.use(async (tx) =>
|
||||
tx
|
||||
.update(TeamTable)
|
||||
.set({
|
||||
...(input.name !== undefined && { name: input.name }),
|
||||
...(input.slug !== undefined && { slug: input.slug })
|
||||
})
|
||||
.where(and(eq(TeamTable.id, input.id), isNull(TeamTable.timeDeleted)))
|
||||
.returning()
|
||||
.then((rows) => {
|
||||
const row = rows.at(0);
|
||||
return row ? serialize(row) : null;
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
const code = (error as { code?: string; cause?: { code?: string } }) ?? {};
|
||||
if (input.slug && (code.code === '23505' || code.cause?.code === '23505')) {
|
||||
throw new SlugTaken(input.slug);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export function serialize(input: typeof TeamTable.$inferSelect): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
|
||||
@@ -4,6 +4,7 @@ import z from 'zod';
|
||||
import { Database } from '../db/index.js';
|
||||
import { Examples } from '../examples.js';
|
||||
import { fn } from '../fn.js';
|
||||
import { UserTable } from '../user/user.sql.js';
|
||||
import { TeamMemberRole, TeamMemberTable } from './member.sql.js';
|
||||
|
||||
export namespace Member {
|
||||
@@ -75,6 +76,33 @@ export namespace Member {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A team's members with who they are, for a page that lists them. One
|
||||
* join rather than a lookup per member; a deleted user is not a member.
|
||||
*/
|
||||
export const listPeople = fn(Info.shape.teamId, async (teamId) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select({
|
||||
id: TeamMemberTable.id,
|
||||
userId: TeamMemberTable.userId,
|
||||
role: TeamMemberTable.role,
|
||||
name: UserTable.name,
|
||||
email: UserTable.email
|
||||
})
|
||||
.from(TeamMemberTable)
|
||||
.innerJoin(UserTable, eq(UserTable.id, TeamMemberTable.userId))
|
||||
.where(
|
||||
and(
|
||||
eq(TeamMemberTable.teamId, teamId),
|
||||
isNull(TeamMemberTable.timeDeleted),
|
||||
isNull(UserTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.orderBy(TeamMemberTable.timeCreated);
|
||||
});
|
||||
});
|
||||
|
||||
export const listByUser = fn(Info.shape.userId, async (userId) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
|
||||
Reference in New Issue
Block a user