Restructure backend into modular API layers with admin/organizador/invitados routes, add role-based middleware, flatten module models, and update build scripts

This commit is contained in:
mberlin
2026-03-19 09:15:22 -03:00
parent 989b19d338
commit 996c6e9241
285 changed files with 6551 additions and 2914 deletions

View File

@@ -0,0 +1,8 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.default = void 0;
var mikro_orm_config_1 = require("./src/mikro-orm.config");
Object.defineProperty(exports, "default", { enumerable: true, get: function () { return __importDefault(mikro_orm_config_1).default; } });

Binary file not shown.

View File

@@ -0,0 +1,36 @@
{
"name": "@planner/server",
"version": "0.1.0",
"private": true,
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"dev": "npm run build && node dist/index.js",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"migration:generate": "mikro-orm migration:create",
"migration:run": "mikro-orm migration:up",
"migration:down": "mikro-orm migration:down"
},
"dependencies": {
"@mikro-orm/core": "^7.0.3",
"@mikro-orm/migrations": "^7.0.3",
"@mikro-orm/postgresql": "^7.0.3",
"bcryptjs": "^3.0.3",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"pg": "^8.20.0",
"reflect-metadata": "^0.1.13",
"uuid": "^13.0.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@mikro-orm/cli": "^7.0.3",
"@types/express": "^4.17.0",
"@types/jsonwebtoken": "^9.0.0",
"@types/node": "^20.0.0",
"dotenv": "^16.3.1",
"ts-node": "^10.9.2",
"typescript": "^5.0.0"
}
}

View File

@@ -0,0 +1,15 @@
const fs = require('fs');
const path = require('path');
const base = path.resolve(__dirname, '../src/modules');
const mods = ['gifts', 'guests', 'todos'];
for (const mod of mods) {
const folder = path.join(base, mod, 'model');
if (fs.existsSync(folder)) {
try {
fs.rmSync(folder, { recursive: true, force: true });
console.log(`Removed folder ${folder}`);
} catch (err) {
console.warn(`Could not remove ${folder}:`, err.message);
}
}
}

View File

@@ -0,0 +1,33 @@
const fs = require('fs');
const path = require('path');
const base = path.resolve(__dirname, '../src/modules');
const mapping = {
guests: 'guest.model.ts',
gifts: 'gift.model.ts',
todos: 'todo.model.ts',
};
for (const mod of Object.keys(mapping)) {
const modDir = path.join(base, mod);
const modelDir = path.join(modDir, 'model');
const src = path.join(modelDir, mapping[mod]);
const dst = path.join(modDir, 'model.ts');
if (fs.existsSync(src)) {
console.log(`Moving ${src} -> ${dst}`);
fs.renameSync(src, dst);
}
if (fs.existsSync(modelDir)) {
try {
const files = fs.readdirSync(modelDir);
if (files.length === 0) {
console.log(`Removing empty directory ${modelDir}`);
fs.rmdirSync(modelDir);
}
} catch (e) {
console.warn(`Failed to clean model directory ${modelDir}:`, e.message);
}
}
}

View File

@@ -0,0 +1,51 @@
import { Router } from "express";
import { GiftService } from "../../../modules/gifts/service";
import { validateBody } from "../../../core/middleware/validate";
import { createGiftSchema, createContributionSchema } from "../../../modules/gifts/types";
import { requireAuth } from "../../../core/middleware/auth";
const service = new GiftService();
export function registerGiftRoutes(router: Router) {
const r = Router();
// Admin routes are protected and can include additional checks
r.use(requireAuth);
r.post("/gift", validateBody(createGiftSchema), async (req, res) => {
const ownerId = (req as any).user?.id;
const gift = await service.createGift({ ...req.body, ownerId });
res.json(gift);
});
r.get("/gift", async (_req, res) => {
const gifts = await service.listGifts();
res.json(gifts);
});
r.get("/gift/:id", async (req, res) => {
const requesterId = (req as any).user?.id;
const gift = await service.getGiftById(req.params.id, requesterId);
if (!gift) {
return res.status(404).json({ error: "Gift not found" });
}
res.json(gift);
});
r.post(
"/gift/contribution",
validateBody(createContributionSchema),
async (req, res) => {
const contribution = await service.createContribution(req.body);
res.json(contribution);
},
);
r.get("/gift/:id/contributions", async (req, res) => {
const requesterId = (req as any).user?.id;
const contributions = await service.listContributions(req.params.id, requesterId);
res.json(contributions);
});
router.use(r);
}

View File

@@ -0,0 +1,36 @@
import { Router } from "express";
import { GuestService } from "../../../modules/guests/service";
import { validateBody } from "../../../core/middleware/validate";
import { createGuestSchema, updateRsvpSchema } from "../../../modules/guests/types";
const service = new GuestService();
export function registerGuestRoutes(router: Router) {
const r = Router();
// Admin can manage guests (same endpoints, can be extended with extra checks)
r.post("/guest", validateBody(createGuestSchema), async (req, res) => {
const guest = await service.createGuest(req.body);
res.json(guest);
});
r.get("/guest", async (_req, res) => {
const guests = await service.listGuests();
res.json(guests);
});
r.patch(
"/guest/:id/rsvp",
validateBody(updateRsvpSchema),
async (req, res) => {
const updated = await service.updateRsvp(req.params.id, req.body);
if (!updated) {
return res.status(404).json({ error: "Guest not found" });
}
res.json(updated);
},
);
router.use(r);
}

View File

@@ -0,0 +1,19 @@
import { Router } from "express";
import { requireRole } from "../middleware";
import { registerGuestRoutes } from "./guests/routes";
import { registerGiftRoutes } from "./gifts/routes";
import { registerTodoRoutes } from "./todos/routes";
export function registerAdminRoutes() {
const router = Router();
// Admin routes are intended for administrators.
// Access control is enforced in api/index.ts, but individual route groups
// also apply role checks so they remain safe if reused elsewhere.
registerGuestRoutes(router);
registerGiftRoutes(router);
registerTodoRoutes(router);
return router;
}

View File

@@ -0,0 +1,33 @@
import { Router } from "express";
import { TodoService } from "../../../modules/todos/service";
import { validateBody } from "../../../core/middleware/validate";
import { createTodoSchema } from "../../../modules/todos/types";
import { requireAuth } from "../../../core/middleware/auth";
const service = new TodoService();
export function registerTodoRoutes(router: Router) {
const r = Router();
r.use(requireAuth);
r.post("/todo", validateBody(createTodoSchema), async (req, res) => {
const todo = await service.createTodo(req.body);
res.json(todo);
});
r.get("/todo", async (_req, res) => {
const todos = await service.listTodos();
res.json(todos);
});
r.patch("/todo/:id/complete", async (req, res) => {
const updated = await service.markComplete(req.params.id);
if (!updated) {
return res.status(404).json({ error: "Todo not found" });
}
res.json(updated);
});
router.use(r);
}

View File

@@ -0,0 +1,8 @@
import { z } from "zod";
export const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
});
export type LoginDto = z.infer<typeof loginSchema>;

View File

@@ -0,0 +1,9 @@
import { z } from "zod";
export const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
name: z.string().optional(),
});
export type RegisterDto = z.infer<typeof registerSchema>;

View File

@@ -0,0 +1,47 @@
import { Router } from "express";
import { AuthService } from "../../modules/auth/auth.service";
import { UserService } from "../../modules/auth/user.service";
import { validateBody } from "../../core/middleware/validate";
import { loginSchema, registerSchema } from "../../modules/auth/types";
const authService = new AuthService();
const userService = new UserService();
export function registerAuthRoutes() {
const router = Router();
router.post("/register", validateBody(registerSchema), async (req, res) => {
try {
const { name, email, password, role } = req.body;
const requestedRole = (role as "admin" | "organizer" | undefined) ?? "organizer";
if (requestedRole === "admin" && process.env.ALLOW_ADMIN_REGISTRATION !== "true") {
return res.status(403).json({ error: "Admin registration is disabled" });
}
const user = await userService.createUser(
process.env.DEFAULT_TENANT_ID || "default",
email,
password,
name,
requestedRole,
);
res.json({ id: user.id, email: user.email, name: user.name, role: user.role, publishKey: user.publishKey });
} catch (err: any) {
res.status(400).json({ error: err.message });
}
});
router.post("/login", validateBody(loginSchema), async (req, res) => {
const { email, password } = req.body;
const user = await userService.validateUser(process.env.DEFAULT_TENANT_ID || "default", email, password);
if (!user) {
return res.status(401).json({ error: "Invalid credentials" });
}
const token = authService.sign({ sub: user.id, email: user.email, role: user.role });
res.json({ token });
});
return router;
}

View File

@@ -0,0 +1,11 @@
import { z } from "zod";
export const createContributionSchema = z.object({
giftId: z.string().uuid(),
contributorName: z.string().min(1),
contributorEmail: z.string().email().optional(),
amount: z.number().min(0),
type: z.enum(["individual", "group"]).optional(),
});
export type CreateContributionDto = z.infer<typeof createContributionSchema>;

View File

@@ -0,0 +1,11 @@
import { z } from "zod";
export const createGiftSchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
imageUrl: z.string().optional(),
price: z.number().optional(),
experience: z.boolean().optional(),
});
export type CreateGiftDto = z.infer<typeof createGiftSchema>;

View File

@@ -0,0 +1,51 @@
import { Router } from "express";
import { GiftService } from "../../modules/gifts/service";
import { validateBody } from "../../core/middleware/validate";
import { createGiftSchema, createContributionSchema } from "../../modules/gifts/types";
import { requireAuth } from "../../core/middleware/auth";
const service = new GiftService();
export function registerGiftRoutes(router: Router) {
const r = Router();
// Protected routes
r.use(requireAuth);
r.post("/gift", validateBody(createGiftSchema), async (req, res) => {
const ownerId = (req as any).user?.id;
const gift = await service.createGift({ ...req.body, ownerId });
res.json(gift);
});
r.get("/gift", async (_req, res) => {
const gifts = await service.listGifts();
res.json(gifts);
});
r.get("/gift/:id", async (req, res) => {
const requesterId = (req as any).user?.id;
const gift = await service.getGiftById(req.params.id, requesterId);
if (!gift) {
return res.status(404).json({ error: "Gift not found" });
}
res.json(gift);
});
r.post(
"/gift/contribution",
validateBody(createContributionSchema),
async (req, res) => {
const contribution = await service.createContribution(req.body);
res.json(contribution);
},
);
r.get("/gift/:id/contributions", async (req, res) => {
const requesterId = (req as any).user?.id;
const contributions = await service.listContributions(req.params.id, requesterId);
res.json(contributions);
});
router.use(r);
}

View File

@@ -0,0 +1,11 @@
import { z } from "zod";
export const createGuestSchema = z.object({
name: z.string().min(1),
email: z.string().email().optional(),
phone: z.string().optional(),
rsvp: z.boolean().optional(),
tableId: z.string().uuid().optional(),
});
export type CreateGuestDto = z.infer<typeof createGuestSchema>;

View File

@@ -0,0 +1,8 @@
import { z } from "zod";
export const updateRsvpSchema = z.object({
rsvp: z.boolean(),
tableId: z.string().uuid().optional(),
});
export type UpdateRsvpDto = z.infer<typeof updateRsvpSchema>;

View File

@@ -0,0 +1,34 @@
import { Router } from "express";
import { GuestService } from "../../modules/guests/service";
import { validateBody } from "../../core/middleware/validate";
import { createGuestSchema, updateRsvpSchema } from "../../modules/guests/types";
const service = new GuestService();
export function registerGuestRoutes(router: Router) {
const r = Router();
r.post("/guest", validateBody(createGuestSchema), async (req, res) => {
const guest = await service.createGuest(req.body);
res.json(guest);
});
r.get("/guest", async (_req, res) => {
const guests = await service.listGuests();
res.json(guests);
});
r.patch(
"/guest/:id/rsvp",
validateBody(updateRsvpSchema),
async (req, res) => {
const updated = await service.updateRsvp(req.params.id, req.body);
if (!updated) {
return res.status(404).json({ error: "Guest not found" });
}
res.json(updated);
},
);
router.use(r);
}

View File

@@ -0,0 +1,7 @@
import { Express } from "express";
export function registerHealthRoutes(app: Express) {
app.get("/api/health", (_req, res) => {
res.json({ ok: true, timestamp: new Date().toISOString() });
});
}

View File

@@ -0,0 +1,25 @@
import type { Express } from "express";
import { registerHealthRoutes } from "./health/health.routes";
import { registerAdminRoutes } from "./admin/routes";
import { registerAuthRoutes } from "./auth/routes";
import { registerOrganizadorRoutes } from "./organizador/routes";
import { registerInvitadosRoutes } from "./invitados/routes";
import { requireRole, requireInvitadoAccess } from "./middleware";
export function registerApiRoutes(app: Express) {
// shared middleware could be added here (logging, tenant resolution, etc.)
registerHealthRoutes(app);
// Auth routes (register / login) are global and shared by all clients.
app.use("/api/auth", registerAuthRoutes());
// Admin-only routes
app.use("/api/admin", requireRole("admin"), registerAdminRoutes());
// Organizers + admins
app.use("/api/organizador", requireRole("admin", "organizer"), registerOrganizadorRoutes());
// Guests can access via publish key or token (guest/organizer/admin)
app.use("/api/invitados", requireInvitadoAccess, registerInvitadosRoutes());
}

View File

@@ -0,0 +1,52 @@
import { Router, RequestHandler } from "express";
import { GiftService } from "../../../modules/gifts/service";
import { validateBody } from "../../../core/middleware/validate";
import { createGiftSchema, createContributionSchema } from "../../../modules/gifts/types";
const service = new GiftService();
export function registerGiftRoutes(router: Router, authMiddleware?: RequestHandler) {
const r = Router();
// Frontend routes are protected to allow user access
if (authMiddleware) {
r.use(authMiddleware);
}
r.post("/gift", validateBody(createGiftSchema), async (req, res) => {
const ownerId = (req as any).user?.id;
const gift = await service.createGift({ ...req.body, ownerId });
res.json(gift);
});
r.get("/gift", async (_req, res) => {
const gifts = await service.listGifts();
res.json(gifts);
});
r.get("/gift/:id", async (req, res) => {
const requesterId = (req as any).user?.id;
const gift = await service.getGiftById(req.params.id, requesterId);
if (!gift) {
return res.status(404).json({ error: "Gift not found" });
}
res.json(gift);
});
r.post(
"/gift/contribution",
validateBody(createContributionSchema),
async (req, res) => {
const contribution = await service.createContribution(req.body);
res.json(contribution);
},
);
r.get("/gift/:id/contributions", async (req, res) => {
const requesterId = (req as any).user?.id;
const contributions = await service.listContributions(req.params.id, requesterId);
res.json(contributions);
});
router.use(r);
}

View File

@@ -0,0 +1,38 @@
import { Router, RequestHandler } from "express";
import { GuestService } from "../../../modules/guests/service";
import { validateBody } from "../../../core/middleware/validate";
import { createGuestSchema, updateRsvpSchema } from "../../../modules/guests/types";
const service = new GuestService();
export function registerGuestRoutes(router: Router, authMiddleware?: RequestHandler) {
const r = Router();
if (authMiddleware) {
r.use(authMiddleware);
}
r.post("/guest", validateBody(createGuestSchema), async (req, res) => {
const guest = await service.createGuest(req.body);
res.json(guest);
});
r.get("/guest", async (_req, res) => {
const guests = await service.listGuests();
res.json(guests);
});
r.patch(
"/guest/:id/rsvp",
validateBody(updateRsvpSchema),
async (req, res) => {
const updated = await service.updateRsvp(req.params.id, req.body);
if (!updated) {
return res.status(404).json({ error: "Guest not found" });
}
res.json(updated);
},
);
router.use(r);
}

View File

@@ -0,0 +1,16 @@
import { Router } from "express";
import { requireInvitadoAccess } from "../middleware";
import { registerGuestRoutes } from "./guests/routes";
import { registerGiftRoutes } from "./gifts/routes";
import { registerTodoRoutes } from "./todos/routes";
export function registerInvitadosRoutes() {
const router = Router();
// Invitados routes can be accessed via publish key (or an authenticated token).
registerGuestRoutes(router, requireInvitadoAccess);
registerGiftRoutes(router, requireInvitadoAccess);
registerTodoRoutes(router, requireInvitadoAccess);
return router;
}

View File

@@ -0,0 +1,34 @@
import { Router, RequestHandler } from "express";
import { TodoService } from "../../../modules/todos/service";
import { validateBody } from "../../../core/middleware/validate";
import { createTodoSchema } from "../../../modules/todos/types";
const service = new TodoService();
export function registerTodoRoutes(router: Router, authMiddleware?: RequestHandler) {
const r = Router();
if (authMiddleware) {
r.use(authMiddleware);
}
r.post("/todo", validateBody(createTodoSchema), async (req, res) => {
const todo = await service.createTodo(req.body);
res.json(todo);
});
r.get("/todo", async (_req, res) => {
const todos = await service.listTodos();
res.json(todos);
});
r.patch("/todo/:id/complete", async (req, res) => {
const updated = await service.markComplete(req.params.id);
if (!updated) {
return res.status(404).json({ error: "Todo not found" });
}
res.json(updated);
});
router.use(r);
}

View File

@@ -0,0 +1,108 @@
import type { NextFunction, Request, Response } from "express";
import { AuthService } from "../modules/auth/auth.service";
import { UserService } from "../modules/auth/user.service";
import type { UserRole } from "../modules/auth/model";
export interface ApiRequest extends Request {
user?: {
id: string;
email: string;
role: UserRole;
publishKeyOwnerId?: string;
};
}
const authService = new AuthService();
const userService = new UserService();
function getToken(req: Request) {
const auth = req.headers.authorization?.split(" ");
return auth?.[1] ?? null;
}
function verifyToken(token: string) {
const payload = authService.verify(token) as any;
if (!payload || !payload.sub || !payload.email) return null;
return payload;
}
export function requireAuth(req: ApiRequest, res: Response, next: NextFunction) {
const token = getToken(req);
if (!token) {
return res.status(401).json({ error: "Missing Authorization header" });
}
const payload = verifyToken(token);
if (!payload) {
return res.status(401).json({ error: "Invalid token" });
}
req.user = {
id: payload.sub,
email: payload.email,
role: (payload.role ?? "guest") as UserRole,
};
next();
}
export function requireRole(...allowedRoles: UserRole[]) {
return (req: ApiRequest, res: Response, next: NextFunction) => {
const token = getToken(req);
if (!token) {
return res.status(401).json({ error: "Missing Authorization header" });
}
const payload = verifyToken(token);
if (!payload) {
return res.status(401).json({ error: "Invalid token" });
}
const role = (payload.role ?? "guest") as UserRole;
if (!allowedRoles.includes(role)) {
return res.status(403).json({ error: "Forbidden" });
}
req.user = {
id: payload.sub,
email: payload.email,
role,
};
next();
};
}
export async function requireInvitadoAccess(req: ApiRequest, res: Response, next: NextFunction) {
const token = getToken(req);
if (token) {
const payload = verifyToken(token);
if (payload) {
req.user = {
id: payload.sub,
email: payload.email,
role: (payload.role ?? "guest") as UserRole,
};
return next();
}
}
const publishKey = (req.headers["x-publish-key"] as string) || (req.query.publishKey as string);
if (!publishKey) {
return res.status(401).json({ error: "Missing publish key" });
}
const organizer = await userService.findByPublishKey(publishKey);
if (!organizer) {
return res.status(401).json({ error: "Invalid publish key" });
}
req.user = {
id: "",
email: "",
role: "guest",
publishKeyOwnerId: organizer.id,
};
next();
}

View File

@@ -0,0 +1,52 @@
import { Router, RequestHandler } from "express";
import { GiftService } from "../../../modules/gifts/service";
import { validateBody } from "../../../core/middleware/validate";
import { createGiftSchema, createContributionSchema } from "../../../modules/gifts/types";
const service = new GiftService();
export function registerGiftRoutes(router: Router, authMiddleware?: RequestHandler) {
const r = Router();
// Frontend routes are protected to allow user access
if (authMiddleware) {
r.use(authMiddleware);
}
r.post("/gift", validateBody(createGiftSchema), async (req, res) => {
const ownerId = (req as any).user?.id;
const gift = await service.createGift({ ...req.body, ownerId });
res.json(gift);
});
r.get("/gift", async (_req, res) => {
const gifts = await service.listGifts();
res.json(gifts);
});
r.get("/gift/:id", async (req, res) => {
const requesterId = (req as any).user?.id;
const gift = await service.getGiftById(req.params.id, requesterId);
if (!gift) {
return res.status(404).json({ error: "Gift not found" });
}
res.json(gift);
});
r.post(
"/gift/contribution",
validateBody(createContributionSchema),
async (req, res) => {
const contribution = await service.createContribution(req.body);
res.json(contribution);
},
);
r.get("/gift/:id/contributions", async (req, res) => {
const requesterId = (req as any).user?.id;
const contributions = await service.listContributions(req.params.id, requesterId);
res.json(contributions);
});
router.use(r);
}

View File

@@ -0,0 +1,38 @@
import { Router, RequestHandler } from "express";
import { GuestService } from "../../../modules/guests/service";
import { validateBody } from "../../../core/middleware/validate";
import { createGuestSchema, updateRsvpSchema } from "../../../modules/guests/types";
const service = new GuestService();
export function registerGuestRoutes(router: Router, authMiddleware?: RequestHandler) {
const r = Router();
if (authMiddleware) {
r.use(authMiddleware);
}
r.post("/guest", validateBody(createGuestSchema), async (req, res) => {
const guest = await service.createGuest(req.body);
res.json(guest);
});
r.get("/guest", async (_req, res) => {
const guests = await service.listGuests();
res.json(guests);
});
r.patch(
"/guest/:id/rsvp",
validateBody(updateRsvpSchema),
async (req, res) => {
const updated = await service.updateRsvp(req.params.id, req.body);
if (!updated) {
return res.status(404).json({ error: "Guest not found" });
}
res.json(updated);
},
);
router.use(r);
}

View File

@@ -0,0 +1,18 @@
import { Router } from "express";
import { requireRole } from "../middleware";
import { registerGuestRoutes } from "./guests/routes";
import { registerGiftRoutes } from "./gifts/routes";
import { registerTodoRoutes } from "./todos/routes";
export function registerOrganizadorRoutes() {
const router = Router();
// Organizador routes are for organizers (and admins).
// Authentication and role enforcement is handled in api/index.ts.
registerGuestRoutes(router, requireRole("admin", "organizer"));
registerGiftRoutes(router, requireRole("admin", "organizer"));
registerTodoRoutes(router, requireRole("admin", "organizer"));
return router;
}

View File

@@ -0,0 +1,34 @@
import { Router, RequestHandler } from "express";
import { TodoService } from "../../../modules/todos/service";
import { validateBody } from "../../../core/middleware/validate";
import { createTodoSchema } from "../../../modules/todos/types";
const service = new TodoService();
export function registerTodoRoutes(router: Router, authMiddleware?: RequestHandler) {
const r = Router();
if (authMiddleware) {
r.use(authMiddleware);
}
r.post("/todo", validateBody(createTodoSchema), async (req, res) => {
const todo = await service.createTodo(req.body);
res.json(todo);
});
r.get("/todo", async (_req, res) => {
const todos = await service.listTodos();
res.json(todos);
});
r.patch("/todo/:id/complete", async (req, res) => {
const updated = await service.markComplete(req.params.id);
if (!updated) {
return res.status(404).json({ error: "Todo not found" });
}
res.json(updated);
});
router.use(r);
}

View File

@@ -0,0 +1,9 @@
import { z } from "zod";
export const createTodoSchema = z.object({
title: z.string().min(1),
description: z.string().optional(),
dueDate: z.string().optional(),
});
export type CreateTodoDto = z.infer<typeof createTodoSchema>;

View File

@@ -0,0 +1,33 @@
import { Router } from "express";
import { TodoService } from "../../modules/todos/service";
import { validateBody } from "../../core/middleware/validate";
import { createTodoSchema } from "../../modules/todos/types";
import { requireAuth } from "../../core/middleware/auth";
const service = new TodoService();
export function registerTodoRoutes(router: Router) {
const r = Router();
r.use(requireAuth);
r.post("/todo", validateBody(createTodoSchema), async (req, res) => {
const todo = await service.createTodo(req.body);
res.json(todo);
});
r.get("/todo", async (_req, res) => {
const todos = await service.listTodos();
res.json(todos);
});
r.patch("/todo/:id/complete", async (req, res) => {
const updated = await service.markComplete(req.params.id);
if (!updated) {
return res.status(404).json({ error: "Todo not found" });
}
res.json(updated);
});
router.use(r);
}

View File

@@ -0,0 +1,23 @@
import express from "express";
import { json } from "body-parser";
import path from "path";
import { registerApiRoutes } from "./api";
export async function createApp() {
const app = express();
app.use(json());
registerApiRoutes(app);
// In production, serve the built admin UI
if (process.env.NODE_ENV === "production") {
const adminDist = path.join(__dirname, "../../admin/dist");
app.use(express.static(adminDist));
app.get("/*", (_req, res) => {
res.sendFile(path.join(adminDist, "index.html"));
});
}
return app;
}

View File

@@ -0,0 +1,24 @@
import { MikroORM } from "@mikro-orm/core";
import config from "../mikro-orm.config";
let orm: MikroORM | null = null;
export async function initOrm() {
if (orm) {
return orm;
}
// MikroORM types can be a bit strict in TS; cast to any to avoid mismatch
orm = await MikroORM.init(config as any);
return orm;
}
export function getOrm() {
if (!orm) {
throw new Error("ORM not initialized. Call initOrm() first.");
}
return orm;
}
export function getEm() {
return getOrm().em.fork();
}

View File

@@ -0,0 +1,3 @@
import { EventEmitter } from "events";
export const eventBus = new EventEmitter();

View File

@@ -0,0 +1,23 @@
import type { NextFunction, Request, Response } from "express";
import jwt from "jsonwebtoken";
export interface AuthenticatedRequest extends Request {
user?: { id: string; email: string; role?: string };
}
export function requireAuth(req: AuthenticatedRequest, res: Response, next: NextFunction) {
const auth = req.headers.authorization?.split(" ");
const token = auth?.[1];
if (!token) {
return res.status(401).json({ error: "Missing Authorization header" });
}
try {
const payload = jwt.verify(token, process.env.JWT_SECRET ?? "change-me") as { sub: string; email: string };
req.user = { id: payload.sub, email: payload.email };
next();
} catch (err) {
return res.status(401).json({ error: "Invalid token" });
}
}

View File

@@ -0,0 +1,13 @@
import type { NextFunction, Request, Response } from "express";
import type { ZodSchema } from "zod";
export function validateBody(schema: ZodSchema<any>) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: "Validation failed", issues: result.error.issues });
}
req.body = result.data;
next();
};
}

View File

@@ -0,0 +1,9 @@
import { v4 as uuidv4 } from "uuid";
export abstract class BaseEntity {
id: string = uuidv4();
tenantId!: string;
metadata?: Record<string, any>;
createdAt: Date = new Date();
updatedAt: Date = new Date();
}

View File

@@ -0,0 +1,3 @@
export function getTenantId() {
return process.env.DEFAULT_TENANT_ID || 'default';
}

View File

@@ -0,0 +1,20 @@
import { createApp } from "./app";
import { initOrm } from "./core/db";
async function main() {
await initOrm();
const app = await createApp();
const port = process.env.PORT ? Number(process.env.PORT) : 3000;
app.listen(port, () => {
// eslint-disable-next-line no-console
console.log(`Server listening on http://localhost:${port}`);
});
}
main().catch((err) => {
// eslint-disable-next-line no-console
console.error("Failed to start server", err);
process.exit(1);
});

View File

View File

View File

@@ -0,0 +1,19 @@
import path from "path";
import { defineConfig } from "@mikro-orm/postgresql";
const src = __dirname;
const dist = path.join(src, "../dist");
export default defineConfig({
clientUrl: process.env.DATABASE_URL ?? "postgresql://postgres:postgres@localhost:5432/planner",
entities: [path.join(dist, "modules", "**", "model.js")],
entitiesTs: [path.join(src, "modules", "**", "model.ts")],
migrations: {
path: path.join(dist, "migrations"),
pathTs: path.join(src, "migrations"),
},
seeder: {
path: path.join(dist, "seeders"),
pathTs: path.join(src, "seeders"),
},
});

View File

@@ -0,0 +1,17 @@
import jwt from "jsonwebtoken";
export class AuthService {
sign(payload: Record<string, any>) {
const secret = process.env.JWT_SECRET || "change-me";
return jwt.sign(payload, secret, { expiresIn: "7d" });
}
verify(token: string) {
const secret = process.env.JWT_SECRET || "change-me";
try {
return jwt.verify(token, secret) as any;
} catch {
return null;
}
}
}

View File

@@ -0,0 +1,66 @@
import { EntitySchema } from "@mikro-orm/core";
import { v4 as uuidv4 } from "uuid";
import { BaseEntity } from "../../core/model/base.entity";
export type UserRole = "admin" | "organizer" | "guest";
export class User extends BaseEntity {
id!: string;
tenantId!: string;
createdAt!: Date;
updatedAt!: Date;
email!: string;
name?: string;
passwordHash!: string;
role!: UserRole;
publishKey?: string;
}
export const UserSchema = new EntitySchema<User>({
class: User,
tableName: "users",
properties: {
id: {
type: "uuid",
primary: true,
default: uuidv4(),
},
tenantId: {
type: "string",
},
metadata: {
type: "json",
nullable: true,
},
createdAt: {
type: "date",
default: new Date(),
},
updatedAt: {
type: "date",
default: new Date(),
},
email: {
type: "string",
nullable: false,
},
name: {
type: "string",
nullable: true,
},
passwordHash: {
type: "string",
nullable: false,
},
role: {
type: "string",
nullable: false,
default: "organizer",
},
publishKey: {
type: "string",
nullable: true,
},
},
});

View File

@@ -0,0 +1,17 @@
import { z } from "zod";
export const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
name: z.string().optional(),
role: z.enum(["admin", "organizer"]).optional(),
});
export type RegisterDto = z.infer<typeof registerSchema>;
export const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
});
export type LoginDto = z.infer<typeof loginSchema>;

View File

@@ -0,0 +1,46 @@
import { getEm } from "../../core/db";
import * as bcrypt from "bcryptjs";
import { v4 as uuidv4 } from "uuid";
import { User, UserRole } from "./model";
export class UserService {
async createUser(
tenantId: string,
email: string,
password: string,
name?: string,
role: UserRole = "organizer",
) {
const em = getEm();
const existing = await em.findOne(User, { tenantId, email });
if (existing) {
throw new Error("User already exists");
}
const user = em.create(User, {
tenantId,
email,
name,
role,
publishKey: role === "organizer" ? uuidv4() : undefined,
passwordHash: await bcrypt.hash(password, 10),
} as any);
await em.persist(user);
await em.flush();
return user;
}
async validateUser(tenantId: string, email: string, password: string) {
const em = getEm();
const user = await em.findOne(User, { tenantId, email });
if (!user) return null;
const isValid = await bcrypt.compare(password, user.passwordHash);
return isValid ? user : null;
}
async findByPublishKey(publishKey: string) {
const em = getEm();
return await em.findOne(User, { publishKey, role: "organizer" });
}
}

View File

@@ -0,0 +1,134 @@
import { EntitySchema } from "@mikro-orm/core";
import { v4 as uuidv4 } from "uuid";
import { BaseEntity } from "../../core/model/base.entity";
export class Gift extends BaseEntity {
id!: string;
tenantId!: string;
createdAt!: Date;
updatedAt!: Date;
name!: string;
description?: string;
imageUrl?: string;
price?: number;
experience?: boolean;
status: string = "pending";
ownerId?: string;
}
export class GiftContribution extends BaseEntity {
id!: string;
tenantId!: string;
createdAt!: Date;
updatedAt!: Date;
giftId!: string;
contributorName!: string;
contributorEmail?: string;
amount!: number;
type: string = "individual";
status: string = "pending";
}
export const GiftSchema = new EntitySchema<Gift>({
class: Gift,
tableName: "gifts",
properties: {
id: {
type: "uuid",
primary: true,
default: uuidv4(),
},
tenantId: {
type: "string",
},
metadata: {
type: "json",
nullable: true,
},
createdAt: {
type: "date",
default: new Date(),
},
updatedAt: {
type: "date",
default: new Date(),
},
name: {
type: "string",
},
description: {
type: "string",
nullable: true,
},
imageUrl: {
type: "string",
nullable: true,
},
price: {
type: "float",
nullable: true,
},
experience: {
type: "boolean",
nullable: true,
},
status: {
type: "string",
default: "pending",
},
ownerId: {
type: "string",
nullable: true,
},
},
});
export const GiftContributionSchema = new EntitySchema<GiftContribution>({
class: GiftContribution,
tableName: "gift_contributions",
properties: {
id: {
type: "uuid",
primary: true,
default: uuidv4(),
},
tenantId: {
type: "string",
},
metadata: {
type: "json",
nullable: true,
},
createdAt: {
type: "date",
default: new Date(),
},
updatedAt: {
type: "date",
default: new Date(),
},
giftId: {
type: "uuid",
},
contributorName: {
type: "string",
},
contributorEmail: {
type: "string",
nullable: true,
},
amount: {
type: "float",
},
type: {
type: "string",
default: "individual",
},
status: {
type: "string",
default: "pending",
},
},
});

View File

@@ -0,0 +1,55 @@
import { eventBus } from "../../core/events";
import { getEm } from "../../core/db";
import { getTenantId } from "../../core/tenant";
import { Gift, GiftContribution } from "./model";
export class GiftService {
private get em() {
return getEm();
}
private get tenantId() {
return getTenantId();
}
async createGift(dto: Partial<Gift>) {
const gift = this.em.create(Gift, {
tenantId: this.tenantId,
...dto,
} as any);
await this.em.persist(gift);
await this.em.flush();
eventBus.emit("gift.created", { gift });
return gift;
}
async createContribution(dto: Partial<GiftContribution>) {
const contribution = this.em.create(GiftContribution, {
tenantId: this.tenantId,
...dto,
} as any);
await this.em.persist(contribution);
await this.em.flush();
eventBus.emit("gift.contribution", { contribution });
return contribution;
}
async listGifts() {
return this.em.find(Gift, { tenantId: this.tenantId });
}
async getGiftById(id: string, requesterId?: string) {
const gift = await this.em.findOne(Gift, { tenantId: this.tenantId, id });
if (!gift) return null;
if (requesterId && gift.ownerId && gift.ownerId !== requesterId) {
return null;
}
return gift;
}
async listContributions(giftId: string, requesterId?: string) {
const gift = await this.getGiftById(giftId, requesterId);
if (!gift) return [];
return this.em.find(GiftContribution, { tenantId: this.tenantId, giftId });
}
}

View File

@@ -0,0 +1,21 @@
import { z } from "zod";
export const createGiftSchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
imageUrl: z.string().optional(),
price: z.number().optional(),
experience: z.boolean().optional(),
});
export type CreateGiftDto = z.infer<typeof createGiftSchema>;
export const createContributionSchema = z.object({
giftId: z.string().uuid(),
contributorName: z.string().min(1),
contributorEmail: z.string().email().optional(),
amount: z.number().min(0),
type: z.enum(["individual", "group"]).optional(),
});
export type CreateContributionDto = z.infer<typeof createContributionSchema>;

View File

@@ -0,0 +1,112 @@
import { EntitySchema } from "@mikro-orm/core";
import { v4 as uuidv4 } from "uuid";
import { BaseEntity } from "../../core/model/base.entity";
export class Guest extends BaseEntity {
id!: string;
tenantId!: string;
createdAt!: Date;
updatedAt!: Date;
name!: string;
email?: string;
phone?: string;
rsvp: boolean = false;
tableId?: string;
}
export class GuestNotificationPreference extends BaseEntity {
id!: string;
tenantId!: string;
createdAt!: Date;
updatedAt!: Date;
guestId!: string;
email: boolean = true;
whatsapp: boolean = false;
metadata?: Record<string, any>;
}
export const GuestSchema = new EntitySchema<Guest>({
class: Guest,
tableName: "guests",
properties: {
id: {
type: "uuid",
primary: true,
default: uuidv4(),
},
tenantId: {
type: "string",
},
metadata: {
type: "json",
nullable: true,
},
createdAt: {
type: "date",
default: new Date(),
},
updatedAt: {
type: "date",
default: new Date(),
},
name: {
type: "string",
},
email: {
type: "string",
nullable: true,
},
phone: {
type: "string",
nullable: true,
},
rsvp: {
type: "boolean",
default: false,
},
tableId: {
type: "uuid",
nullable: true,
},
},
});
export const GuestNotificationPreferenceSchema = new EntitySchema<GuestNotificationPreference>({
class: GuestNotificationPreference,
tableName: "guest_notification_preferences",
properties: {
id: {
type: "uuid",
primary: true,
default: uuidv4(),
},
tenantId: {
type: "string",
},
metadata: {
type: "json",
nullable: true,
},
createdAt: {
type: "date",
default: new Date(),
},
updatedAt: {
type: "date",
default: new Date(),
},
guestId: {
type: "uuid",
},
email: {
type: "boolean",
default: true,
},
whatsapp: {
type: "boolean",
default: false,
},
},
});

View File

@@ -0,0 +1,43 @@
import { eventBus } from "../../core/events";
import { getEm } from "../../core/db";
import { getTenantId } from "../../core/tenant";
import { Guest } from "./model";
import type { UpdateRsvpDto } from "./types";
export class GuestService {
private get em() {
return getEm();
}
private get tenantId() {
return getTenantId();
}
async createGuest(dto: Partial<Guest>) {
const guest = this.em.create(Guest, {
tenantId: this.tenantId,
...dto,
} as any);
await this.em.persist(guest);
await this.em.flush();
eventBus.emit("guest.invited", { guest });
return guest;
}
async updateRsvp(id: string, dto: UpdateRsvpDto) {
const guest = await this.em.findOne(Guest, { tenantId: this.tenantId, id });
if (!guest) return null;
guest.rsvp = dto.rsvp;
if (dto.tableId !== undefined) {
guest.tableId = dto.tableId;
}
await this.em.persist(guest);
await this.em.flush();
eventBus.emit("guest.rsvp", { guest });
return guest;
}
async listGuests() {
return this.em.find(Guest, { tenantId: this.tenantId });
}
}

View File

@@ -0,0 +1,18 @@
import { z } from "zod";
export const createGuestSchema = z.object({
name: z.string().min(1),
email: z.string().email().optional(),
phone: z.string().optional(),
rsvp: z.boolean().optional(),
tableId: z.string().uuid().optional(),
});
export type CreateGuestDto = z.infer<typeof createGuestSchema>;
export const updateRsvpSchema = z.object({
rsvp: z.boolean(),
tableId: z.string().uuid().optional(),
});
export type UpdateRsvpDto = z.infer<typeof updateRsvpSchema>;

View File

@@ -0,0 +1,30 @@
import { EntitySchema } from "@mikro-orm/core";
import { BaseEntity } from "../../core/model/base.entity";
export class TodoItem extends BaseEntity {
id!: string;
tenantId!: string;
createdAt!: Date;
updatedAt!: Date;
title!: string;
description?: string;
completed: boolean = false;
dueDate?: Date;
}
export const TodoItemSchema = new EntitySchema<TodoItem>({
class: TodoItem,
tableName: "todo_items",
properties: {
id: { type: "uuid", primary: true },
tenantId: { type: "string" },
metadata: { type: "json", nullable: true },
createdAt: { type: "date", defaultRaw: "now()" },
updatedAt: { type: "date", defaultRaw: "now()" },
title: { type: "string" },
description: { type: "text", nullable: true },
completed: { type: "boolean", default: false },
dueDate: { type: "date", nullable: true },
},
});

View File

@@ -0,0 +1,39 @@
import { eventBus } from "../../core/events";
import { getEm } from "../../core/db";
import { getTenantId } from "../../core/tenant";
import { TodoItem } from "./model";
export class TodoService {
private get em() {
return getEm();
}
private get tenantId() {
return getTenantId();
}
async createTodo(dto: Partial<TodoItem>) {
const todo = this.em.create(TodoItem, {
tenantId: this.tenantId,
...dto,
} as any);
await this.em.persist(todo);
await this.em.flush();
eventBus.emit("todo.created", { todo });
return todo;
}
async listTodos() {
return this.em.find(TodoItem, { tenantId: this.tenantId });
}
async markComplete(id: string) {
const todo = await this.em.findOne(TodoItem, { tenantId: this.tenantId, id });
if (!todo) return null;
todo.completed = true;
await this.em.persist(todo);
await this.em.flush();
eventBus.emit("todo.completed", { todo });
return todo;
}
}

View File

@@ -0,0 +1,9 @@
import { z } from "zod";
export const createTodoSchema = z.object({
title: z.string().min(1),
description: z.string().optional(),
dueDate: z.string().optional(),
});
export type CreateTodoDto = z.infer<typeof createTodoSchema>;

View File

View File

View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}