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:
8
packages/server/mikro-orm.config.js
Normal file
8
packages/server/mikro-orm.config.js
Normal 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; } });
|
||||
BIN
packages/server/mikro-orm.config.ts
Normal file
BIN
packages/server/mikro-orm.config.ts
Normal file
Binary file not shown.
36
packages/server/package.json
Normal file
36
packages/server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
15
packages/server/scripts/cleanup-model-folders.js
Normal file
15
packages/server/scripts/cleanup-model-folders.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
33
packages/server/scripts/flatten-models.js
Normal file
33
packages/server/scripts/flatten-models.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
51
packages/server/src/api/admin/gifts/routes.ts
Normal file
51
packages/server/src/api/admin/gifts/routes.ts
Normal 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);
|
||||
}
|
||||
36
packages/server/src/api/admin/guests/routes.ts
Normal file
36
packages/server/src/api/admin/guests/routes.ts
Normal 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);
|
||||
}
|
||||
19
packages/server/src/api/admin/routes.ts
Normal file
19
packages/server/src/api/admin/routes.ts
Normal 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;
|
||||
}
|
||||
33
packages/server/src/api/admin/todos/routes.ts
Normal file
33
packages/server/src/api/admin/todos/routes.ts
Normal 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);
|
||||
}
|
||||
8
packages/server/src/api/auth/dto/login.dto.ts
Normal file
8
packages/server/src/api/auth/dto/login.dto.ts
Normal 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>;
|
||||
9
packages/server/src/api/auth/dto/register.dto.ts
Normal file
9
packages/server/src/api/auth/dto/register.dto.ts
Normal 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>;
|
||||
47
packages/server/src/api/auth/routes.ts
Normal file
47
packages/server/src/api/auth/routes.ts
Normal 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;
|
||||
}
|
||||
11
packages/server/src/api/gifts/dto/create-contribution.dto.ts
Normal file
11
packages/server/src/api/gifts/dto/create-contribution.dto.ts
Normal 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>;
|
||||
11
packages/server/src/api/gifts/dto/create-gift.dto.ts
Normal file
11
packages/server/src/api/gifts/dto/create-gift.dto.ts
Normal 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>;
|
||||
51
packages/server/src/api/gifts/routes.ts
Normal file
51
packages/server/src/api/gifts/routes.ts
Normal 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);
|
||||
}
|
||||
11
packages/server/src/api/guests/dto/create-guest.dto.ts
Normal file
11
packages/server/src/api/guests/dto/create-guest.dto.ts
Normal 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>;
|
||||
8
packages/server/src/api/guests/dto/update-rsvp.dto.ts
Normal file
8
packages/server/src/api/guests/dto/update-rsvp.dto.ts
Normal 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>;
|
||||
34
packages/server/src/api/guests/routes.ts
Normal file
34
packages/server/src/api/guests/routes.ts
Normal 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);
|
||||
}
|
||||
7
packages/server/src/api/health/health.routes.ts
Normal file
7
packages/server/src/api/health/health.routes.ts
Normal 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() });
|
||||
});
|
||||
}
|
||||
25
packages/server/src/api/index.ts
Normal file
25
packages/server/src/api/index.ts
Normal 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());
|
||||
}
|
||||
52
packages/server/src/api/invitados/gifts/routes.ts
Normal file
52
packages/server/src/api/invitados/gifts/routes.ts
Normal 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);
|
||||
}
|
||||
38
packages/server/src/api/invitados/guests/routes.ts
Normal file
38
packages/server/src/api/invitados/guests/routes.ts
Normal 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);
|
||||
}
|
||||
16
packages/server/src/api/invitados/routes.ts
Normal file
16
packages/server/src/api/invitados/routes.ts
Normal 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;
|
||||
}
|
||||
34
packages/server/src/api/invitados/todos/routes.ts
Normal file
34
packages/server/src/api/invitados/todos/routes.ts
Normal 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);
|
||||
}
|
||||
108
packages/server/src/api/middleware.ts
Normal file
108
packages/server/src/api/middleware.ts
Normal 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();
|
||||
}
|
||||
52
packages/server/src/api/organizador/gifts/routes.ts
Normal file
52
packages/server/src/api/organizador/gifts/routes.ts
Normal 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);
|
||||
}
|
||||
38
packages/server/src/api/organizador/guests/routes.ts
Normal file
38
packages/server/src/api/organizador/guests/routes.ts
Normal 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);
|
||||
}
|
||||
18
packages/server/src/api/organizador/routes.ts
Normal file
18
packages/server/src/api/organizador/routes.ts
Normal 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;
|
||||
}
|
||||
34
packages/server/src/api/organizador/todos/routes.ts
Normal file
34
packages/server/src/api/organizador/todos/routes.ts
Normal 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);
|
||||
}
|
||||
9
packages/server/src/api/todos/dto/create-todo.dto.ts
Normal file
9
packages/server/src/api/todos/dto/create-todo.dto.ts
Normal 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>;
|
||||
33
packages/server/src/api/todos/routes.ts
Normal file
33
packages/server/src/api/todos/routes.ts
Normal 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);
|
||||
}
|
||||
23
packages/server/src/app.ts
Normal file
23
packages/server/src/app.ts
Normal 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;
|
||||
}
|
||||
24
packages/server/src/core/db.ts
Normal file
24
packages/server/src/core/db.ts
Normal 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();
|
||||
}
|
||||
3
packages/server/src/core/events.ts
Normal file
3
packages/server/src/core/events.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { EventEmitter } from "events";
|
||||
|
||||
export const eventBus = new EventEmitter();
|
||||
23
packages/server/src/core/middleware/auth.ts
Normal file
23
packages/server/src/core/middleware/auth.ts
Normal 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" });
|
||||
}
|
||||
}
|
||||
13
packages/server/src/core/middleware/validate.ts
Normal file
13
packages/server/src/core/middleware/validate.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
9
packages/server/src/core/model/base.entity.ts
Normal file
9
packages/server/src/core/model/base.entity.ts
Normal 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();
|
||||
}
|
||||
3
packages/server/src/core/tenant.ts
Normal file
3
packages/server/src/core/tenant.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function getTenantId() {
|
||||
return process.env.DEFAULT_TENANT_ID || 'default';
|
||||
}
|
||||
20
packages/server/src/index.ts
Normal file
20
packages/server/src/index.ts
Normal 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);
|
||||
});
|
||||
0
packages/server/src/jobs/.gitkeep
Normal file
0
packages/server/src/jobs/.gitkeep
Normal file
0
packages/server/src/links/.gitkeep
Normal file
0
packages/server/src/links/.gitkeep
Normal file
19
packages/server/src/mikro-orm.config.ts
Normal file
19
packages/server/src/mikro-orm.config.ts
Normal 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"),
|
||||
},
|
||||
});
|
||||
17
packages/server/src/modules/auth/auth.service.ts
Normal file
17
packages/server/src/modules/auth/auth.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
66
packages/server/src/modules/auth/model.ts
Normal file
66
packages/server/src/modules/auth/model.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
17
packages/server/src/modules/auth/types.ts
Normal file
17
packages/server/src/modules/auth/types.ts
Normal 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>;
|
||||
46
packages/server/src/modules/auth/user.service.ts
Normal file
46
packages/server/src/modules/auth/user.service.ts
Normal 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" });
|
||||
}
|
||||
}
|
||||
134
packages/server/src/modules/gifts/model.ts
Normal file
134
packages/server/src/modules/gifts/model.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
});
|
||||
55
packages/server/src/modules/gifts/service.ts
Normal file
55
packages/server/src/modules/gifts/service.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
21
packages/server/src/modules/gifts/types.ts
Normal file
21
packages/server/src/modules/gifts/types.ts
Normal 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>;
|
||||
112
packages/server/src/modules/guests/model.ts
Normal file
112
packages/server/src/modules/guests/model.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
43
packages/server/src/modules/guests/service.ts
Normal file
43
packages/server/src/modules/guests/service.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
18
packages/server/src/modules/guests/types.ts
Normal file
18
packages/server/src/modules/guests/types.ts
Normal 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>;
|
||||
30
packages/server/src/modules/todos/model.ts
Normal file
30
packages/server/src/modules/todos/model.ts
Normal 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 },
|
||||
},
|
||||
});
|
||||
39
packages/server/src/modules/todos/service.ts
Normal file
39
packages/server/src/modules/todos/service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
9
packages/server/src/modules/todos/types.ts
Normal file
9
packages/server/src/modules/todos/types.ts
Normal 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>;
|
||||
0
packages/server/src/subscribers/.gitkeep
Normal file
0
packages/server/src/subscribers/.gitkeep
Normal file
0
packages/server/src/workflows/.gitkeep
Normal file
0
packages/server/src/workflows/.gitkeep
Normal file
18
packages/server/tsconfig.json
Normal file
18
packages/server/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user