Remove onelga-local-services project from main repo

This commit is contained in:
paulji peters 2025-10-21 04:28:13 +01:00
parent ff259c1903
commit 9254469ef4
54 changed files with 5 additions and 1898 deletions

5
projects/README.md Normal file
View File

@ -0,0 +1,5 @@
# Projects
The `onelga-local-services` project has been moved to a dedicated repository at `../onelga-local-services`.
Please clone or copy that repository to continue working on the application outside of the main `awesome-copilot` repo.

View File

@ -1,148 +0,0 @@
# Onelga Local Services
Onelga Local Services is a full-stack platform that demonstrates a municipal services portal with authentication, admin tooling, and citizen-facing news.
## Project Structure
```
projects/onelga-local-services/
├── backend/ # Node.js + Express + Prisma API
├── frontend/ # React + TypeScript dashboard and citizen portal
└── docker-compose.yml
```
## Getting Started
### Prerequisites
- Node.js 18+
- pnpm, npm, or yarn
- (Optional) Docker Desktop if you prefer running Postgres locally via Docker Compose
### Backend Setup
1. Install dependencies:
```bash
cd projects/onelga-local-services/backend
npm install
```
2. Copy environment variables:
```bash
cp .env.example .env
```
Update the values if needed. The default configuration uses SQLite.
3. Generate the Prisma client and apply migrations:
```bash
npx prisma migrate dev --name init
```
4. Seed demo data (admin, staff, and citizen accounts plus sample content):
```bash
npm run prisma:seed
```
5. Start the development server:
```bash
npm run dev
```
The API will be available at `http://localhost:4000`.
### Frontend Setup
1. Install dependencies:
```bash
cd projects/onelga-local-services/frontend
npm install
```
2. Copy environment variables:
```bash
cp .env.example .env
```
Adjust `VITE_API_URL` if your backend runs on a different URL.
3. Start the development server:
```bash
npm run dev
```
Open the app at `http://localhost:5173`.
### Demo Credentials
Use the seeded accounts to explore the platform locally:
| Role | Email | Password |
| ------ | -------------------- | ---------- |
| Admin | `admin@onelga.local` | `Passw0rd!` |
| Staff | `staff@onelga.local` | `Passw0rd!` |
| Citizen| `citizen@onelga.local` | `Passw0rd!` |
### Smoke Test Script
After starting the backend, run a quick health check:
```bash
cd projects/onelga-local-services/backend
npx ts-node scripts/smoke-test.ts
```
The script signs in with the admin account, fetches dashboard stats, and retrieves the news feed.
### Unlocking Accounts
If you lock an account due to repeated failed logins, reset it with:
```bash
cd projects/onelga-local-services/backend
npm run unlock:user -- user@example.com
```
### Docker Compose (Optional)
A `docker-compose.yml` file is provided to run Postgres locally. Update `DATABASE_URL` in your backend `.env` before starting.
```bash
cd projects/onelga-local-services
docker-compose up -d
```
Run `npx prisma migrate deploy` against the Postgres instance and reseed data.
## Available Scripts
### Backend
- `npm run dev` — Start the Express server with hot reload.
- `npm run build` — Compile TypeScript output.
- `npm run prisma:seed` — Seed baseline data.
- `npm run seed:test` — Seed extended demo data.
- `npm run unlock:user -- <email>` — Clear failed login attempts for a user.
- `npm run typecheck` / `npm run lint` — Static analysis.
### Frontend
- `npm run dev` — Start the Vite dev server.
- `npm run build` — Build production assets.
- `npm run typecheck` / `npm run lint` — Static analysis.
## API Overview
Key backend endpoints are namespaced under `/api`:
- `POST /api/auth/login` — Authenticate a user.
- `GET /api/auth/validate` — Validate a JWT.
- `GET /api/admin/stats` — Admin dashboard metrics.
- `GET /api/admin/applications` — List service applications.
- `POST /api/admin/applications/:id/decide` — Approve or reject an application.
- `GET /api/news` — Public news feed.
- `GET /api/news/admin/articles` — Admin news management (requires admin or staff role).
Refer to the source code for the full set of routes and controllers.
## Testing Checklist
1. Run `npm run typecheck` in both `backend` and `frontend`.
2. Start the backend and frontend dev servers.
3. Sign in with the admin account.
4. Navigate to `/admin`, `/admin/news`, and `/admin/requests` to confirm data loads.
5. Execute the smoke test script.
## License
MIT

View File

@ -1,9 +0,0 @@
PORT=4000
DATABASE_URL="file:./dev.db"
JWT_SECRET="your-jwt-secret"
JWT_EXPIRES_IN="1d"
FRONTEND_URL="http://localhost:5173"
SMTP_HOST="smtp.example.com"
SMTP_PORT=587
SMTP_USER="username"
SMTP_PASS="password"

View File

@ -1,26 +0,0 @@
{
"env": {
"es2021": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:import/recommended",
"plugin:promise/recommended",
"plugin:jsdoc/recommended",
"prettier"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": ["./tsconfig.json"],
"tsconfigRootDir": __dirname,
"sourceType": "module"
},
"plugins": ["@typescript-eslint", "prettier"],
"rules": {
"prettier/prettier": "error",
"import/no-unresolved": "off",
"jsdoc/require-jsdoc": "off"
},
"ignorePatterns": ["dist", "node_modules"]
}

View File

@ -1,4 +0,0 @@
/node_modules
/dist
/.env
/prisma/dev.db*

View File

@ -1,48 +0,0 @@
{
"name": "onelga-local-services-backend",
"version": "1.0.0",
"description": "Backend API for the Onelga Local Services platform",
"main": "dist/server.js",
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"lint": "eslint 'src/**/*.{ts,tsx}'",
"typecheck": "tsc --noEmit",
"prisma:migrate": "prisma migrate dev",
"prisma:generate": "prisma generate",
"prisma:seed": "ts-node prisma/seed.ts",
"seed:test": "ts-node scripts/seed-test.ts",
"seed:staff": "ts-node scripts/seed-test-staff.ts",
"unlock:user": "ts-node scripts/unlock-user.ts"
},
"dependencies": {
"@prisma/client": "^5.15.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.5",
"@types/morgan": "^1.9.7",
"@types/node": "^20.14.9",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsdoc": "^48.2.6",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-promise": "^6.1.1",
"prisma": "^5.15.0",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"typescript": "^5.4.5",
"@typescript-eslint/eslint-plugin": "^7.13.0",
"@typescript-eslint/parser": "^7.13.0"
}
}

View File

@ -1,94 +0,0 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
enum Role {
CITIZEN
STAFF
ADMIN
}
enum ApplicationStatus {
PENDING
UNDER_REVIEW
APPROVED
REJECTED
PENDING_DOCUMENTS
COMPLETED
}
model User {
id String @id @default(cuid())
email String @unique
firstName String
lastName String
passwordHash String
role Role @default(CITIZEN)
failedLoginAttempts Int @default(0)
lockoutUntil DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
newsArticles NewsArticle[]
applications Application[]
auditLogs AuditLog[]
}
model Application {
id String @id @default(cuid())
user User @relation(fields: [userId], references: [id])
userId String
type String
status ApplicationStatus @default(PENDING)
data Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
documents Document[]
assignments ServiceAssignment[]
}
model Document {
id String @id @default(cuid())
application Application @relation(fields: [applicationId], references: [id])
applicationId String
filename String
url String
status String
uploadedAt DateTime @default(now())
}
model NewsArticle {
id String @id @default(cuid())
title String
slug String @unique
content String
published Boolean @default(false)
publishedAt DateTime?
author User? @relation(fields: [authorId], references: [id])
authorId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ServiceAssignment {
id String @id @default(cuid())
application Application @relation(fields: [applicationId], references: [id])
applicationId String
staff User @relation(fields: [staffId], references: [id])
staffId String
notes String?
createdAt DateTime @default(now())
}
model AuditLog {
id String @id @default(cuid())
actor User? @relation(fields: [actorId], references: [id])
actorId String?
action String
details Json?
createdAt DateTime @default(now())
}

View File

@ -1,81 +0,0 @@
import "dotenv/config";
import { PrismaClient, Role, ApplicationStatus } from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
const users = [
{
email: "admin@onelga.local",
role: Role.ADMIN,
firstName: "Admin",
lastName: "User",
},
{
email: "staff@onelga.local",
role: Role.STAFF,
firstName: "Staff",
lastName: "Member",
},
{
email: "citizen@onelga.local",
role: Role.CITIZEN,
firstName: "Citizen",
lastName: "Resident",
},
];
async function main() {
const passwordHash = await bcrypt.hash("Passw0rd!", 10);
for (const user of users) {
await prisma.user.upsert({
where: { email: user.email },
update: {},
create: {
...user,
passwordHash,
},
});
}
const admin = await prisma.user.findUnique({ where: { email: "admin@onelga.local" } });
const citizen = await prisma.user.findUnique({ where: { email: "citizen@onelga.local" } });
if (!admin || !citizen) {
throw new Error("Seed users not found");
}
await prisma.newsArticle.upsert({
where: { slug: "welcome-to-onelga" },
update: {},
create: {
title: "Welcome to Onelga Services",
slug: "welcome-to-onelga",
content: "Discover services, news, and updates for Onelga citizens.",
published: true,
publishedAt: new Date(),
authorId: admin.id,
},
});
await prisma.application.upsert({
where: { id: "demo-application" },
update: {},
create: {
id: "demo-application",
type: "building-permit",
status: ApplicationStatus.PENDING,
data: { projectName: "Community Center" },
userId: citizen.id,
},
});
}
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (error) => {
console.error(error);
await prisma.$disconnect();
process.exit(1);
});

View File

@ -1,33 +0,0 @@
import "dotenv/config";
import { PrismaClient, Role } from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
async function seedStaff() {
const passwordHash = await bcrypt.hash("Passw0rd!", 10);
await prisma.user.upsert({
where: { email: "staff@onelga.local" },
update: {},
create: {
email: "staff@onelga.local",
firstName: "Staff",
lastName: "Member",
role: Role.STAFF,
passwordHash,
},
});
console.log("Seeded staff account");
}
seedStaff()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (error) => {
console.error(error);
await prisma.$disconnect();
process.exit(1);
});

View File

@ -1,97 +0,0 @@
import "dotenv/config";
import { PrismaClient, Role, ApplicationStatus } from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
async function seed() {
const passwordHash = await bcrypt.hash("Passw0rd!", 10);
const admin = await prisma.user.upsert({
where: { email: "admin@onelga.local" },
update: {},
create: {
email: "admin@onelga.local",
firstName: "Admin",
lastName: "User",
role: Role.ADMIN,
passwordHash,
},
});
const staff = await prisma.user.upsert({
where: { email: "staff@onelga.local" },
update: {},
create: {
email: "staff@onelga.local",
firstName: "Staff",
lastName: "Member",
role: Role.STAFF,
passwordHash,
},
});
const citizen = await prisma.user.upsert({
where: { email: "citizen@onelga.local" },
update: {},
create: {
email: "citizen@onelga.local",
firstName: "Citizen",
lastName: "Resident",
role: Role.CITIZEN,
passwordHash,
},
});
await prisma.newsArticle.createMany({
data: [
{
title: "City Approves Park Renovation",
slug: "park-renovation",
content: "The city council approved the renovation budget for Central Park.",
published: true,
publishedAt: new Date(),
authorId: admin.id,
},
{
title: "Seasonal Road Maintenance Schedule",
slug: "road-maintenance",
content: "Road maintenance is scheduled for the coming weeks across the city.",
published: true,
publishedAt: new Date(),
authorId: staff.id,
},
],
skipDuplicates: true,
});
await prisma.application.createMany({
data: [
{
userId: citizen.id,
type: "business-license",
status: ApplicationStatus.UNDER_REVIEW,
data: { businessName: "Citizen Bakery" },
},
{
userId: citizen.id,
type: "waste-management",
status: ApplicationStatus.PENDING,
data: { requestedBins: 2 },
},
],
skipDuplicates: true,
});
console.log("Seeded test data");
}
seed()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (error) => {
console.error(error);
await prisma.$disconnect();
process.exit(1);
});

View File

@ -1,53 +0,0 @@
const BASE_URL = process.env.BASE_URL ?? "http://localhost:4000";
const ADMIN_EMAIL = process.env.ADMIN_EMAIL ?? "admin@onelga.local";
const PASSWORD = process.env.ADMIN_PASSWORD ?? "Passw0rd!";
type LoginResponse = {
token: string;
};
type AdminStats = {
totalUsers: number;
totalApplications: number;
pendingApplications: number;
publishedArticles: number;
};
async function main() {
const loginResponse = await fetch(`${BASE_URL}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: ADMIN_EMAIL, password: PASSWORD }),
});
if (!loginResponse.ok) {
throw new Error(`Login failed with status ${loginResponse.status}`);
}
const loginData = (await loginResponse.json()) as LoginResponse;
console.log("Login successful");
const statsResponse = await fetch(`${BASE_URL}/api/admin/stats`, {
headers: { Authorization: `Bearer ${loginData.token}` },
});
if (!statsResponse.ok) {
throw new Error(`Fetching stats failed with status ${statsResponse.status}`);
}
const stats = (await statsResponse.json()) as AdminStats;
console.log("Admin stats:", stats);
const newsResponse = await fetch(`${BASE_URL}/api/news`);
if (!newsResponse.ok) {
throw new Error(`Fetching news failed with status ${newsResponse.status}`);
}
const news = (await newsResponse.json()) as unknown[];
console.log(`Fetched ${news.length} news articles`);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@ -1,30 +0,0 @@
import "dotenv/config";
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function unlock(email: string) {
const user = await prisma.user.update({
where: { email: email.toLowerCase() },
data: { failedLoginAttempts: 0, lockoutUntil: null },
});
console.log(`Unlocked user ${user.email}`);
}
const email = process.argv[2];
if (!email) {
console.error("Usage: ts-node scripts/unlock-user.ts <email>");
process.exit(1);
}
unlock(email)
.then(async () => {
await prisma.$disconnect();
})
.catch(async (error) => {
console.error(error);
await prisma.$disconnect();
process.exit(1);
});

View File

@ -1,24 +0,0 @@
import cors from "cors";
import express from "express";
import morgan from "morgan";
import env from "./config/env";
import routes from "./routes";
const app = express();
app.use(cors({ origin: env.FRONTEND_URL, credentials: true }));
app.use(express.json());
app.use(morgan("dev"));
app.get("/api/health", (_req, res) => {
res.json({ status: "ok" });
});
app.use("/api", routes);
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error(err);
res.status(500).json({ message: "Internal server error" });
});
export default app;

View File

@ -1,32 +0,0 @@
import dotenv from "dotenv";
dotenv.config();
const requiredEnvVars = ["PORT", "DATABASE_URL", "JWT_SECRET", "JWT_EXPIRES_IN", "FRONTEND_URL"] as const;
type RequiredEnv = (typeof requiredEnvVars)[number];
type EnvConfig = Record<RequiredEnv, string> & {
smtpHost?: string;
smtpPort?: number;
smtpUser?: string;
smtpPass?: string;
};
const env: EnvConfig = requiredEnvVars.reduce((acc, key) => {
const value = process.env[key];
if (!value) {
throw new Error(`Missing required environment variable: ${key}`);
}
acc[key] = value;
return acc;
}, {} as EnvConfig);
if (process.env.SMTP_HOST) {
env.smtpHost = process.env.SMTP_HOST;
env.smtpPort = process.env.SMTP_PORT ? Number(process.env.SMTP_PORT) : undefined;
env.smtpUser = process.env.SMTP_USER;
env.smtpPass = process.env.SMTP_PASS;
}
export default env;

View File

@ -1,31 +0,0 @@
import { Request, Response } from "express";
import { ApplicationStatus } from "@prisma/client";
import { listUsers } from "../services/userService";
import { getDashboardStats, listApplications, updateApplicationStatus } from "../services/applicationService";
export const getStats = async (_req: Request, res: Response) => {
const stats = await getDashboardStats();
return res.json(stats);
};
export const getUsers = async (_req: Request, res: Response) => {
const users = await listUsers();
return res.json(users);
};
export const getApplications = async (req: Request, res: Response) => {
const status = req.query.status as ApplicationStatus | undefined;
const applications = await listApplications(status ? { status } : undefined);
return res.json(applications);
};
export const decideApplication = async (req: Request, res: Response) => {
const { id } = req.params;
const { status } = req.body as { status: ApplicationStatus };
if (!status) {
return res.status(400).json({ message: "Status is required" });
}
const updated = await updateApplicationStatus(id, status);
return res.json(updated);
};

View File

@ -1,90 +0,0 @@
import { Request, Response } from "express";
import { Role } from "@prisma/client";
import {
findUserByEmail,
createUser,
verifyPassword,
recordFailedAttempt,
resetFailedAttempts,
getUserById,
} from "../services/userService";
import { signToken } from "../utils/token";
const normalizeEmail = (email: string) => email.trim().toLowerCase();
const sanitizeUser = (user: { id: string; email: string; role: Role; firstName: string; lastName: string }) => ({
id: user.id,
email: user.email,
role: user.role,
firstName: user.firstName,
lastName: user.lastName,
});
export const register = async (req: Request, res: Response) => {
const { email, password, firstName, lastName } = req.body;
if (!email || !password) {
return res.status(400).json({ message: "Email and password are required" });
}
if (!firstName || !lastName) {
return res.status(400).json({ message: "First and last name are required" });
}
const existing = await findUserByEmail(normalizeEmail(email));
if (existing) {
return res.status(409).json({ message: "Email already in use" });
}
const user = await createUser({ email, password, firstName, lastName });
const token = signToken({ sub: user.id, role: user.role });
return res.status(201).json({
user: sanitizeUser(user),
token,
});
};
export const login = async (req: Request, res: Response) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ message: "Email and password are required" });
}
const user = await findUserByEmail(normalizeEmail(email));
if (!user) {
return res.status(401).json({ message: "Invalid credentials" });
}
if (user.lockoutUntil && user.lockoutUntil > new Date()) {
return res.status(423).json({ message: "Account locked. Try again later." });
}
const passwordMatches = await verifyPassword(password, user.passwordHash);
if (!passwordMatches) {
await recordFailedAttempt(user.id);
return res.status(401).json({ message: "Invalid credentials" });
}
if (user.failedLoginAttempts > 0 || user.lockoutUntil) {
await resetFailedAttempts(user.id);
}
const token = signToken({ sub: user.id, role: user.role });
return res.json({
token,
user: sanitizeUser(user),
});
};
export const validateToken = async (req: Request, res: Response) => {
const { user } = req as Request & { user?: { id: string; role: Role } };
if (!user) {
return res.status(401).json({ message: "Invalid token" });
}
const freshUser = await getUserById(user.id);
if (!freshUser) {
return res.status(401).json({ message: "Invalid token" });
}
return res.json({ user: sanitizeUser(freshUser) });
};

View File

@ -1,50 +0,0 @@
import { Request, Response } from "express";
import { createArticle, deleteArticle, getAdminStats, getArticleBySlug, listAdminArticles, listPublishedArticles, updateArticle } from "../services/newsService";
export const getPublicNews = async (_req: Request, res: Response) => {
const articles = await listPublishedArticles();
return res.json(articles);
};
export const getArticle = async (req: Request, res: Response) => {
const article = await getArticleBySlug(req.params.slug);
if (!article || !article.published) {
return res.status(404).json({ message: "Article not found" });
}
return res.json(article);
};
export const getAdminArticles = async (_req: Request, res: Response) => {
const articles = await listAdminArticles();
return res.json(articles);
};
export const createAdminArticle = async (req: Request, res: Response) => {
const { title, slug, content, published } = req.body;
const user = (req as Request & { user?: { id: string } }).user;
if (!title || !slug) {
return res.status(400).json({ message: "Title and slug are required" });
}
const article = await createArticle({ title, slug, content, published, authorId: user?.id });
return res.status(201).json(article);
};
export const updateAdminArticle = async (req: Request, res: Response) => {
const { id } = req.params;
const { title, slug, content, published } = req.body;
const article = await updateArticle(id, { title, slug, content, published });
return res.json(article);
};
export const removeAdminArticle = async (req: Request, res: Response) => {
const { id } = req.params;
await deleteArticle(id);
return res.status(204).send();
};
export const getNewsStats = async (_req: Request, res: Response) => {
const stats = await getAdminStats();
return res.json(stats);
};

View File

@ -1,42 +0,0 @@
import { Role } from "@prisma/client";
import { NextFunction, Request, Response } from "express";
import { verifyToken } from "../utils/token";
import { getUserById } from "../services/userService";
export interface AuthenticatedRequest extends Request {
user?: {
id: string;
role: Role;
};
}
export const authenticate = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
return res.status(401).json({ message: "Authentication token missing" });
}
try {
const token = authHeader.substring(7);
const decoded = verifyToken(token);
const user = await getUserById(decoded.sub);
if (!user) {
return res.status(401).json({ message: "Invalid authentication token" });
}
req.user = { id: user.id, role: user.role };
return next();
} catch (error) {
return res.status(401).json({ message: "Invalid authentication token" });
}
};
export const authorize = (roles: Role[]) => {
return (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
if (!req.user || !roles.includes(req.user.role)) {
return res.status(403).json({ message: "You do not have permission to perform this action" });
}
return next();
};
};

View File

@ -1,16 +0,0 @@
import { Role } from "@prisma/client";
import { Router } from "express";
import { authenticate, authorize } from "../middleware/auth";
import { decideApplication, getApplications, getStats, getUsers } from "../controllers/adminController";
const router = Router();
router.use(authenticate, authorize([Role.ADMIN, Role.STAFF]));
router.get("/dashboard/stats", getStats);
router.get("/stats", getStats);
router.get("/users", authorize([Role.ADMIN]), getUsers);
router.get("/applications", getApplications);
router.post("/applications/:id/decide", decideApplication);
export default router;

View File

@ -1,11 +0,0 @@
import { Router } from "express";
import { authenticate } from "../middleware/auth";
import { login, register, validateToken } from "../controllers/authController";
const router = Router();
router.post("/login", login);
router.post("/register", register);
router.get("/validate", authenticate, validateToken);
export default router;

View File

@ -1,12 +0,0 @@
import { Router } from "express";
import authRoutes from "./authRoutes";
import adminRoutes from "./adminRoutes";
import newsRoutes from "./newsRoutes";
const router = Router();
router.use("/auth", authRoutes);
router.use("/admin", adminRoutes);
router.use("/news", newsRoutes);
export default router;

View File

@ -1,26 +0,0 @@
import { Role } from "@prisma/client";
import { Router } from "express";
import { authenticate, authorize } from "../middleware/auth";
import {
createAdminArticle,
getAdminArticles,
getArticle,
getNewsStats,
getPublicNews,
removeAdminArticle,
updateAdminArticle,
} from "../controllers/newsController";
const router = Router();
router.get("/", getPublicNews);
router.get("/admin/articles", authenticate, authorize([Role.ADMIN, Role.STAFF]), getAdminArticles);
router.post("/admin/articles", authenticate, authorize([Role.ADMIN, Role.STAFF]), createAdminArticle);
router.put("/admin/articles/:id", authenticate, authorize([Role.ADMIN, Role.STAFF]), updateAdminArticle);
router.delete("/admin/articles/:id", authenticate, authorize([Role.ADMIN, Role.STAFF]), removeAdminArticle);
router.get("/admin/stats", authenticate, authorize([Role.ADMIN, Role.STAFF]), getNewsStats);
router.get("/:slug", getArticle);
export default router;

View File

@ -1,19 +0,0 @@
import env from "./config/env";
import app from "./app";
import prisma from "./utils/prisma";
const port = Number(env.PORT);
async function start() {
try {
await prisma.$connect();
app.listen(port, () => {
console.log(`Server listening on port ${port}`);
});
} catch (error) {
console.error("Failed to start server", error);
process.exit(1);
}
}
void start();

View File

@ -1,43 +0,0 @@
import { ApplicationStatus, Prisma } from "@prisma/client";
import prisma from "../utils/prisma";
export const listApplications = async (filters?: { status?: ApplicationStatus }) => {
return prisma.application.findMany({
where: filters,
include: {
user: {
select: { id: true, email: true, firstName: true, lastName: true, role: true },
},
documents: true,
assignments: true,
},
orderBy: { createdAt: "desc" },
});
};
export const updateApplicationStatus = (id: string, status: ApplicationStatus) => {
return prisma.application.update({
where: { id },
data: { status },
});
};
export const createApplication = (data: Prisma.ApplicationCreateInput) => {
return prisma.application.create({ data });
};
export const getDashboardStats = async () => {
const [totalUsers, totalApplications, pendingApplications, publishedArticles] = await Promise.all([
prisma.user.count(),
prisma.application.count(),
prisma.application.count({ where: { status: ApplicationStatus.PENDING } }),
prisma.newsArticle.count({ where: { published: true } }),
]);
return {
totalUsers,
totalApplications,
pendingApplications,
publishedArticles,
};
};

View File

@ -1,60 +0,0 @@
import prisma from "../utils/prisma";
export const listPublishedArticles = () => {
return prisma.newsArticle.findMany({
where: { published: true },
orderBy: { publishedAt: "desc" },
});
};
export const getArticleBySlug = (slug: string) => {
return prisma.newsArticle.findUnique({ where: { slug } });
};
export const listAdminArticles = () => {
return prisma.newsArticle.findMany({ orderBy: { createdAt: "desc" } });
};
export const createArticle = (data: {
title: string;
slug: string;
content: string;
published?: boolean;
authorId?: string;
}) => {
return prisma.newsArticle.create({
data: {
...data,
publishedAt: data.published ? new Date() : null,
},
});
};
export const updateArticle = (id: string, data: {
title?: string;
slug?: string;
content?: string;
published?: boolean;
}) => {
return prisma.newsArticle.update({
where: { id },
data: {
...data,
publishedAt: typeof data.published === "boolean" ? (data.published ? new Date() : null) : undefined,
},
});
};
export const deleteArticle = (id: string) => {
return prisma.newsArticle.delete({ where: { id } });
};
export const getAdminStats = async () => {
const [totalArticles, publishedArticles, drafts] = await Promise.all([
prisma.newsArticle.count(),
prisma.newsArticle.count({ where: { published: true } }),
prisma.newsArticle.count({ where: { published: false } }),
]);
return { totalArticles, publishedArticles, drafts };
};

View File

@ -1,77 +0,0 @@
import { Role } from "@prisma/client";
import bcrypt from "bcryptjs";
import prisma from "../utils/prisma";
const MAX_FAILED_ATTEMPTS = 5;
const LOCKOUT_DURATION_MINUTES = 15;
export const findUserByEmail = (email: string) => {
return prisma.user.findUnique({ where: { email: email.toLowerCase() } });
};
export const getUserById = (id: string) => {
return prisma.user.findUnique({ where: { id } });
};
export const createUser = async (params: {
email: string;
password: string;
firstName: string;
lastName: string;
role?: Role;
}) => {
const hash = await bcrypt.hash(params.password, 10);
return prisma.user.create({
data: {
email: params.email.toLowerCase(),
passwordHash: hash,
firstName: params.firstName,
lastName: params.lastName,
role: params.role ?? Role.CITIZEN,
},
});
};
export const verifyPassword = (password: string, hash: string) => bcrypt.compare(password, hash);
export const recordFailedAttempt = async (userId: string) => {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
return null;
}
const failedAttempts = user.failedLoginAttempts + 1;
const updateData: { failedLoginAttempts: number; lockoutUntil?: Date } = {
failedLoginAttempts: failedAttempts,
};
if (failedAttempts >= MAX_FAILED_ATTEMPTS) {
updateData.lockoutUntil = new Date(Date.now() + LOCKOUT_DURATION_MINUTES * 60 * 1000);
}
return prisma.user.update({
where: { id: userId },
data: updateData,
});
};
export const resetFailedAttempts = (userId: string) => {
return prisma.user.update({
where: { id: userId },
data: { failedLoginAttempts: 0, lockoutUntil: null },
});
};
export const listUsers = () => {
return prisma.user.findMany({
orderBy: { createdAt: "desc" },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
role: true,
createdAt: true,
},
});
};

View File

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

View File

@ -1,15 +0,0 @@
import jwt from "jsonwebtoken";
import env from "../config/env";
interface TokenPayload {
sub: string;
role: string;
}
export const signToken = (payload: TokenPayload) => {
return jwt.sign(payload, env.JWT_SECRET, { expiresIn: env.JWT_EXPIRES_IN });
};
export const verifyToken = (token: string) => {
return jwt.verify(token, env.JWT_SECRET) as TokenPayload & { iat: number; exp: number };
};

View File

@ -1,28 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"rootDir": "src",
"outDir": "dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": [
"node"
],
"moduleResolution": "node",
"lib": [
"ES2020",
"DOM"
]
},
"include": [
"src"
],
"exclude": [
"node_modules",
"dist"
]
}

View File

@ -1,17 +0,0 @@
version: "3.9"
services:
db:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: onelga
POSTGRES_PASSWORD: onelga
POSTGRES_DB: onelga
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
volumes:
postgres-data:

View File

@ -1 +0,0 @@
VITE_API_URL="http://localhost:4000"

View File

@ -1,22 +0,0 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended",
"plugin:react-refresh/recommended",
"prettier"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": ["@typescript-eslint"],
"rules": {
"react-refresh/only-export-components": "off"
}
}

View File

@ -1,3 +0,0 @@
/node_modules
/dist
/.env

View File

@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Onelga Local Services</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -1,38 +0,0 @@
{
"name": "onelga-local-services-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint 'src/**/*.{ts,tsx}'",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@mui/icons-material": "^5.15.21",
"@mui/material": "^5.15.21",
"@reduxjs/toolkit": "^2.2.5",
"@tanstack/react-query": "^5.40.0",
"axios": "^1.7.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-redux": "^9.1.2",
"react-router-dom": "^6.23.1"
},
"devDependencies": {
"@types/node": "^20.14.9",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.13.0",
"@typescript-eslint/parser": "^7.13.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7",
"typescript": "^5.4.5",
"vite": "^5.2.11"
}
}

View File

@ -1,37 +0,0 @@
import { Navigate, Route, Routes } from "react-router-dom";
import AppLayout from "./components/AppLayout";
import ProtectedRoute from "./components/ProtectedRoute";
import LoginPage from "./pages/LoginPage";
import NewsPage from "./pages/NewsPage";
import AdminDashboardPage from "./pages/dashboards/AdminDashboardPage";
import NewsManagementPage from "./pages/dashboards/NewsManagementPage";
import ServiceRequestsPage from "./pages/dashboards/ServiceRequestsPage";
import StaffDashboardPage from "./pages/dashboards/StaffDashboardPage";
import HomePage from "./pages/HomePage";
import { Role } from "./types";
const App = () => {
return (
<Routes>
<Route element={<AppLayout />}>
<Route index element={<HomePage />} />
<Route path="news" element={<NewsPage />} />
<Route path="login" element={<LoginPage />} />
<Route element={<ProtectedRoute roles={[Role.ADMIN, Role.STAFF]} />}>
<Route path="admin" element={<AdminDashboardPage />} />
<Route path="admin/news" element={<NewsManagementPage />} />
<Route path="admin/requests" element={<ServiceRequestsPage />} />
</Route>
<Route element={<ProtectedRoute roles={[Role.STAFF]} />}>
<Route path="staff" element={<StaffDashboardPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
);
};
export default App;

View File

@ -1,49 +0,0 @@
import { AppBar, Box, Button, Container, Toolbar, Typography } from "@mui/material";
import { Link as RouterLink, Outlet, useNavigate } from "react-router-dom";
import { useAppDispatch, useAppSelector } from "../store/hooks";
import { clearCredentials } from "../store/slices/authSlice";
const AppLayout = () => {
const { user } = useAppSelector((state) => state.auth);
const dispatch = useAppDispatch();
const navigate = useNavigate();
const handleLogout = () => {
dispatch(clearCredentials());
navigate("/login");
};
return (
<Box sx={{ minHeight: "100vh", bgcolor: "background.default" }}>
<AppBar position="static">
<Toolbar>
<Typography variant="h6" sx={{ flexGrow: 1 }} component={RouterLink} to="/" color="inherit" style={{ textDecoration: "none" }}>
Onelga Local Services
</Typography>
{user ? (
<>
<Button color="inherit" component={RouterLink} to="/admin">
Dashboard
</Button>
<Button color="inherit" component={RouterLink} to="/news">
News
</Button>
<Button color="inherit" onClick={handleLogout}>
Logout
</Button>
</>
) : (
<Button color="inherit" component={RouterLink} to="/login">
Login
</Button>
)}
</Toolbar>
</AppBar>
<Container sx={{ py: 4 }}>
<Outlet />
</Container>
</Box>
);
};
export default AppLayout;

View File

@ -1,23 +0,0 @@
import { Role } from "../types";
import { Navigate, Outlet } from "react-router-dom";
import { useAppSelector } from "../store/hooks";
interface ProtectedRouteProps {
roles?: Role[];
}
const ProtectedRoute = ({ roles }: ProtectedRouteProps) => {
const { token, user } = useAppSelector((state) => state.auth);
if (!token || !user) {
return <Navigate to="/login" replace />;
}
if (roles && !roles.includes(user.role)) {
return <Navigate to="/" replace />;
}
return <Outlet />;
};
export default ProtectedRoute;

View File

@ -1,37 +0,0 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { CssBaseline, ThemeProvider, createTheme } from "@mui/material";
import { Provider } from "react-redux";
import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
import { store } from "./store";
const theme = createTheme({
palette: {
mode: "light",
primary: {
main: "#0063b2",
},
secondary: {
main: "#f57c00",
},
},
});
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={theme}>
<CssBaseline />
<BrowserRouter>
<App />
</BrowserRouter>
</ThemeProvider>
</QueryClientProvider>
</Provider>
</React.StrictMode>,
);

View File

@ -1,29 +0,0 @@
import { Box, Button, Grid, Typography } from "@mui/material";
import { Link as RouterLink } from "react-router-dom";
const HomePage = () => {
return (
<Box>
<Typography variant="h4" gutterBottom>
Welcome to Onelga Local Services
</Typography>
<Typography variant="body1" color="text.secondary" paragraph>
Access government services, manage applications, and stay informed with the latest community news.
</Typography>
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid item>
<Button variant="contained" component={RouterLink} to="/login">
Login
</Button>
</Grid>
<Grid item>
<Button variant="outlined" component={RouterLink} to="/news">
View News
</Button>
</Grid>
</Grid>
</Box>
);
};
export default HomePage;

View File

@ -1,53 +0,0 @@
import { useState } from "react";
import { Alert, Box, Button, Paper, Stack, TextField, Typography } from "@mui/material";
import { useNavigate } from "react-router-dom";
import { login as loginRequest } from "../services/api";
import { useAppDispatch } from "../store/hooks";
import { setCredentials } from "../store/slices/authSlice";
const LoginPage = () => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const [email, setEmail] = useState("admin@onelga.local");
const [password, setPassword] = useState("Passw0rd!");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setLoading(true);
setError(null);
try {
const response = await loginRequest(email, password);
dispatch(setCredentials(response));
navigate("/admin");
} catch (err) {
setError("Login failed. Please check your credentials.");
} finally {
setLoading(false);
}
};
return (
<Stack alignItems="center" justifyContent="center">
<Paper elevation={3} sx={{ p: 4, width: "100%", maxWidth: 400 }}>
<Typography variant="h5" gutterBottom>
Sign in
</Typography>
<Box component="form" onSubmit={handleSubmit} noValidate>
<Stack spacing={2}>
<TextField label="Email" type="email" fullWidth required value={email} onChange={(event) => setEmail(event.target.value)} />
<TextField label="Password" type="password" fullWidth required value={password} onChange={(event) => setPassword(event.target.value)} />
{error ? <Alert severity="error">{error}</Alert> : null}
<Button type="submit" variant="contained" disabled={loading}>
{loading ? "Signing in..." : "Sign in"}
</Button>
</Stack>
</Box>
</Paper>
</Stack>
);
};
export default LoginPage;

View File

@ -1,40 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { Alert, Card, CardContent, CircularProgress, Grid, Typography } from "@mui/material";
import { fetchPublicNews } from "../services/api";
const NewsPage = () => {
const { data, isLoading, error } = useQuery({ queryKey: ["news"], queryFn: fetchPublicNews });
if (isLoading) {
return <CircularProgress />;
}
if (error) {
return <Alert severity="error">Failed to load news.</Alert>;
}
if (!data?.length) {
return <Typography>No articles available.</Typography>;
}
return (
<Grid container spacing={2}>
{data.map((article) => (
<Grid item xs={12} md={6} key={article.id}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
{article.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{article.content.slice(0, 140)}...
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
);
};
export default NewsPage;

View File

@ -1,48 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { Alert, Card, CardContent, CircularProgress, Grid, Typography } from "@mui/material";
import { fetchAdminStats } from "../../services/api";
import { useAppSelector } from "../../store/hooks";
const AdminDashboardPage = () => {
const { token } = useAppSelector((state) => state.auth);
const { data, isLoading, error } = useQuery({
queryKey: ["admin-stats"],
queryFn: () => fetchAdminStats(token ?? ""),
enabled: Boolean(token),
});
if (isLoading) {
return <CircularProgress />;
}
if (error || !data) {
return <Alert severity="error">Unable to load dashboard statistics.</Alert>;
}
const cards = [
{ label: "Total Users", value: data.totalUsers },
{ label: "Total Applications", value: data.totalApplications },
{ label: "Pending Applications", value: data.pendingApplications },
{ label: "Published Articles", value: data.publishedArticles },
];
return (
<Grid container spacing={2}>
{cards.map((card) => (
<Grid item xs={12} md={3} key={card.label}>
<Card>
<CardContent>
<Typography variant="h6">{card.label}</Typography>
<Typography variant="h4" color="primary">
{card.value}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
);
};
export default AdminDashboardPage;

View File

@ -1,42 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { Alert, CircularProgress, List, ListItem, ListItemText, Typography } from "@mui/material";
import { fetchAdminArticles } from "../../services/api";
import { useAppSelector } from "../../store/hooks";
const NewsManagementPage = () => {
const { token } = useAppSelector((state) => state.auth);
const { data, isLoading, error } = useQuery({
queryKey: ["admin-articles"],
queryFn: () => fetchAdminArticles(token ?? ""),
enabled: Boolean(token),
});
if (isLoading) {
return <CircularProgress />;
}
if (error) {
return <Alert severity="error">Unable to load articles.</Alert>;
}
if (!data?.length) {
return <Typography>No articles found.</Typography>;
}
return (
<List>
{data.map((article) => (
<ListItem key={article.id} divider>
<ListItemText
primary={article.title}
secondary={article.published ? "Published" : "Draft"}
primaryTypographyProps={{ fontWeight: 600 }}
/>
</ListItem>
))}
</List>
);
};
export default NewsManagementPage;

View File

@ -1,52 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { Alert, CircularProgress, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Typography } from "@mui/material";
import { fetchApplications } from "../../services/api";
import { useAppSelector } from "../../store/hooks";
import type { ApplicationSummary } from "../../types";
const ServiceRequestsPage = () => {
const { token } = useAppSelector((state) => state.auth);
const { data, isLoading, error } = useQuery({
queryKey: ["admin-applications"],
queryFn: () => fetchApplications(token ?? ""),
enabled: Boolean(token),
});
if (isLoading) {
return <CircularProgress />;
}
if (error) {
return <Alert severity="error">Unable to load service requests.</Alert>;
}
if (!data?.length) {
return <Typography>No service requests found.</Typography>;
}
return (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Type</TableCell>
<TableCell>Status</TableCell>
<TableCell>Created</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data.map((application: ApplicationSummary) => (
<TableRow key={application.id}>
<TableCell>{application.type}</TableCell>
<TableCell>{application.status}</TableCell>
<TableCell>{new Date(application.createdAt).toLocaleDateString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
};
export default ServiceRequestsPage;

View File

@ -1,23 +0,0 @@
import { Alert, Box, Typography } from "@mui/material";
import { useAppSelector } from "../../store/hooks";
const StaffDashboardPage = () => {
const { user } = useAppSelector((state) => state.auth);
if (!user) {
return <Alert severity="error">You must be signed in.</Alert>;
}
return (
<Box>
<Typography variant="h5" gutterBottom>
Welcome back, {user.firstName}
</Typography>
<Typography color="text.secondary">
Use the admin dashboard to review and manage service assignments.
</Typography>
</Box>
);
};
export default StaffDashboardPage;

View File

@ -1,52 +0,0 @@
import axios from "axios";
import type { ApplicationSummary, NewsArticle, User } from "../types";
const api = axios.create({
baseURL: "/api",
});
export interface LoginResponse {
token: string;
user: User;
}
export const login = async (email: string, password: string) => {
const response = await api.post<LoginResponse>("/auth/login", { email, password });
return response.data;
};
export const fetchAdminStats = async (token: string) => {
const response = await api.get("/admin/stats", {
headers: { Authorization: `Bearer ${token}` },
});
return response.data as {
totalUsers: number;
totalApplications: number;
pendingApplications: number;
publishedArticles: number;
};
};
export const fetchApplications = async (token: string) => {
const response = await api.get<Array<ApplicationSummary & { createdAt: string }>>("/admin/applications", {
headers: { Authorization: `Bearer ${token}` },
});
return response.data.map((application) => ({
id: application.id,
type: application.type,
status: application.status,
createdAt: application.createdAt,
}));
};
export const fetchAdminArticles = async (token: string) => {
const response = await api.get<NewsArticle[]>("/news/admin/articles", {
headers: { Authorization: `Bearer ${token}` },
});
return response.data;
};
export const fetchPublicNews = async () => {
const response = await api.get<NewsArticle[]>("/news");
return response.data;
};

View File

@ -1,5 +0,0 @@
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import type { AppDispatch, RootState } from "./index";
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

View File

@ -1,11 +0,0 @@
import { configureStore } from "@reduxjs/toolkit";
import authReducer from "./slices/authSlice";
export const store = configureStore({
reducer: {
auth: authReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

View File

@ -1,30 +0,0 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import type { User } from "../../types";
export interface AuthState {
token: string | null;
user: User | null;
}
const initialState: AuthState = {
token: null,
user: null,
};
const authSlice = createSlice({
name: "auth",
initialState,
reducers: {
setCredentials: (state, action: PayloadAction<{ token: string; user: User }>) => {
state.token = action.payload.token;
state.user = action.payload.user;
},
clearCredentials: (state) => {
state.token = null;
state.user = null;
},
},
});
export const { setCredentials, clearCredentials } = authSlice.actions;
export default authSlice.reducer;

View File

@ -1,25 +0,0 @@
export type Role = "ADMIN" | "STAFF" | "CITIZEN";
export interface User {
id: string;
email: string;
firstName: string;
lastName: string;
role: Role;
}
export interface ApplicationSummary {
id: string;
type: string;
status: string;
createdAt: string;
}
export interface NewsArticle {
id: string;
title: string;
slug: string;
content: string;
published: boolean;
publishedAt?: string;
}

View File

@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -1,9 +0,0 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -1,15 +0,0 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": {
target: process.env.VITE_API_URL ?? "http://localhost:4000",
changeOrigin: true,
},
},
},
});