add:type assigner

This commit is contained in:
2026-03-09 16:14:25 +09:00
parent 8a3c10e785
commit 2bfd94e358
15 changed files with 715 additions and 98 deletions

View File

@@ -1,4 +1,4 @@
import { eq } from 'drizzle-orm'
import { eq, count } from 'drizzle-orm'
import { db } from '../db/index.ts'
import { tickets } from '../db/schema.ts'
import type { StorageAdapter, Ticket, TicketType } from '../types.ts'
@@ -14,6 +14,14 @@ export class SQLiteAdapter implements StorageAdapter {
return rows[0] ? toTicket(rows[0]) : null
}
async countTicketsByUser(userId: string): Promise<number> {
const result = await db
.select({ count: count() })
.from(tickets)
.where(eq(tickets.userId, userId))
return result[0]?.count ?? 0
}
async createTicket(
data: Pick<Ticket, 'subject' | 'description' | 'type'> & { userId?: string }
): Promise<Ticket> {

View File

@@ -1,6 +1,6 @@
import { Database } from 'bun:sqlite'
import { drizzle } from 'drizzle-orm/bun-sqlite'
import { tickets } from './schema.ts'
import { tickets, users, sessions } from './schema.ts'
const sqlite = new Database('app.db')
export const db = drizzle(sqlite, { schema: { tickets } })
export const db = drizzle(sqlite, { schema: { tickets, users, sessions } })

View File

@@ -1,18 +1,20 @@
import { int, text, sqliteTable } from "drizzle-orm/sqlite-core";
export const users = sqliteTable("users", {
id: text("id").primaryKey(), // internal UUID
googleId: text("googleId").notNull().unique(), // Google's `sub` claim
username: text("username").notNull().unique(), // generated: "silent-crimson-falcon"
avatarUrl: text("avatarUrl"), // Google profile picture
createdAt: text("createdAt").notNull(),
});
export const tickets = sqliteTable("tickets", {
id: text("id").primaryKey(),
userId: text("userId").references(() => users.id, { onDelete: "set null" }),
subject: text("subject").notNull(),
description: text("description").notNull().default(""),
type: text("type", {
enum: [
"bug",
"billing",
"account",
"feature-request",
"feedback",
"other",
],
enum: ["bug", "billing", "account", "feature-request", "feedback", "other"],
})
.notNull()
.default("other"),
@@ -24,16 +26,8 @@ export const tickets = sqliteTable("tickets", {
createdAt: text("createdAt").notNull(),
});
export const users = sqliteTable('users', {
id: text('id').primaryKey(), // internal UUID
googleId: text('googleId').notNull().unique(), // Google's `sub` claim
username: text('username').notNull().unique(), // generated: "silent-crimson-falcon"
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
})
export const sessions = sqliteTable("sessions", {
id: text("id").primaryKey(),
data: text("data").notNull(),
expiresAt: int("expires_at").notNull(), // unix ms
});

View File

@@ -1,4 +1,3 @@
import fp from "fastify-plugin";
import type { FastifyPluginAsync } from "fastify";
import { OAuth2Client } from "google-auth-library";
import {

View File

@@ -1,5 +1,6 @@
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify";
import type { Ticket, TicketType } from "../types.ts";
import { TICKET_LIMIT } from "../types.ts";
async function requireAuth(req: FastifyRequest, reply: FastifyReply) {
if (!req.isAuthenticated) {
@@ -8,6 +9,11 @@ async function requireAuth(req: FastifyRequest, reply: FastifyReply) {
}
export const ticketsRouter: FastifyPluginAsync = async (app) => {
// GET /api/tickets/all — admin view, returns all tickets in the system
app.get("/all", { preHandler: requireAuth }, async (req) => {
return req.storage.getTickets();
});
// GET /api/tickets
app.get("/", { preHandler: requireAuth }, async (req) => {
return req.storage.getTickets();
@@ -32,6 +38,20 @@ export const ticketsRouter: FastifyPluginAsync = async (app) => {
if (!subject?.trim()) {
return reply.status(400).send({ error: "subject is required" });
}
// Enforce per-user ticket limit
if (req.user?.id) {
const userTicketCount = await req.storage.countTicketsByUser(req.user.id);
if (userTicketCount >= TICKET_LIMIT) {
return reply.status(429).send({
error: "ticket_limit_reached",
message: `You have reached the maximum of ${TICKET_LIMIT} support tickets. Please delete some from the admin view before creating new ones.`,
limit: TICKET_LIMIT,
current: userTicketCount,
});
}
}
const ticket = await req.storage.createTicket({
subject: subject.trim(),
description,

View File

@@ -24,9 +24,12 @@ export interface Ticket {
createdAt: string
}
export const TICKET_LIMIT = 3
export interface StorageAdapter {
getTickets(): Promise<Ticket[]>
getTicket(id: string): Promise<Ticket | null>
countTicketsByUser(userId: string): Promise<number>
createTicket(data: Pick<Ticket, 'subject' | 'description' | 'type'> & { userId?: string }): Promise<Ticket>
updateTicket(id: string, patch: Partial<Ticket>): Promise<Ticket | null>
deleteTicket(id: string): Promise<void>