Initial commit: TicketingSystem
Some checks failed
Build & Push / Build Client (push) Failing after 9s
Build & Push / Build Server (push) Failing after 28s

Internal ticketing app with CTI routing, severity levels, and n8n integration.

Stack: Express + TypeScript + Prisma + PostgreSQL / React + Vite + Tailwind
- JWT auth for users, API key auth for service accounts (Goddard/n8n)
- CTI hierarchy (Category > Type > Item) for ticket routing
- Severity 1-5, auto-close resolved tickets after 14 days
- Gitea Actions CI/CD building separate server/client images
- Production docker-compose.yml with Traefik integration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-30 19:38:32 -04:00
commit 21894fad7a
50 changed files with 3293 additions and 0 deletions

4
server/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
.env
*.log

4
server/.env.example Normal file
View File

@@ -0,0 +1,4 @@
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/ticketing"
JWT_SECRET="change-this-to-a-long-random-secret"
CLIENT_URL="http://localhost:5173"
PORT=3000

15
server/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx prisma generate
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/prisma ./prisma
COPY package*.json ./
CMD ["npm", "run", "start:prod"]

35
server/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "ticketing-server",
"version": "1.0.0",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"start:prod": "prisma migrate deploy && node dist/index.js",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"db:generate": "prisma generate",
"db:seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@prisma/client": "^5.22.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-async-errors": "^3.1.0",
"jsonwebtoken": "^9.0.2",
"node-cron": "^3.0.3",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.10.0",
"prisma": "^5.22.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

100
server/prisma/schema.prisma Normal file
View File

@@ -0,0 +1,100 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
ADMIN
AGENT
SERVICE
}
enum TicketStatus {
OPEN
IN_PROGRESS
RESOLVED
CLOSED
}
model User {
id String @id @default(cuid())
username String @unique
email String @unique
passwordHash String
displayName String
role Role @default(AGENT)
apiKey String? @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
assignedTickets Ticket[] @relation("AssignedTickets")
createdTickets Ticket[] @relation("CreatedTickets")
comments Comment[]
}
model Category {
id String @id @default(cuid())
name String @unique
types Type[]
tickets Ticket[]
}
model Type {
id String @id @default(cuid())
name String
categoryId String
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
items Item[]
tickets Ticket[]
@@unique([categoryId, name])
}
model Item {
id String @id @default(cuid())
name String
typeId String
type Type @relation(fields: [typeId], references: [id], onDelete: Cascade)
tickets Ticket[]
@@unique([typeId, name])
}
model Ticket {
id String @id @default(cuid())
title String
overview String
severity Int
status TicketStatus @default(OPEN)
categoryId String
typeId String
itemId String
assigneeId String?
createdById String
resolvedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
category Category @relation(fields: [categoryId], references: [id])
type Type @relation(fields: [typeId], references: [id])
item Item @relation(fields: [itemId], references: [id])
assignee User? @relation("AssignedTickets", fields: [assigneeId], references: [id])
createdBy User @relation("CreatedTickets", fields: [createdById], references: [id])
comments Comment[]
}
model Comment {
id String @id @default(cuid())
body String
ticketId String
authorId String
createdAt DateTime @default(now())
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
author User @relation(fields: [authorId], references: [id])
}

102
server/prisma/seed.ts Normal file
View File

@@ -0,0 +1,102 @@
import { PrismaClient } from '@prisma/client'
import bcrypt from 'bcryptjs'
import crypto from 'crypto'
const prisma = new PrismaClient()
async function main() {
console.log('Seeding database...')
// Admin user
await prisma.user.upsert({
where: { username: 'admin' },
update: {},
create: {
username: 'admin',
email: 'admin@internal',
displayName: 'Admin',
passwordHash: await bcrypt.hash('admin123', 12),
role: 'ADMIN',
},
})
// Goddard — n8n service account
const apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`
const goddard = await prisma.user.upsert({
where: { username: 'goddard' },
update: {},
create: {
username: 'goddard',
email: 'goddard@internal',
displayName: 'Goddard',
passwordHash: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 12),
role: 'SERVICE',
apiKey,
},
})
const existingGoddard = await prisma.user.findUnique({ where: { username: 'goddard' } })
console.log(`\nGoddard API key: ${existingGoddard?.apiKey ?? apiKey}`)
console.log('(This key is only displayed once on first seed — copy it now)\n')
// Sample CTI structure
const theWrightServer = await prisma.category.upsert({
where: { name: 'TheWrightServer' },
update: {},
create: { name: 'TheWrightServer' },
})
const homelab = await prisma.category.upsert({
where: { name: 'Homelab' },
update: {},
create: { name: 'Homelab' },
})
const automation = await prisma.type.upsert({
where: { categoryId_name: { categoryId: theWrightServer.id, name: 'Automation' } },
update: {},
create: { name: 'Automation', categoryId: theWrightServer.id },
})
const media = await prisma.type.upsert({
where: { categoryId_name: { categoryId: theWrightServer.id, name: 'Media' } },
update: {},
create: { name: 'Media', categoryId: theWrightServer.id },
})
const infrastructure = await prisma.type.upsert({
where: { categoryId_name: { categoryId: homelab.id, name: 'Infrastructure' } },
update: {},
create: { name: 'Infrastructure', categoryId: homelab.id },
})
await prisma.item.upsert({
where: { typeId_name: { typeId: automation.id, name: 'Backup' } },
update: {},
create: { name: 'Backup', typeId: automation.id },
})
await prisma.item.upsert({
where: { typeId_name: { typeId: automation.id, name: 'Sync' } },
update: {},
create: { name: 'Sync', typeId: automation.id },
})
await prisma.item.upsert({
where: { typeId_name: { typeId: media.id, name: 'Plex' } },
update: {},
create: { name: 'Plex', typeId: media.id },
})
await prisma.item.upsert({
where: { typeId_name: { typeId: infrastructure.id, name: 'Proxmox' } },
update: {},
create: { name: 'Proxmox', typeId: infrastructure.id },
})
console.log('Seed complete.')
}
main()
.catch((e) => { console.error(e); process.exit(1) })
.finally(() => prisma.$disconnect())

41
server/src/index.ts Normal file
View File

@@ -0,0 +1,41 @@
import 'express-async-errors'
import express from 'express'
import cors from 'cors'
import dotenv from 'dotenv'
import authRoutes from './routes/auth'
import ticketRoutes from './routes/tickets'
import ctiRoutes from './routes/cti'
import userRoutes from './routes/users'
import { authenticate } from './middleware/auth'
import { errorHandler } from './middleware/errorHandler'
import { startAutoCloseJob } from './jobs/autoClose'
dotenv.config()
if (!process.env.JWT_SECRET) {
console.error('FATAL: JWT_SECRET is not set')
process.exit(1)
}
const app = express()
app.use(cors({ origin: process.env.CLIENT_URL || 'http://localhost:5173' }))
app.use(express.json())
// Public
app.use('/api/auth', authRoutes)
// Protected
app.use('/api/tickets', authenticate, ticketRoutes)
app.use('/api/cti', authenticate, ctiRoutes)
app.use('/api/users', authenticate, userRoutes)
app.use(errorHandler)
startAutoCloseJob()
const PORT = Number(process.env.PORT) || 3000
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`)
})

View File

@@ -0,0 +1,24 @@
import cron from 'node-cron'
import prisma from '../lib/prisma'
export function startAutoCloseJob() {
// Run every hour — closes RESOLVED tickets that have been resolved for 14+ days
cron.schedule('0 * * * *', async () => {
const twoWeeksAgo = new Date()
twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14)
const result = await prisma.ticket.updateMany({
where: {
status: 'RESOLVED',
resolvedAt: { lte: twoWeeksAgo },
},
data: { status: 'CLOSED' },
})
if (result.count > 0) {
console.log(`[AutoClose] Closed ${result.count} ticket(s) after 2-week resolution period`)
}
})
console.log('[AutoClose] Job scheduled — runs every hour')
}

5
server/src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,5 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export default prisma

View File

@@ -0,0 +1,57 @@
import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
import prisma from '../lib/prisma'
export interface AuthRequest extends Request {
user?: {
id: string
role: string
username: string
}
}
export const authenticate = async (
req: AuthRequest,
res: Response,
next: NextFunction
) => {
const apiKey = req.headers['x-api-key'] as string | undefined
if (apiKey) {
const user = await prisma.user.findUnique({ where: { apiKey } })
if (!user || user.role !== 'SERVICE') {
return res.status(401).json({ error: 'Invalid API key' })
}
req.user = { id: user.id, role: user.role, username: user.username }
return next()
}
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Unauthorized' })
}
const token = authHeader.split(' ')[1]
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as {
id: string
role: string
username: string
}
req.user = payload
next()
} catch {
return res.status(401).json({ error: 'Invalid or expired token' })
}
}
export const requireAdmin = (
req: AuthRequest,
res: Response,
next: NextFunction
) => {
if (req.user?.role !== 'ADMIN') {
return res.status(403).json({ error: 'Admin access required' })
}
next()
}

View File

@@ -0,0 +1,29 @@
import { Request, Response, NextFunction } from 'express'
import { ZodError } from 'zod'
export function errorHandler(
err: any,
req: Request,
res: Response,
next: NextFunction
) {
console.error(err)
if (err instanceof ZodError) {
return res.status(400).json({ error: 'Validation error', details: err.flatten() })
}
// Prisma unique constraint violation
if (err.code === 'P2002') {
return res.status(409).json({ error: 'A record with that value already exists' })
}
// Prisma record not found
if (err.code === 'P2025') {
return res.status(404).json({ error: 'Record not found' })
}
const status = err.status || err.statusCode || 500
const message = err.message || 'Internal server error'
res.status(status).json({ error: message })
}

54
server/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,54 @@
import { Router } from 'express'
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import { z } from 'zod'
import prisma from '../lib/prisma'
import { authenticate, AuthRequest } from '../middleware/auth'
const router = Router()
const loginSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
})
router.post('/login', async (req, res) => {
const { username, password } = loginSchema.parse(req.body)
const user = await prisma.user.findUnique({ where: { username } })
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return res.status(401).json({ error: 'Invalid credentials' })
}
if (user.role === 'SERVICE') {
return res.status(401).json({ error: 'Service accounts must authenticate via API key' })
}
const token = jwt.sign(
{ id: user.id, role: user.role, username: user.username },
process.env.JWT_SECRET!,
{ expiresIn: '24h' }
)
res.json({
token,
user: {
id: user.id,
username: user.username,
displayName: user.displayName,
email: user.email,
role: user.role,
},
})
})
router.get('/me', authenticate, async (req: AuthRequest, res) => {
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
select: { id: true, username: true, displayName: true, email: true, role: true },
})
if (!user) return res.status(404).json({ error: 'User not found' })
res.json(user)
})
export default router

View File

@@ -0,0 +1,49 @@
import { Router } from 'express'
import { z } from 'zod'
import prisma from '../lib/prisma'
import { AuthRequest } from '../middleware/auth'
// mergeParams: true so req.params.ticketId is accessible from the parent router
const router = Router({ mergeParams: true })
const commentSchema = z.object({
body: z.string().min(1),
})
router.post('/', async (req: AuthRequest, res) => {
const { body } = commentSchema.parse(req.body)
const ticket = await prisma.ticket.findUnique({
where: { id: (req.params as any).ticketId },
})
if (!ticket) return res.status(404).json({ error: 'Ticket not found' })
const comment = await prisma.comment.create({
data: {
body,
ticketId: (req.params as any).ticketId,
authorId: req.user!.id,
},
include: {
author: { select: { id: true, username: true, displayName: true } },
},
})
res.status(201).json(comment)
})
router.delete('/:commentId', async (req: AuthRequest, res) => {
const comment = await prisma.comment.findUnique({
where: { id: req.params.commentId },
})
if (!comment) return res.status(404).json({ error: 'Comment not found' })
if (comment.authorId !== req.user!.id && req.user!.role !== 'ADMIN') {
return res.status(403).json({ error: 'Not allowed' })
}
await prisma.comment.delete({ where: { id: req.params.commentId } })
res.status(204).send()
})
export default router

113
server/src/routes/cti.ts Normal file
View File

@@ -0,0 +1,113 @@
import { Router } from 'express'
import { z } from 'zod'
import prisma from '../lib/prisma'
import { requireAdmin } from '../middleware/auth'
const router = Router()
const nameSchema = z.object({ name: z.string().min(1).max(100) })
// ── Categories ────────────────────────────────────────────────────────────────
router.get('/categories', async (_req, res) => {
const categories = await prisma.category.findMany({ orderBy: { name: 'asc' } })
res.json(categories)
})
router.post('/categories', requireAdmin, async (req, res) => {
const { name } = nameSchema.parse(req.body)
const category = await prisma.category.create({ data: { name } })
res.status(201).json(category)
})
router.put('/categories/:id', requireAdmin, async (req, res) => {
const { name } = nameSchema.parse(req.body)
const category = await prisma.category.update({
where: { id: req.params.id },
data: { name },
})
res.json(category)
})
router.delete('/categories/:id', requireAdmin, async (req, res) => {
await prisma.category.delete({ where: { id: req.params.id } })
res.status(204).send()
})
// ── Types ─────────────────────────────────────────────────────────────────────
router.get('/types', async (req, res) => {
const { categoryId } = req.query
const types = await prisma.type.findMany({
where: categoryId ? { categoryId: categoryId as string } : undefined,
include: { category: true },
orderBy: { name: 'asc' },
})
res.json(types)
})
router.post('/types', requireAdmin, async (req, res) => {
const { name, categoryId } = z
.object({ name: z.string().min(1).max(100), categoryId: z.string().min(1) })
.parse(req.body)
const type = await prisma.type.create({
data: { name, categoryId },
include: { category: true },
})
res.status(201).json(type)
})
router.put('/types/:id', requireAdmin, async (req, res) => {
const { name } = nameSchema.parse(req.body)
const type = await prisma.type.update({
where: { id: req.params.id },
data: { name },
include: { category: true },
})
res.json(type)
})
router.delete('/types/:id', requireAdmin, async (req, res) => {
await prisma.type.delete({ where: { id: req.params.id } })
res.status(204).send()
})
// ── Items ─────────────────────────────────────────────────────────────────────
router.get('/items', async (req, res) => {
const { typeId } = req.query
const items = await prisma.item.findMany({
where: typeId ? { typeId: typeId as string } : undefined,
include: { type: { include: { category: true } } },
orderBy: { name: 'asc' },
})
res.json(items)
})
router.post('/items', requireAdmin, async (req, res) => {
const { name, typeId } = z
.object({ name: z.string().min(1).max(100), typeId: z.string().min(1) })
.parse(req.body)
const item = await prisma.item.create({
data: { name, typeId },
include: { type: { include: { category: true } } },
})
res.status(201).json(item)
})
router.put('/items/:id', requireAdmin, async (req, res) => {
const { name } = nameSchema.parse(req.body)
const item = await prisma.item.update({
where: { id: req.params.id },
data: { name },
include: { type: { include: { category: true } } },
})
res.json(item)
})
router.delete('/items/:id', requireAdmin, async (req, res) => {
await prisma.item.delete({ where: { id: req.params.id } })
res.status(204).send()
})
export default router

View File

@@ -0,0 +1,130 @@
import { Router } from 'express'
import { z } from 'zod'
import prisma from '../lib/prisma'
import { requireAdmin, AuthRequest } from '../middleware/auth'
import commentRouter from './comments'
const router = Router()
const ticketInclude = {
category: true,
type: true,
item: true,
assignee: { select: { id: true, username: true, displayName: true } },
createdBy: { select: { id: true, username: true, displayName: true } },
comments: {
include: { author: { select: { id: true, username: true, displayName: true } } },
orderBy: { createdAt: 'asc' as const },
},
} as const
const createSchema = z.object({
title: z.string().min(1).max(255),
overview: z.string().min(1),
severity: z.number().int().min(1).max(5),
categoryId: z.string().min(1),
typeId: z.string().min(1),
itemId: z.string().min(1),
assigneeId: z.string().optional(),
})
const updateSchema = z.object({
title: z.string().min(1).max(255).optional(),
overview: z.string().min(1).optional(),
severity: z.number().int().min(1).max(5).optional(),
status: z.enum(['OPEN', 'IN_PROGRESS', 'RESOLVED', 'CLOSED']).optional(),
categoryId: z.string().min(1).optional(),
typeId: z.string().min(1).optional(),
itemId: z.string().min(1).optional(),
assigneeId: z.string().nullable().optional(),
})
// Mount comment sub-router
router.use('/:ticketId/comments', commentRouter)
// GET /api/tickets
router.get('/', async (req: AuthRequest, res) => {
const { status, severity, assigneeId, categoryId, search } = req.query
const where: Record<string, any> = {}
if (status) where.status = status
if (severity) where.severity = Number(severity)
if (assigneeId) where.assigneeId = assigneeId
if (categoryId) where.categoryId = categoryId
if (search) {
where.OR = [
{ title: { contains: search as string, mode: 'insensitive' } },
{ overview: { contains: search as string, mode: 'insensitive' } },
]
}
const tickets = await prisma.ticket.findMany({
where,
include: {
category: true,
type: true,
item: true,
assignee: { select: { id: true, username: true, displayName: true } },
createdBy: { select: { id: true, username: true, displayName: true } },
_count: { select: { comments: true } },
},
orderBy: [{ severity: 'asc' }, { createdAt: 'desc' }],
})
res.json(tickets)
})
// GET /api/tickets/:id
router.get('/:id', async (req, res) => {
const ticket = await prisma.ticket.findUnique({
where: { id: req.params.id },
include: ticketInclude,
})
if (!ticket) return res.status(404).json({ error: 'Ticket not found' })
res.json(ticket)
})
// POST /api/tickets
router.post('/', async (req: AuthRequest, res) => {
const data = createSchema.parse(req.body)
const ticket = await prisma.ticket.create({
data: { ...data, createdById: req.user!.id },
include: ticketInclude,
})
res.status(201).json(ticket)
})
// PATCH /api/tickets/:id
router.patch('/:id', async (req: AuthRequest, res) => {
const data = updateSchema.parse(req.body)
const existing = await prisma.ticket.findUnique({ where: { id: req.params.id } })
if (!existing) return res.status(404).json({ error: 'Ticket not found' })
const update: Record<string, any> = { ...data }
if (data.status === 'RESOLVED' && existing.status !== 'RESOLVED') {
update.resolvedAt = new Date()
} else if (data.status && data.status !== 'RESOLVED' && existing.status === 'RESOLVED') {
// Re-opening a resolved ticket resets the 2-week auto-close timer
update.resolvedAt = null
}
const ticket = await prisma.ticket.update({
where: { id: req.params.id },
data: update,
include: ticketInclude,
})
res.json(ticket)
})
// DELETE /api/tickets/:id — admin only
router.delete('/:id', requireAdmin, async (req, res) => {
await prisma.ticket.delete({ where: { id: req.params.id } })
res.status(204).send()
})
export default router

103
server/src/routes/users.ts Normal file
View File

@@ -0,0 +1,103 @@
import { Router } from 'express'
import bcrypt from 'bcryptjs'
import crypto from 'crypto'
import { z } from 'zod'
import prisma from '../lib/prisma'
import { requireAdmin, AuthRequest } from '../middleware/auth'
const router = Router()
const userSelect = {
id: true,
username: true,
displayName: true,
email: true,
role: true,
apiKey: true,
createdAt: true,
} as const
router.get('/', async (_req, res) => {
const users = await prisma.user.findMany({
select: { id: true, username: true, displayName: true, email: true, role: true, createdAt: true },
orderBy: { displayName: 'asc' },
})
res.json(users)
})
router.post('/', requireAdmin, async (req, res) => {
const data = z
.object({
username: z.string().min(1).max(50),
email: z.string().email(),
displayName: z.string().min(1).max(100),
password: z.string().min(8).optional(),
role: z.enum(['ADMIN', 'AGENT', 'SERVICE']).default('AGENT'),
})
.parse(req.body)
const passwordHash = data.password
? await bcrypt.hash(data.password, 12)
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 12)
const apiKey =
data.role === 'SERVICE' ? `sk_${crypto.randomBytes(32).toString('hex')}` : undefined
const user = await prisma.user.create({
data: {
username: data.username,
email: data.email,
displayName: data.displayName,
passwordHash,
role: data.role,
apiKey,
},
select: userSelect,
})
res.status(201).json(user)
})
router.patch('/:id', requireAdmin, async (req, res) => {
const data = z
.object({
displayName: z.string().min(1).max(100).optional(),
email: z.string().email().optional(),
password: z.string().min(8).optional(),
role: z.enum(['ADMIN', 'AGENT', 'SERVICE']).optional(),
regenerateApiKey: z.boolean().optional(),
})
.parse(req.body)
const update: Record<string, any> = {}
if (data.displayName) update.displayName = data.displayName
if (data.email) update.email = data.email
if (data.password) update.passwordHash = await bcrypt.hash(data.password, 12)
if (data.role) {
update.role = data.role
if (data.role === 'SERVICE' && !update.apiKey) {
update.apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`
}
}
if (data.regenerateApiKey) {
update.apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`
}
const user = await prisma.user.update({
where: { id: req.params.id },
data: update,
select: userSelect,
})
res.json(user)
})
router.delete('/:id', requireAdmin, async (req: AuthRequest, res) => {
if (req.params.id === req.user!.id) {
return res.status(400).json({ error: 'Cannot delete your own account' })
}
await prisma.user.delete({ where: { id: req.params.id } })
res.status(204).send()
})
export default router

16
server/tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}