init
This commit is contained in:
34
backend/.gitignore
vendored
Normal file
34
backend/.gitignore
vendored
Normal 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
3
backend/README.md
Normal 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
10
backend/drizzle.config.ts
Normal 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',
|
||||
},
|
||||
})
|
||||
7
backend/drizzle/0000_absurd_centennial.sql
Normal file
7
backend/drizzle/0000_absurd_centennial.sql
Normal 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
|
||||
);
|
||||
13
backend/drizzle/0001_graceful_sue_storm.sql
Normal file
13
backend/drizzle/0001_graceful_sue_storm.sql
Normal 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;
|
||||
1
backend/drizzle/0002_careful_micromax.sql
Normal file
1
backend/drizzle/0002_careful_micromax.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `tickets` ADD `type` text DEFAULT 'other' NOT NULL;
|
||||
65
backend/drizzle/meta/0000_snapshot.json
Normal file
65
backend/drizzle/meta/0000_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
65
backend/drizzle/meta/0001_snapshot.json
Normal file
65
backend/drizzle/meta/0001_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
73
backend/drizzle/meta/0002_snapshot.json
Normal file
73
backend/drizzle/meta/0002_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
27
backend/drizzle/meta/_journal.json
Normal file
27
backend/drizzle/meta/_journal.json
Normal 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
24
backend/package.json
Normal 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
3
backend/src/README.md
Normal 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
|
||||
38
backend/src/adapters/sqlite.ts
Normal file
38
backend/src/adapters/sqlite.ts
Normal 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
6
backend/src/db/index.ts
Normal 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
25
backend/src/db/schema.ts
Normal 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
19
backend/src/index.ts
Normal 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}`)
|
||||
})
|
||||
16
backend/src/middleware/auth.ts
Normal file
16
backend/src/middleware/auth.ts
Normal 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
|
||||
})
|
||||
})
|
||||
21
backend/src/middleware/storage.ts
Normal file
21
backend/src/middleware/storage.ts
Normal 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)
|
||||
10
backend/src/routes/storageMode.ts
Normal file
10
backend/src/routes/storageMode.ts
Normal 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' })
|
||||
})
|
||||
}
|
||||
30
backend/src/routes/tickets.ts
Normal file
30
backend/src/routes/tickets.ts
Normal 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
24
backend/src/types.ts
Normal 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
12
backend/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user