This commit is contained in:
kokopi
2026-03-08 19:40:53 +09:00
commit 16bc00632d
67 changed files with 2476 additions and 0 deletions

3
backend/src/README.md Normal file
View File

@@ -0,0 +1,3 @@
bun db:generate # generates SQL migration files in /drizzle
bun db:migrate # applies them to support.db
bun db:studio # opens a browser UI to inspect the DB

View File

@@ -0,0 +1,38 @@
import { eq } from 'drizzle-orm'
import { db } from '../db/index.ts'
import { tickets } from '../db/schema.ts'
import type { Ticket, StorageAdapter } from '../types.ts'
export class SQLiteAdapter implements StorageAdapter {
getTickets(): Ticket[] {
return db.select().from(tickets).all()
}
getTicket(id: string): Ticket | null {
return db.select().from(tickets).where(eq(tickets.id, id)).get() ?? null
}
createTicket({ subject, description, type }: Pick<Ticket, 'subject' | 'description' | 'type'>): Ticket {
const ticket: Ticket = {
id: crypto.randomUUID(),
subject,
description,
status: 'open',
type,
createdAt: new Date().toISOString(),
}
db.insert(tickets).values(ticket).run()
return ticket
}
updateTicket(id: string, patch: Partial<Ticket>): Ticket | null {
const current = this.getTicket(id)
if (!current) return null
db.update(tickets).set(patch).where(eq(tickets.id, id)).run()
return { ...current, ...patch }
}
deleteTicket(id: string): void {
db.delete(tickets).where(eq(tickets.id, id)).run()
}
}

6
backend/src/db/index.ts Normal file
View File

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

25
backend/src/db/schema.ts Normal file
View File

@@ -0,0 +1,25 @@
import { int, text, sqliteTable } from "drizzle-orm/sqlite-core";
export const tickets = sqliteTable("tickets", {
id: text("id").primaryKey(),
subject: text("subject").notNull(),
description: text("description").notNull().default(""),
type: text("type", {
enum: [
"bug",
"billing",
"account",
"feature-request",
"feedback",
"other",
],
})
.notNull()
.default("other"),
status: text("status", {
enum: ["open", "in-progress", "resolved", "closed"],
})
.notNull()
.default("open"),
createdAt: text("createdAt").notNull(),
});

19
backend/src/index.ts Normal file
View File

@@ -0,0 +1,19 @@
import Fastify from 'fastify'
import cors from '@fastify/cors'
import { authMiddleware } from './middleware/auth.ts'
import { storageMiddleware } from './middleware/storage.ts'
import { storageModeRouter } from './routes/storageMode.ts'
import { ticketsRouter } from './routes/tickets.ts'
const app = Fastify({ logger: true })
const PORT = Number(process.env.PORT) || 3000
await app.register(cors)
await app.register(authMiddleware)
await app.register(storageMiddleware)
await app.register(storageModeRouter, { prefix: '/api/storage-mode' })
await app.register(ticketsRouter, { prefix: '/api/tickets' })
app.listen({ port: PORT }, () => {
console.log(`Backend running on http://localhost:${PORT}`)
})

View File

@@ -0,0 +1,16 @@
import fp from 'fastify-plugin'
import type { FastifyPluginAsync } from 'fastify'
declare module 'fastify' {
interface FastifyRequest {
isAuthenticated: boolean
}
}
export const authMiddleware: FastifyPluginAsync = fp(async (app) => {
app.decorateRequest('isAuthenticated', false)
app.addHook('onRequest', async (req) => {
// hardcoded false — replace with real session/token check when auth is implemented
req.isAuthenticated = false
})
})

View File

@@ -0,0 +1,21 @@
import fp from 'fastify-plugin'
import type { FastifyPluginAsync } from 'fastify'
import { SQLiteAdapter } from '../adapters/sqlite.ts'
import type { StorageAdapter } from '../types.ts'
declare module 'fastify' {
interface FastifyRequest {
storage: StorageAdapter
}
}
const adapter: StorageAdapter = new SQLiteAdapter()
const plugin: FastifyPluginAsync = async (app) => {
app.decorateRequest('storage', null)
app.addHook('onRequest', async (req) => {
req.storage = adapter
})
}
export const storageMiddleware = fp(plugin)

View File

@@ -0,0 +1,10 @@
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' })
})
}

View File

@@ -0,0 +1,30 @@
import type { FastifyPluginAsync } from 'fastify'
import type { Ticket } 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()
})
}

24
backend/src/types.ts Normal file
View File

@@ -0,0 +1,24 @@
export type TicketType =
| "bug"
| "billing"
| "account"
| "feature-request"
| "feedback"
| "other";
export interface Ticket {
id: string;
subject: string;
description: string;
status: "open" | "in-progress" | "resolved" | "closed";
type: TicketType;
createdAt: string;
}
export interface StorageAdapter {
getTickets(): Ticket[];
getTicket(id: string): Ticket | null;
createTicket(data: Pick<Ticket, "subject" | "description" | "type">): Ticket;
updateTicket(id: string, patch: Partial<Ticket>): Ticket | null;
deleteTicket(id: string): void;
}