add:table filters

This commit is contained in:
2026-03-09 17:24:00 +09:00
parent 794fbad9bb
commit 63fea501a1
9 changed files with 591 additions and 77 deletions

View File

@@ -1,62 +1,147 @@
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'
import { eq, count, desc, and, type SQL } from "drizzle-orm";
import { db } from "../db/index.ts";
import { tickets, users } from "../db/schema.ts";
import type {
StorageAdapter,
Ticket,
TicketType,
PaginatedTickets,
TicketFilters,
} from "../types.ts";
// Explicit column selection shared by all ticket queries
const ticketSelect = {
id: tickets.id,
userId: tickets.userId,
subject: tickets.subject,
description: tickets.description,
type: tickets.type,
status: tickets.status,
createdAt: tickets.createdAt,
username: users.username,
};
// Let TypeScript infer the row type directly from the select shape
type TicketRow = {
id: string;
userId: string | null;
subject: string;
description: string;
type: string;
status: string;
createdAt: string;
username: string | null;
};
export class SQLiteAdapter implements StorageAdapter {
async getTickets(): Promise<Ticket[]> {
const rows = await db.select().from(tickets).orderBy(tickets.createdAt)
return rows.map(toTicket).reverse()
const rows = await db
.select(ticketSelect)
.from(tickets)
.leftJoin(users, eq(tickets.userId, users.id))
.orderBy(desc(tickets.createdAt));
return rows.map(toTicket);
}
async getTicketsByUser(userId: string): Promise<Ticket[]> {
const rows = await db
.select(ticketSelect)
.from(tickets)
.leftJoin(users, eq(tickets.userId, users.id))
.where(eq(tickets.userId, userId))
.orderBy(desc(tickets.createdAt));
return rows.map(toTicket);
}
async getTicketsPaginated(
limit: number,
offset: number,
filters: TicketFilters = {},
): Promise<PaginatedTickets> {
const conditions: SQL[] = [];
if (filters.status) conditions.push(eq(tickets.status, filters.status));
if (filters.type) conditions.push(eq(tickets.type, filters.type));
if (filters.userId) conditions.push(eq(tickets.userId, filters.userId));
const where = conditions.length > 0 ? and(...conditions) : undefined;
const [rows, totalResult] = await Promise.all([
db
.select(ticketSelect)
.from(tickets)
.leftJoin(users, eq(tickets.userId, users.id))
.where(where)
.orderBy(desc(tickets.createdAt))
.limit(limit)
.offset(offset),
db.select({ count: count() }).from(tickets).where(where),
]);
return {
data: rows.map(toTicket),
total: totalResult[0]?.count ?? 0,
};
}
async getTicket(id: string): Promise<Ticket | null> {
const rows = await db.select().from(tickets).where(eq(tickets.id, id))
return rows[0] ? toTicket(rows[0]) : null
const rows = await db
.select(ticketSelect)
.from(tickets)
.leftJoin(users, eq(tickets.userId, users.id))
.where(eq(tickets.id, id));
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
.where(eq(tickets.userId, userId));
return result[0]?.count ?? 0;
}
async createTicket(
data: Pick<Ticket, 'subject' | 'description' | 'type'> & { userId?: string }
data: Pick<Ticket, "subject" | "description" | "type"> & {
userId?: string;
},
): Promise<Ticket> {
const id = crypto.randomUUID()
const now = new Date().toISOString()
const id = crypto.randomUUID();
const now = new Date().toISOString();
await db.insert(tickets).values({
id,
userId: data.userId ?? null,
subject: data.subject,
description: data.description,
type: data.type,
status: 'open',
status: "open",
createdAt: now,
})
return (await this.getTicket(id))!
});
return (await this.getTicket(id))!;
}
async updateTicket(id: string, patch: Partial<Ticket>): Promise<Ticket | null> {
await db.update(tickets).set(patch).where(eq(tickets.id, id))
return this.getTicket(id)
async updateTicket(
id: string,
patch: Partial<Ticket>,
): Promise<Ticket | null> {
// Strip username — it's a derived field from the join, not a column
const { username: _, ...columnPatch } = patch as Ticket;
await db.update(tickets).set(columnPatch).where(eq(tickets.id, id));
return this.getTicket(id);
}
async deleteTicket(id: string): Promise<void> {
await db.delete(tickets).where(eq(tickets.id, id))
await db.delete(tickets).where(eq(tickets.id, id));
}
}
function toTicket(row: typeof tickets.$inferSelect): Ticket {
function toTicket(row: TicketRow): Ticket {
return {
id: row.id,
userId: row.userId,
username: row.username ?? null,
subject: row.subject,
description: row.description,
type: row.type as TicketType,
status: row.status as Ticket['status'],
status: row.status as Ticket["status"],
createdAt: row.createdAt,
}
};
}

View File

@@ -2,6 +2,8 @@ import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify";
import type { Ticket, TicketType } from "../types.ts";
import { TICKET_LIMIT } from "../types.ts";
const PAGE_SIZE = 20;
async function requireAuth(req: FastifyRequest, reply: FastifyReply) {
if (!req.isAuthenticated) {
return reply.status(401).send({ error: "Unauthorized" });
@@ -9,14 +11,42 @@ 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/all — admin view, paginated with optional filters
app.get<{
Querystring: {
page?: string;
status?: Ticket["status"];
type?: TicketType;
mine?: string; // "true" to restrict to the requesting user's tickets
};
}>("/all", { preHandler: requireAuth }, async (req) => {
const page = Math.max(1, parseInt(req.query.page ?? "1", 10) || 1);
const offset = (page - 1) * PAGE_SIZE;
const filters = {
status: req.query.status,
type: req.query.type,
// If mine=true, scope to the current user's tickets
userId: req.query.mine === "true" ? req.user!.id : undefined,
};
const result = await req.storage.getTicketsPaginated(
PAGE_SIZE,
offset,
filters,
);
return {
data: result.data,
total: result.total,
page,
pageSize: PAGE_SIZE,
totalPages: Math.max(1, Math.ceil(result.total / PAGE_SIZE)),
};
});
// GET /api/tickets
// GET /api/tickets — returns only the current user's tickets
app.get("/", { preHandler: requireAuth }, async (req) => {
return req.storage.getTickets();
return req.storage.getTicketsByUser(req.user!.id);
});
// GET /api/tickets/:id
@@ -61,21 +91,31 @@ export const ticketsRouter: FastifyPluginAsync = async (app) => {
return reply.status(201).send(ticket);
});
// PATCH /api/tickets/:id
// PATCH /api/tickets/:id — user may only update their own tickets
app.patch<{
Params: { id: string };
Body: Partial<Ticket>;
}>("/:id", { preHandler: requireAuth }, async (req, reply) => {
const existing = await req.storage.getTicket(req.params.id);
if (!existing) return reply.status(404).send({ error: "Not found" });
if (existing.userId !== req.user!.id) {
return reply.status(403).send({ error: "Forbidden" });
}
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
// DELETE /api/tickets/:id — user may only delete their own tickets
app.delete<{ Params: { id: string } }>(
"/:id",
{ preHandler: requireAuth },
async (req, reply) => {
const existing = await req.storage.getTicket(req.params.id);
if (!existing) return reply.status(404).send({ error: "Not found" });
if (existing.userId !== req.user!.id) {
return reply.status(403).send({ error: "Forbidden" });
}
await req.storage.deleteTicket(req.params.id);
return reply.status(204).send();
},

View File

@@ -17,6 +17,7 @@ export type TicketType =
export interface Ticket {
id: string
userId: string | null
username: string | null
subject: string
description: string
type: TicketType
@@ -26,8 +27,21 @@ export interface Ticket {
export const TICKET_LIMIT = 3
export interface TicketFilters {
status?: Ticket['status']
type?: TicketType
userId?: string
}
export interface PaginatedTickets {
data: Ticket[]
total: number
}
export interface StorageAdapter {
getTickets(): Promise<Ticket[]>
getTicketsByUser(userId: string): Promise<Ticket[]>
getTicketsPaginated(limit: number, offset: number, filters?: TicketFilters): Promise<PaginatedTickets>
getTicket(id: string): Promise<Ticket | null>
countTicketsByUser(userId: string): Promise<number>
createTicket(data: Pick<Ticket, 'subject' | 'description' | 'type'> & { userId?: string }): Promise<Ticket>