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

34
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

3
backend/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

10
backend/drizzle.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'sqlite',
dbCredentials: {
url: 'file:app.db',
},
})

View File

@@ -0,0 +1,7 @@
CREATE TABLE `tickets` (
`id` integer PRIMARY KEY NOT NULL,
`subject` text NOT NULL,
`description` text DEFAULT '' NOT NULL,
`status` text DEFAULT 'open' NOT NULL,
`createdAt` text NOT NULL
);

View File

@@ -0,0 +1,13 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_tickets` (
`id` text PRIMARY KEY NOT NULL,
`subject` text NOT NULL,
`description` text DEFAULT '' NOT NULL,
`status` text DEFAULT 'open' NOT NULL,
`createdAt` text NOT NULL
);
--> statement-breakpoint
INSERT INTO `__new_tickets`("id", "subject", "description", "status", "createdAt") SELECT "id", "subject", "description", "status", "createdAt" FROM `tickets`;--> statement-breakpoint
DROP TABLE `tickets`;--> statement-breakpoint
ALTER TABLE `__new_tickets` RENAME TO `tickets`;--> statement-breakpoint
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1 @@
ALTER TABLE `tickets` ADD `type` text DEFAULT 'other' NOT NULL;

View File

@@ -0,0 +1,65 @@
{
"version": "6",
"dialect": "sqlite",
"id": "87c08147-0081-4a13-874f-4874a3afff28",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"tickets": {
"name": "tickets",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"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": "''"
},
"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": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,65 @@
{
"version": "6",
"dialect": "sqlite",
"id": "a93a08da-ec35-4fb5-98fe-1713d5f9e147",
"prevId": "87c08147-0081-4a13-874f-4874a3afff28",
"tables": {
"tickets": {
"name": "tickets",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"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": "''"
},
"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": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,73 @@
{
"version": "6",
"dialect": "sqlite",
"id": "ad5d265f-896b-4cc1-b477-d00583523a0b",
"prevId": "a93a08da-ec35-4fb5-98fe-1713d5f9e147",
"tables": {
"tickets": {
"name": "tickets",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"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": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,27 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1772962978698,
"tag": "0000_absurd_centennial",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1772963819154,
"tag": "0001_graceful_sue_storm",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1772964242556,
"tag": "0002_careful_micromax",
"breakpoints": true
}
]
}

24
backend/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "personal-support-ticket-system-backend",
"type": "module",
"scripts": {
"dev": "bun --watch src/index.ts",
"build": "tsc",
"start": "bun dist/index.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@fastify/cors": "latest",
"drizzle-orm": "^0.45.1",
"fastify": "latest",
"fastify-plugin": "latest"
},
"devDependencies": {
"@libsql/client": "^0.17.0",
"@types/node": "^25.3.5",
"drizzle-kit": "^0.31.9",
"typescript": "^5.9.3"
}
}

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;
}

12
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"allowImportingTsExtensions": true,
"moduleResolution": "bundler",
"strict": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}