add:type assigner
This commit is contained in:
1
backend/drizzle/0004_orange_katie_power.sql
Normal file
1
backend/drizzle/0004_orange_katie_power.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `tickets` ADD `userId` text REFERENCES users(id);
|
||||
185
backend/drizzle/meta/0004_snapshot.json
Normal file
185
backend/drizzle/meta/0004_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import fp from "fastify-plugin";
|
||||
import type { FastifyPluginAsync } from "fastify";
|
||||
import { OAuth2Client } from "google-auth-library";
|
||||
import {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user