all repos — stealth-developers @ d198bbf452eb549845999f475e608f713fe774df

api: types !!
vi did:web:vt3e.cat
Mon, 29 Jun 2026 02:26:53 +0100
commit

d198bbf452eb549845999f475e608f713fe774df

parent

60c7770aaf3859b7ded2371dd62d6e72be931242

M apps/api/package.jsonapps/api/package.json

@@ -8,6 +8,7 @@ "dev": "bun run --watch src/index.ts"

}, "dependencies": { "@elysia/cors": "^1.4.2", + "@elysia/openapi": "^1.4.15", "@stealth-developers/config": "workspace:*", "@stealth-developers/db": "workspace:*", "elysia": "latest"
M apps/api/src/index.tsapps/api/src/index.ts

@@ -1,12 +1,18 @@

+import cors from "@elysia/cors"; +import openapi from "@elysia/openapi"; import config from "@stealth-developers/config"; import { getUser } from "@stealth-developers/db"; -import { Elysia } from "elysia"; +import { Elysia, t } from "elysia"; import { authPlugin } from "./lib/auth"; import { getLinkedActors } from "./lib/db"; +import { sanitiseActors, sanitiseUser } from "./lib/sanitise"; +import { ActorSchema, ErrorSchema, UserSchema } from "./lib/schemas"; import * as routes from "./routes"; const app = new Elysia() + .use(openapi()) + .use(cors()) .use(authPlugin) .onError(({ error, set, request }) => { set.status = 500;

@@ -22,14 +28,31 @@ .resolve(({ session, status }) => {

if (!session) return status(401, { message: "unauthorized" }); return { session }; }) - .get("/me", async ({ session }) => { - const [user, actors] = await Promise.all([ - getUser(session.userId), - getLinkedActors(session.userId), - ]); + .get( + "/me", + async ({ session }) => { + const [user, actors] = await Promise.all([ + getUser(session.userId), + getLinkedActors(session.userId), + ]); - return { user, actors }; - }) + return { + user: user ? sanitiseUser(user) : null, + actors: sanitiseActors(actors), + }; + }, + { + response: { + 200: t.Object({ + user: t.Nullable(UserSchema), + actors: t.Array(ActorSchema), + }), + 401: ErrorSchema, + 500: ErrorSchema, + }, + tags: ["auth"], + }, + ) .use(routes.guildRoutes), ) .listen(config.api.port);
M apps/api/src/lib/sanitise.tsapps/api/src/lib/sanitise.ts

@@ -1,7 +1,6 @@

import { Actor, Guild, - Ticket, TicketAttachment, TicketComment, TicketMessage,

@@ -10,13 +9,24 @@ TicketWithRelations,

User, } from "@stealth-developers/db"; +import { + type SanitisedActor, + type SanitisedGuild, + type SanitisedTicket, + type SanitisedTicketAttachment, + type SanitisedTicketComment, + type SanitisedTicketMessage, + type SanitisedTicketParticipant, + type SanitisedUser, +} from "./schemas"; + export interface SanitiseOptions { isModerator: boolean; anonymiseStaff?: boolean; } -// -export function sanitiseGuild(guild: Guild) { +// Guild +export function sanitiseGuild(guild: Guild): SanitisedGuild { return { id: guild.id, discordId: guild.discordId,

@@ -29,12 +39,12 @@ updatedAt: guild.updatedAt,

}; } -export function sanitiseGuilds(guilds: Guild[]) { +export function sanitiseGuilds(guilds: Guild[]): SanitisedGuild[] { return guilds.map(sanitiseGuild); } -// -export function sanitiseUser(user: User) { +// User +export function sanitiseUser(user: User): SanitisedUser { return { id: user.id, discordId: user.discordId,

@@ -47,12 +57,12 @@ updatedAt: user.updatedAt,

}; } -export function sanitiseUsers(users: User[]) { +export function sanitiseUsers(users: User[]): SanitisedUser[] { return users.map(sanitiseUser); } -// -export function sanitiseActor(actor: Actor) { +// Actor +export function sanitiseActor(actor: Actor): SanitisedActor { return { actorId: actor.id, guildId: actor.guildId,

@@ -68,12 +78,15 @@ updatedAt: actor.updatedAt,

}; } -export function sanitiseActors(actors: Actor[]) { +export function sanitiseActors(actors: Actor[]): SanitisedActor[] { return actors.map(sanitiseActor); } -// -export function sanitiseTicket(ticket: TicketWithRelations, options: SanitiseOptions) { +// Ticket +export function sanitiseTicket( + ticket: TicketWithRelations, + options: SanitiseOptions, +): SanitisedTicket { const { isModerator, anonymiseStaff = false } = options; const shouldAnonymise = !isModerator && anonymiseStaff;

@@ -112,8 +125,11 @@ hasAuthorMessage: ticket.hasAuthorMessage,

}; } -// -export function sanitiseTicketMessage(message: TicketMessage, options: SanitiseOptions) { +// TicketMessage +export function sanitiseTicketMessage( + message: TicketMessage, + options: SanitiseOptions, +): SanitisedTicketMessage | null { const { isModerator, anonymiseStaff = false } = options; if (message.isPrivate && !isModerator) return null;

@@ -130,8 +146,8 @@

isPrivate: message.isPrivate, content: message.content, - embeds: message.embeds, - components: message.components, + embeds: message.embeds ?? [], + components: message.components ?? [], createdAt: message.createdAt, editedAt: message.editedAt,

@@ -139,17 +155,20 @@ deletedAt: message.deletedAt,

}; } -export function sanitiseTicketMessages(messages: TicketMessage[], options: SanitiseOptions) { +export function sanitiseTicketMessages( + messages: TicketMessage[], + options: SanitiseOptions, +): SanitisedTicketMessage[] { return messages .map((msg) => sanitiseTicketMessage(msg, options)) - .filter((msg): msg is NonNullable<ReturnType<typeof sanitiseTicketMessage>> => msg !== null); + .filter((msg): msg is SanitisedTicketMessage => msg !== null); } -// +// TicketParticipant export function sanitiseTicketParticipant( participant: TicketParticipant, options: SanitiseOptions, -) { +): SanitisedTicketParticipant | null { const { isModerator, anonymiseStaff = false } = options; const isStaffParticipant = participant.role === "moderator";

@@ -171,14 +190,17 @@

export function sanitiseTicketParticipants( participants: TicketParticipant[], options: SanitiseOptions, -) { +): SanitisedTicketParticipant[] { return participants .map((p) => sanitiseTicketParticipant(p, options)) - .filter((p): p is NonNullable<ReturnType<typeof sanitiseTicketParticipant>> => p !== null); + .filter((p): p is SanitisedTicketParticipant => p !== null); } -// -export function sanitiseTicketComment(comment: TicketComment, options: SanitiseOptions) { +// TicketComment +export function sanitiseTicketComment( + comment: TicketComment, + options: SanitiseOptions, +): SanitisedTicketComment | null { const { isModerator } = options; if (!isModerator) return null;

@@ -193,14 +215,20 @@ updatedAt: comment.updatedAt,

}; } -export function sanitiseTicketComments(comments: TicketComment[], options: SanitiseOptions) { +export function sanitiseTicketComments( + comments: TicketComment[], + options: SanitiseOptions, +): SanitisedTicketComment[] { return comments .map((c) => sanitiseTicketComment(c, options)) - .filter((c): c is NonNullable<ReturnType<typeof sanitiseTicketComment>> => c !== null); + .filter((c): c is SanitisedTicketComment => c !== null); } -// -export function sanitiseTicketAttachment(attachment: TicketAttachment, options: SanitiseOptions) { +// TicketAttachment +export function sanitiseTicketAttachment( + attachment: TicketAttachment, + options: SanitiseOptions, +): SanitisedTicketAttachment { const { isModerator, anonymiseStaff = false } = options; const shouldAnonymise = !isModerator && anonymiseStaff;

@@ -220,6 +248,6 @@

export function sanitiseTicketAttachments( attachments: TicketAttachment[], options: SanitiseOptions, -) { +): SanitisedTicketAttachment[] { return attachments.map((a) => sanitiseTicketAttachment(a, options)); }
A apps/api/src/lib/schemas.ts

@@ -0,0 +1,142 @@

+import type { APIEmbed, APIMessageTopLevelComponent } from "@stealth-developers/db"; + +import { type Static, t } from "elysia"; + +export const GuildSchema = t.Object({ + id: t.Number(), + discordId: t.String(), + name: t.String(), + icon: t.Nullable(t.String()), + createdAt: t.Date(), + updatedAt: t.Date(), +}); +export type SanitisedGuild = Static<typeof GuildSchema>; + +export const UserSchema = t.Object({ + id: t.Number(), + + discordId: t.Nullable(t.String()), + username: t.Nullable(t.String()), + displayName: t.Nullable(t.String()), + + createdAt: t.Date(), + updatedAt: t.Date(), +}); +export type SanitisedUser = Static<typeof UserSchema>; + +export const ActorSchema = t.Object({ + actorId: t.Number(), + guildId: t.Nullable(t.Number()), + userId: t.Nullable(t.Number()), + + discordId: t.Nullable(t.String()), + username: t.Nullable(t.String()), + displayName: t.Nullable(t.String()), + avatar: t.Nullable(t.String()), + + createdAt: t.Date(), + updatedAt: t.Date(), +}); +export type SanitisedActor = Static<typeof ActorSchema>; + +export const TicketSchema = t.Object({ + id: t.String(), + guildId: t.Number(), + localId: t.Number(), + status: t.String(), + channelId: t.Nullable(t.String()), + threadId: t.Nullable(t.String()), + closeReason: t.Nullable(t.String()), + privateReason: t.Nullable(t.String()), + openedAt: t.Date(), + closedAt: t.Nullable(t.Date()), + closedBy: t.Nullable(t.Number()), + lastSubjectActivityAt: t.Nullable(t.Date()), + lastClaimantActivityAt: t.Nullable(t.Date()), + openedBy: t.Number(), + openReason: t.Nullable(t.String()), + subject: t.Nullable(t.Number()), + claimedBy: t.Nullable(t.Number()), + claimedAt: t.Nullable(t.Date()), + thankedAt: t.Nullable(t.Date()), + warnedAt: t.Nullable(t.Date()), + lastMessageAt: t.Nullable(t.Date()), + hasModeratorMessage: t.Nullable(t.Boolean()), + hasAuthorMessage: t.Nullable(t.Boolean()), +}); +export type SanitisedTicket = Static<typeof TicketSchema>; + +export const TicketMessageSchema = t.Object({ + id: t.String(), + messageId: t.Nullable(t.String()), + ticketId: t.String(), + actorId: t.Nullable(t.Number()), + actorRole: t.String(), + isPrivate: t.Boolean(), + content: t.String(), + embeds: t.Unsafe<APIEmbed[]>({ type: "array" }), + components: t.Unsafe<APIMessageTopLevelComponent[]>({ type: "array" }), + createdAt: t.Date(), + editedAt: t.Nullable(t.Date()), + deletedAt: t.Nullable(t.Date()), +}); +export type SanitisedTicketMessage = Static<typeof TicketMessageSchema>; + +export const TicketParticipantSchema = t.Object({ + id: t.Number(), + ticketId: t.String(), + actorId: t.Number(), + role: t.String(), + reason: t.Nullable(t.String()), + createdAt: t.Date(), +}); +export type SanitisedTicketParticipant = Static<typeof TicketParticipantSchema>; + +export const TicketCommentSchema = t.Object({ + id: t.String(), + ticketId: t.String(), + actorId: t.Number(), + content: t.String(), + createdAt: t.Date(), + updatedAt: t.Date(), +}); +export type SanitisedTicketComment = Static<typeof TicketCommentSchema>; + +export const TicketAttachmentSchema = t.Object({ + id: t.String(), + messageId: t.Nullable(t.String()), + ticketId: t.String(), + actorId: t.Nullable(t.Number()), + fileName: t.String(), + fileType: t.String(), + fileSize: t.Number(), +}); +export type SanitisedTicketAttachment = Static<typeof TicketAttachmentSchema>; + +export const ErrorSchema = t.Object({ + message: t.String(), +}); +export type ErrorResponse = Static<typeof ErrorSchema>; + +// +export const GuildAccessSchema = t.Object({ + id: t.Number(), + discordId: t.String(), + name: t.String(), + icon: t.Nullable(t.String()), + actor: t.Object({ + username: t.Nullable(t.String()), + displayName: t.Nullable(t.String()), + avatar: t.Nullable(t.String()), + actorId: t.Number(), + userId: t.Nullable(t.Number()), + discordId: t.Nullable(t.String()), + }), +}); +export type GuildAccess = Static<typeof GuildAccessSchema>; + +export const TicketsResponseSchema = t.Object({ + tickets: t.Array(TicketSchema), + count: t.Number(), +}); +export type TicketsResponse = Static<typeof TicketsResponseSchema>;
M apps/api/src/routes/auth.tsapps/api/src/routes/auth.ts

@@ -13,10 +13,16 @@ import { CLIENT_ID, CLIENT_SECRET, FRONTEND_URL, REDIRECT_URI } from "../lib/constants";

export const authRoutes = new Elysia().group("/auth", (app) => app - .get("/login", ({ redirect }) => { - const redirectUri = `https://discord.com/oauth2/authorize?client_id=${CLIENT_ID}&response_type=code&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&scope=identify+guilds`; - return redirect(redirectUri, 302); - }) + .get( + "/login", + ({ redirect }) => { + const redirectUri = `https://discord.com/oauth2/authorize?client_id=${CLIENT_ID}&response_type=code&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&scope=identify+guilds`; + return redirect(redirectUri, 302); + }, + { + tags: ["auth"], + }, + ) .get( "/callback", async ({ query: { code }, cookie: { session_id }, redirect }) => {

@@ -80,6 +86,7 @@

return redirect(FRONTEND_URL); }, { + tags: ["auth"], query: t.Object({ code: t.String(), }),
M apps/api/src/routes/guilds.tsapps/api/src/routes/guilds.ts

@@ -15,6 +15,16 @@ sanitiseTicketComments,

sanitiseTicketMessages, sanitiseTicketParticipants, } from "../lib/sanitise"; +import { + ErrorSchema, + GuildAccessSchema, + TicketAttachmentSchema, + TicketCommentSchema, + TicketMessageSchema, + TicketParticipantSchema, + TicketSchema, + TicketsResponseSchema, +} from "../lib/schemas"; export const guildRoutes = new Elysia({ prefix: "/guilds/:guildId" }) .use(authPlugin)

@@ -29,23 +39,34 @@ if (!actor) return status(403, { message: "No access to this guild" });

return { actor, guild }; }) - .get("/", ({ actor, guild }) => { - return { - id: guild.id, - discordId: guild.discordId, - name: guild.name, - icon: guild.icon, - actor: { - username: actor.username, - displayName: actor.displayName, - avatar: actor.avatar, - - actorId: actor.id, - userId: actor.userId, - discordId: actor.discordId, + .get( + "/", + ({ actor, guild }) => { + return { + id: guild.id, + discordId: guild.discordId, + name: guild.name, + icon: guild.icon, + actor: { + username: actor.username, + displayName: actor.displayName, + avatar: actor.avatar, + actorId: actor.id, + userId: actor.userId, + discordId: actor.discordId, + }, + }; + }, + { + response: { + 200: GuildAccessSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, }, - }; - }) + tags: ["guilds"], + }, + ) .group("/tickets", (app) => app .get(

@@ -65,10 +86,17 @@ count: tickets.length,

}; }, { + tags: ["tickets"], query: t.Object({ limit: t.Number({ default: 50 }), offset: t.Number({ default: 0 }), }), + response: { + 200: TicketsResponseSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, }, ) .group("/:ticketId", (ticketGroup) =>

@@ -82,39 +110,99 @@ if (!hasAccess) return status(403, { message: "No access to this ticket" });

return { ticket }; }) - .get("/", ({ ticket, actor }) => { - return sanitiseTicket(ticket, { - isModerator: !!actor.moderator, - anonymiseStaff: false, - }); - }) - .get("/messages", ({ ticket, actor }) => { - const messages = ticket.messages ?? ticket.messages ?? []; - return sanitiseTicketMessages(messages, { - isModerator: !!actor.moderator, - anonymiseStaff: false, - }); - }) - .get("/participants", ({ ticket, actor }) => { - const participants = ticket.participants ?? ticket.participants ?? []; - return sanitiseTicketParticipants(participants, { - isModerator: !!actor.moderator, - anonymiseStaff: false, - }); - }) - .get("/comments", ({ ticket, actor }) => { - const comments = ticket.comments ?? ticket.comments ?? []; - return sanitiseTicketComments(comments, { - isModerator: !!actor.moderator, - anonymiseStaff: false, - }); - }) - .get("/attachments", ({ ticket, actor }) => { - const attachments = ticket.attachments ?? ticket.attachments ?? []; - return sanitiseTicketAttachments(attachments, { - isModerator: !!actor.moderator, - anonymiseStaff: false, - }); - }), + .get( + "/", + ({ ticket, actor }) => { + return sanitiseTicket(ticket, { + isModerator: !!actor.moderator, + anonymiseStaff: false, + }); + }, + { + response: { + 200: TicketSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + tags: ["tickets"], + }, + ) + .get( + "/messages", + ({ ticket, actor }) => { + const messages = ticket.messages ?? []; + return sanitiseTicketMessages(messages, { + isModerator: !!actor.moderator, + anonymiseStaff: false, + }); + }, + { + response: { + 200: t.Array(TicketMessageSchema), + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + tags: ["tickets"], + }, + ) + .get( + "/participants", + ({ ticket, actor }) => { + const participants = ticket.participants ?? []; + return sanitiseTicketParticipants(participants, { + isModerator: !!actor.moderator, + anonymiseStaff: false, + }); + }, + { + response: { + 200: t.Array(TicketParticipantSchema), + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + tags: ["tickets"], + }, + ) + .get( + "/comments", + ({ ticket, actor }) => { + const comments = ticket.comments ?? []; + return sanitiseTicketComments(comments, { + isModerator: !!actor.moderator, + anonymiseStaff: false, + }); + }, + { + response: { + 200: t.Array(TicketCommentSchema), + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + tags: ["tickets"], + }, + ) + .get( + "/attachments", + ({ ticket, actor }) => { + const attachments = ticket.attachments ?? []; + return sanitiseTicketAttachments(attachments, { + isModerator: !!actor.moderator, + anonymiseStaff: false, + }); + }, + { + response: { + 200: t.Array(TicketAttachmentSchema), + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + tags: ["tickets"], + }, + ), ), );