Add Onelga Local Services full-stack reference implementation
This commit is contained in:
parent
a09e74243f
commit
ff259c1903
148
projects/onelga-local-services/README.md
Normal file
148
projects/onelga-local-services/README.md
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
# 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
|
||||||
9
projects/onelga-local-services/backend/.env.example
Normal file
9
projects/onelga-local-services/backend/.env.example
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
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"
|
||||||
26
projects/onelga-local-services/backend/.eslintrc.json
Normal file
26
projects/onelga-local-services/backend/.eslintrc.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
4
projects/onelga-local-services/backend/.gitignore
vendored
Normal file
4
projects/onelga-local-services/backend/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
/node_modules
|
||||||
|
/dist
|
||||||
|
/.env
|
||||||
|
/prisma/dev.db*
|
||||||
48
projects/onelga-local-services/backend/package.json
Normal file
48
projects/onelga-local-services/backend/package.json
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
94
projects/onelga-local-services/backend/prisma/schema.prisma
Normal file
94
projects/onelga-local-services/backend/prisma/schema.prisma
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
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())
|
||||||
|
}
|
||||||
81
projects/onelga-local-services/backend/prisma/seed.ts
Normal file
81
projects/onelga-local-services/backend/prisma/seed.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
97
projects/onelga-local-services/backend/scripts/seed-test.ts
Normal file
97
projects/onelga-local-services/backend/scripts/seed-test.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
53
projects/onelga-local-services/backend/scripts/smoke-test.ts
Normal file
53
projects/onelga-local-services/backend/scripts/smoke-test.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
24
projects/onelga-local-services/backend/src/app.ts
Normal file
24
projects/onelga-local-services/backend/src/app.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
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;
|
||||||
32
projects/onelga-local-services/backend/src/config/env.ts
Normal file
32
projects/onelga-local-services/backend/src/config/env.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
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;
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
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);
|
||||||
|
};
|
||||||
@ -0,0 +1,90 @@
|
|||||||
|
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) });
|
||||||
|
};
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
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);
|
||||||
|
};
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
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();
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
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;
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
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;
|
||||||
12
projects/onelga-local-services/backend/src/routes/index.ts
Normal file
12
projects/onelga-local-services/backend/src/routes/index.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
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;
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
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;
|
||||||
19
projects/onelga-local-services/backend/src/server.ts
Normal file
19
projects/onelga-local-services/backend/src/server.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
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();
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
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 };
|
||||||
|
};
|
||||||
@ -0,0 +1,77 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export default prisma;
|
||||||
15
projects/onelga-local-services/backend/src/utils/token.ts
Normal file
15
projects/onelga-local-services/backend/src/utils/token.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
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 };
|
||||||
|
};
|
||||||
28
projects/onelga-local-services/backend/tsconfig.json
Normal file
28
projects/onelga-local-services/backend/tsconfig.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
}
|
||||||
17
projects/onelga-local-services/docker-compose.yml
Normal file
17
projects/onelga-local-services/docker-compose.yml
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
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:
|
||||||
1
projects/onelga-local-services/frontend/.env.example
Normal file
1
projects/onelga-local-services/frontend/.env.example
Normal file
@ -0,0 +1 @@
|
|||||||
|
VITE_API_URL="http://localhost:4000"
|
||||||
22
projects/onelga-local-services/frontend/.eslintrc.json
Normal file
22
projects/onelga-local-services/frontend/.eslintrc.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
projects/onelga-local-services/frontend/.gitignore
vendored
Normal file
3
projects/onelga-local-services/frontend/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
/node_modules
|
||||||
|
/dist
|
||||||
|
/.env
|
||||||
12
projects/onelga-local-services/frontend/index.html
Normal file
12
projects/onelga-local-services/frontend/index.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!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>
|
||||||
38
projects/onelga-local-services/frontend/package.json
Normal file
38
projects/onelga-local-services/frontend/package.json
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
37
projects/onelga-local-services/frontend/src/App.tsx
Normal file
37
projects/onelga-local-services/frontend/src/App.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
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;
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
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;
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
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;
|
||||||
37
projects/onelga-local-services/frontend/src/main.tsx
Normal file
37
projects/onelga-local-services/frontend/src/main.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
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>,
|
||||||
|
);
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
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;
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
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;
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
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;
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
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;
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
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;
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
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;
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
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;
|
||||||
52
projects/onelga-local-services/frontend/src/services/api.ts
Normal file
52
projects/onelga-local-services/frontend/src/services/api.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
|
||||||
|
import type { AppDispatch, RootState } from "./index";
|
||||||
|
|
||||||
|
export const useAppDispatch = () => useDispatch<AppDispatch>();
|
||||||
|
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||||
11
projects/onelga-local-services/frontend/src/store/index.ts
Normal file
11
projects/onelga-local-services/frontend/src/store/index.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
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;
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
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;
|
||||||
25
projects/onelga-local-services/frontend/src/types/index.ts
Normal file
25
projects/onelga-local-services/frontend/src/types/index.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
21
projects/onelga-local-services/frontend/tsconfig.json
Normal file
21
projects/onelga-local-services/frontend/tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"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" }]
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
15
projects/onelga-local-services/frontend/vite.config.ts
Normal file
15
projects/onelga-local-services/frontend/vite.config.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user