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

@@ -0,0 +1 @@
ALTER TABLE `tickets` ADD `userId` text REFERENCES users(id);

View File

@@ -0,0 +1,185 @@
{
"version": "6",
"dialect": "sqlite",
"id": "e6fe4296-6abf-4980-88a2-604afe8a49ba",
"prevId": "16b0dd57-a0de-4237-813b-455b4b8c0157",
"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
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": false,
"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": {
"tickets_userId_users_id_fk": {
"name": "tickets_userId_users_id_fk",
"tableFrom": "tickets",
"tableTo": "users",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"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": {}
}
}

View File

@@ -29,6 +29,13 @@
"when": 1773034525409,
"tag": "0003_bitter_ink",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1773040371683,
"tag": "0004_orange_katie_power",
"breakpoints": true
}
]
}

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>