add:oauth
This commit is contained in:
16
backend/drizzle/0003_bitter_ink.sql
Normal file
16
backend/drizzle/0003_bitter_ink.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE `sessions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`data` text NOT NULL,
|
||||
`expires_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `users` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`googleId` text NOT NULL,
|
||||
`username` text NOT NULL,
|
||||
`avatarUrl` text,
|
||||
`createdAt` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `users_googleId_unique` ON `users` (`googleId`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
|
||||
164
backend/drizzle/meta/0003_snapshot.json
Normal file
164
backend/drizzle/meta/0003_snapshot.json
Normal file
@@ -0,0 +1,164 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "16b0dd57-a0de-4237-813b-455b4b8c0157",
|
||||
"prevId": "ad5d265f-896b-4cc1-b477-d00583523a0b",
|
||||
"tables": {
|
||||
"sessions": {
|
||||
"name": "sessions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"data": {
|
||||
"name": "data",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tickets": {
|
||||
"name": "tickets",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"subject": {
|
||||
"name": "subject",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'other'"
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'open'"
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"googleId": {
|
||||
"name": "googleId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"avatarUrl": {
|
||||
"name": "avatarUrl",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_googleId_unique": {
|
||||
"name": "users_googleId_unique",
|
||||
"columns": [
|
||||
"googleId"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"users_username_unique": {
|
||||
"name": "users_username_unique",
|
||||
"columns": [
|
||||
"username"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,13 @@
|
||||
"when": 1772964242556,
|
||||
"tag": "0002_careful_micromax",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1773034525409,
|
||||
"tag": "0003_bitter_ink",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "latest",
|
||||
"@fastify/csrf-protection": "^7.1.0",
|
||||
"@fastify/session": "^11.1.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"fastify": "latest",
|
||||
|
||||
@@ -31,3 +31,9 @@ export const users = sqliteTable('users', {
|
||||
avatarUrl: text('avatarUrl'), // Google profile picture
|
||||
createdAt: text('createdAt').notNull(),
|
||||
})
|
||||
|
||||
export const sessions = sqliteTable('sessions', {
|
||||
id: text('id').primaryKey(),
|
||||
data: text('data').notNull(),
|
||||
expiresAt: int('expires_at').notNull(), // unix ms
|
||||
})
|
||||
|
||||
70
backend/src/db/sessionStore.ts
Normal file
70
backend/src/db/sessionStore.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { Session } from 'fastify'
|
||||
import { eq, lt } from 'drizzle-orm'
|
||||
import { db } from './index.js'
|
||||
import { sessions } from './schema.js'
|
||||
|
||||
type Callback = (err?: any) => void
|
||||
type CallbackSession = (err: any, result?: Session | null) => void
|
||||
|
||||
export class SqliteSessionStore {
|
||||
private prune() {
|
||||
db.delete(sessions).where(lt(sessions.expiresAt, Date.now())).run()
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.prune()
|
||||
setInterval(() => this.prune(), 60 * 60 * 1000).unref()
|
||||
}
|
||||
|
||||
get(sessionId: string, callback: CallbackSession): void {
|
||||
try {
|
||||
const row = db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.id, sessionId))
|
||||
.get()
|
||||
|
||||
if (!row) return callback(null, null)
|
||||
|
||||
if (row.expiresAt < Date.now()) {
|
||||
this.destroy(sessionId, () => {})
|
||||
return callback(null, null)
|
||||
}
|
||||
|
||||
callback(null, JSON.parse(row.data) as Session)
|
||||
} catch (err) {
|
||||
callback(err)
|
||||
}
|
||||
}
|
||||
|
||||
set(sessionId: string, session: Session, callback: Callback): void {
|
||||
try {
|
||||
const expiresAt =
|
||||
session.cookie.expires instanceof Date
|
||||
? session.cookie.expires.getTime()
|
||||
: Date.now() + (session.cookie.originalMaxAge ?? 7 * 24 * 60 * 60 * 1000)
|
||||
|
||||
db
|
||||
.insert(sessions)
|
||||
.values({ id: sessionId, data: JSON.stringify(session), expiresAt })
|
||||
.onConflictDoUpdate({
|
||||
target: sessions.id,
|
||||
set: { data: JSON.stringify(session), expiresAt },
|
||||
})
|
||||
.run()
|
||||
|
||||
callback()
|
||||
} catch (err) {
|
||||
callback(err)
|
||||
}
|
||||
}
|
||||
|
||||
destroy(sessionId: string, callback: Callback): void {
|
||||
try {
|
||||
db.delete(sessions).where(eq(sessions.id, sessionId)).run()
|
||||
callback()
|
||||
} catch (err) {
|
||||
callback(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,17 @@ import Fastify from 'fastify'
|
||||
import cors from '@fastify/cors'
|
||||
import cookie from '@fastify/cookie'
|
||||
import session from '@fastify/session'
|
||||
import { authMiddleware } from './middleware/auth.ts'
|
||||
import { storageMiddleware } from './middleware/storage.ts'
|
||||
import { storageModeRouter } from './routes/storageMode.ts'
|
||||
import { ticketsRouter } from './routes/tickets.ts'
|
||||
import { authRouter } from './routes/auth.ts'
|
||||
import csrf from '@fastify/csrf-protection'
|
||||
|
||||
import { authMiddleware } from './middleware/auth.js'
|
||||
import { storageMiddleware } from './middleware/storage.js'
|
||||
import { ticketsRouter } from './routes/tickets.js'
|
||||
import { authRouter } from './routes/auth.js'
|
||||
import { SqliteSessionStore } from './db/sessionStore.js'
|
||||
|
||||
const isProd = process.env.NODE_ENV === 'production'
|
||||
|
||||
const app = Fastify({ logger: true })
|
||||
const PORT = Number(process.env.PORT) || 4500
|
||||
|
||||
await app.register(cors, {
|
||||
origin: process.env.FRONTEND_URL ?? 'http://localhost:5173',
|
||||
@@ -19,20 +22,27 @@ await app.register(cors, {
|
||||
await app.register(cookie)
|
||||
|
||||
await app.register(session, {
|
||||
secret: process.env.SESSION_SECRET ?? 'dev-secret-change-in-production-min-32-chars!!',
|
||||
secret: process.env.SESSION_SECRET!,
|
||||
store: new SqliteSessionStore(), // ← persistent SQLite store
|
||||
cookie: {
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
httpOnly: true,
|
||||
maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
|
||||
secure: isProd, // HTTPS-only in production
|
||||
sameSite: isProd ? 'strict' : 'lax', // strict in prod, lax in dev
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days in ms
|
||||
},
|
||||
saveUninitialized: false,
|
||||
})
|
||||
|
||||
if (isProd) {
|
||||
await app.register(csrf, {
|
||||
sessionPlugin: '@fastify/session',
|
||||
})
|
||||
}
|
||||
|
||||
await app.register(authMiddleware)
|
||||
await app.register(storageMiddleware)
|
||||
|
||||
await app.register(storageModeRouter, { prefix: '/api/storage-mode' })
|
||||
await app.register(ticketsRouter, { prefix: '/api/tickets' })
|
||||
await app.register(authRouter, { prefix: '/api/auth' })
|
||||
await app.register(authRouter, { prefix: '/api/auth' })
|
||||
await app.register(ticketsRouter, { prefix: '/api/tickets' })
|
||||
|
||||
await app.listen({ port: PORT })
|
||||
console.log(`Backend running on http://localhost:${PORT}`)
|
||||
await app.listen({ port: 4500, host: 'localhost' })
|
||||
|
||||
@@ -1,133 +1,110 @@
|
||||
import fp from "fastify-plugin";
|
||||
import type { FastifyPluginAsync } from "fastify";
|
||||
import { OAuth2Client } from "google-auth-library";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
uniqueNamesGenerator,
|
||||
adjectives,
|
||||
colors,
|
||||
animals,
|
||||
} from "unique-names-generator";
|
||||
import { db } from "../db/index.ts";
|
||||
import { users } from "../db/schema.ts";
|
||||
import type { User } from "../types.ts";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../db/index.js";
|
||||
import { users } from "../db/schema.js";
|
||||
|
||||
const CLIENT_ID = process.env.GOOGLE_CLIENT_ID ?? "";
|
||||
const CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET ?? "";
|
||||
const REDIRECT_URI =
|
||||
process.env.GOOGLE_REDIRECT_URI ?? "http://localhost:4500/api/auth/callback";
|
||||
const FRONTEND_URL = process.env.FRONTEND_URL ?? "http://localhost:5173";
|
||||
const client = new OAuth2Client(
|
||||
process.env.GOOGLE_CLIENT_ID,
|
||||
process.env.GOOGLE_CLIENT_SECRET,
|
||||
process.env.GOOGLE_REDIRECT_URI,
|
||||
);
|
||||
|
||||
const oauthClient = new OAuth2Client(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI);
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
|
||||
function generateUsername(): string {
|
||||
return uniqueNamesGenerator({
|
||||
dictionaries: [adjectives, animals],
|
||||
separator: " ",
|
||||
length: 3,
|
||||
});
|
||||
}
|
||||
|
||||
export const authRouter: FastifyPluginAsync = async (app) => {
|
||||
// ── Step 1: Redirect to Google ──────────────────────────────
|
||||
app.get("/google", async (_req, reply) => {
|
||||
const url = oauthClient.generateAuthUrl({
|
||||
access_type: "online",
|
||||
// Only request openid + profile — no email scope
|
||||
export const authRouter: FastifyPluginAsync = async (fastify) => {
|
||||
// GET /api/auth/google — redirect to Google consent screen
|
||||
fastify.get("/google", async (_req, reply) => {
|
||||
const url = client.generateAuthUrl({
|
||||
access_type: "offline",
|
||||
scope: ["openid", "profile"],
|
||||
prompt: "select_account",
|
||||
});
|
||||
return reply.redirect(url);
|
||||
reply.redirect(url);
|
||||
});
|
||||
|
||||
// ── Step 2: Google redirects back here ──────────────────────
|
||||
app.get<{ Querystring: { code?: string; error?: string } }>(
|
||||
// GET /api/auth/callback — exchange code, upsert user, set session
|
||||
fastify.get<{ Querystring: { code?: string } }>(
|
||||
"/callback",
|
||||
async (req, reply) => {
|
||||
const { code, error } = req.query;
|
||||
const { code } = req.query;
|
||||
if (!code) return reply.status(400).send({ error: "Missing code" });
|
||||
|
||||
if (error || !code) {
|
||||
return reply.redirect(`${FRONTEND_URL}?error=oauth_denied`);
|
||||
}
|
||||
const { tokens } = await client.getToken(code);
|
||||
client.setCredentials(tokens);
|
||||
|
||||
try {
|
||||
const { tokens } = await oauthClient.getToken(code);
|
||||
oauthClient.setCredentials(tokens);
|
||||
const ticket = await client.verifyIdToken({
|
||||
idToken: tokens.id_token!,
|
||||
audience: process.env.GOOGLE_CLIENT_ID,
|
||||
});
|
||||
const payload = ticket.getPayload()!;
|
||||
|
||||
const ticket = await oauthClient.verifyIdToken({
|
||||
idToken: tokens.id_token!,
|
||||
audience: CLIENT_ID,
|
||||
// Look up existing user by Google ID
|
||||
let user = db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.googleId, payload.sub))
|
||||
.get();
|
||||
|
||||
if (!user) {
|
||||
let username = uniqueNamesGenerator({
|
||||
dictionaries: [adjectives, animals],
|
||||
separator: " ",
|
||||
length: 2,
|
||||
});
|
||||
const payload = ticket.getPayload();
|
||||
if (!payload?.sub) {
|
||||
return reply.redirect(`${FRONTEND_URL}?error=invalid_token`);
|
||||
}
|
||||
|
||||
const googleId = payload.sub;
|
||||
const avatarUrl = payload.picture ?? null;
|
||||
|
||||
// Find or create user
|
||||
const existing = await db
|
||||
.select()
|
||||
// Collision retry — append a random suffix if username is taken
|
||||
const existing = db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.googleId, googleId))
|
||||
.limit(1);
|
||||
.where(eq(users.username, username))
|
||||
.get();
|
||||
if (existing) username += `-${Math.floor(Math.random() * 1000)}`;
|
||||
|
||||
let user: User;
|
||||
const newUser = {
|
||||
id: crypto.randomUUID(),
|
||||
googleId: payload.sub,
|
||||
username,
|
||||
avatarUrl: payload.picture ?? null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (existing[0]) {
|
||||
// Returning user — refresh avatar
|
||||
await db
|
||||
.update(users)
|
||||
.set({ avatarUrl })
|
||||
.where(eq(users.googleId, googleId));
|
||||
user = {
|
||||
id: existing[0].id,
|
||||
googleId: existing[0].googleId,
|
||||
username: existing[0].username,
|
||||
avatarUrl: avatarUrl ?? existing[0].avatarUrl,
|
||||
createdAt: existing[0].createdAt,
|
||||
};
|
||||
} else {
|
||||
// New user — generate readable username
|
||||
let username = generateUsername();
|
||||
// Retry on collision (very rare)
|
||||
const collision = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.username, username))
|
||||
.limit(1);
|
||||
if (collision[0]) {
|
||||
username = `${generateUsername()}-${Math.floor(Math.random() * 999)}`;
|
||||
}
|
||||
db.insert(users).values(newUser).run();
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
await db
|
||||
.insert(users)
|
||||
.values({ id, googleId, username, avatarUrl, createdAt: now });
|
||||
user = { id, googleId, username, avatarUrl, createdAt: now };
|
||||
}
|
||||
|
||||
req.session.user = user;
|
||||
return reply.redirect(FRONTEND_URL);
|
||||
} catch (err) {
|
||||
app.log.error(err, "OAuth callback error");
|
||||
return reply.redirect(`${FRONTEND_URL}?error=server_error`);
|
||||
user = newUser;
|
||||
}
|
||||
|
||||
req.session.user = user;
|
||||
reply.redirect(process.env.FRONTEND_URL ?? "http://localhost:5173");
|
||||
},
|
||||
);
|
||||
|
||||
// /api/auth/me
|
||||
app.get("/me", async (req, reply) => {
|
||||
if (!req.isAuthenticated || !req.user) {
|
||||
return reply.status(401).send({ user: null });
|
||||
}
|
||||
return reply.send({ user: req.user });
|
||||
// GET /api/auth/me — return current session user
|
||||
fastify.get("/me", async (req, reply) => {
|
||||
if (!req.isAuthenticated)
|
||||
return reply.status(401).send({ error: "Unauthenticated" });
|
||||
reply.send(req.user);
|
||||
});
|
||||
|
||||
// /api/auth/logout
|
||||
app.post("/logout", async (req, reply) => {
|
||||
// POST /api/auth/logout
|
||||
fastify.post("/logout", async (req, reply) => {
|
||||
await req.session.destroy();
|
||||
return reply.send({ ok: true });
|
||||
reply.send({ ok: true });
|
||||
});
|
||||
|
||||
// GET /api/auth/csrf-token — used by the frontend to get a CSRF token
|
||||
// before making state-mutating requests in production.
|
||||
// In dev this route still exists but returns a no-op token.
|
||||
fastify.get("/csrf-token", async (req, reply) => {
|
||||
if (isProd) {
|
||||
const token = reply.generateCsrf();
|
||||
return reply.send({ token });
|
||||
}
|
||||
return reply.send({ token: null });
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
|
||||
export const storageModeRouter: FastifyPluginAsync = async (app) => {
|
||||
app.get('/', async (req, reply) => {
|
||||
if (!req.isAuthenticated) {
|
||||
return reply.status(401).send({ storageMode: 'local' })
|
||||
}
|
||||
return reply.status(200).send({ storageMode: 'remote' })
|
||||
})
|
||||
}
|
||||
@@ -1,30 +1,63 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import type { Ticket } from '../types.ts'
|
||||
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify";
|
||||
import type { Ticket, TicketType } from "../types.ts";
|
||||
|
||||
export const ticketsRouter: FastifyPluginAsync = async (app) => {
|
||||
app.get('/', async (req) => req.storage.getTickets())
|
||||
|
||||
app.get<{ Params: { id: string } }>('/:id', async (req, reply) => {
|
||||
const ticket = req.storage.getTicket(req.params.id)
|
||||
if (!ticket) return reply.status(404).send({ error: 'Not found' })
|
||||
return ticket
|
||||
})
|
||||
|
||||
app.post<{ Body: Pick<Ticket, 'subject' | 'description'> }>('/', async (req, reply) => {
|
||||
const { subject, description } = req.body
|
||||
if (!subject?.trim()) return reply.status(400).send({ error: 'subject is required' })
|
||||
return reply.status(201).send(req.storage.createTicket({ subject, description }))
|
||||
})
|
||||
|
||||
app.patch<{ Params: { id: string }; Body: Partial<Ticket> }>('/:id', async (req, reply) => {
|
||||
const ticket = req.storage.updateTicket(req.params.id, req.body)
|
||||
if (!ticket) return reply.status(404).send({ error: 'Not found' })
|
||||
return ticket
|
||||
})
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/:id', async (req, reply) => {
|
||||
req.storage.deleteTicket(req.params.id)
|
||||
return reply.status(204).send()
|
||||
})
|
||||
async function requireAuth(req: FastifyRequest, reply: FastifyReply) {
|
||||
if (!req.isAuthenticated) {
|
||||
return reply.status(401).send({ error: "Unauthorized" });
|
||||
}
|
||||
}
|
||||
|
||||
export const ticketsRouter: FastifyPluginAsync = async (app) => {
|
||||
// GET /api/tickets
|
||||
app.get("/", { preHandler: requireAuth }, async (req) => {
|
||||
return req.storage.getTickets();
|
||||
});
|
||||
|
||||
// GET /api/tickets/:id
|
||||
app.get<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requireAuth },
|
||||
async (req, reply) => {
|
||||
const ticket = await req.storage.getTicket(req.params.id);
|
||||
if (!ticket) return reply.status(404).send({ error: "Not found" });
|
||||
return ticket;
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/tickets
|
||||
app.post<{
|
||||
Body: { subject: string; description?: string; type?: TicketType };
|
||||
}>("/", { preHandler: requireAuth }, async (req, reply) => {
|
||||
const { subject, description = "", type = "other" } = req.body;
|
||||
if (!subject?.trim()) {
|
||||
return reply.status(400).send({ error: "subject is required" });
|
||||
}
|
||||
const ticket = await req.storage.createTicket({
|
||||
subject: subject.trim(),
|
||||
description,
|
||||
type,
|
||||
userId: req.user?.id,
|
||||
});
|
||||
return reply.status(201).send(ticket);
|
||||
});
|
||||
|
||||
// PATCH /api/tickets/:id
|
||||
app.patch<{
|
||||
Params: { id: string };
|
||||
Body: Partial<Ticket>;
|
||||
}>("/:id", { preHandler: requireAuth }, async (req, reply) => {
|
||||
const ticket = await req.storage.updateTicket(req.params.id, req.body);
|
||||
if (!ticket) return reply.status(404).send({ error: "Not found" });
|
||||
return ticket;
|
||||
});
|
||||
|
||||
// DELETE /api/tickets/:id
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requireAuth },
|
||||
async (req, reply) => {
|
||||
await req.storage.deleteTicket(req.params.id);
|
||||
return reply.status(204).send();
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user