Productionize EventSphere platform

This commit is contained in:
Austin A
2026-04-25 21:02:19 +01:00
commit 1f1d30a9f5
171 changed files with 18682 additions and 0 deletions

25
.env.example Normal file
View File

@@ -0,0 +1,25 @@
NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
PUBLIC_API_URL=http://localhost:4000
CORS_ORIGINS=http://localhost:3000
DATABASE_URL=postgresql://eventsphere:eventsphere@localhost:5432/eventsphere
REDIS_URL=redis://localhost:6379
JWT_SECRET=replace_me
JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=30d
AUTO_BOOTSTRAP=0
ALLOW_PUBLIC_SIGNUP=0
DEFAULT_TENANT_NAME=EventSphere
DEFAULT_TENANT_SLUG=eventsphere
DEFAULT_SUPERADMIN_FULL_NAME=Super Admin
DEFAULT_SUPERADMIN_EMAIL=superadmin@eventsphere.local
DEFAULT_SUPERADMIN_PASSWORD=replace_me
AFRICASTALKING_USERNAME=sandbox
AFRICASTALKING_API_KEY=replace_me
AFRICASTALKING_SENDER_ID=replace_me
AFRICASTALKING_WHATSAPP_URL=replace_me
PAYSTACK_SECRET_KEY=replace_me
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=replace_me
SMTP_PASS=replace_me
SMTP_FROM=EventSphere <no-reply@example.com>

29
.env.production.example Normal file
View File

@@ -0,0 +1,29 @@
TRAEFIK_ACME_EMAIL=admin@brainshare.ng
TRAEFIK_DOMAIN_APP=event.brainshare.ng
TRAEFIK_DOMAIN_API=api.event.brainshare.ng
POSTGRES_PASSWORD=replace_me
DATABASE_URL=postgresql://eventsphere:replace_me@postgres:5432/eventsphere
REDIS_URL=redis://redis:6379
NEXT_PUBLIC_API_URL=https://api.event.brainshare.ng/api/v1
PUBLIC_API_URL=https://api.event.brainshare.ng
CORS_ORIGINS=https://event.brainshare.ng
JWT_SECRET=replace_me
JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=30d
AUTO_BOOTSTRAP=1
ALLOW_PUBLIC_SIGNUP=0
DEFAULT_TENANT_NAME=Brainshare Events
DEFAULT_TENANT_SLUG=brainshare
DEFAULT_SUPERADMIN_FULL_NAME=Super Admin
DEFAULT_SUPERADMIN_EMAIL=superadmin@brainshare.ng
DEFAULT_SUPERADMIN_PASSWORD=replace_me
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=replace_me
SMTP_PASS=replace_me
SMTP_FROM=Brainshare Events <no-reply@brainshare.ng>
AFRICASTALKING_USERNAME=replace_me
AFRICASTALKING_API_KEY=replace_me
AFRICASTALKING_SENDER_ID=replace_me
AFRICASTALKING_WHATSAPP_URL=replace_me
PAYSTACK_SECRET_KEY=replace_me

17
.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
node_modules/
apps/*/node_modules/
apps/*/.next/
apps/*/dist/
apps/*/.turbo/
apps/*/uploads/
uploads/
.env
.env.*
!.env.example
!.env.production.example
*.log
*.tsbuildinfo
*.tar.gz
event Ui*.png
coverage/
.DS_Store

34
README.md Normal file
View File

@@ -0,0 +1,34 @@
# EventSphere Enterprise Event Management Platform
Production-oriented multi-tenant event management SaaS with a Next.js admin/public frontend, NestJS API, PostgreSQL persistence, Redis-backed workers, RBAC, audit logging, payments, communications, QR check-in, CRM, forms, workflows, calendar routing, and reporting.
## Apps
- `apps/web`: Next.js 14 App Router frontend.
- `apps/api`: NestJS backend API.
- `packages/shared`: shared TypeScript contracts.
## Core modules
Events, event pages, ticket types, attendees, invitees, RSVP, registrations, QR codes, live check-in logs, calendar routing forms, bookings, email/SMS/WhatsApp via worker queues, CRM leads/deals/activities, Paystack payments/webhooks, reporting, tenant settings, integrations, users, roles, and permissions.
## Quick start
```bash
pnpm install
pnpm --filter api prisma:generate
pnpm dev
```
## Environment
Copy `.env.example` to `.env`.
For production, copy `.env.production.example` to `.env`, replace every secret, and use `docker-compose.prod.yml`.
## Verification
```bash
pnpm run typecheck
pnpm run lint
pnpm run build
docker compose -f docker-compose.prod.yml config --quiet
```
## First Tenant
Set `AUTO_BOOTSTRAP=1` with the `DEFAULT_*` values for first deployment, or visit `/setup` before any tenant exists. After the first tenant exists, public tenant creation is disabled unless `ALLOW_PUBLIC_SIGNUP=1`.

20
apps/api/Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM node:20-alpine
WORKDIR /repo
RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/api/package.json apps/api/package.json
RUN pnpm install --frozen-lockfile --filter api...
COPY apps/api apps/api
RUN pnpm --filter api prisma:generate
RUN pnpm --filter api build
WORKDIR /repo/apps/api
ENV PORT=4000
EXPOSE 4000
CMD ["pnpm", "start"]

View File

@@ -0,0 +1,24 @@
import js from "@eslint/js";
import path from "node:path";
import { fileURLToPath } from "node:url";
import tseslint from "typescript-eslint";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default [
{ ignores: ["dist/**", "node_modules/**"] },
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["**/*.ts"],
languageOptions: {
parserOptions: {
project: "./tsconfig.json",
tsconfigRootDir: __dirname
}
},
rules: {
"@typescript-eslint/no-explicit-any": "off"
}
}
];

55
apps/api/package.json Normal file
View File

@@ -0,0 +1,55 @@
{
"name": "api",
"scripts": {
"dev": "nest start --watch",
"build": "nest build",
"start": "prisma migrate deploy --schema prisma/schema.prisma && node dist/main.js",
"worker": "node dist/worker.js",
"lint": "eslint .",
"typecheck": "tsc -p tsconfig.json --noEmit",
"prisma:generate": "prisma generate --schema prisma/schema.prisma",
"prisma:migrate:dev": "prisma migrate dev --schema prisma/schema.prisma",
"prisma:migrate:deploy": "prisma migrate deploy --schema prisma/schema.prisma"
},
"dependencies": {
"@nestjs/common": "^10.4.6",
"@nestjs/core": "^10.4.6",
"@nestjs/platform-express": "^10.4.6",
"@nestjs/config": "^3.2.3",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/swagger": "^7.4.2",
"@nestjs/throttler": "^6.4.0",
"@prisma/client": "^6.16.2",
"bullmq": "^5.58.3",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"cookie-parser": "^1.4.7",
"ioredis": "^5.6.1",
"multer": "^2.0.2",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"swagger-ui-express": "^5.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"nodemailer": "^6.9.15",
"qrcode": "^1.5.4",
"axios": "^1.7.7"
},
"devDependencies": {
"@nestjs/cli": "^10.4.5",
"@eslint/js": "^9.22.0",
"@types/node": "^22.8.6",
"@types/bcrypt": "^5.0.2",
"@types/cookie-parser": "^1.4.9",
"@types/express": "^4.17.21",
"@types/nodemailer": "^6.4.17",
"@types/passport-jwt": "^4.0.1",
"@types/swagger-ui-express": "^4.1.8",
"eslint": "^9.22.0",
"prisma": "^6.16.2",
"typescript-eslint": "^8.26.1",
"typescript": "^5.6.3"
}
}

View File

@@ -0,0 +1,116 @@
-- CreateSchema
CREATE SCHEMA IF NOT EXISTS "public";
-- CreateTable
CREATE TABLE "Tenant" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Tenant_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"fullName" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"refreshTokenId" TEXT,
"refreshTokenHash" TEXT,
"refreshTokenExpiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Role" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Role_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Permission" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Permission_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserRole" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UserRole_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RolePermission" (
"id" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"permissionId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RolePermission_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Tenant_slug_key" ON "Tenant"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "User_refreshTokenId_key" ON "User"("refreshTokenId");
-- CreateIndex
CREATE INDEX "User_tenantId_idx" ON "User"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "User_tenantId_email_key" ON "User"("tenantId", "email");
-- CreateIndex
CREATE INDEX "Role_tenantId_idx" ON "Role"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "Role_tenantId_name_key" ON "Role"("tenantId", "name");
-- CreateIndex
CREATE UNIQUE INDEX "Permission_key_key" ON "Permission"("key");
-- CreateIndex
CREATE UNIQUE INDEX "UserRole_userId_roleId_key" ON "UserRole"("userId", "roleId");
-- CreateIndex
CREATE UNIQUE INDEX "RolePermission_roleId_permissionId_key" ON "RolePermission"("roleId", "permissionId");
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Role" ADD CONSTRAINT "Role_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserRole" ADD CONSTRAINT "UserRole_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserRole" ADD CONSTRAINT "UserRole_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RolePermission" ADD CONSTRAINT "RolePermission_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RolePermission" ADD CONSTRAINT "RolePermission_permissionId_fkey" FOREIGN KEY ("permissionId") REFERENCES "Permission"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,29 @@
-- CreateEnum
CREATE TYPE "EventStatus" AS ENUM ('draft', 'active', 'closed');
-- CreateTable
CREATE TABLE "Event" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"status" "EventStatus" NOT NULL DEFAULT 'draft',
"startsAt" TIMESTAMP(3) NOT NULL,
"venue" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Event_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Event_tenantId_idx" ON "Event"("tenantId");
-- CreateIndex
CREATE INDEX "Event_tenantId_status_idx" ON "Event"("tenantId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "Event_tenantId_slug_key" ON "Event"("tenantId", "slug");
-- AddForeignKey
ALTER TABLE "Event" ADD CONSTRAINT "Event_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,54 @@
-- CreateTable
CREATE TABLE "Attendee" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"eventId" TEXT,
"fullName" TEXT NOT NULL,
"email" TEXT NOT NULL,
"phone" TEXT,
"tags" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Attendee_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"tenantId" TEXT,
"actorUserId" TEXT,
"action" TEXT NOT NULL,
"entityType" TEXT,
"entityId" TEXT,
"ip" TEXT,
"userAgent" TEXT,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Attendee_tenantId_email_key" ON "Attendee"("tenantId", "email");
-- CreateIndex
CREATE INDEX "Attendee_tenantId_idx" ON "Attendee"("tenantId");
-- CreateIndex
CREATE INDEX "Attendee_tenantId_eventId_idx" ON "Attendee"("tenantId", "eventId");
-- CreateIndex
CREATE INDEX "AuditLog_tenantId_idx" ON "AuditLog"("tenantId");
-- CreateIndex
CREATE INDEX "AuditLog_actorUserId_idx" ON "AuditLog"("actorUserId");
-- CreateIndex
CREATE INDEX "AuditLog_entityType_entityId_idx" ON "AuditLog"("entityType", "entityId");
-- AddForeignKey
ALTER TABLE "Attendee" ADD CONSTRAINT "Attendee_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Attendee" ADD CONSTRAINT "Attendee_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,40 @@
-- CreateEnum
CREATE TYPE "RegistrationStatus" AS ENUM ('pending', 'confirmed', 'cancelled');
-- CreateTable
CREATE TABLE "Registration" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"attendeeId" TEXT NOT NULL,
"status" "RegistrationStatus" NOT NULL DEFAULT 'pending',
"code" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Registration_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Registration_code_key" ON "Registration"("code");
-- CreateIndex
CREATE UNIQUE INDEX "Registration_tenantId_eventId_attendeeId_key" ON "Registration"("tenantId", "eventId", "attendeeId");
-- CreateIndex
CREATE INDEX "Registration_tenantId_idx" ON "Registration"("tenantId");
-- CreateIndex
CREATE INDEX "Registration_tenantId_eventId_idx" ON "Registration"("tenantId", "eventId");
-- CreateIndex
CREATE INDEX "Registration_eventId_idx" ON "Registration"("eventId");
-- AddForeignKey
ALTER TABLE "Registration" ADD CONSTRAINT "Registration_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Registration" ADD CONSTRAINT "Registration_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Registration" ADD CONSTRAINT "Registration_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,109 @@
-- CreateEnum
CREATE TYPE "InviteeStatus" AS ENUM ('invited', 'delivered', 'opened', 'rsvped', 'bounced');
-- CreateEnum
CREATE TYPE "RSVPResponse" AS ENUM ('yes', 'no', 'maybe');
-- CreateEnum
CREATE TYPE "PaymentStatus" AS ENUM ('initialized', 'success', 'failed');
-- CreateTable
CREATE TABLE "Invitee" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"fullName" TEXT NOT NULL,
"email" TEXT NOT NULL,
"phone" TEXT,
"status" "InviteeStatus" NOT NULL DEFAULT 'invited',
"code" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Invitee_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RSVP" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"inviteeId" TEXT,
"response" "RSVPResponse" NOT NULL,
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RSVP_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PaymentTransaction" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"amountKobo" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'NGN',
"provider" TEXT NOT NULL DEFAULT 'paystack',
"reference" TEXT NOT NULL,
"status" "PaymentStatus" NOT NULL DEFAULT 'initialized',
"raw" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PaymentTransaction_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Invitee_code_key" ON "Invitee"("code");
-- CreateIndex
CREATE UNIQUE INDEX "Invitee_tenantId_eventId_email_key" ON "Invitee"("tenantId", "eventId", "email");
-- CreateIndex
CREATE INDEX "Invitee_tenantId_idx" ON "Invitee"("tenantId");
-- CreateIndex
CREATE INDEX "Invitee_tenantId_eventId_idx" ON "Invitee"("tenantId", "eventId");
-- CreateIndex
CREATE INDEX "RSVP_tenantId_idx" ON "RSVP"("tenantId");
-- CreateIndex
CREATE INDEX "RSVP_tenantId_eventId_idx" ON "RSVP"("tenantId", "eventId");
-- CreateIndex
CREATE INDEX "RSVP_inviteeId_idx" ON "RSVP"("inviteeId");
-- CreateIndex
CREATE UNIQUE INDEX "PaymentTransaction_reference_key" ON "PaymentTransaction"("reference");
-- CreateIndex
CREATE INDEX "PaymentTransaction_tenantId_idx" ON "PaymentTransaction"("tenantId");
-- CreateIndex
CREATE INDEX "PaymentTransaction_tenantId_eventId_idx" ON "PaymentTransaction"("tenantId", "eventId");
-- CreateIndex
CREATE INDEX "PaymentTransaction_eventId_idx" ON "PaymentTransaction"("eventId");
-- AddForeignKey
ALTER TABLE "Invitee" ADD CONSTRAINT "Invitee_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Invitee" ADD CONSTRAINT "Invitee_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RSVP" ADD CONSTRAINT "RSVP_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RSVP" ADD CONSTRAINT "RSVP_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RSVP" ADD CONSTRAINT "RSVP_inviteeId_fkey" FOREIGN KEY ("inviteeId") REFERENCES "Invitee"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PaymentTransaction" ADD CONSTRAINT "PaymentTransaction_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PaymentTransaction" ADD CONSTRAINT "PaymentTransaction_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,50 @@
-- CreateTable
CREATE TABLE "TenantSetting" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"appName" TEXT,
"logoUrl" TEXT,
"modules" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TenantSetting_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "IntegrationSetting" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"value" TEXT,
"isSecret" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "IntegrationSetting_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "TenantSetting_tenantId_key" ON "TenantSetting"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "IntegrationSetting_tenantId_key_key" ON "IntegrationSetting"("tenantId", "key");
-- CreateIndex
CREATE INDEX "IntegrationSetting_tenantId_idx" ON "IntegrationSetting"("tenantId");
-- AddForeignKey
ALTER TABLE "TenantSetting" ADD CONSTRAINT "TenantSetting_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "IntegrationSetting" ADD CONSTRAINT "IntegrationSetting_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "phone" TEXT,
ADD COLUMN "addressLine1" TEXT,
ADD COLUMN "addressLine2" TEXT,
ADD COLUMN "city" TEXT,
ADD COLUMN "state" TEXT,
ADD COLUMN "country" TEXT,
ADD COLUMN "avatarUrl" TEXT,
ADD COLUMN "mustChangePassword" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,671 @@
-- CreateEnum
CREATE TYPE "QRCodeStatus" AS ENUM ('active', 'revoked');
-- CreateEnum
CREATE TYPE "CheckInResult" AS ENUM ('checked_in', 'duplicate', 'rejected');
-- CreateEnum
CREATE TYPE "CommunicationChannel" AS ENUM ('email', 'sms', 'whatsapp');
-- CreateEnum
CREATE TYPE "CommunicationStatus" AS ENUM ('queued', 'sent', 'failed');
-- AlterTable
ALTER TABLE "Registration" ADD COLUMN "checkedInAt" TIMESTAMP(3),
ADD COLUMN "source" TEXT NOT NULL DEFAULT 'admin',
ADD COLUMN "ticketTypeId" TEXT;
-- AlterTable
ALTER TABLE "PaymentTransaction" ADD COLUMN "registrationId" TEXT,
ADD COLUMN "ticketTypeId" TEXT;
-- CreateTable
CREATE TABLE "EventPage" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"heroTitle" TEXT,
"description" TEXT,
"content" JSONB,
"theme" JSONB,
"publishedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "EventPage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TicketType" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"priceKobo" INTEGER NOT NULL DEFAULT 0,
"currency" TEXT NOT NULL DEFAULT 'NGN',
"capacity" INTEGER,
"soldCount" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TicketType_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Form" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"eventId" TEXT,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"isPublic" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Form_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FormField" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"formId" TEXT NOT NULL,
"label" TEXT NOT NULL,
"key" TEXT NOT NULL,
"type" TEXT NOT NULL,
"required" BOOLEAN NOT NULL DEFAULT false,
"options" JSONB,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FormField_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FormSubmission" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"formId" TEXT NOT NULL,
"eventId" TEXT,
"attendeeId" TEXT,
"registrationId" TEXT,
"data" JSONB NOT NULL,
"source" TEXT NOT NULL DEFAULT 'admin',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FormSubmission_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "QRCode" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"registrationId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"status" "QRCodeStatus" NOT NULL DEFAULT 'active',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "QRCode_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CheckIn" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"registrationId" TEXT NOT NULL,
"attendeeId" TEXT,
"checkedInByUserId" TEXT,
"code" TEXT NOT NULL,
"result" "CheckInResult" NOT NULL DEFAULT 'checked_in',
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CheckIn_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CommunicationTemplate" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"channel" "CommunicationChannel" NOT NULL,
"subject" TEXT,
"body" TEXT NOT NULL,
"variables" JSONB,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CommunicationTemplate_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CommunicationLog" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"templateId" TEXT,
"inviteeId" TEXT,
"registrationId" TEXT,
"channel" "CommunicationChannel" NOT NULL,
"to" TEXT NOT NULL,
"subject" TEXT,
"body" TEXT NOT NULL,
"status" "CommunicationStatus" NOT NULL DEFAULT 'queued',
"providerMessageId" TEXT,
"error" TEXT,
"raw" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CommunicationLog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PaystackWebhookEvent" (
"id" TEXT NOT NULL,
"tenantId" TEXT,
"event" TEXT NOT NULL,
"reference" TEXT,
"status" TEXT NOT NULL DEFAULT 'received',
"raw" JSONB NOT NULL,
"receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"processedAt" TIMESTAMP(3),
CONSTRAINT "PaystackWebhookEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CRMLead" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"eventId" TEXT,
"attendeeId" TEXT,
"fullName" TEXT NOT NULL,
"email" TEXT NOT NULL,
"phone" TEXT,
"source" TEXT NOT NULL DEFAULT 'manual',
"status" TEXT NOT NULL DEFAULT 'new',
"valueKobo" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CRMLead_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CRMDeal" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"leadId" TEXT,
"eventId" TEXT,
"title" TEXT NOT NULL,
"stage" TEXT NOT NULL DEFAULT 'new',
"valueKobo" INTEGER NOT NULL DEFAULT 0,
"probability" INTEGER NOT NULL DEFAULT 0,
"ownerUserId" TEXT,
"expectedCloseAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CRMDeal_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CRMActivity" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"leadId" TEXT,
"dealId" TEXT,
"userId" TEXT,
"type" TEXT NOT NULL DEFAULT 'note',
"note" TEXT NOT NULL,
"dueAt" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CRMActivity_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Workflow" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Workflow_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WorkflowTrigger" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"workflowId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"config" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WorkflowTrigger_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WorkflowAction" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"workflowId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"config" JSONB,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WorkflowAction_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CalendarRoutingForm" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"eventId" TEXT,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"durationMinutes" INTEGER NOT NULL DEFAULT 30,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CalendarRoutingForm_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CalendarSlot" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"routingFormId" TEXT NOT NULL,
"startsAt" TIMESTAMP(3) NOT NULL,
"endsAt" TIMESTAMP(3) NOT NULL,
"capacity" INTEGER NOT NULL DEFAULT 1,
"bookedCount" INTEGER NOT NULL DEFAULT 0,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CalendarSlot_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Booking" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"routingFormId" TEXT NOT NULL,
"eventId" TEXT,
"attendeeId" TEXT,
"fullName" TEXT NOT NULL,
"email" TEXT NOT NULL,
"phone" TEXT,
"startsAt" TIMESTAMP(3) NOT NULL,
"endsAt" TIMESTAMP(3) NOT NULL,
"status" TEXT NOT NULL DEFAULT 'booked',
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Booking_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "EventPage_eventId_key" ON "EventPage"("eventId");
-- CreateIndex
CREATE INDEX "EventPage_tenantId_idx" ON "EventPage"("tenantId");
-- CreateIndex
CREATE INDEX "TicketType_tenantId_idx" ON "TicketType"("tenantId");
-- CreateIndex
CREATE INDEX "TicketType_tenantId_eventId_idx" ON "TicketType"("tenantId", "eventId");
-- CreateIndex
CREATE UNIQUE INDEX "TicketType_tenantId_eventId_name_key" ON "TicketType"("tenantId", "eventId", "name");
-- CreateIndex
CREATE INDEX "Form_tenantId_idx" ON "Form"("tenantId");
-- CreateIndex
CREATE INDEX "Form_tenantId_eventId_idx" ON "Form"("tenantId", "eventId");
-- CreateIndex
CREATE UNIQUE INDEX "Form_tenantId_slug_key" ON "Form"("tenantId", "slug");
-- CreateIndex
CREATE INDEX "FormField_tenantId_idx" ON "FormField"("tenantId");
-- CreateIndex
CREATE INDEX "FormField_formId_idx" ON "FormField"("formId");
-- CreateIndex
CREATE UNIQUE INDEX "FormField_formId_key_key" ON "FormField"("formId", "key");
-- CreateIndex
CREATE INDEX "FormSubmission_tenantId_idx" ON "FormSubmission"("tenantId");
-- CreateIndex
CREATE INDEX "FormSubmission_formId_idx" ON "FormSubmission"("formId");
-- CreateIndex
CREATE INDEX "FormSubmission_eventId_idx" ON "FormSubmission"("eventId");
-- CreateIndex
CREATE INDEX "FormSubmission_attendeeId_idx" ON "FormSubmission"("attendeeId");
-- CreateIndex
CREATE INDEX "FormSubmission_registrationId_idx" ON "FormSubmission"("registrationId");
-- CreateIndex
CREATE UNIQUE INDEX "QRCode_registrationId_key" ON "QRCode"("registrationId");
-- CreateIndex
CREATE UNIQUE INDEX "QRCode_code_key" ON "QRCode"("code");
-- CreateIndex
CREATE INDEX "QRCode_tenantId_idx" ON "QRCode"("tenantId");
-- CreateIndex
CREATE INDEX "CheckIn_tenantId_idx" ON "CheckIn"("tenantId");
-- CreateIndex
CREATE INDEX "CheckIn_tenantId_eventId_idx" ON "CheckIn"("tenantId", "eventId");
-- CreateIndex
CREATE INDEX "CheckIn_registrationId_idx" ON "CheckIn"("registrationId");
-- CreateIndex
CREATE INDEX "CheckIn_attendeeId_idx" ON "CheckIn"("attendeeId");
-- CreateIndex
CREATE INDEX "CommunicationTemplate_tenantId_idx" ON "CommunicationTemplate"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "CommunicationTemplate_tenantId_name_channel_key" ON "CommunicationTemplate"("tenantId", "name", "channel");
-- CreateIndex
CREATE INDEX "CommunicationLog_tenantId_idx" ON "CommunicationLog"("tenantId");
-- CreateIndex
CREATE INDEX "CommunicationLog_templateId_idx" ON "CommunicationLog"("templateId");
-- CreateIndex
CREATE INDEX "CommunicationLog_inviteeId_idx" ON "CommunicationLog"("inviteeId");
-- CreateIndex
CREATE INDEX "CommunicationLog_registrationId_idx" ON "CommunicationLog"("registrationId");
-- CreateIndex
CREATE INDEX "PaystackWebhookEvent_tenantId_idx" ON "PaystackWebhookEvent"("tenantId");
-- CreateIndex
CREATE INDEX "PaystackWebhookEvent_reference_idx" ON "PaystackWebhookEvent"("reference");
-- CreateIndex
CREATE INDEX "CRMLead_tenantId_idx" ON "CRMLead"("tenantId");
-- CreateIndex
CREATE INDEX "CRMLead_eventId_idx" ON "CRMLead"("eventId");
-- CreateIndex
CREATE INDEX "CRMLead_attendeeId_idx" ON "CRMLead"("attendeeId");
-- CreateIndex
CREATE UNIQUE INDEX "CRMLead_tenantId_email_key" ON "CRMLead"("tenantId", "email");
-- CreateIndex
CREATE INDEX "CRMDeal_tenantId_idx" ON "CRMDeal"("tenantId");
-- CreateIndex
CREATE INDEX "CRMDeal_leadId_idx" ON "CRMDeal"("leadId");
-- CreateIndex
CREATE INDEX "CRMDeal_eventId_idx" ON "CRMDeal"("eventId");
-- CreateIndex
CREATE INDEX "CRMActivity_tenantId_idx" ON "CRMActivity"("tenantId");
-- CreateIndex
CREATE INDEX "CRMActivity_leadId_idx" ON "CRMActivity"("leadId");
-- CreateIndex
CREATE INDEX "CRMActivity_dealId_idx" ON "CRMActivity"("dealId");
-- CreateIndex
CREATE INDEX "CRMActivity_userId_idx" ON "CRMActivity"("userId");
-- CreateIndex
CREATE INDEX "Workflow_tenantId_idx" ON "Workflow"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "Workflow_tenantId_name_key" ON "Workflow"("tenantId", "name");
-- CreateIndex
CREATE INDEX "WorkflowTrigger_tenantId_idx" ON "WorkflowTrigger"("tenantId");
-- CreateIndex
CREATE INDEX "WorkflowTrigger_workflowId_idx" ON "WorkflowTrigger"("workflowId");
-- CreateIndex
CREATE INDEX "WorkflowAction_tenantId_idx" ON "WorkflowAction"("tenantId");
-- CreateIndex
CREATE INDEX "WorkflowAction_workflowId_idx" ON "WorkflowAction"("workflowId");
-- CreateIndex
CREATE INDEX "CalendarRoutingForm_tenantId_idx" ON "CalendarRoutingForm"("tenantId");
-- CreateIndex
CREATE INDEX "CalendarRoutingForm_eventId_idx" ON "CalendarRoutingForm"("eventId");
-- CreateIndex
CREATE UNIQUE INDEX "CalendarRoutingForm_tenantId_slug_key" ON "CalendarRoutingForm"("tenantId", "slug");
-- CreateIndex
CREATE INDEX "CalendarSlot_tenantId_idx" ON "CalendarSlot"("tenantId");
-- CreateIndex
CREATE INDEX "CalendarSlot_routingFormId_idx" ON "CalendarSlot"("routingFormId");
-- CreateIndex
CREATE INDEX "CalendarSlot_startsAt_idx" ON "CalendarSlot"("startsAt");
-- CreateIndex
CREATE INDEX "Booking_tenantId_idx" ON "Booking"("tenantId");
-- CreateIndex
CREATE INDEX "Booking_routingFormId_idx" ON "Booking"("routingFormId");
-- CreateIndex
CREATE INDEX "Booking_eventId_idx" ON "Booking"("eventId");
-- CreateIndex
CREATE INDEX "Booking_attendeeId_idx" ON "Booking"("attendeeId");
-- CreateIndex
CREATE INDEX "Booking_startsAt_idx" ON "Booking"("startsAt");
-- CreateIndex
CREATE INDEX "Registration_ticketTypeId_idx" ON "Registration"("ticketTypeId");
-- CreateIndex
CREATE INDEX "PaymentTransaction_registrationId_idx" ON "PaymentTransaction"("registrationId");
-- CreateIndex
CREATE INDEX "PaymentTransaction_ticketTypeId_idx" ON "PaymentTransaction"("ticketTypeId");
-- AddForeignKey
ALTER TABLE "Registration" ADD CONSTRAINT "Registration_ticketTypeId_fkey" FOREIGN KEY ("ticketTypeId") REFERENCES "TicketType"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PaymentTransaction" ADD CONSTRAINT "PaymentTransaction_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PaymentTransaction" ADD CONSTRAINT "PaymentTransaction_ticketTypeId_fkey" FOREIGN KEY ("ticketTypeId") REFERENCES "TicketType"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventPage" ADD CONSTRAINT "EventPage_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventPage" ADD CONSTRAINT "EventPage_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketType" ADD CONSTRAINT "TicketType_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketType" ADD CONSTRAINT "TicketType_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Form" ADD CONSTRAINT "Form_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Form" ADD CONSTRAINT "Form_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FormField" ADD CONSTRAINT "FormField_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FormField" ADD CONSTRAINT "FormField_formId_fkey" FOREIGN KEY ("formId") REFERENCES "Form"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FormSubmission" ADD CONSTRAINT "FormSubmission_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FormSubmission" ADD CONSTRAINT "FormSubmission_formId_fkey" FOREIGN KEY ("formId") REFERENCES "Form"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FormSubmission" ADD CONSTRAINT "FormSubmission_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FormSubmission" ADD CONSTRAINT "FormSubmission_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FormSubmission" ADD CONSTRAINT "FormSubmission_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QRCode" ADD CONSTRAINT "QRCode_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QRCode" ADD CONSTRAINT "QRCode_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CheckIn" ADD CONSTRAINT "CheckIn_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CheckIn" ADD CONSTRAINT "CheckIn_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CheckIn" ADD CONSTRAINT "CheckIn_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CheckIn" ADD CONSTRAINT "CheckIn_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CheckIn" ADD CONSTRAINT "CheckIn_checkedInByUserId_fkey" FOREIGN KEY ("checkedInByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CommunicationTemplate" ADD CONSTRAINT "CommunicationTemplate_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CommunicationLog" ADD CONSTRAINT "CommunicationLog_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CommunicationLog" ADD CONSTRAINT "CommunicationLog_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "CommunicationTemplate"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CommunicationLog" ADD CONSTRAINT "CommunicationLog_inviteeId_fkey" FOREIGN KEY ("inviteeId") REFERENCES "Invitee"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CommunicationLog" ADD CONSTRAINT "CommunicationLog_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PaystackWebhookEvent" ADD CONSTRAINT "PaystackWebhookEvent_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CRMLead" ADD CONSTRAINT "CRMLead_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CRMLead" ADD CONSTRAINT "CRMLead_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CRMLead" ADD CONSTRAINT "CRMLead_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CRMDeal" ADD CONSTRAINT "CRMDeal_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CRMDeal" ADD CONSTRAINT "CRMDeal_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "CRMLead"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CRMDeal" ADD CONSTRAINT "CRMDeal_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CRMActivity" ADD CONSTRAINT "CRMActivity_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CRMActivity" ADD CONSTRAINT "CRMActivity_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "CRMLead"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CRMActivity" ADD CONSTRAINT "CRMActivity_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "CRMDeal"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CRMActivity" ADD CONSTRAINT "CRMActivity_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Workflow" ADD CONSTRAINT "Workflow_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WorkflowTrigger" ADD CONSTRAINT "WorkflowTrigger_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WorkflowTrigger" ADD CONSTRAINT "WorkflowTrigger_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "Workflow"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WorkflowAction" ADD CONSTRAINT "WorkflowAction_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WorkflowAction" ADD CONSTRAINT "WorkflowAction_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "Workflow"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CalendarRoutingForm" ADD CONSTRAINT "CalendarRoutingForm_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CalendarRoutingForm" ADD CONSTRAINT "CalendarRoutingForm_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CalendarSlot" ADD CONSTRAINT "CalendarSlot_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CalendarSlot" ADD CONSTRAINT "CalendarSlot_routingFormId_fkey" FOREIGN KEY ("routingFormId") REFERENCES "CalendarRoutingForm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_routingFormId_fkey" FOREIGN KEY ("routingFormId") REFERENCES "CalendarRoutingForm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1 @@
provider = "postgresql"

View File

@@ -0,0 +1,782 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Tenant {
id String @id @default(cuid())
name String
slug String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
roles Role[]
events Event[]
attendees Attendee[]
registrations Registration[]
invitees Invitee[]
rsvps RSVP[]
payments PaymentTransaction[]
settings TenantSetting?
integrations IntegrationSetting[]
eventPages EventPage[]
ticketTypes TicketType[]
forms Form[]
formSubmissions FormSubmission[]
qrCodes QRCode[]
checkIns CheckIn[]
communicationTemplates CommunicationTemplate[]
communicationLogs CommunicationLog[]
paystackWebhookEvents PaystackWebhookEvent[]
crmLeads CRMLead[]
crmDeals CRMDeal[]
crmActivities CRMActivity[]
workflows Workflow[]
workflowTriggers WorkflowTrigger[]
workflowActions WorkflowAction[]
calendarRoutingForms CalendarRoutingForm[]
calendarSlots CalendarSlot[]
bookings Booking[]
formFields FormField[]
}
model User {
id String @id @default(cuid())
tenantId String
email String
fullName String
phone String?
addressLine1 String?
addressLine2 String?
city String?
state String?
country String?
avatarUrl String?
mustChangePassword Boolean @default(false)
passwordHash String
refreshTokenId String? @unique
refreshTokenHash String?
refreshTokenExpiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
roles UserRole[]
performedCheckIns CheckIn[] @relation("CheckInUser")
crmActivities CRMActivity[] @relation("CRMActivityUser")
@@unique([tenantId, email])
@@index([tenantId])
}
model TenantSetting {
id String @id @default(cuid())
tenantId String @unique
appName String?
logoUrl String?
modules Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
}
model IntegrationSetting {
id String @id @default(cuid())
tenantId String
key String
value String?
isSecret Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@unique([tenantId, key])
@@index([tenantId])
}
model Role {
id String @id @default(cuid())
tenantId String
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
users UserRole[]
permissions RolePermission[]
@@unique([tenantId, name])
@@index([tenantId])
}
model Permission {
id String @id @default(cuid())
key String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
roles RolePermission[]
}
model UserRole {
id String @id @default(cuid())
userId String
roleId String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
@@unique([userId, roleId])
}
model RolePermission {
id String @id @default(cuid())
roleId String
permissionId String
createdAt DateTime @default(now())
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
@@unique([roleId, permissionId])
}
enum EventStatus {
draft
active
closed
}
model Event {
id String @id @default(cuid())
tenantId String
name String
slug String
status EventStatus @default(draft)
startsAt DateTime
venue String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
attendees Attendee[]
registrations Registration[]
invitees Invitee[]
rsvps RSVP[]
payments PaymentTransaction[]
eventPage EventPage?
ticketTypes TicketType[]
forms Form[]
formSubmissions FormSubmission[]
checkIns CheckIn[]
crmLeads CRMLead[]
crmDeals CRMDeal[]
calendarRoutingForms CalendarRoutingForm[]
bookings Booking[]
@@unique([tenantId, slug])
@@index([tenantId])
@@index([tenantId, status])
}
enum RegistrationStatus {
pending
confirmed
cancelled
}
model Registration {
id String @id @default(cuid())
tenantId String
eventId String
attendeeId String
ticketTypeId String?
status RegistrationStatus @default(pending)
code String @unique
source String @default("admin")
checkedInAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
attendee Attendee @relation(fields: [attendeeId], references: [id], onDelete: Cascade)
ticketType TicketType? @relation(fields: [ticketTypeId], references: [id], onDelete: SetNull)
qrCode QRCode?
checkIns CheckIn[]
formSubmissions FormSubmission[]
communicationLogs CommunicationLog[]
payments PaymentTransaction[]
@@unique([tenantId, eventId, attendeeId])
@@index([tenantId])
@@index([tenantId, eventId])
@@index([eventId])
@@index([ticketTypeId])
}
enum InviteeStatus {
invited
delivered
opened
rsvped
bounced
}
model Invitee {
id String @id @default(cuid())
tenantId String
eventId String
fullName String
email String
phone String?
status InviteeStatus @default(invited)
code String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
rsvps RSVP[]
communicationLogs CommunicationLog[]
@@unique([tenantId, eventId, email])
@@index([tenantId])
@@index([tenantId, eventId])
}
enum RSVPResponse {
yes
no
maybe
}
model RSVP {
id String @id @default(cuid())
tenantId String
eventId String
inviteeId String?
response RSVPResponse
note String?
createdAt DateTime @default(now())
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
invitee Invitee? @relation(fields: [inviteeId], references: [id], onDelete: SetNull)
@@index([tenantId])
@@index([tenantId, eventId])
@@index([inviteeId])
}
enum PaymentStatus {
initialized
success
failed
}
model PaymentTransaction {
id String @id @default(cuid())
tenantId String
eventId String
registrationId String?
ticketTypeId String?
email String
amountKobo Int
currency String @default("NGN")
provider String @default("paystack")
reference String @unique
status PaymentStatus @default(initialized)
raw Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull)
ticketType TicketType? @relation(fields: [ticketTypeId], references: [id], onDelete: SetNull)
@@index([tenantId])
@@index([tenantId, eventId])
@@index([eventId])
@@index([registrationId])
@@index([ticketTypeId])
}
model Attendee {
id String @id @default(cuid())
tenantId String
eventId String?
fullName String
email String
phone String?
tags String[] @default([])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull)
registrations Registration[]
formSubmissions FormSubmission[]
checkIns CheckIn[]
crmLeads CRMLead[]
bookings Booking[]
@@unique([tenantId, email])
@@index([tenantId])
@@index([tenantId, eventId])
}
model AuditLog {
id String @id @default(cuid())
tenantId String?
actorUserId String?
action String
entityType String?
entityId String?
ip String?
userAgent String?
metadata Json?
createdAt DateTime @default(now())
@@index([tenantId])
@@index([actorUserId])
@@index([entityType, entityId])
}
model EventPage {
id String @id @default(cuid())
tenantId String
eventId String @unique
title String
heroTitle String?
description String?
content Json?
theme Json?
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
@@index([tenantId])
}
model TicketType {
id String @id @default(cuid())
tenantId String
eventId String
name String
description String?
priceKobo Int @default(0)
currency String @default("NGN")
capacity Int?
soldCount Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
registrations Registration[]
payments PaymentTransaction[]
@@unique([tenantId, eventId, name])
@@index([tenantId])
@@index([tenantId, eventId])
}
model Form {
id String @id @default(cuid())
tenantId String
eventId String?
name String
slug String
description String?
isPublic Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull)
fields FormField[]
submissions FormSubmission[]
@@unique([tenantId, slug])
@@index([tenantId])
@@index([tenantId, eventId])
}
model FormField {
id String @id @default(cuid())
tenantId String
formId String
label String
key String
type String
required Boolean @default(false)
options Json?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
@@unique([formId, key])
@@index([tenantId])
@@index([formId])
}
model FormSubmission {
id String @id @default(cuid())
tenantId String
formId String
eventId String?
attendeeId String?
registrationId String?
data Json
source String @default("admin")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull)
attendee Attendee? @relation(fields: [attendeeId], references: [id], onDelete: SetNull)
registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull)
@@index([tenantId])
@@index([formId])
@@index([eventId])
@@index([attendeeId])
@@index([registrationId])
}
model QRCode {
id String @id @default(cuid())
tenantId String
registrationId String @unique
code String @unique
payload Json
status QRCodeStatus @default(active)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
registration Registration @relation(fields: [registrationId], references: [id], onDelete: Cascade)
@@index([tenantId])
}
enum QRCodeStatus {
active
revoked
}
model CheckIn {
id String @id @default(cuid())
tenantId String
eventId String
registrationId String
attendeeId String?
checkedInByUserId String?
code String
result CheckInResult @default(checked_in)
note String?
createdAt DateTime @default(now())
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
registration Registration @relation(fields: [registrationId], references: [id], onDelete: Cascade)
attendee Attendee? @relation(fields: [attendeeId], references: [id], onDelete: SetNull)
checkedInBy User? @relation("CheckInUser", fields: [checkedInByUserId], references: [id], onDelete: SetNull)
@@index([tenantId])
@@index([tenantId, eventId])
@@index([registrationId])
@@index([attendeeId])
}
enum CheckInResult {
checked_in
duplicate
rejected
}
model CommunicationTemplate {
id String @id @default(cuid())
tenantId String
name String
channel CommunicationChannel
subject String?
body String
variables Json?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
logs CommunicationLog[]
@@unique([tenantId, name, channel])
@@index([tenantId])
}
model CommunicationLog {
id String @id @default(cuid())
tenantId String
templateId String?
inviteeId String?
registrationId String?
channel CommunicationChannel
to String
subject String?
body String
status CommunicationStatus @default(queued)
providerMessageId String?
error String?
raw Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
template CommunicationTemplate? @relation(fields: [templateId], references: [id], onDelete: SetNull)
invitee Invitee? @relation(fields: [inviteeId], references: [id], onDelete: SetNull)
registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull)
@@index([tenantId])
@@index([templateId])
@@index([inviteeId])
@@index([registrationId])
}
enum CommunicationChannel {
email
sms
whatsapp
}
enum CommunicationStatus {
queued
sent
failed
}
model PaystackWebhookEvent {
id String @id @default(cuid())
tenantId String?
event String
reference String?
status String @default("received")
raw Json
receivedAt DateTime @default(now())
processedAt DateTime?
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: SetNull)
@@index([tenantId])
@@index([reference])
}
model CRMLead {
id String @id @default(cuid())
tenantId String
eventId String?
attendeeId String?
fullName String
email String
phone String?
source String @default("manual")
status String @default("new")
valueKobo Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull)
attendee Attendee? @relation(fields: [attendeeId], references: [id], onDelete: SetNull)
deals CRMDeal[]
activities CRMActivity[]
@@unique([tenantId, email])
@@index([tenantId])
@@index([eventId])
@@index([attendeeId])
}
model CRMDeal {
id String @id @default(cuid())
tenantId String
leadId String?
eventId String?
title String
stage String @default("new")
valueKobo Int @default(0)
probability Int @default(0)
ownerUserId String?
expectedCloseAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
lead CRMLead? @relation(fields: [leadId], references: [id], onDelete: SetNull)
event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull)
activities CRMActivity[]
@@index([tenantId])
@@index([leadId])
@@index([eventId])
}
model CRMActivity {
id String @id @default(cuid())
tenantId String
leadId String?
dealId String?
userId String?
type String @default("note")
note String
dueAt DateTime?
completedAt DateTime?
createdAt DateTime @default(now())
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
lead CRMLead? @relation(fields: [leadId], references: [id], onDelete: SetNull)
deal CRMDeal? @relation(fields: [dealId], references: [id], onDelete: SetNull)
user User? @relation("CRMActivityUser", fields: [userId], references: [id], onDelete: SetNull)
@@index([tenantId])
@@index([leadId])
@@index([dealId])
@@index([userId])
}
model Workflow {
id String @id @default(cuid())
tenantId String
name String
description String?
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
triggers WorkflowTrigger[]
actions WorkflowAction[]
@@unique([tenantId, name])
@@index([tenantId])
}
model WorkflowTrigger {
id String @id @default(cuid())
tenantId String
workflowId String
type String
config Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@index([workflowId])
}
model WorkflowAction {
id String @id @default(cuid())
tenantId String
workflowId String
type String
config Json?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@index([workflowId])
}
model CalendarRoutingForm {
id String @id @default(cuid())
tenantId String
eventId String?
name String
slug String
description String?
durationMinutes Int @default(30)
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull)
slots CalendarSlot[]
bookings Booking[]
@@unique([tenantId, slug])
@@index([tenantId])
@@index([eventId])
}
model CalendarSlot {
id String @id @default(cuid())
tenantId String
routingFormId String
startsAt DateTime
endsAt DateTime
capacity Int @default(1)
bookedCount Int @default(0)
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
routingForm CalendarRoutingForm @relation(fields: [routingFormId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@index([routingFormId])
@@index([startsAt])
}
model Booking {
id String @id @default(cuid())
tenantId String
routingFormId String
eventId String?
attendeeId String?
fullName String
email String
phone String?
startsAt DateTime
endsAt DateTime
status String @default("booked")
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
routingForm CalendarRoutingForm @relation(fields: [routingFormId], references: [id], onDelete: Cascade)
event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull)
attendee Attendee? @relation(fields: [attendeeId], references: [id], onDelete: SetNull)
@@index([tenantId])
@@index([routingFormId])
@@index([eventId])
@@index([attendeeId])
@@index([startsAt])
}

View File

@@ -0,0 +1,69 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { PrismaModule } from "./prisma/prisma.module";
import { EventsModule } from "./modules/events/events.module";
import { AttendeesModule } from "./modules/attendees/attendees.module";
import { RegistrationsModule } from "./modules/registrations/registrations.module";
import { InviteesModule } from "./modules/invitees/invitees.module";
import { RsvpsModule } from "./modules/rsvps/rsvps.module";
import { CommunicationsModule } from "./modules/communications/communications.module";
import { PaymentsModule } from "./modules/payments/payments.module";
import { CrmModule } from "./modules/crm/crm.module";
import { WorkflowsModule } from "./modules/workflows/workflows.module";
import { QrcodeModule } from "./modules/qrcode/qrcode.module";
import { AuthModule } from "./modules/auth/auth.module";
import { TenantsModule } from "./modules/tenants/tenants.module";
import { HealthModule } from "./modules/health/health.module";
import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
import { APP_GUARD } from "@nestjs/core";
import { AuditModule } from "./modules/audit/audit.module";
import { QueuesModule } from "./modules/queues/queues.module";
import { ReportsModule } from "./modules/reports/reports.module";
import { SettingsModule } from "./modules/settings/settings.module";
import { IntegrationsModule } from "./modules/integrations/integrations.module";
import { UsersModule } from "./modules/users/users.module";
import { RolesModule } from "./modules/roles/roles.module";
import { FormsModule } from "./modules/forms/forms.module";
import { CalendarModule } from "./modules/calendar/calendar.module";
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ThrottlerModule.forRoot([
{
ttl: 60_000,
limit: 120
}
]),
PrismaModule,
AuthModule,
AuditModule,
QueuesModule,
TenantsModule,
HealthModule,
EventsModule,
AttendeesModule,
RegistrationsModule,
InviteesModule,
RsvpsModule,
CommunicationsModule,
PaymentsModule,
CrmModule,
WorkflowsModule,
QrcodeModule,
ReportsModule,
SettingsModule,
IntegrationsModule,
UsersModule,
RolesModule,
FormsModule,
CalendarModule
],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard
}
]
})
export class AppModule {}

View File

@@ -0,0 +1,29 @@
import { ArgumentsHost, Catch, HttpException, HttpStatus } from "@nestjs/common";
import type { ExceptionFilter } from "@nestjs/common";
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const res = ctx.getResponse();
const req = ctx.getRequest();
const timestamp = new Date().toISOString();
const path = req?.originalUrl ?? req?.url;
if (exception instanceof HttpException) {
const statusCode = exception.getStatus();
const response = exception.getResponse() as any;
const message = Array.isArray(response?.message) ? response.message.join(", ") : response?.message ?? exception.message;
res.status(statusCode).json({ statusCode, message, path, timestamp });
return;
}
res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: "Internal server error",
path,
timestamp
});
}
}

46
apps/api/src/main.ts Normal file
View File

@@ -0,0 +1,46 @@
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { ValidationPipe } from "@nestjs/common";
import cookieParser from "cookie-parser";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import { AllExceptionsFilter } from "./common/filters/all-exceptions.filter";
import express from "express";
import path from "node:path";
import fs from "node:fs";
async function bootstrap() {
const app = await NestFactory.create(AppModule, { bodyParser: false });
const corsOrigins = (process.env.CORS_ORIGINS ?? process.env.PUBLIC_WEB_URL ?? "")
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
app.enableCors({ origin: corsOrigins.length ? corsOrigins : true, credentials: true });
app.setGlobalPrefix("api/v1");
app.use(
express.json({
verify: (req: any, _res, buf) => {
req.rawBody = buf;
}
})
);
app.use(express.urlencoded({ extended: true }));
const uploadsDir = process.env.UPLOADS_DIR ?? path.join(process.cwd(), "uploads");
fs.mkdirSync(uploadsDir, { recursive: true });
app.use("/uploads", express.static(uploadsDir));
app.use(cookieParser());
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true
})
);
app.useGlobalFilters(new AllExceptionsFilter());
const config = new DocumentBuilder().setTitle("EventSphere API").setVersion("v1").build();
const doc = SwaggerModule.createDocument(app, config);
SwaggerModule.setup("api", app, doc);
await app.listen(process.env.PORT || 4000);
}
bootstrap();

View File

@@ -0,0 +1,50 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from "@nestjs/common";
import type { Request } from "express";
import { AttendeesService } from "./attendees.service";
import { CreateAttendeeDto, UpdateAttendeeDto } from "./dto";
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
@Controller("attendees")
export class AttendeesController {
constructor(private readonly service: AttendeesService) {}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("attendees.read")
@Get()
findAll(@Req() req: Request) {
const tenantId = (req.user as any).tenantId as string;
return this.service.findAll(tenantId);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("attendees.write")
@Post()
create(@Req() req: Request, @Body() dto: CreateAttendeeDto) {
const tenantId = (req.user as any).tenantId as string;
return this.service.create(tenantId, dto);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("attendees.read")
@Get(":id")
findOne(@Req() req: Request, @Param("id") id: string) {
const tenantId = (req.user as any).tenantId as string;
return this.service.findOne(tenantId, id);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("attendees.write")
@Patch(":id")
update(@Req() req: Request, @Param("id") id: string, @Body() dto: UpdateAttendeeDto) {
const tenantId = (req.user as any).tenantId as string;
return this.service.update(tenantId, id, dto);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("attendees.write")
@Delete(":id")
remove(@Req() req: Request, @Param("id") id: string) {
const tenantId = (req.user as any).tenantId as string;
return this.service.remove(tenantId, id);
}
}

View File

@@ -0,0 +1,5 @@
import { Module } from "@nestjs/common";
import { AttendeesController } from "./attendees.controller";
import { AttendeesService } from "./attendees.service";
@Module({ controllers: [AttendeesController], providers: [AttendeesService], exports: [AttendeesService] })
export class AttendeesModule {}

View File

@@ -0,0 +1,47 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../../prisma/prisma.service";
import type { CreateAttendeeDto, UpdateAttendeeDto } from "./dto";
@Injectable()
export class AttendeesService {
constructor(private readonly prisma: PrismaService) {}
findAll(tenantId: string) {
return this.prisma.attendee.findMany({ where: { tenantId }, orderBy: { createdAt: "desc" } });
}
async findOne(tenantId: string, id: string) {
const row = await this.prisma.attendee.findFirst({ where: { tenantId, id } });
if (!row) throw new NotFoundException("Attendee not found");
return row;
}
async create(tenantId: string, dto: CreateAttendeeDto) {
try {
return await this.prisma.attendee.create({
data: { tenantId, fullName: dto.fullName, email: dto.email.toLowerCase(), phone: dto.phone }
});
} catch (e: any) {
if (String(e?.code) === "P2002") throw new BadRequestException("Attendee email already exists");
throw e;
}
}
async update(tenantId: string, id: string, dto: UpdateAttendeeDto) {
await this.findOne(tenantId, id);
return this.prisma.attendee.update({
where: { id },
data: {
fullName: dto.fullName,
email: dto.email ? dto.email.toLowerCase() : undefined,
phone: dto.phone
}
});
}
async remove(tenantId: string, id: string) {
await this.findOne(tenantId, id);
await this.prisma.attendee.delete({ where: { id } });
return { ok: true };
}
}

View File

@@ -0,0 +1,29 @@
import { IsEmail, IsNotEmpty, IsOptional, IsString } from "class-validator";
export class CreateAttendeeDto {
@IsString()
@IsNotEmpty()
fullName!: string;
@IsEmail()
email!: string;
@IsOptional()
@IsString()
phone?: string;
}
export class UpdateAttendeeDto {
@IsOptional()
@IsString()
@IsNotEmpty()
fullName?: string;
@IsOptional()
@IsEmail()
email?: string;
@IsOptional()
@IsString()
phone?: string;
}

View File

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { AuditService } from "./audit.service";
@Module({
providers: [AuditService],
exports: [AuditService]
})
export class AuditModule {}

View File

@@ -0,0 +1,29 @@
import { Injectable } from "@nestjs/common";
import type { Request } from "express";
import { PrismaService } from "../../prisma/prisma.service";
@Injectable()
export class AuditService {
constructor(private readonly prisma: PrismaService) {}
async log(action: string, req?: Request, options?: { tenantId?: string; actorUserId?: string; entityType?: string; entityId?: string; metadata?: any }) {
const ip =
(req?.headers?.["x-forwarded-for"] as string | undefined)?.split(",")[0]?.trim() ??
(req as any)?.ip ??
undefined;
const userAgent = (req?.headers?.["user-agent"] as string | undefined) ?? undefined;
await this.prisma.auditLog.create({
data: {
action,
tenantId: options?.tenantId,
actorUserId: options?.actorUserId,
entityType: options?.entityType,
entityId: options?.entityId,
metadata: options?.metadata,
ip,
userAgent
}
});
}
}

View File

@@ -0,0 +1,71 @@
import { Body, Controller, Get, Post, Req, Res, UseGuards } from "@nestjs/common";
import type { Request, Response } from "express";
import { AuthService } from "./auth.service";
import { BootstrapDto, ChangePasswordDto, LoginDto, RefreshDto } from "./dto";
import { JwtAuthGuard } from "./guards";
import { PrismaService } from "../../prisma/prisma.service";
@Controller("auth")
export class AuthController {
constructor(
private readonly service: AuthService,
private readonly prisma: PrismaService
) {}
@Post("bootstrap")
async bootstrap(@Body() dto: BootstrapDto, @Res({ passthrough: true }) res: Response) {
const result = await this.service.bootstrap(dto);
this.service.setRefreshCookie(res, result.refreshToken);
return { accessToken: result.accessToken, user: result.user, tenant: result.tenant };
}
@Post("login")
async login(@Req() req: Request, @Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
const result = await this.service.login(dto, req);
this.service.setRefreshCookie(res, result.refreshToken);
return { accessToken: result.accessToken, user: result.user, tenant: result.tenant };
}
@Post("refresh")
async refresh(@Body() dto: RefreshDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
const token = dto.refreshToken ?? req.cookies?.refreshToken;
const result = await this.service.refresh(token);
this.service.setRefreshCookie(res, result.refreshToken);
return { accessToken: result.accessToken };
}
@Post("logout")
async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
const token = req.cookies?.refreshToken;
await this.service.logout(token, req);
res.clearCookie("refreshToken");
return { ok: true };
}
@UseGuards(JwtAuthGuard)
@Get("me")
async me(@Req() req: Request) {
const jwtUser = req.user as any;
const userId = String(jwtUser?.sub ?? "");
if (!userId) return { user: req.user };
const dbUser = await this.prisma.user.findUnique({ where: { id: userId } });
if (!dbUser) return { user: req.user };
return {
user: {
...jwtUser,
email: dbUser.email,
fullName: dbUser.fullName,
avatarUrl: dbUser.avatarUrl ?? null,
mustChangePassword: dbUser.mustChangePassword
}
};
}
@UseGuards(JwtAuthGuard)
@Post("change-password")
async changePassword(@Req() req: Request, @Body() dto: ChangePasswordDto) {
const userId = (req.user as any).sub as string;
await this.service.changePassword(userId, dto.oldPassword, dto.newPassword, req);
return { ok: true };
}
}

View File

@@ -0,0 +1,17 @@
import { Global, Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { JwtStrategy } from "./jwt.strategy";
import { JwtAuthGuard, PermissionsGuard, TenantGuard } from "./guards";
import { AuditModule } from "../audit/audit.module";
@Global()
@Module({
imports: [PassportModule, JwtModule.register({}), AuditModule],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, JwtAuthGuard, TenantGuard, PermissionsGuard],
exports: [AuthService, JwtAuthGuard, TenantGuard, PermissionsGuard]
})
export class AuthModule {}

View File

@@ -0,0 +1,332 @@
import { BadRequestException, Injectable, UnauthorizedException, type OnModuleInit } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import bcrypt from "bcrypt";
import { randomBytes } from "node:crypto";
import { PrismaService } from "../../prisma/prisma.service";
import { BootstrapDto, LoginDto } from "./dto";
import type { Request, Response } from "express";
import type { JwtUser } from "./types";
import { AuditService } from "../audit/audit.service";
const PERMISSION_KEYS = [
"events.read",
"events.write",
"attendees.read",
"attendees.write",
"communications.read",
"communications.write",
"invitees.read",
"invitees.write",
"rsvps.read",
"rsvps.write",
"registrations.read",
"registrations.write",
"payments.read",
"payments.write",
"reports.read",
"settings.read",
"settings.write",
"integrations.read",
"integrations.write",
"users.read",
"users.write",
"roles.read",
"roles.write",
"forms.read",
"forms.write",
"workflows.read",
"workflows.write",
"qrcodes.read",
"qrcodes.write",
"calendar.read",
"calendar.write",
"crm.read",
"crm.write",
"checkins.read",
"checkins.write"
];
function nowPlusSeconds(seconds: number) {
return new Date(Date.now() + seconds * 1000);
}
function toSeconds(input: string | undefined, fallbackSeconds: number) {
if (!input) return fallbackSeconds;
const match = input.match(/^(\d+)(s|m|h|d)$/);
if (!match) return fallbackSeconds;
const value = Number(match[1]);
const unit = match[2];
if (unit === "s") return value;
if (unit === "m") return value * 60;
if (unit === "h") return value * 3600;
return value * 86400;
}
@Injectable()
export class AuthService implements OnModuleInit {
private readonly accessTtlSeconds: number;
private readonly refreshTtlSeconds: number;
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly audit: AuditService
) {
this.accessTtlSeconds = toSeconds(process.env.JWT_ACCESS_TTL, 15 * 60);
this.refreshTtlSeconds = toSeconds(process.env.JWT_REFRESH_TTL, 30 * 24 * 3600);
}
async onModuleInit() {
await this.ensureSystemPermissions();
const enabled = process.env.AUTO_BOOTSTRAP === "1" || process.env.AUTO_BOOTSTRAP === "true";
if (!enabled) return;
const tenants = await this.prisma.tenant.count();
if (tenants > 0) return;
const tenantName = process.env.DEFAULT_TENANT_NAME;
const tenantSlug = process.env.DEFAULT_TENANT_SLUG;
const adminFullName = process.env.DEFAULT_SUPERADMIN_FULL_NAME;
const adminEmail = process.env.DEFAULT_SUPERADMIN_EMAIL;
const adminPassword = process.env.DEFAULT_SUPERADMIN_PASSWORD;
if (!tenantName || !tenantSlug || !adminFullName || !adminEmail || !adminPassword) {
throw new Error("AUTO_BOOTSTRAP is enabled but DEFAULT_* environment variables are missing");
}
const dto: BootstrapDto = {
tenantName,
tenantSlug,
adminFullName,
adminEmail,
adminPassword
};
await this.bootstrap(dto);
}
async ensureSystemPermissions() {
await this.prisma.permission.createMany({
data: PERMISSION_KEYS.map((key) => ({ key })),
skipDuplicates: true
});
const [superAdminRoles, permissions] = await Promise.all([
this.prisma.role.findMany({ where: { name: "super_admin" } }),
this.prisma.permission.findMany({ where: { key: { in: PERMISSION_KEYS } } })
]);
if (!superAdminRoles.length || !permissions.length) return;
await this.prisma.rolePermission.createMany({
data: superAdminRoles.flatMap((role) => permissions.map((permission) => ({ roleId: role.id, permissionId: permission.id }))),
skipDuplicates: true
});
}
setRefreshCookie(res: Response, refreshToken: string) {
const isProd = process.env.NODE_ENV === "production";
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
secure: isProd,
sameSite: "lax",
path: "/",
maxAge: this.refreshTtlSeconds * 1000
});
}
async bootstrap(dto: BootstrapDto, req?: Request) {
const tenantCount = await this.prisma.tenant.count();
if (tenantCount > 0 && process.env.ALLOW_PUBLIC_SIGNUP !== "1" && process.env.ALLOW_PUBLIC_SIGNUP !== "true") {
throw new BadRequestException("Tenant setup is disabled after initial bootstrap");
}
const existing = await this.prisma.tenant.findUnique({ where: { slug: dto.tenantSlug } });
if (existing) throw new BadRequestException("Tenant already exists");
const passwordHash = await bcrypt.hash(dto.adminPassword, 12);
const tenant = await this.prisma.tenant.create({
data: {
name: dto.tenantName,
slug: dto.tenantSlug,
roles: {
create: [
{ name: "super_admin" },
{ name: "admin" },
{ name: "manager" }
]
}
}
});
await this.prisma.permission.createMany({
data: PERMISSION_KEYS.map((key) => ({ key })),
skipDuplicates: true
});
const [superAdminRole, permissions] = await Promise.all([
this.prisma.role.findFirst({ where: { tenantId: tenant.id, name: "super_admin" } }),
this.prisma.permission.findMany({ where: { key: { in: PERMISSION_KEYS } } })
]);
if (!superAdminRole) throw new BadRequestException("Bootstrap role missing");
await this.prisma.rolePermission.createMany({
data: permissions.map((p) => ({ roleId: superAdminRole.id, permissionId: p.id })),
skipDuplicates: true
});
const user = await this.prisma.user.create({
data: {
tenantId: tenant.id,
email: dto.adminEmail.toLowerCase(),
fullName: dto.adminFullName,
mustChangePassword: true,
passwordHash,
roles: {
create: [{ roleId: superAdminRole.id }]
}
}
});
const tokens = await this.issueTokens(user.id);
await this.audit.log("auth.bootstrap", req, { tenantId: tenant.id, actorUserId: user.id });
return {
...tokens,
user: { id: user.id, email: user.email, fullName: user.fullName, tenantId: user.tenantId, mustChangePassword: user.mustChangePassword },
tenant: { id: tenant.id, name: tenant.name, slug: tenant.slug }
};
}
async login(dto: LoginDto, req?: Request) {
const tenant = await this.prisma.tenant.findUnique({ where: { slug: dto.tenantSlug } });
if (!tenant) throw new UnauthorizedException("Invalid credentials");
const user = await this.prisma.user.findUnique({
where: { tenantId_email: { tenantId: tenant.id, email: dto.email.toLowerCase() } }
});
if (!user) throw new UnauthorizedException("Invalid credentials");
const ok = await bcrypt.compare(dto.password, user.passwordHash);
if (!ok) throw new UnauthorizedException("Invalid credentials");
const tokens = await this.issueTokens(user.id);
await this.audit.log("auth.login", req, { tenantId: tenant.id, actorUserId: user.id });
return {
...tokens,
user: { id: user.id, email: user.email, fullName: user.fullName, tenantId: user.tenantId, mustChangePassword: user.mustChangePassword },
tenant: { id: tenant.id, name: tenant.name, slug: tenant.slug }
};
}
async refresh(refreshToken: string | undefined) {
if (!refreshToken) throw new UnauthorizedException("Missing refresh token");
let payload: any;
try {
payload = await this.jwt.verifyAsync(refreshToken, { secret: process.env.JWT_SECRET });
} catch {
throw new UnauthorizedException("Invalid refresh token");
}
if (payload?.type !== "refresh") throw new UnauthorizedException("Invalid refresh token");
const userId = String(payload.sub);
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user || !user.refreshTokenHash || !user.refreshTokenId || !user.refreshTokenExpiresAt) {
throw new UnauthorizedException("Invalid refresh token");
}
if (user.refreshTokenExpiresAt.getTime() < Date.now()) throw new UnauthorizedException("Refresh token expired");
if (String(payload.rid) !== user.refreshTokenId) throw new UnauthorizedException("Invalid refresh token");
const ok = await bcrypt.compare(refreshToken, user.refreshTokenHash);
if (!ok) throw new UnauthorizedException("Invalid refresh token");
return this.issueTokens(user.id);
}
async logout(refreshToken: string | undefined, req?: Request) {
if (!refreshToken) return;
let payload: any;
try {
payload = await this.jwt.verifyAsync(refreshToken, { secret: process.env.JWT_SECRET });
} catch {
return;
}
if (payload?.type !== "refresh") return;
await this.prisma.user.update({
where: { id: String(payload.sub) },
data: { refreshTokenId: null, refreshTokenHash: null, refreshTokenExpiresAt: null }
});
await this.audit.log("auth.logout", req, { tenantId: String(payload.tenantId), actorUserId: String(payload.sub) });
}
async buildJwtUser(userId: string): Promise<JwtUser> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
roles: {
include: {
role: {
include: {
permissions: { include: { permission: true } }
}
}
}
}
}
});
if (!user) throw new UnauthorizedException("User not found");
const roleNames = user.roles.map((ur) => ur.role.name);
const permissionKeys = Array.from(new Set(user.roles.flatMap((ur) => ur.role.permissions.map((rp) => rp.permission.key))));
return {
sub: user.id,
tenantId: user.tenantId,
email: user.email,
fullName: user.fullName,
avatarUrl: user.avatarUrl ?? null,
mustChangePassword: user.mustChangePassword,
roleNames,
permissionKeys
};
}
async changePassword(userId: string, oldPassword: string, newPassword: string, req?: Request) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedException("User not found");
const ok = await bcrypt.compare(oldPassword, user.passwordHash);
if (!ok) throw new UnauthorizedException("Invalid password");
const passwordHash = await bcrypt.hash(newPassword, 12);
await this.prisma.user.update({
where: { id: userId },
data: { passwordHash, mustChangePassword: false, refreshTokenId: null, refreshTokenHash: null, refreshTokenExpiresAt: null }
});
await this.audit.log("auth.changePassword", req, { tenantId: user.tenantId, actorUserId: user.id });
}
private async issueTokens(userId: string) {
const jwtUser = await this.buildJwtUser(userId);
const accessToken = await this.jwt.signAsync(
{ ...jwtUser, type: "access" },
{ secret: process.env.JWT_SECRET, expiresIn: this.accessTtlSeconds }
);
const rid = randomBytes(16).toString("hex");
const refreshToken = await this.jwt.signAsync(
{ sub: jwtUser.sub, tenantId: jwtUser.tenantId, type: "refresh", rid },
{ secret: process.env.JWT_SECRET, expiresIn: this.refreshTtlSeconds }
);
const refreshTokenHash = await bcrypt.hash(refreshToken, 12);
await this.prisma.user.update({
where: { id: userId },
data: { refreshTokenId: rid, refreshTokenHash, refreshTokenExpiresAt: nowPlusSeconds(this.refreshTtlSeconds) }
});
return { accessToken, refreshToken };
}
}

View File

@@ -0,0 +1,51 @@
import { IsEmail, IsNotEmpty, IsOptional, IsString, Matches, MinLength } from "class-validator";
export class BootstrapDto {
@IsString()
@IsNotEmpty()
tenantName!: string;
@IsString()
@Matches(/^[a-z0-9-]+$/)
tenantSlug!: string;
@IsEmail()
adminEmail!: string;
@IsString()
@MinLength(8)
adminPassword!: string;
@IsString()
@IsNotEmpty()
adminFullName!: string;
}
export class LoginDto {
@IsString()
@Matches(/^[a-z0-9-]+$/)
tenantSlug!: string;
@IsEmail()
email!: string;
@IsString()
@MinLength(8)
password!: string;
}
export class RefreshDto {
@IsOptional()
@IsString()
refreshToken?: string;
}
export class ChangePasswordDto {
@IsString()
@MinLength(8)
oldPassword!: string;
@IsString()
@MinLength(8)
newPassword!: string;
}

View File

@@ -0,0 +1,37 @@
import { CanActivate, ExecutionContext, Injectable, SetMetadata } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { AuthGuard } from "@nestjs/passport";
import type { JwtUser } from "./types";
export const REQUIRE_PERMISSIONS_KEY = "requirePermissions";
export const RequirePermissions = (...permissions: string[]) => SetMetadata(REQUIRE_PERMISSIONS_KEY, permissions);
@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {}
@Injectable()
export class TenantGuard implements CanActivate {
canActivate(context: ExecutionContext) {
const req = context.switchToHttp().getRequest();
const user = req.user as JwtUser | undefined;
return Boolean(user?.tenantId);
}
}
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext) {
const handler = context.getHandler();
const cls = context.getClass();
const required: string[] =
this.reflector.getAllAndOverride(REQUIRE_PERMISSIONS_KEY, [handler, cls]) ?? [];
if (!required.length) return true;
const req = context.switchToHttp().getRequest();
const user = req.user as JwtUser | undefined;
const keys = new Set(user?.permissionKeys ?? []);
return required.every((k) => keys.has(k));
}
}

View File

@@ -0,0 +1,22 @@
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { PrismaService } from "../../prisma/prisma.service";
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private readonly prisma: PrismaService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET
});
}
async validate(payload: any) {
if (!payload || payload.type !== "access") throw new UnauthorizedException("Invalid token");
const user = await this.prisma.user.findUnique({ where: { id: String(payload.sub) } });
if (!user) throw new UnauthorizedException("Invalid token");
return payload;
}
}

View File

@@ -0,0 +1,10 @@
export type JwtUser = {
sub: string;
tenantId: string;
email: string;
fullName: string;
avatarUrl?: string | null;
mustChangePassword: boolean;
roleNames: string[];
permissionKeys: string[];
};

View File

@@ -0,0 +1,51 @@
import { Body, Controller, Get, Post, Query, Req, UseGuards } from "@nestjs/common";
import type { Request } from "express";
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
import { CalendarService } from "./calendar.service";
@Controller("calendar")
export class CalendarController {
constructor(private readonly service: CalendarService) {}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("calendar.read")
@Get("routing-forms")
routingForms(@Req() req: Request) {
return this.service.routingForms((req.user as any).tenantId as string);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("calendar.write")
@Post("routing-forms")
createRoutingForm(@Req() req: Request, @Body() payload: Record<string, unknown>) {
return this.service.createRoutingForm((req.user as any).tenantId as string, payload);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("calendar.read")
@Get("slots")
slots(@Req() req: Request, @Query("routingFormId") routingFormId?: string) {
return this.service.slots((req.user as any).tenantId as string, routingFormId);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("calendar.write")
@Post("slots")
createSlot(@Req() req: Request, @Body() payload: Record<string, unknown>) {
return this.service.createSlot((req.user as any).tenantId as string, payload);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("calendar.read")
@Get("bookings")
bookings(@Req() req: Request) {
return this.service.bookings((req.user as any).tenantId as string);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("calendar.write")
@Post("bookings")
createBooking(@Req() req: Request, @Body() payload: Record<string, unknown>) {
return this.service.createBooking((req.user as any).tenantId as string, payload);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { CalendarController } from "./calendar.controller";
import { CalendarService } from "./calendar.service";
@Module({
controllers: [CalendarController],
providers: [CalendarService]
})
export class CalendarModule {}

View File

@@ -0,0 +1,92 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../../prisma/prisma.service";
@Injectable()
export class CalendarService {
constructor(private readonly prisma: PrismaService) {}
routingForms(tenantId: string) {
return this.prisma.calendarRoutingForm.findMany({
where: { tenantId },
orderBy: { createdAt: "desc" },
include: { event: true, _count: { select: { slots: true, bookings: true } } }
});
}
createRoutingForm(tenantId: string, payload: any) {
return this.prisma.calendarRoutingForm.create({
data: {
tenantId,
eventId: payload.eventId || null,
name: String(payload.name ?? ""),
slug: String(payload.slug ?? "").toLowerCase().trim(),
description: payload.description ? String(payload.description) : null,
durationMinutes: Number(payload.durationMinutes ?? 30),
active: payload.active === undefined ? true : Boolean(payload.active)
}
});
}
slots(tenantId: string, routingFormId?: string) {
return this.prisma.calendarSlot.findMany({
where: { tenantId, ...(routingFormId ? { routingFormId } : {}) },
orderBy: { startsAt: "asc" },
include: { routingForm: true }
});
}
async createSlot(tenantId: string, payload: any) {
const routingForm = await this.prisma.calendarRoutingForm.findFirst({ where: { tenantId, id: String(payload.routingFormId ?? "") } });
if (!routingForm) throw new NotFoundException("Routing form not found");
const startsAt = new Date(String(payload.startsAt ?? ""));
const endsAt = new Date(String(payload.endsAt ?? ""));
if (!Number.isFinite(startsAt.getTime()) || !Number.isFinite(endsAt.getTime()) || endsAt <= startsAt) {
throw new BadRequestException("Invalid slot time");
}
return this.prisma.calendarSlot.create({
data: {
tenantId,
routingFormId: routingForm.id,
startsAt,
endsAt,
capacity: Number(payload.capacity ?? 1),
active: payload.active === undefined ? true : Boolean(payload.active)
},
include: { routingForm: true }
});
}
bookings(tenantId: string) {
return this.prisma.booking.findMany({
where: { tenantId },
orderBy: { startsAt: "desc" },
include: { routingForm: true, event: true, attendee: true }
});
}
async createBooking(tenantId: string, payload: any) {
const routingForm = await this.prisma.calendarRoutingForm.findFirst({ where: { tenantId, id: String(payload.routingFormId ?? "") } });
if (!routingForm) throw new NotFoundException("Routing form not found");
const startsAt = new Date(String(payload.startsAt ?? ""));
const endsAt = new Date(String(payload.endsAt ?? ""));
if (!Number.isFinite(startsAt.getTime()) || !Number.isFinite(endsAt.getTime()) || endsAt <= startsAt) {
throw new BadRequestException("Invalid booking time");
}
return this.prisma.booking.create({
data: {
tenantId,
routingFormId: routingForm.id,
eventId: payload.eventId || routingForm.eventId || null,
attendeeId: payload.attendeeId || null,
fullName: String(payload.fullName ?? ""),
email: String(payload.email ?? "").toLowerCase().trim(),
phone: payload.phone ? String(payload.phone) : null,
startsAt,
endsAt,
status: payload.status ? String(payload.status) : "booked",
notes: payload.notes ? String(payload.notes) : null
},
include: { routingForm: true, event: true, attendee: true }
});
}
}

View File

@@ -0,0 +1,60 @@
import { Body, Controller, Get, Post, Req, UseGuards } from "@nestjs/common";
import type { Request } from "express";
import { CommunicationsService } from "./communications.service";
import { SendEmailDto, SendSmsDto, SendWhatsappDto } from "./dto";
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
@Controller("communications")
export class CommunicationsController {
constructor(private readonly service: CommunicationsService) {}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("communications.read")
@Get()
findAll(@Req() req: Request) {
const tenantId = (req.user as any).tenantId as string;
return this.service.findAll(tenantId);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("communications.read")
@Get("templates")
templates(@Req() req: Request) {
const tenantId = (req.user as any).tenantId as string;
return this.service.templates(tenantId);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("communications.write")
@Post("templates")
createTemplate(@Req() req: Request, @Body() payload: Record<string, unknown>) {
const tenantId = (req.user as any).tenantId as string;
return this.service.createTemplate(tenantId, payload);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("communications.write")
@Post("email")
sendEmail(@Req() req: Request, @Body() dto: SendEmailDto) {
const tenantId = (req.user as any).tenantId as string;
const actorUserId = (req.user as any).sub as string;
return this.service.sendEmail(tenantId, actorUserId, dto, req);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("communications.write")
@Post("sms")
sendSms(@Req() req: Request, @Body() dto: SendSmsDto) {
const tenantId = (req.user as any).tenantId as string;
const actorUserId = (req.user as any).sub as string;
return this.service.sendSms(tenantId, actorUserId, dto, req);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("communications.write")
@Post("whatsapp")
sendWhatsapp(@Req() req: Request, @Body() dto: SendWhatsappDto) {
const tenantId = (req.user as any).tenantId as string;
const actorUserId = (req.user as any).sub as string;
return this.service.sendWhatsapp(tenantId, actorUserId, dto, req);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from "@nestjs/common";
import { CommunicationsController } from "./communications.controller";
import { CommunicationsService } from "./communications.service";
import { QueuesModule } from "../queues/queues.module";
import { AuditModule } from "../audit/audit.module";
@Module({
imports: [QueuesModule, AuditModule],
controllers: [CommunicationsController],
providers: [CommunicationsService],
exports: [CommunicationsService]
})
export class CommunicationsModule {}

View File

@@ -0,0 +1,132 @@
import { Injectable } from "@nestjs/common";
import type { Request } from "express";
import { QueuesService } from "../queues/queues.service";
import { AuditService } from "../audit/audit.service";
import type { SendEmailDto, SendSmsDto, SendWhatsappDto } from "./dto";
import { PrismaService } from "../../prisma/prisma.service";
@Injectable()
export class CommunicationsService {
constructor(
private readonly queues: QueuesService,
private readonly audit: AuditService,
private readonly prisma: PrismaService
) {}
findAll(tenantId: string) {
return this.prisma.communicationLog.findMany({
where: { tenantId },
orderBy: { createdAt: "desc" },
include: { template: true, invitee: true, registration: true }
});
}
templates(tenantId: string) {
return this.prisma.communicationTemplate.findMany({ where: { tenantId }, orderBy: { createdAt: "desc" } });
}
createTemplate(tenantId: string, payload: any) {
return this.prisma.communicationTemplate.create({
data: {
tenantId,
name: String(payload.name ?? ""),
channel: payload.channel ?? "email",
subject: payload.subject ? String(payload.subject) : null,
body: String(payload.body ?? ""),
variables: payload.variables ?? undefined,
isActive: payload.isActive === undefined ? true : Boolean(payload.isActive)
}
});
}
async sendEmail(tenantId: string, actorUserId: string, dto: SendEmailDto, req: Request) {
const log = await this.prisma.communicationLog.create({
data: {
tenantId,
channel: "email",
to: dto.to,
subject: dto.subject,
body: dto.html ?? dto.text ?? "",
status: "queued"
}
});
await this.queues.queue("communications").add("sendEmail", {
tenantId,
actorUserId,
communicationLogId: log.id,
to: dto.to,
subject: dto.subject,
text: dto.text,
html: dto.html
});
await this.audit.log("communications.sendEmail.queued", req, {
tenantId,
actorUserId,
entityType: "email",
metadata: { to: dto.to, subject: dto.subject }
});
return { ok: true };
}
async sendSms(tenantId: string, actorUserId: string, dto: SendSmsDto, req: Request) {
const log = await this.prisma.communicationLog.create({
data: {
tenantId,
channel: "sms",
to: dto.to,
body: dto.message,
status: "queued"
}
});
await this.queues.queue("communications").add("sendSms", {
tenantId,
actorUserId,
communicationLogId: log.id,
to: dto.to,
message: dto.message,
from: dto.from
});
await this.audit.log("communications.sendSms.queued", req, {
tenantId,
actorUserId,
entityType: "sms",
metadata: { to: dto.to }
});
return { ok: true };
}
async sendWhatsapp(tenantId: string, actorUserId: string, dto: SendWhatsappDto, req: Request) {
const log = await this.prisma.communicationLog.create({
data: {
tenantId,
channel: "whatsapp",
to: dto.to,
body: dto.message,
status: "queued"
}
});
await this.queues.queue("communications").add("sendWhatsapp", {
tenantId,
actorUserId,
communicationLogId: log.id,
to: dto.to,
message: dto.message
});
await this.audit.log("communications.sendWhatsapp.queued", req, {
tenantId,
actorUserId,
entityType: "whatsapp",
metadata: { to: dto.to }
});
return { ok: true };
}
}

View File

@@ -0,0 +1,42 @@
import { IsEmail, IsNotEmpty, IsOptional, IsString } from "class-validator";
export class SendEmailDto {
@IsEmail()
to!: string;
@IsString()
@IsNotEmpty()
subject!: string;
@IsOptional()
@IsString()
text?: string;
@IsOptional()
@IsString()
html?: string;
}
export class SendSmsDto {
@IsString()
@IsNotEmpty()
to!: string;
@IsString()
@IsNotEmpty()
message!: string;
@IsOptional()
@IsString()
from?: string;
}
export class SendWhatsappDto {
@IsString()
@IsNotEmpty()
to!: string;
@IsString()
@IsNotEmpty()
message!: string;
}

View File

@@ -0,0 +1,87 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from "@nestjs/common";
import type { Request } from "express";
import { CrmService } from "./crm.service";
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
@Controller("crm")
export class CrmController {
constructor(private readonly service: CrmService) {}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("crm.read")
@Get("summary")
summary(@Req() req: Request) {
return this.service.summary((req.user as any).tenantId as string);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("crm.read")
@Get("leads")
leads(@Req() req: Request) {
return this.service.leads((req.user as any).tenantId as string);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("crm.write")
@Post("leads")
createLead(@Req() req: Request, @Body() payload: Record<string, unknown>) {
return this.service.createLead((req.user as any).tenantId as string, payload);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("crm.write")
@Patch("leads/:id")
updateLead(@Req() req: Request, @Param("id") id: string, @Body() payload: Record<string, unknown>) {
return this.service.updateLead((req.user as any).tenantId as string, id, payload);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("crm.write")
@Delete("leads/:id")
removeLead(@Req() req: Request, @Param("id") id: string) {
return this.service.removeLead((req.user as any).tenantId as string, id);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("crm.read")
@Get("deals")
deals(@Req() req: Request) {
return this.service.deals((req.user as any).tenantId as string);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("crm.write")
@Post("deals")
createDeal(@Req() req: Request, @Body() payload: Record<string, unknown>) {
return this.service.createDeal((req.user as any).tenantId as string, payload);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("crm.write")
@Patch("deals/:id")
updateDeal(@Req() req: Request, @Param("id") id: string, @Body() payload: Record<string, unknown>) {
return this.service.updateDeal((req.user as any).tenantId as string, id, payload);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("crm.write")
@Delete("deals/:id")
removeDeal(@Req() req: Request, @Param("id") id: string) {
return this.service.removeDeal((req.user as any).tenantId as string, id);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("crm.read")
@Get("activities")
activities(@Req() req: Request) {
return this.service.activities((req.user as any).tenantId as string);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("crm.write")
@Post("activities")
createActivity(@Req() req: Request, @Body() payload: Record<string, unknown>) {
return this.service.createActivity((req.user as any).tenantId as string, (req.user as any).sub as string, payload);
}
}

View File

@@ -0,0 +1,5 @@
import { Module } from "@nestjs/common";
import { CrmController } from "./crm.controller";
import { CrmService } from "./crm.service";
@Module({ controllers: [CrmController], providers: [CrmService], exports: [CrmService] })
export class CrmModule {}

View File

@@ -0,0 +1,141 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../../prisma/prisma.service";
@Injectable()
export class CrmService {
constructor(private readonly prisma: PrismaService) {}
async summary(tenantId: string) {
const [leadCount, dealCount, wonDeals, openDeals, leadsByStatus] = await Promise.all([
this.prisma.cRMLead.count({ where: { tenantId } }),
this.prisma.cRMDeal.count({ where: { tenantId } }),
this.prisma.cRMDeal.aggregate({ where: { tenantId, stage: "closed_won" }, _sum: { valueKobo: true } }),
this.prisma.cRMDeal.aggregate({ where: { tenantId, stage: { notIn: ["closed_won", "closed_lost"] } }, _sum: { valueKobo: true } }),
this.prisma.cRMLead.groupBy({ by: ["status"], where: { tenantId }, _count: { _all: true } })
]);
return {
leadCount,
dealCount,
wonValueKobo: wonDeals._sum.valueKobo ?? 0,
openValueKobo: openDeals._sum.valueKobo ?? 0,
leadsByStatus: leadsByStatus.map((row) => ({ status: row.status, count: row._count._all }))
};
}
leads(tenantId: string) {
return this.prisma.cRMLead.findMany({
where: { tenantId },
orderBy: { createdAt: "desc" },
include: { event: true, attendee: true, deals: true }
});
}
createLead(tenantId: string, payload: any) {
return this.prisma.cRMLead.create({
data: {
tenantId,
eventId: payload.eventId || null,
attendeeId: payload.attendeeId || null,
fullName: String(payload.fullName ?? ""),
email: String(payload.email ?? "").toLowerCase().trim(),
phone: payload.phone ? String(payload.phone) : null,
source: payload.source ? String(payload.source) : "manual",
status: payload.status ? String(payload.status) : "new",
valueKobo: Number(payload.valueKobo ?? 0)
}
});
}
async updateLead(tenantId: string, id: string, payload: any) {
const row = await this.prisma.cRMLead.findFirst({ where: { tenantId, id } });
if (!row) throw new NotFoundException("Lead not found");
return this.prisma.cRMLead.update({
where: { id },
data: {
fullName: payload.fullName === undefined ? undefined : String(payload.fullName),
email: payload.email === undefined ? undefined : String(payload.email).toLowerCase().trim(),
phone: payload.phone === undefined ? undefined : payload.phone ? String(payload.phone) : null,
source: payload.source === undefined ? undefined : String(payload.source),
status: payload.status === undefined ? undefined : String(payload.status),
valueKobo: payload.valueKobo === undefined ? undefined : Number(payload.valueKobo)
}
});
}
async removeLead(tenantId: string, id: string) {
const row = await this.prisma.cRMLead.findFirst({ where: { tenantId, id } });
if (!row) throw new NotFoundException("Lead not found");
await this.prisma.cRMLead.delete({ where: { id } });
return { ok: true };
}
deals(tenantId: string) {
return this.prisma.cRMDeal.findMany({
where: { tenantId },
orderBy: { updatedAt: "desc" },
include: { lead: true, event: true, activities: true }
});
}
createDeal(tenantId: string, payload: any) {
return this.prisma.cRMDeal.create({
data: {
tenantId,
leadId: payload.leadId || null,
eventId: payload.eventId || null,
title: String(payload.title ?? ""),
stage: payload.stage ? String(payload.stage) : "new",
valueKobo: Number(payload.valueKobo ?? 0),
probability: Number(payload.probability ?? 0),
ownerUserId: payload.ownerUserId || null,
expectedCloseAt: payload.expectedCloseAt ? new Date(payload.expectedCloseAt) : null
}
});
}
async updateDeal(tenantId: string, id: string, payload: any) {
const row = await this.prisma.cRMDeal.findFirst({ where: { tenantId, id } });
if (!row) throw new NotFoundException("Deal not found");
return this.prisma.cRMDeal.update({
where: { id },
data: {
title: payload.title === undefined ? undefined : String(payload.title),
stage: payload.stage === undefined ? undefined : String(payload.stage),
valueKobo: payload.valueKobo === undefined ? undefined : Number(payload.valueKobo),
probability: payload.probability === undefined ? undefined : Number(payload.probability),
expectedCloseAt: payload.expectedCloseAt === undefined ? undefined : payload.expectedCloseAt ? new Date(payload.expectedCloseAt) : null
}
});
}
async removeDeal(tenantId: string, id: string) {
const row = await this.prisma.cRMDeal.findFirst({ where: { tenantId, id } });
if (!row) throw new NotFoundException("Deal not found");
await this.prisma.cRMDeal.delete({ where: { id } });
return { ok: true };
}
activities(tenantId: string) {
return this.prisma.cRMActivity.findMany({
where: { tenantId },
orderBy: { createdAt: "desc" },
include: { lead: true, deal: true }
});
}
createActivity(tenantId: string, userId: string, payload: any) {
return this.prisma.cRMActivity.create({
data: {
tenantId,
userId,
leadId: payload.leadId || null,
dealId: payload.dealId || null,
type: payload.type ? String(payload.type) : "note",
note: String(payload.note ?? ""),
dueAt: payload.dueAt ? new Date(payload.dueAt) : null,
completedAt: payload.completedAt ? new Date(payload.completedAt) : null
}
});
}
}

View File

@@ -0,0 +1,47 @@
import { IsDateString, IsIn, IsNotEmpty, IsOptional, IsString, Matches } from "class-validator";
export class CreateEventDto {
@IsString()
@IsNotEmpty()
name!: string;
@IsString()
@Matches(/^[a-z0-9-]+$/)
slug!: string;
@IsOptional()
@IsIn(["draft", "active", "closed"])
status?: "draft" | "active" | "closed";
@IsDateString()
startsAt!: string;
@IsString()
@IsNotEmpty()
venue!: string;
}
export class UpdateEventDto {
@IsOptional()
@IsString()
@IsNotEmpty()
name?: string;
@IsOptional()
@IsString()
@Matches(/^[a-z0-9-]+$/)
slug?: string;
@IsOptional()
@IsIn(["draft", "active", "closed"])
status?: "draft" | "active" | "closed";
@IsOptional()
@IsDateString()
startsAt?: string;
@IsOptional()
@IsString()
@IsNotEmpty()
venue?: string;
}

View File

@@ -0,0 +1,74 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from "@nestjs/common";
import type { Request } from "express";
import { EventsService } from "./events.service";
import { CreateEventDto, UpdateEventDto } from "./dto";
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
@Controller("events")
export class EventsController {
constructor(private readonly service: EventsService) {}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("events.read")
@Get()
findAll(@Req() req: Request) {
const tenantId = (req.user as any).tenantId as string;
return this.service.findAll(tenantId);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("events.write")
@Post()
create(@Req() req: Request, @Body() dto: CreateEventDto) {
const tenantId = (req.user as any).tenantId as string;
return this.service.create(tenantId, dto);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("events.read")
@Get(":id")
findOne(@Req() req: Request, @Param("id") id: string) {
const tenantId = (req.user as any).tenantId as string;
return this.service.findOne(tenantId, id);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("events.read")
@Get(":id/details")
findDetails(@Req() req: Request, @Param("id") id: string) {
const tenantId = (req.user as any).tenantId as string;
return this.service.findDetails(tenantId, id);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("events.write")
@Patch(":id")
update(@Req() req: Request, @Param("id") id: string, @Body() dto: UpdateEventDto) {
const tenantId = (req.user as any).tenantId as string;
return this.service.update(tenantId, id, dto);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("events.write")
@Delete(":id")
remove(@Req() req: Request, @Param("id") id: string) {
const tenantId = (req.user as any).tenantId as string;
return this.service.remove(tenantId, id);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("events.write")
@Post(":id/ticket-types")
createTicketType(@Req() req: Request, @Param("id") id: string, @Body() payload: Record<string, unknown>) {
const tenantId = (req.user as any).tenantId as string;
return this.service.createTicketType(tenantId, id, payload);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("events.write")
@Patch(":id/page")
upsertPage(@Req() req: Request, @Param("id") id: string, @Body() payload: Record<string, unknown>) {
const tenantId = (req.user as any).tenantId as string;
return this.service.upsertPage(tenantId, id, payload);
}
}

View File

@@ -0,0 +1,5 @@
import { Module } from "@nestjs/common";
import { EventsController } from "./events.controller";
import { EventsService } from "./events.service";
@Module({ controllers: [EventsController], providers: [EventsService], exports: [EventsService] })
export class EventsModule {}

View File

@@ -0,0 +1,110 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../../prisma/prisma.service";
import type { CreateEventDto, UpdateEventDto } from "./dto";
@Injectable()
export class EventsService {
constructor(private readonly prisma: PrismaService) {}
findAll(tenantId: string) {
return this.prisma.event.findMany({ where: { tenantId }, orderBy: { createdAt: "desc" } });
}
async findOne(tenantId: string, id: string) {
const row = await this.prisma.event.findFirst({ where: { tenantId, id } });
if (!row) throw new NotFoundException("Event not found");
return row;
}
async findDetails(tenantId: string, id: string) {
const row = await this.prisma.event.findFirst({
where: { tenantId, id },
include: {
eventPage: true,
ticketTypes: { orderBy: { createdAt: "asc" } },
_count: { select: { registrations: true, invitees: true, rsvps: true, checkIns: true } }
}
});
if (!row) throw new NotFoundException("Event not found");
return row;
}
async create(tenantId: string, dto: CreateEventDto) {
try {
return await this.prisma.event.create({
data: {
tenantId,
name: dto.name,
slug: dto.slug,
status: dto.status ?? "draft",
startsAt: new Date(dto.startsAt),
venue: dto.venue
}
});
} catch (e: any) {
if (String(e?.code) === "P2002") throw new BadRequestException("Event slug already exists");
throw e;
}
}
async update(tenantId: string, id: string, dto: UpdateEventDto) {
await this.findOne(tenantId, id);
return this.prisma.event.update({
where: { id },
data: {
name: dto.name,
slug: dto.slug,
status: dto.status,
startsAt: dto.startsAt ? new Date(dto.startsAt) : undefined,
venue: dto.venue
}
});
}
async remove(tenantId: string, id: string) {
await this.findOne(tenantId, id);
await this.prisma.event.delete({ where: { id } });
return { ok: true };
}
async upsertPage(tenantId: string, eventId: string, payload: any) {
const event = await this.findOne(tenantId, eventId);
return this.prisma.eventPage.upsert({
where: { eventId: event.id },
create: {
tenantId,
eventId: event.id,
title: String(payload.title ?? event.name),
heroTitle: payload.heroTitle ? String(payload.heroTitle) : event.name,
description: payload.description ? String(payload.description) : null,
content: payload.content ?? undefined,
theme: payload.theme ?? undefined,
publishedAt: payload.published ? new Date() : null
},
update: {
title: payload.title === undefined ? undefined : String(payload.title),
heroTitle: payload.heroTitle === undefined ? undefined : payload.heroTitle ? String(payload.heroTitle) : null,
description: payload.description === undefined ? undefined : payload.description ? String(payload.description) : null,
content: payload.content === undefined ? undefined : payload.content,
theme: payload.theme === undefined ? undefined : payload.theme,
publishedAt: payload.published === undefined ? undefined : payload.published ? new Date() : null
}
});
}
async createTicketType(tenantId: string, eventId: string, payload: any) {
const event = await this.findOne(tenantId, eventId);
return this.prisma.ticketType.create({
data: {
tenantId,
eventId: event.id,
name: String(payload.name ?? ""),
description: payload.description ? String(payload.description) : null,
priceKobo: Number(payload.priceKobo ?? Math.round(Number(payload.priceNaira ?? 0) * 100)),
currency: payload.currency ? String(payload.currency) : "NGN",
capacity: payload.capacity === undefined || payload.capacity === null || payload.capacity === "" ? null : Number(payload.capacity),
isActive: payload.isActive === undefined ? true : Boolean(payload.isActive)
}
});
}
}

View File

@@ -0,0 +1,65 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, Req, UseGuards } from "@nestjs/common";
import type { Request } from "express";
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
import { FormsService } from "./forms.service";
@Controller("forms")
export class FormsController {
constructor(private readonly service: FormsService) {}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("forms.read")
@Get()
findAll(@Req() req: Request) {
return this.service.findAll((req.user as any).tenantId as string);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("forms.write")
@Post()
create(@Req() req: Request, @Body() payload: Record<string, unknown>) {
return this.service.create((req.user as any).tenantId as string, payload);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("forms.read")
@Get("submissions")
submissions(@Req() req: Request, @Query("formId") formId?: string) {
return this.service.submissions((req.user as any).tenantId as string, formId);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("forms.read")
@Get(":id")
findOne(@Req() req: Request, @Param("id") id: string) {
return this.service.findOne((req.user as any).tenantId as string, id);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("forms.write")
@Patch(":id")
update(@Req() req: Request, @Param("id") id: string, @Body() payload: Record<string, unknown>) {
return this.service.update((req.user as any).tenantId as string, id, payload);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("forms.write")
@Put(":id/fields")
replaceFields(@Req() req: Request, @Param("id") id: string, @Body() payload: { fields?: any[] }) {
return this.service.replaceFields((req.user as any).tenantId as string, id, payload.fields ?? []);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("forms.write")
@Post(":id/submissions")
submit(@Req() req: Request, @Param("id") id: string, @Body() payload: Record<string, unknown>) {
return this.service.submit((req.user as any).tenantId as string, id, payload);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("forms.write")
@Delete(":id")
remove(@Req() req: Request, @Param("id") id: string) {
return this.service.remove((req.user as any).tenantId as string, id);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { FormsController } from "./forms.controller";
import { FormsService } from "./forms.service";
@Module({
controllers: [FormsController],
providers: [FormsService]
})
export class FormsModule {}

View File

@@ -0,0 +1,123 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../../prisma/prisma.service";
@Injectable()
export class FormsService {
constructor(private readonly prisma: PrismaService) {}
findAll(tenantId: string) {
return this.prisma.form.findMany({
where: { tenantId },
orderBy: { createdAt: "desc" },
include: {
event: true,
fields: { orderBy: { sortOrder: "asc" } },
_count: { select: { submissions: true } }
}
});
}
async findOne(tenantId: string, id: string) {
const row = await this.prisma.form.findFirst({
where: { tenantId, id },
include: {
event: true,
fields: { orderBy: { sortOrder: "asc" } },
submissions: { orderBy: { createdAt: "desc" } }
}
});
if (!row) throw new NotFoundException("Form not found");
return row;
}
create(tenantId: string, payload: any) {
const fields = Array.isArray(payload.fields) ? payload.fields : [];
return this.prisma.form.create({
data: {
tenantId,
eventId: payload.eventId || null,
name: String(payload.name ?? ""),
slug: String(payload.slug ?? "").toLowerCase().trim(),
description: payload.description ? String(payload.description) : null,
isPublic: Boolean(payload.isPublic ?? false),
fields: {
create: fields.map((field: any, index: number) => ({
tenantId,
label: String(field.label ?? ""),
key: String(field.key ?? "").trim(),
type: String(field.type ?? "text"),
required: Boolean(field.required ?? false),
options: field.options ?? undefined,
sortOrder: Number(field.sortOrder ?? index)
}))
}
},
include: { fields: { orderBy: { sortOrder: "asc" } }, event: true }
});
}
async update(tenantId: string, id: string, payload: any) {
await this.findOne(tenantId, id);
return this.prisma.form.update({
where: { id },
data: {
eventId: payload.eventId === undefined ? undefined : payload.eventId || null,
name: payload.name === undefined ? undefined : String(payload.name),
slug: payload.slug === undefined ? undefined : String(payload.slug).toLowerCase().trim(),
description: payload.description === undefined ? undefined : payload.description ? String(payload.description) : null,
isPublic: payload.isPublic === undefined ? undefined : Boolean(payload.isPublic)
},
include: { fields: { orderBy: { sortOrder: "asc" } }, event: true }
});
}
async replaceFields(tenantId: string, formId: string, fields: any[]) {
await this.findOne(tenantId, formId);
await this.prisma.formField.deleteMany({ where: { tenantId, formId } });
if (fields.length) {
await this.prisma.formField.createMany({
data: fields.map((field, index) => ({
tenantId,
formId,
label: String(field.label ?? ""),
key: String(field.key ?? "").trim(),
type: String(field.type ?? "text"),
required: Boolean(field.required ?? false),
options: field.options ?? undefined,
sortOrder: Number(field.sortOrder ?? index)
}))
});
}
return this.findOne(tenantId, formId);
}
async remove(tenantId: string, id: string) {
await this.findOne(tenantId, id);
await this.prisma.form.delete({ where: { id } });
return { ok: true };
}
submissions(tenantId: string, formId?: string) {
return this.prisma.formSubmission.findMany({
where: { tenantId, ...(formId ? { formId } : {}) },
orderBy: { createdAt: "desc" },
include: { form: true, event: true, attendee: true, registration: true }
});
}
async submit(tenantId: string, formId: string, payload: any) {
const form = await this.prisma.form.findFirst({ where: { tenantId, id: formId } });
if (!form) throw new NotFoundException("Form not found");
return this.prisma.formSubmission.create({
data: {
tenantId,
formId,
eventId: payload.eventId || form.eventId || null,
attendeeId: payload.attendeeId || null,
registrationId: payload.registrationId || null,
data: payload.data ?? {},
source: payload.source ? String(payload.source) : "admin"
}
});
}
}

View File

@@ -0,0 +1,9 @@
import { Controller, Get } from "@nestjs/common";
@Controller("health")
export class HealthController {
@Get()
health() {
return { ok: true };
}
}

View File

@@ -0,0 +1,5 @@
import { Module } from "@nestjs/common";
import { HealthController } from "./health.controller";
@Module({ controllers: [HealthController] })
export class HealthModule {}

View File

@@ -0,0 +1,16 @@
import { IsBoolean, IsNotEmpty, IsOptional, IsString } from "class-validator";
export class UpsertIntegrationSettingDto {
@IsString()
@IsNotEmpty()
key!: string;
@IsOptional()
@IsString()
value?: string;
@IsOptional()
@IsBoolean()
isSecret?: boolean;
}

View File

@@ -0,0 +1,27 @@
import { Body, Controller, Get, Post, Req, UseGuards } from "@nestjs/common";
import type { Request } from "express";
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
import { UpsertIntegrationSettingDto } from "./dto";
import { IntegrationsService } from "./integrations.service";
@Controller("integrations")
export class IntegrationsController {
constructor(private readonly service: IntegrationsService) {}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("integrations.read")
@Get()
findAll(@Req() req: Request) {
const tenantId = (req.user as any).tenantId as string;
return this.service.findAll(tenantId);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("integrations.write")
@Post()
upsert(@Req() req: Request, @Body() dto: UpsertIntegrationSettingDto) {
const tenantId = (req.user as any).tenantId as string;
return this.service.upsert(tenantId, dto);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { IntegrationsController } from "./integrations.controller";
import { IntegrationsService } from "./integrations.service";
@Module({
controllers: [IntegrationsController],
providers: [IntegrationsService],
exports: [IntegrationsService]
})
export class IntegrationsModule {}

View File

@@ -0,0 +1,22 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../../prisma/prisma.service";
import type { UpsertIntegrationSettingDto } from "./dto";
@Injectable()
export class IntegrationsService {
constructor(private readonly prisma: PrismaService) {}
findAll(tenantId: string) {
return this.prisma.integrationSetting.findMany({ where: { tenantId }, orderBy: { key: "asc" } });
}
async upsert(tenantId: string, dto: UpsertIntegrationSettingDto) {
const row = await this.prisma.integrationSetting.upsert({
where: { tenantId_key: { tenantId, key: dto.key } },
create: { tenantId, key: dto.key, value: dto.value ?? null, isSecret: dto.isSecret ?? false },
update: { value: dto.value ?? null, isSecret: dto.isSecret ?? false }
});
return row;
}
}

View File

@@ -0,0 +1,45 @@
import { IsEmail, IsIn, IsOptional, IsString, MinLength } from "class-validator";
export class CreateInviteeDto {
@IsString()
@MinLength(2)
fullName!: string;
@IsEmail()
email!: string;
@IsOptional()
@IsString()
phone?: string;
@IsString()
eventId!: string;
}
export class UpdateInviteeDto {
@IsOptional()
@IsString()
@MinLength(2)
fullName?: string;
@IsOptional()
@IsEmail()
email?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsIn(["invited", "delivered", "opened", "rsvped", "bounced"])
status?: "invited" | "delivered" | "opened" | "rsvped" | "bounced";
}
export class PublicRsvpDto {
@IsIn(["yes", "no", "maybe"])
response!: "yes" | "no" | "maybe";
@IsOptional()
@IsString()
note?: string;
}

View File

@@ -0,0 +1,51 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from "@nestjs/common";
import type { Request } from "express";
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
import { CreateInviteeDto, UpdateInviteeDto } from "./dto";
import { InviteesService } from "./invitees.service";
@Controller("invitees")
export class InviteesController {
constructor(private readonly service: InviteesService) {}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("invitees.read")
@Get()
findAll(@Req() req: Request) {
const tenantId = (req.user as any).tenantId as string;
return this.service.findAll(tenantId);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("invitees.write")
@Post()
create(@Req() req: Request, @Body() dto: CreateInviteeDto) {
const tenantId = (req.user as any).tenantId as string;
return this.service.create(tenantId, dto);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("invitees.read")
@Get(":id")
findOne(@Req() req: Request, @Param("id") id: string) {
const tenantId = (req.user as any).tenantId as string;
return this.service.findOne(tenantId, id);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("invitees.write")
@Patch(":id")
update(@Req() req: Request, @Param("id") id: string, @Body() dto: UpdateInviteeDto) {
const tenantId = (req.user as any).tenantId as string;
return this.service.update(tenantId, id, dto);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("invitees.write")
@Delete(":id")
remove(@Req() req: Request, @Param("id") id: string) {
const tenantId = (req.user as any).tenantId as string;
return this.service.remove(tenantId, id);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { AuditModule } from "../audit/audit.module";
import { QueuesModule } from "../queues/queues.module";
import { InviteesController } from "./invitees.controller";
import { InviteesService } from "./invitees.service";
import { PublicInviteesController } from "./public-invitees.controller";
@Module({
imports: [AuditModule, QueuesModule],
controllers: [InviteesController, PublicInviteesController],
providers: [InviteesService],
exports: [InviteesService]
})
export class InviteesModule {}

View File

@@ -0,0 +1,136 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { randomBytes } from "crypto";
import { PrismaService } from "../../prisma/prisma.service";
import { AuditService } from "../audit/audit.service";
import { QueuesService } from "../queues/queues.service";
import { CreateInviteeDto, PublicRsvpDto, UpdateInviteeDto } from "./dto";
@Injectable()
export class InviteesService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
private readonly queues: QueuesService
) {}
async findAll(tenantId: string) {
return this.prisma.invitee.findMany({
where: { tenantId },
orderBy: { createdAt: "desc" },
include: { event: true }
});
}
async create(tenantId: string, dto: CreateInviteeDto) {
const email = dto.email.toLowerCase().trim();
const event = await this.prisma.event.findFirst({ where: { id: dto.eventId, tenantId } });
if (!event) throw new NotFoundException("Event not found");
const code = randomBytes(10).toString("hex").toUpperCase();
try {
const invitee = await this.prisma.invitee.create({
data: {
tenantId,
eventId: dto.eventId,
fullName: dto.fullName,
email,
phone: dto.phone?.trim().length ? dto.phone : null,
code
}
});
const baseUrl = (process.env.PUBLIC_WEB_URL ?? "http://localhost:3000").replace(/\/$/, "");
const subject = `Invitation: ${event.name}`;
const text = `Hello ${invitee.fullName},\n\nYou are invited to ${event.name}.\nRSVP here: ${baseUrl}/invite/${invitee.code}\n\nThank you.`;
await this.queues.queue("communications").add("sendEmail", {
tenantId,
to: invitee.email,
subject,
text,
inviteeId: invitee.id
});
await this.audit.log("invitees.create", undefined, { tenantId, entityType: "Invitee", entityId: invitee.id, metadata: { eventId: dto.eventId } });
return invitee;
} catch (e: any) {
if (typeof e?.message === "string" && e.message.includes("Invitee_tenantId_eventId_email_key")) {
throw new BadRequestException("Invitee already exists for this event");
}
throw e;
}
}
async findOne(tenantId: string, id: string) {
const invitee = await this.prisma.invitee.findFirst({
where: { id, tenantId },
include: { event: true, rsvps: { orderBy: { createdAt: "desc" } } }
});
if (!invitee) throw new NotFoundException("Invitee not found");
return invitee;
}
async update(tenantId: string, id: string, dto: UpdateInviteeDto) {
await this.findOne(tenantId, id);
const invitee = await this.prisma.invitee.update({
where: { id },
data: {
fullName: dto.fullName,
email: dto.email ? dto.email.toLowerCase().trim() : undefined,
phone: dto.phone === undefined ? undefined : dto.phone?.trim().length ? dto.phone : null,
status: dto.status
}
});
await this.audit.log("invitees.update", undefined, { tenantId, entityType: "Invitee", entityId: id });
return invitee;
}
async remove(tenantId: string, id: string) {
await this.findOne(tenantId, id);
await this.prisma.invitee.delete({ where: { id } });
await this.audit.log("invitees.delete", undefined, { tenantId, entityType: "Invitee", entityId: id });
return { ok: true };
}
async publicGetInviteeByCode(code: string) {
const invitee = await this.prisma.invitee.findFirst({
where: { code },
include: { event: true }
});
if (!invitee) throw new NotFoundException("Invite not found");
return {
invitee: {
code: invitee.code,
fullName: invitee.fullName,
email: invitee.email,
phone: invitee.phone,
status: invitee.status
},
event: {
id: invitee.event.id,
name: invitee.event.name,
slug: invitee.event.slug,
startsAt: invitee.event.startsAt,
venue: invitee.event.venue,
status: invitee.event.status
}
};
}
async publicRsvp(code: string, dto: PublicRsvpDto) {
const invitee = await this.prisma.invitee.findFirst({ where: { code } });
if (!invitee) throw new NotFoundException("Invite not found");
const rsvp = await this.prisma.rSVP.create({
data: {
tenantId: invitee.tenantId,
eventId: invitee.eventId,
inviteeId: invitee.id,
response: dto.response,
note: dto.note?.trim().length ? dto.note : null
}
});
await this.prisma.invitee.update({ where: { id: invitee.id }, data: { status: "rsvped" } });
await this.audit.log("rsvps.public", undefined, { tenantId: invitee.tenantId, entityType: "RSVP", entityId: rsvp.id, metadata: { inviteeId: invitee.id, response: dto.response } });
return { ok: true };
}
}

View File

@@ -0,0 +1,19 @@
import { Body, Controller, Get, Param, Post } from "@nestjs/common";
import { PublicRsvpDto } from "./dto";
import { InviteesService } from "./invitees.service";
@Controller("public/invitees")
export class PublicInviteesController {
constructor(private readonly service: InviteesService) {}
@Get(":code")
getInvite(@Param("code") code: string) {
return this.service.publicGetInviteeByCode(code);
}
@Post(":code/rsvp")
rsvp(@Param("code") code: string, @Body() dto: PublicRsvpDto) {
return this.service.publicRsvp(code, dto);
}
}

View File

@@ -0,0 +1,15 @@
import { IsEmail, IsNumber, IsOptional, IsString, Min } from "class-validator";
export class PublicInitializePaystackDto {
@IsEmail()
email!: string;
@IsNumber()
@Min(1)
amountNaira!: number;
@IsOptional()
@IsString()
tenantSlug?: string;
}

View File

@@ -0,0 +1,65 @@
import { Body, Controller, Get, Headers, Param, Post, Query, Req, UnauthorizedException, UseGuards } from "@nestjs/common";
import type { Request } from "express";
import { createHmac } from "node:crypto";
import { PaymentsService } from "./payments.service";
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
import { PublicInitializePaystackDto } from "./dto";
import { QueuesService } from "../queues/queues.service";
import { PrismaService } from "../../prisma/prisma.service";
@Controller("payments")
export class PaymentsController {
constructor(private readonly service: PaymentsService) {}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("payments.read")
@Get()
findAll(@Req() req: Request) {
const tenantId = (req.user as any).tenantId as string;
return this.service.findAll(tenantId);
}
}
@Controller("public/payments")
export class PublicPaymentsController {
constructor(private readonly service: PaymentsService) {}
@Post("events/:slug/paystack/initialize")
initialize(@Param("slug") slug: string, @Body() dto: PublicInitializePaystackDto) {
return this.service.initializePaystackPublic(slug, dto.email, dto.amountNaira, dto.tenantSlug);
}
}
@Controller("public/webhooks/paystack")
export class PaystackWebhooksController {
constructor(
private readonly queues: QueuesService,
private readonly prisma: PrismaService
) {}
@Post()
async handle(
@Req() req: Request & { rawBody?: Buffer },
@Headers("x-paystack-signature") signature?: string,
@Body() body?: any,
@Query("tenantSlug") tenantSlug?: string
) {
let secret = process.env.PAYSTACK_SECRET_KEY;
if (tenantSlug) {
const tenant = await this.prisma.tenant.findUnique({ where: { slug: tenantSlug } });
if (tenant) {
const integrationSecret = await this.prisma.integrationSetting.findUnique({
where: { tenantId_key: { tenantId: tenant.id, key: "paystack.secretKey" } }
});
secret = integrationSecret?.value ?? secret;
}
}
if (!secret) throw new UnauthorizedException();
const raw = req.rawBody ?? Buffer.from(JSON.stringify(body ?? {}));
const computed = createHmac("sha512", secret).update(raw).digest("hex");
if (!signature || computed !== signature) throw new UnauthorizedException();
await this.queues.queue("webhooks").add("paystack", { body, tenantSlug, receivedAt: new Date().toISOString() });
return { ok: true };
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { PaymentsController, PaystackWebhooksController, PublicPaymentsController } from "./payments.controller";
import { PaymentsService } from "./payments.service";
import { AuditModule } from "../audit/audit.module";
import { QueuesModule } from "../queues/queues.module";
@Module({ imports: [AuditModule, QueuesModule], controllers: [PaymentsController, PublicPaymentsController, PaystackWebhooksController], providers: [PaymentsService], exports: [PaymentsService] })
export class PaymentsModule {}

View File

@@ -0,0 +1,82 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import axios from "axios";
import { randomBytes } from "node:crypto";
import { PrismaService } from "../../prisma/prisma.service";
import { AuditService } from "../audit/audit.service";
@Injectable()
export class PaymentsService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService
) {}
findAll(tenantId: string) {
return this.prisma.paymentTransaction.findMany({
where: { tenantId },
orderBy: { createdAt: "desc" },
include: { event: true }
});
}
async initializePaystackPublic(eventSlug: string, email: string, amountNaira: number, tenantSlug?: string) {
const resolvedTenantSlug = tenantSlug ?? process.env.DEFAULT_TENANT_SLUG ?? "";
if (!resolvedTenantSlug) throw new BadRequestException("Missing tenantSlug");
const tenant = await this.prisma.tenant.findUnique({ where: { slug: resolvedTenantSlug } });
if (!tenant) throw new NotFoundException("Tenant not found");
const event = await this.prisma.event.findFirst({ where: { tenantId: tenant.id, slug: eventSlug } });
if (!event) throw new NotFoundException("Event not found");
const amount = Number.isFinite(amountNaira) ? Math.round(amountNaira * 100) : NaN;
if (!amount || amount < 100) throw new BadRequestException("Invalid amount");
const secret =
(await this.prisma.integrationSetting
.findUnique({ where: { tenantId_key: { tenantId: tenant.id, key: "paystack.secretKey" } } })
.then((r) => r?.value ?? null)) ??
process.env.PAYSTACK_SECRET_KEY;
if (!secret) throw new BadRequestException("PAYSTACK_SECRET_KEY is not configured");
const reference = `EVT-${randomBytes(10).toString("hex").toUpperCase()}`;
const row = await this.prisma.paymentTransaction.create({
data: {
tenantId: tenant.id,
eventId: event.id,
email: email.toLowerCase().trim(),
amountKobo: amount,
reference,
status: "initialized",
raw: Prisma.JsonNull
}
});
const callbackUrl =
(await this.prisma.integrationSetting
.findUnique({ where: { tenantId_key: { tenantId: tenant.id, key: "paystack.callbackUrl" } } })
.then((r) => r?.value ?? null)) ??
process.env.PAYSTACK_CALLBACK_URL ??
undefined;
const res = await axios.post(
"https://api.paystack.co/transaction/initialize",
{ email: row.email, amount: row.amountKobo, reference, callback_url: callbackUrl },
{ headers: { Authorization: `Bearer ${secret}`, "Content-Type": "application/json" } }
);
const payload = res.data as any;
if (!payload?.status || !payload?.data?.authorization_url) {
throw new BadRequestException("Paystack initialization failed");
}
await this.prisma.paymentTransaction.update({ where: { id: row.id }, data: { raw: payload } });
await this.audit.log("payments.paystack.initialize", undefined, { tenantId: tenant.id, entityType: "PaymentTransaction", entityId: row.id, metadata: { eventId: event.id } });
return {
reference,
authorizationUrl: String(payload.data.authorization_url)
};
}
}

View File

@@ -0,0 +1,25 @@
import { Body, Controller, Get, Post, Req, UseGuards } from "@nestjs/common";
import type { Request } from "express";
import { QrcodeService } from "./qrcode.service";
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
@Controller("qrcode")
export class QrcodeController {
constructor(private readonly service: QrcodeService) {}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("qrcodes.read")
@Get()
findAll(@Req() req: Request) {
const tenantId = (req.user as any).tenantId as string;
return this.service.findAll(tenantId);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("qrcodes.write")
@Post()
create(@Req() req: Request, @Body() payload: { registrationId?: string }) {
const tenantId = (req.user as any).tenantId as string;
return this.service.createForRegistration(tenantId, String(payload.registrationId ?? ""));
}
}

View File

@@ -0,0 +1,5 @@
import { Module } from "@nestjs/common";
import { QrcodeController } from "./qrcode.controller";
import { QrcodeService } from "./qrcode.service";
@Module({ controllers: [QrcodeController], providers: [QrcodeService], exports: [QrcodeService] })
export class QrcodeModule {}

View File

@@ -0,0 +1,52 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import QR from "qrcode";
import { PrismaService } from "../../prisma/prisma.service";
@Injectable()
export class QrcodeService {
constructor(private readonly prisma: PrismaService) {}
findAll(tenantId: string) {
return this.prisma.qRCode.findMany({
where: { tenantId },
orderBy: { createdAt: "desc" },
include: {
registration: { include: { attendee: true, event: true } }
}
});
}
async createForRegistration(tenantId: string, registrationId: string) {
const registration = await this.prisma.registration.findFirst({
where: { id: registrationId, tenantId },
include: { attendee: true, event: true }
});
if (!registration) throw new NotFoundException("Registration not found");
const payload = {
code: registration.code,
eventId: registration.eventId,
tenantId: registration.tenantId,
registrationId: registration.id
};
const qrCode = await this.prisma.qRCode.upsert({
where: { registrationId: registration.id },
create: {
tenantId,
registrationId: registration.id,
code: registration.code,
payload
},
update: {
code: registration.code,
payload,
status: "active"
},
include: { registration: { include: { attendee: true, event: true } } }
});
const dataUrl = await QR.toDataURL(JSON.stringify(payload), { errorCorrectionLevel: "M", margin: 1, width: 320 });
return { qrCode, dataUrl };
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { QueuesService } from "./queues.service";
@Module({
providers: [QueuesService],
exports: [QueuesService]
})
export class QueuesModule {}

View File

@@ -0,0 +1,279 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
import { Queue, Worker } from "bullmq";
import Redis from "ioredis";
import nodemailer from "nodemailer";
import axios from "axios";
import { PrismaService } from "../../prisma/prisma.service";
type QueueName = "communications" | "qrcode" | "workflows" | "webhooks" | "reports";
@Injectable()
export class QueuesService implements OnModuleInit, OnModuleDestroy {
private readonly connection: Redis;
private readonly queues: Record<QueueName, Queue>;
private readonly workers: Worker[] = [];
private readonly transporters = new Map<string, nodemailer.Transporter>();
constructor(private readonly prisma: PrismaService) {
const url = process.env.REDIS_URL ?? "redis://localhost:6379";
this.connection = new Redis(url, { maxRetriesPerRequest: null });
this.queues = {
communications: new Queue("communications", { connection: this.connection }),
qrcode: new Queue("qrcode", { connection: this.connection }),
workflows: new Queue("workflows", { connection: this.connection }),
webhooks: new Queue("webhooks", { connection: this.connection }),
reports: new Queue("reports", { connection: this.connection })
};
}
async onModuleInit() {
if (process.env.PROCESS_QUEUES !== "1") return;
this.workers.push(
new Worker(
"communications",
async (job) => {
if (job.name === "sendEmail") {
const tenantId = String((job.data as any).tenantId ?? "");
const host = (await this.getIntegrationValue(tenantId, "smtp.host")) ?? process.env.SMTP_HOST;
const portRaw = (await this.getIntegrationValue(tenantId, "smtp.port")) ?? process.env.SMTP_PORT ?? "587";
const user = (await this.getIntegrationValue(tenantId, "smtp.user")) ?? process.env.SMTP_USER;
const pass = (await this.getIntegrationValue(tenantId, "smtp.pass")) ?? process.env.SMTP_PASS;
const from = (await this.getIntegrationValue(tenantId, "smtp.from")) ?? process.env.SMTP_FROM ?? user;
const port = Number(portRaw);
if (!host || !port || !user || !pass || !from) throw new Error("SMTP is not configured");
const transporterKey = `${host}:${port}:${user}`;
const transporter =
this.transporters.get(transporterKey) ??
nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: { user, pass }
});
this.transporters.set(transporterKey, transporter);
const to = String((job.data as any).to ?? "");
const subject = String((job.data as any).subject ?? "");
const text = (job.data as any).text ? String((job.data as any).text) : undefined;
const html = (job.data as any).html ? String((job.data as any).html) : undefined;
const communicationLogId = (job.data as any).communicationLogId ? String((job.data as any).communicationLogId) : undefined;
if (!to || !subject) throw new Error("Invalid email payload");
try {
const sent = await transporter.sendMail({ from, to, subject, text, html });
const inviteeId = (job.data as any).inviteeId ? String((job.data as any).inviteeId) : undefined;
if (inviteeId) {
await this.prisma.invitee.updateMany({ where: { id: inviteeId }, data: { status: "delivered" } });
}
await this.markCommunicationLog(communicationLogId, "sent", sent?.messageId ? String(sent.messageId) : undefined);
return { ok: true };
} catch (e: any) {
await this.markCommunicationLog(communicationLogId, "failed", undefined, e?.message ? String(e.message) : "Delivery failed");
throw e;
}
}
if (job.name === "sendSms" || job.name === "sendWhatsapp") {
const tenantId = String((job.data as any).tenantId ?? "");
const username =
(await this.getIntegrationValue(tenantId, "africastalking.username")) ??
process.env.AFRICASTALKING_USERNAME ??
process.env.AT_USERNAME;
const apiKey =
(await this.getIntegrationValue(tenantId, "africastalking.apiKey")) ??
process.env.AFRICASTALKING_API_KEY ??
process.env.AT_API_KEY;
if (!username || !apiKey) throw new Error("Africa's Talking is not configured");
const to = String((job.data as any).to ?? "");
const message = String((job.data as any).message ?? "");
const communicationLogId = (job.data as any).communicationLogId ? String((job.data as any).communicationLogId) : undefined;
if (!to || !message) throw new Error("Invalid message payload");
try {
let providerMessageId: string | undefined;
if (job.name === "sendSms") {
const from =
(job.data as any).from
? String((job.data as any).from)
: (await this.getIntegrationValue(tenantId, "africastalking.senderId")) ??
process.env.AFRICASTALKING_SENDER_ID ??
process.env.AT_SMS_FROM;
const body = new URLSearchParams();
body.set("username", username);
body.set("to", to);
body.set("message", message);
if (from) body.set("from", from);
const res = await axios.post("https://api.africastalking.com/version1/messaging", body.toString(), {
headers: { apikey: apiKey, "Content-Type": "application/x-www-form-urlencoded" }
});
providerMessageId = String((res.data as any)?.SMSMessageData?.Recipients?.[0]?.messageId ?? "");
} else {
const url =
(await this.getIntegrationValue(tenantId, "africastalking.whatsappUrl")) ??
process.env.AFRICASTALKING_WHATSAPP_URL ??
process.env.AT_WHATSAPP_URL;
if (!url) throw new Error("AT_WHATSAPP_URL is not configured");
const res = await axios.post(
url,
{ username, to, message },
{ headers: { apikey: apiKey, "Content-Type": "application/json" } }
);
providerMessageId = String((res.data as any)?.id ?? "");
}
const inviteeId = (job.data as any).inviteeId ? String((job.data as any).inviteeId) : undefined;
if (inviteeId) {
await this.prisma.invitee.updateMany({ where: { id: inviteeId }, data: { status: "delivered" } });
}
await this.markCommunicationLog(communicationLogId, "sent", providerMessageId || undefined);
return { ok: true };
} catch (e: any) {
await this.markCommunicationLog(communicationLogId, "failed", undefined, e?.message ? String(e.message) : "Delivery failed");
throw e;
}
}
return { ok: true };
},
{ connection: this.connection }
)
);
this.workers.push(
new Worker(
"qrcode",
async () => {
return { ok: true };
},
{ connection: this.connection }
)
);
this.workers.push(
new Worker(
"workflows",
async () => {
return { ok: true };
},
{ connection: this.connection }
)
);
this.workers.push(
new Worker(
"webhooks",
async (job) => {
if (job.name === "paystack") {
const payload = (job.data as any)?.body;
const event = String(payload?.event ?? "");
const reference = String(payload?.data?.reference ?? "");
const tenantSlug = (job.data as any)?.tenantSlug ? String((job.data as any).tenantSlug) : undefined;
const tenant = tenantSlug ? await this.prisma.tenant.findUnique({ where: { slug: tenantSlug } }) : null;
const webhook = await this.prisma.paystackWebhookEvent.create({
data: {
tenantId: tenant?.id ?? null,
event,
reference: reference || null,
raw: payload ?? {},
status: "received"
}
});
if (reference) {
const nextStatus =
event === "charge.success" ? "success" :
event === "charge.failed" ? "failed" :
undefined;
if (nextStatus) {
await this.prisma.paymentTransaction.updateMany({
where: { reference, ...(tenant?.id ? { tenantId: tenant.id } : {}) },
data: { status: nextStatus, raw: payload }
});
}
}
await this.prisma.paystackWebhookEvent.update({
where: { id: webhook.id },
data: { status: "processed", processedAt: new Date() }
});
return { ok: true };
}
return { ok: true };
},
{ connection: this.connection }
)
);
this.workers.push(
new Worker(
"reports",
async (job) => {
if (job.name === "registrationsCsv") {
const tenantId = String((job.data as any).tenantId ?? "");
const eventId = (job.data as any).eventId ? String((job.data as any).eventId) : undefined;
if (!tenantId) throw new Error("Missing tenantId");
const rows = await this.prisma.registration.findMany({
where: { tenantId, ...(eventId ? { eventId } : {}) },
orderBy: { createdAt: "desc" },
include: { attendee: true, event: true }
});
const header = ["code", "status", "createdAt", "attendeeFullName", "attendeeEmail", "eventName"].join(",");
const lines = rows.map((r) => {
const fields = [
r.code,
r.status,
r.createdAt.toISOString(),
r.attendee.fullName,
r.attendee.email,
r.event.name
].map((v) => `"${String(v).replaceAll('"', '""')}"`);
return fields.join(",");
});
return { csv: [header, ...lines].join("\n") };
}
return { ok: true };
},
{ connection: this.connection }
)
);
}
async onModuleDestroy() {
await Promise.allSettled(this.workers.map((w) => w.close()));
await Promise.allSettled(Object.values(this.queues).map((q) => q.close()));
await this.connection.quit();
}
queue(name: QueueName) {
return this.queues[name];
}
private async getIntegrationValue(tenantId: string, key: string) {
if (!tenantId) return null;
const row = await this.prisma.integrationSetting.findUnique({ where: { tenantId_key: { tenantId, key } } });
return row?.value ?? null;
}
private async markCommunicationLog(id: string | undefined, status: "sent" | "failed", providerMessageId?: string, error?: string) {
if (!id) return;
await this.prisma.communicationLog.updateMany({
where: { id },
data: { status, providerMessageId, error }
});
}
}

View File

@@ -0,0 +1,24 @@
import { IsEmail, IsNotEmpty, IsOptional, IsString } from "class-validator";
export class CreateRegistrationDto {
@IsString()
@IsNotEmpty()
eventId!: string;
@IsString()
@IsNotEmpty()
attendeeId!: string;
}
export class PublicRegisterDto {
@IsString()
@IsNotEmpty()
fullName!: string;
@IsEmail()
email!: string;
@IsOptional()
@IsString()
phone?: string;
}

View File

@@ -0,0 +1,115 @@
import { Body, Controller, Get, Param, Post, Query, Req, UseGuards } from "@nestjs/common";
import type { Request } from "express";
import { RegistrationsService } from "./registrations.service";
import { CreateRegistrationDto, PublicRegisterDto } from "./dto";
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
@Controller("registrations")
export class RegistrationsController {
constructor(private readonly service: RegistrationsService) {}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("registrations.read")
@Get()
findAll(@Req() req: Request) {
const tenantId = (req.user as any).tenantId as string;
return this.service.findAll(tenantId);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("registrations.write")
@Post()
create(@Req() req: Request, @Body() dto: CreateRegistrationDto) {
const tenantId = (req.user as any).tenantId as string;
return this.service.create(tenantId, dto);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("registrations.read")
@Get("by-code/:code")
async byCode(@Req() req: Request, @Param("code") code: string) {
const tenantId = (req.user as any).tenantId as string;
const row = await this.service.registrationByCode(tenantId, code);
return {
registration: {
id: row.id,
code: row.code,
status: row.status,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
event: { id: row.event.id, name: row.event.name, slug: row.event.slug, startsAt: row.event.startsAt, venue: row.event.venue },
attendee: { id: row.attendee.id, fullName: row.attendee.fullName, email: row.attendee.email, phone: row.attendee.phone }
}
};
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("registrations.read")
@Get("recent-checkins")
async recentCheckins(@Req() req: Request, @Query("limit") limit?: string) {
const tenantId = (req.user as any).tenantId as string;
const rows = await this.service.recentCheckins(tenantId, limit ? Number(limit) : 25);
return {
checkins: rows.map((r) => ({
id: r.id,
code: r.code,
status: r.registration.status,
checkedInAt: r.createdAt,
result: r.result,
event: { id: r.event.id, name: r.event.name },
attendee: r.attendee ? { id: r.attendee.id, fullName: r.attendee.fullName, email: r.attendee.email } : null
}))
};
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("registrations.write")
@Post("by-code/:code/check-in")
async checkIn(@Req() req: Request, @Param("code") code: string) {
const tenantId = (req.user as any).tenantId as string;
const actorUserId = (req.user as any).sub as string;
return this.service.checkInByCode(tenantId, actorUserId, code, req);
}
}
@Controller("public/events")
export class PublicRegistrationsController {
constructor(private readonly service: RegistrationsService) {}
@Get(":slug")
async getEvent(@Param("slug") slug: string, @Query("tenantSlug") tenantSlug?: string) {
const resolved = tenantSlug ?? process.env.DEFAULT_TENANT_SLUG;
if (!resolved) return { message: "Missing tenantSlug" };
const { event } = await this.service.publicEventBySlug(resolved, slug);
return { event };
}
@Post(":slug/register")
async register(@Param("slug") slug: string, @Body() dto: PublicRegisterDto, @Query("tenantSlug") tenantSlug?: string) {
return this.service.publicRegister(slug, dto, tenantSlug);
}
}
@Controller("public/registrations")
export class PublicRegistrationLookupController {
constructor(private readonly service: RegistrationsService) {}
@Get(":code")
async byCode(@Param("code") code: string) {
const row = await this.service.publicRegistrationByCode(code);
return {
registration: {
id: row.id,
code: row.code,
status: row.status,
createdAt: row.createdAt,
event: { id: row.event.id, name: row.event.name, slug: row.event.slug, startsAt: row.event.startsAt, venue: row.event.venue },
attendee: { id: row.attendee.id, fullName: row.attendee.fullName, email: row.attendee.email, phone: row.attendee.phone }
}
};
}
@Get(":code/qrcode")
async qr(@Param("code") code: string) {
return this.service.publicRegistrationQr(code);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { PublicRegistrationLookupController, PublicRegistrationsController, RegistrationsController } from "./registrations.controller";
import { RegistrationsService } from "./registrations.service";
import { AuditModule } from "../audit/audit.module";
@Module({
imports: [AuditModule],
controllers: [RegistrationsController, PublicRegistrationsController, PublicRegistrationLookupController],
providers: [RegistrationsService],
exports: [RegistrationsService]
})
export class RegistrationsModule {}

View File

@@ -0,0 +1,207 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import type { Request } from "express";
import { randomBytes } from "node:crypto";
import { PrismaService } from "../../prisma/prisma.service";
import { AuditService } from "../audit/audit.service";
import type { CreateRegistrationDto, PublicRegisterDto } from "./dto";
import QRCode from "qrcode";
@Injectable()
export class RegistrationsService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService
) {}
findAll(tenantId: string) {
return this.prisma.registration.findMany({
where: { tenantId },
orderBy: { createdAt: "desc" },
include: { attendee: true, event: true }
});
}
async create(tenantId: string, dto: CreateRegistrationDto) {
const event = await this.prisma.event.findFirst({ where: { id: dto.eventId, tenantId } });
if (!event) throw new NotFoundException("Event not found");
const attendee = await this.prisma.attendee.findFirst({ where: { id: dto.attendeeId, tenantId } });
if (!attendee) throw new NotFoundException("Attendee not found");
const code = randomBytes(5).toString("hex").toUpperCase();
try {
return await this.prisma.registration.create({
data: { tenantId, eventId: event.id, attendeeId: attendee.id, code, status: "pending", source: "admin" },
include: { attendee: true, event: true }
});
} catch (e: any) {
if (String(e?.code) === "P2002") throw new BadRequestException("Registration already exists");
throw e;
}
}
async publicEventBySlug(tenantSlug: string, eventSlug: string) {
const tenant = await this.prisma.tenant.findUnique({ where: { slug: tenantSlug } });
if (!tenant) throw new NotFoundException("Tenant not found");
const event = await this.prisma.event.findFirst({
where: { tenantId: tenant.id, slug: eventSlug },
include: {
eventPage: true,
ticketTypes: { where: { isActive: true }, orderBy: { createdAt: "asc" } }
}
});
if (!event) throw new NotFoundException("Event not found");
return { tenant, event };
}
async publicRegister(eventSlug: string, dto: PublicRegisterDto, tenantSlug?: string) {
const resolvedTenantSlug = tenantSlug ?? process.env.DEFAULT_TENANT_SLUG ?? "";
if (!resolvedTenantSlug) throw new BadRequestException("Missing tenant slug");
const { tenant, event } = await this.publicEventBySlug(resolvedTenantSlug, eventSlug);
const attendee = await this.prisma.attendee.upsert({
where: { tenantId_email: { tenantId: tenant.id, email: dto.email.toLowerCase() } },
update: { fullName: dto.fullName, phone: dto.phone },
create: { tenantId: tenant.id, eventId: event.id, fullName: dto.fullName, email: dto.email.toLowerCase(), phone: dto.phone }
});
const code = randomBytes(5).toString("hex").toUpperCase();
try {
const registration = await this.prisma.registration.create({
data: { tenantId: tenant.id, eventId: event.id, attendeeId: attendee.id, code, status: "pending", source: "landing_page" }
});
await this.prisma.cRMLead.upsert({
where: { tenantId_email: { tenantId: tenant.id, email: attendee.email } },
create: {
tenantId: tenant.id,
eventId: event.id,
attendeeId: attendee.id,
fullName: attendee.fullName,
email: attendee.email,
phone: attendee.phone,
source: "registration",
status: "new"
},
update: {
eventId: event.id,
attendeeId: attendee.id,
fullName: attendee.fullName,
phone: attendee.phone,
source: "registration"
}
});
return { registrationId: registration.id, code };
} catch (e: any) {
if (String(e?.code) === "P2002") throw new BadRequestException("Already registered");
throw e;
}
}
async publicRegistrationByCode(code: string) {
const row = await this.prisma.registration.findUnique({
where: { code: code.toUpperCase() },
include: { attendee: true, event: true, tenant: true }
});
if (!row) throw new NotFoundException("Registration not found");
return row;
}
async publicRegistrationQr(code: string) {
const row = await this.publicRegistrationByCode(code);
const payload = JSON.stringify({ code: row.code, eventId: row.eventId, tenantId: row.tenantId });
await this.prisma.qRCode.upsert({
where: { registrationId: row.id },
create: {
tenantId: row.tenantId,
registrationId: row.id,
code: row.code,
payload: { code: row.code, eventId: row.eventId, tenantId: row.tenantId, registrationId: row.id }
},
update: {
code: row.code,
payload: { code: row.code, eventId: row.eventId, tenantId: row.tenantId, registrationId: row.id },
status: "active"
}
});
const dataUrl = await QRCode.toDataURL(payload, { errorCorrectionLevel: "M", margin: 1, width: 320 });
return { code: row.code, dataUrl };
}
async registrationByCode(tenantId: string, code: string) {
const row = await this.prisma.registration.findFirst({
where: { tenantId, code: code.toUpperCase() },
include: { attendee: true, event: true }
});
if (!row) throw new NotFoundException("Registration not found");
return row;
}
async recentCheckins(tenantId: string, limit = 25) {
const take = Math.min(Math.max(Number(limit || 25), 1), 100);
return this.prisma.checkIn.findMany({
where: { tenantId },
orderBy: { createdAt: "desc" },
take,
include: { registration: true, attendee: true, event: true }
});
}
async checkInByCode(tenantId: string, actorUserId: string, code: string, req?: Request) {
const row = await this.prisma.registration.findFirst({
where: { tenantId, code: code.toUpperCase() },
include: { attendee: true, event: true }
});
if (!row) throw new NotFoundException("Registration not found");
if (row.status === "confirmed") {
await this.prisma.checkIn.create({
data: {
tenantId,
eventId: row.eventId,
registrationId: row.id,
attendeeId: row.attendeeId,
checkedInByUserId: actorUserId,
code: row.code,
result: "duplicate",
note: "Already checked in"
}
});
await this.audit.log("registrations.checkin.duplicate", req, {
tenantId,
actorUserId,
entityType: "registration",
entityId: row.id,
metadata: { code: row.code, eventId: row.eventId, attendeeId: row.attendeeId }
});
return { registration: row, alreadyCheckedIn: true };
}
const updated = await this.prisma.registration.update({
where: { id: row.id },
data: { status: "confirmed", checkedInAt: new Date() },
include: { attendee: true, event: true }
});
await this.prisma.checkIn.create({
data: {
tenantId,
eventId: updated.eventId,
registrationId: updated.id,
attendeeId: updated.attendeeId,
checkedInByUserId: actorUserId,
code: updated.code,
result: "checked_in"
}
});
await this.audit.log("registrations.checkin", req, {
tenantId,
actorUserId,
entityType: "registration",
entityId: updated.id,
metadata: { code: updated.code, eventId: updated.eventId, attendeeId: updated.attendeeId }
});
return { registration: updated, alreadyCheckedIn: false };
}
}

View File

@@ -0,0 +1,32 @@
import { Controller, Get, Param, Post, Query, Req, UseGuards } from "@nestjs/common";
import type { Request } from "express";
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
import { ReportsService } from "./reports.service";
@Controller("reports")
export class ReportsController {
constructor(private readonly service: ReportsService) {}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("reports.read")
@Get("summary")
summary(@Req() req: Request) {
const tenantId = (req.user as any).tenantId as string;
return this.service.summary(tenantId);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("reports.read")
@Post("registrations-csv")
queueRegistrations(@Req() req: Request, @Query("eventId") eventId?: string) {
const tenantId = (req.user as any).tenantId as string;
return this.service.queueRegistrationsCsv(tenantId, eventId);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("reports.read")
@Get("jobs/:id")
getJob(@Param("id") id: string) {
return this.service.getJob(id);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { QueuesModule } from "../queues/queues.module";
import { ReportsController } from "./reports.controller";
import { ReportsService } from "./reports.service";
@Module({
imports: [QueuesModule],
controllers: [ReportsController],
providers: [ReportsService]
})
export class ReportsModule {}

View File

@@ -0,0 +1,105 @@
import { Injectable } from "@nestjs/common";
import { Job } from "bullmq";
import { QueuesService } from "../queues/queues.service";
import { PrismaService } from "../../prisma/prisma.service";
@Injectable()
export class ReportsService {
constructor(
private readonly queues: QueuesService,
private readonly prisma: PrismaService
) {}
async summary(tenantId: string) {
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const [attendees, registrations, confirmed, checkedIns, revenue, events, recentRegistrations, rsvpGroups, sourceGroups, activeEvents] =
await Promise.all([
this.prisma.attendee.count({ where: { tenantId } }),
this.prisma.registration.findMany({
where: { tenantId, createdAt: { gte: since } },
select: { createdAt: true, status: true, source: true }
}),
this.prisma.registration.count({ where: { tenantId, status: "confirmed" } }),
this.prisma.checkIn.count({ where: { tenantId, result: "checked_in" } }),
this.prisma.paymentTransaction.aggregate({ where: { tenantId, status: "success" }, _sum: { amountKobo: true } }),
this.prisma.event.count({ where: { tenantId } }),
this.prisma.registration.findMany({
where: { tenantId },
orderBy: { createdAt: "desc" },
take: 5,
include: { attendee: true, event: true }
}),
this.prisma.rSVP.groupBy({ by: ["response"], where: { tenantId }, _count: { _all: true } }),
this.prisma.registration.groupBy({ by: ["source"], where: { tenantId }, _count: { _all: true } }),
this.prisma.event.findMany({
where: { tenantId },
orderBy: { startsAt: "asc" },
include: { registrations: true, payments: true },
take: 8
})
]);
const totalRecent = registrations.length;
const confirmedRecent = registrations.filter((row) => row.status === "confirmed").length;
const conversionRate = totalRecent ? Math.round((confirmedRecent / totalRecent) * 1000) / 10 : 0;
const chartMap = new Map<string, { name: string; registrations: number; confirmed: number }>();
for (let i = 29; i >= 0; i -= 1) {
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
const key = d.toISOString().slice(0, 10);
chartMap.set(key, { name: key, registrations: 0, confirmed: 0 });
}
for (const row of registrations) {
const key = row.createdAt.toISOString().slice(0, 10);
const bucket = chartMap.get(key);
if (!bucket) continue;
bucket.registrations += 1;
if (row.status === "confirmed") bucket.confirmed += 1;
}
return {
metrics: [
{ label: "Total Attendees", value: attendees, change: "live" },
{ label: "Confirmed", value: confirmed, change: "live" },
{ label: "Checked-in", value: checkedIns, change: "live" },
{ label: "Revenue", value: revenue._sum.amountKobo ?? 0, change: "live", currency: "NGN" },
{ label: "Events", value: events, change: "live" },
{ label: "Conversion Rate", value: conversionRate, change: "last 30 days", suffix: "%" }
],
chartData: Array.from(chartMap.values()),
recentRegistrations: recentRegistrations.map((row) => ({
id: row.id,
code: row.code,
status: row.status,
createdAt: row.createdAt,
attendee: row.attendee,
event: row.event
})),
topEvents: activeEvents.map((event) => ({
id: event.id,
name: event.name,
slug: event.slug,
status: event.status,
startsAt: event.startsAt,
venue: event.venue,
registrations: event.registrations.length,
revenueKobo: event.payments.filter((payment) => payment.status === "success").reduce((sum, payment) => sum + payment.amountKobo, 0)
})),
rsvpStatus: rsvpGroups.map((row) => ({ response: row.response, count: row._count._all })),
registrationSources: sourceGroups.map((row) => ({ source: row.source, count: row._count._all }))
};
}
async queueRegistrationsCsv(tenantId: string, eventId?: string) {
const job = await this.queues.queue("reports").add("registrationsCsv", { tenantId, eventId });
return { jobId: job.id };
}
async getJob(jobId: string) {
const job = await Job.fromId(this.queues.queue("reports"), jobId);
if (!job) return { state: "not_found" as const };
const state = await job.getState();
const result = (job.returnvalue as any) ?? null;
return { state, result };
}
}

View File

@@ -0,0 +1,18 @@
import { ArrayNotEmpty, IsArray, IsNotEmpty, IsOptional, IsString } from "class-validator";
export class CreateRoleDto {
@IsString()
@IsNotEmpty()
name!: string;
@IsOptional()
@IsArray()
@ArrayNotEmpty()
permissionKeys?: string[];
}
export class SetRolePermissionsDto {
@IsArray()
permissionKeys!: string[];
}

View File

@@ -0,0 +1,55 @@
import { Body, Controller, Delete, Get, Param, Post, Put, Req, UseGuards } from "@nestjs/common";
import type { Request } from "express";
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
import { CreateRoleDto, SetRolePermissionsDto } from "./dto";
import { RolesService } from "./roles.service";
@Controller("permissions")
export class PermissionsController {
constructor(private readonly service: RolesService) {}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("roles.read")
@Get()
list() {
return this.service.listPermissions();
}
}
@Controller("roles")
export class RolesController {
constructor(private readonly service: RolesService) {}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("roles.read")
@Get()
findAll(@Req() req: Request) {
const tenantId = (req.user as any).tenantId as string;
return this.service.findAll(tenantId);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("roles.write")
@Post()
create(@Req() req: Request, @Body() dto: CreateRoleDto) {
const tenantId = (req.user as any).tenantId as string;
return this.service.create(tenantId, dto);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("roles.write")
@Put(":id/permissions")
setPermissions(@Req() req: Request, @Param("id") id: string, @Body() dto: SetRolePermissionsDto) {
const tenantId = (req.user as any).tenantId as string;
return this.service.setPermissions(tenantId, id, dto);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("roles.write")
@Delete(":id")
remove(@Req() req: Request, @Param("id") id: string) {
const tenantId = (req.user as any).tenantId as string;
return this.service.remove(tenantId, id);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { PermissionsController, RolesController } from "./roles.controller";
import { RolesService } from "./roles.service";
@Module({
controllers: [RolesController, PermissionsController],
providers: [RolesService]
})
export class RolesModule {}

View File

@@ -0,0 +1,65 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../../prisma/prisma.service";
import type { CreateRoleDto, SetRolePermissionsDto } from "./dto";
@Injectable()
export class RolesService {
constructor(private readonly prisma: PrismaService) {}
listPermissions() {
return this.prisma.permission.findMany({ orderBy: { key: "asc" } });
}
findAll(tenantId: string) {
return this.prisma.role.findMany({
where: { tenantId },
orderBy: { name: "asc" },
include: {
permissions: { include: { permission: true } },
users: true
}
});
}
async create(tenantId: string, dto: CreateRoleDto) {
const role = await this.prisma.role.create({
data: {
tenantId,
name: dto.name,
permissions: dto.permissionKeys?.length
? {
create: dto.permissionKeys.map((key) => ({
permission: { connect: { key } }
}))
}
: undefined
},
include: { permissions: { include: { permission: true } } }
});
return role;
}
async setPermissions(tenantId: string, roleId: string, dto: SetRolePermissionsDto) {
const role = await this.prisma.role.findFirst({ where: { id: roleId, tenantId } });
if (!role) throw new NotFoundException("Role not found");
const keys = Array.from(new Set(dto.permissionKeys.map((k) => String(k))));
const perms = await this.prisma.permission.findMany({ where: { key: { in: keys } } });
if (perms.length !== keys.length) throw new BadRequestException("Unknown permission key");
await this.prisma.rolePermission.deleteMany({ where: { roleId } });
await this.prisma.rolePermission.createMany({
data: perms.map((p) => ({ roleId, permissionId: p.id })),
skipDuplicates: true
});
return { ok: true };
}
async remove(tenantId: string, roleId: string) {
const role = await this.prisma.role.findFirst({ where: { id: roleId, tenantId } });
if (!role) throw new NotFoundException("Role not found");
await this.prisma.role.delete({ where: { id: roleId } });
return { ok: true };
}
}

View File

@@ -0,0 +1,18 @@
import { Controller, Get, Req, UseGuards } from "@nestjs/common";
import type { Request } from "express";
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
import { RsvpsService } from "./rsvps.service";
@Controller("rsvps")
export class RsvpsController {
constructor(private readonly service: RsvpsService) {}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("rsvps.read")
@Get()
findAll(@Req() req: Request) {
const tenantId = (req.user as any).tenantId as string;
return this.service.findAll(tenantId);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { RsvpsController } from "./rsvps.controller";
import { RsvpsService } from "./rsvps.service";
@Module({
controllers: [RsvpsController],
providers: [RsvpsService]
})
export class RsvpsModule {}

View File

@@ -0,0 +1,16 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../../prisma/prisma.service";
@Injectable()
export class RsvpsService {
constructor(private readonly prisma: PrismaService) {}
findAll(tenantId: string) {
return this.prisma.rSVP.findMany({
where: { tenantId },
orderBy: { createdAt: "desc" },
include: { event: true, invitee: true }
});
}
}

View File

@@ -0,0 +1,12 @@
import { IsObject, IsOptional, IsString } from "class-validator";
export class UpdateTenantSettingsDto {
@IsOptional()
@IsString()
appName?: string;
@IsOptional()
@IsObject()
modules?: Record<string, boolean>;
}

View File

@@ -0,0 +1,82 @@
import { BadRequestException, Body, Controller, Get, Patch, Query, Req, UploadedFile, UseGuards, UseInterceptors } from "@nestjs/common";
import type { Request } from "express";
import { FileInterceptor } from "@nestjs/platform-express";
import { diskStorage } from "multer";
import path from "node:path";
import { randomBytes } from "node:crypto";
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
import { UpdateTenantSettingsDto } from "./dto";
import { SettingsService } from "./settings.service";
import { PrismaService } from "../../prisma/prisma.service";
@Controller("settings")
export class SettingsController {
constructor(
private readonly service: SettingsService,
private readonly prisma: PrismaService
) {}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("settings.read")
@Get()
get(@Req() req: Request) {
const tenantId = (req.user as any).tenantId as string;
return this.service.get(tenantId);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("settings.write")
@Patch()
update(@Req() req: Request, @Body() dto: UpdateTenantSettingsDto) {
const tenantId = (req.user as any).tenantId as string;
return this.service.update(tenantId, dto);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("settings.write")
@Patch("logo")
@UseInterceptors(
FileInterceptor("file", {
storage: diskStorage({
destination: (_req, _file, cb) => {
const dir = process.env.UPLOADS_DIR ?? path.join(process.cwd(), "uploads");
cb(null, dir);
},
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname || "").slice(0, 10);
cb(null, `logo-${Date.now()}-${randomBytes(6).toString("hex")}${ext}`);
}
}),
limits: { fileSize: 5 * 1024 * 1024 }
})
)
async uploadLogo(@Req() req: Request, @UploadedFile() file?: any) {
const tenantId = (req.user as any).tenantId as string;
if (!file) throw new BadRequestException("Missing file");
const base = process.env.PUBLIC_API_URL ?? "";
const url = `${base.replace(/\/$/, "")}/uploads/${encodeURIComponent(file.filename)}`;
return this.service.setLogo(tenantId, url);
}
}
@Controller("public/branding")
export class PublicBrandingController {
constructor(private readonly prisma: PrismaService) {}
@Get()
async get(@Query("tenantSlug") tenantSlug?: string) {
const slug = tenantSlug ?? process.env.DEFAULT_TENANT_SLUG ?? "";
if (!slug) throw new BadRequestException("Missing tenantSlug");
const tenant = await this.prisma.tenant.findUnique({ where: { slug } });
if (!tenant) throw new BadRequestException("Tenant not found");
const settings = await this.prisma.tenantSetting.findUnique({ where: { tenantId: tenant.id } });
return {
tenant: { id: tenant.id, slug: tenant.slug, name: tenant.name },
branding: {
appName: settings?.appName ?? tenant.name,
logoUrl: settings?.logoUrl ?? null
},
modules: (settings?.modules as any) ?? null
};
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { PublicBrandingController, SettingsController } from "./settings.controller";
import { SettingsService } from "./settings.service";
@Module({
controllers: [SettingsController, PublicBrandingController],
providers: [SettingsService],
exports: [SettingsService]
})
export class SettingsModule {}

View File

@@ -0,0 +1,40 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../../prisma/prisma.service";
import type { UpdateTenantSettingsDto } from "./dto";
@Injectable()
export class SettingsService {
constructor(private readonly prisma: PrismaService) {}
async get(tenantId: string) {
const row = await this.prisma.tenantSetting.findUnique({ where: { tenantId } });
return {
appName: row?.appName ?? null,
logoUrl: row?.logoUrl ?? null,
modules: (row?.modules as any) ?? null
};
}
async update(tenantId: string, dto: UpdateTenantSettingsDto) {
const row = await this.prisma.tenantSetting.upsert({
where: { tenantId },
create: { tenantId, appName: dto.appName, modules: dto.modules ?? undefined },
update: { appName: dto.appName, modules: dto.modules ?? undefined }
});
return {
appName: row.appName ?? null,
logoUrl: row.logoUrl ?? null,
modules: (row.modules as any) ?? null
};
}
async setLogo(tenantId: string, logoUrl: string) {
const row = await this.prisma.tenantSetting.upsert({
where: { tenantId },
create: { tenantId, logoUrl },
update: { logoUrl }
});
return { logoUrl: row.logoUrl ?? null };
}
}

View File

@@ -0,0 +1,12 @@
import { Controller, Get, Param } from "@nestjs/common";
import { TenantsService } from "./tenants.service";
@Controller("tenants")
export class TenantsController {
constructor(private readonly service: TenantsService) {}
@Get(":slug")
findOneBySlug(@Param("slug") slug: string) {
return this.service.findBySlug(slug);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { TenantsController } from "./tenants.controller";
import { TenantsService } from "./tenants.service";
@Module({
controllers: [TenantsController],
providers: [TenantsService],
exports: [TenantsService]
})
export class TenantsModule {}

View File

@@ -0,0 +1,11 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../../prisma/prisma.service";
@Injectable()
export class TenantsService {
constructor(private readonly prisma: PrismaService) {}
findBySlug(slug: string) {
return this.prisma.tenant.findUnique({ where: { slug } });
}
}

View File

@@ -0,0 +1,96 @@
import { ArrayNotEmpty, IsArray, IsEmail, IsNotEmpty, IsOptional, IsString, MinLength } from "class-validator";
export class UpdateMyProfileDto {
@IsOptional()
@IsString()
fullName?: string;
@IsOptional()
@IsEmail()
email?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
addressLine1?: string;
@IsOptional()
@IsString()
addressLine2?: string;
@IsOptional()
@IsString()
city?: string;
@IsOptional()
@IsString()
state?: string;
@IsOptional()
@IsString()
country?: string;
}
export class CreateUserDto {
@IsEmail()
email!: string;
@IsString()
@IsNotEmpty()
fullName!: string;
@IsString()
@MinLength(8)
password!: string;
@IsOptional()
@IsArray()
@ArrayNotEmpty()
roleIds?: string[];
}
export class UpdateUserDto {
@IsOptional()
@IsEmail()
email?: string;
@IsOptional()
@IsString()
fullName?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
addressLine1?: string;
@IsOptional()
@IsString()
addressLine2?: string;
@IsOptional()
@IsString()
city?: string;
@IsOptional()
@IsString()
state?: string;
@IsOptional()
@IsString()
country?: string;
@IsOptional()
@IsString()
@MinLength(8)
password?: string;
@IsOptional()
@IsArray()
roleIds?: string[];
}

View File

@@ -0,0 +1,85 @@
import { BadRequestException, Body, Controller, Delete, Get, Param, Patch, Post, Req, UploadedFile, UseGuards, UseInterceptors } from "@nestjs/common";
import type { Request } from "express";
import { FileInterceptor } from "@nestjs/platform-express";
import { diskStorage } from "multer";
import path from "node:path";
import { randomBytes } from "node:crypto";
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
import { CreateUserDto, UpdateMyProfileDto, UpdateUserDto } from "./dto";
import { UsersService } from "./users.service";
@Controller("users")
export class UsersController {
constructor(private readonly service: UsersService) {}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("users.read")
@Get()
findAll(@Req() req: Request) {
const tenantId = (req.user as any).tenantId as string;
return this.service.findAll(tenantId);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("users.write")
@Post()
create(@Req() req: Request, @Body() dto: CreateUserDto) {
const tenantId = (req.user as any).tenantId as string;
return this.service.create(tenantId, dto);
}
@UseGuards(JwtAuthGuard, TenantGuard)
@Patch("me")
updateMeProfile(@Req() req: Request, @Body() dto: UpdateMyProfileDto) {
const userId = (req.user as any).sub as string;
return this.service.updateMe(userId, dto);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("users.write")
@Patch(":id")
update(@Req() req: Request, @Param("id") id: string, @Body() dto: UpdateUserDto) {
const tenantId = (req.user as any).tenantId as string;
return this.service.update(tenantId, id, dto);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("users.write")
@Delete(":id")
remove(@Req() req: Request, @Param("id") id: string) {
const tenantId = (req.user as any).tenantId as string;
return this.service.remove(tenantId, id);
}
@UseGuards(JwtAuthGuard, TenantGuard)
@Get("me")
me(@Req() req: Request) {
const userId = (req.user as any).sub as string;
return this.service.me(userId);
}
@UseGuards(JwtAuthGuard, TenantGuard)
@Patch("me/avatar")
@UseInterceptors(
FileInterceptor("file", {
storage: diskStorage({
destination: (_req, _file, cb) => {
const dir = process.env.UPLOADS_DIR ?? path.join(process.cwd(), "uploads");
cb(null, dir);
},
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname || "").slice(0, 10);
cb(null, `avatar-${Date.now()}-${randomBytes(6).toString("hex")}${ext}`);
}
}),
limits: { fileSize: 5 * 1024 * 1024 }
})
)
async uploadAvatar(@Req() req: Request, @UploadedFile() file?: any) {
const userId = (req.user as any).sub as string;
if (!file) throw new BadRequestException("Missing file");
const base = process.env.PUBLIC_API_URL ?? "";
const url = `${base.replace(/\/$/, "")}/uploads/${encodeURIComponent(file.filename)}`;
return this.service.setAvatar(userId, url);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { UsersController } from "./users.controller";
import { UsersService } from "./users.service";
@Module({
controllers: [UsersController],
providers: [UsersService]
})
export class UsersModule {}

View File

@@ -0,0 +1,118 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import bcrypt from "bcrypt";
import { PrismaService } from "../../prisma/prisma.service";
import type { CreateUserDto, UpdateMyProfileDto, UpdateUserDto } from "./dto";
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
findAll(tenantId: string) {
return this.prisma.user.findMany({
where: { tenantId },
orderBy: { createdAt: "desc" },
include: { roles: { include: { role: true } } }
});
}
async create(tenantId: string, dto: CreateUserDto) {
const passwordHash = await bcrypt.hash(dto.password, 12);
const user = await this.prisma.user.create({
data: {
tenantId,
email: dto.email.toLowerCase(),
fullName: dto.fullName,
mustChangePassword: true,
passwordHash,
roles: dto.roleIds?.length ? { create: dto.roleIds.map((roleId) => ({ roleId })) } : undefined
}
});
return { id: user.id };
}
async update(tenantId: string, userId: string, dto: UpdateUserDto) {
const row = await this.prisma.user.findFirst({ where: { id: userId, tenantId } });
if (!row) throw new NotFoundException("User not found");
const passwordHash = dto.password ? await bcrypt.hash(dto.password, 12) : undefined;
await this.prisma.user.update({
where: { id: userId },
data: {
email: dto.email ? dto.email.toLowerCase() : undefined,
fullName: dto.fullName,
phone: dto.phone,
addressLine1: dto.addressLine1,
addressLine2: dto.addressLine2,
city: dto.city,
state: dto.state,
country: dto.country,
passwordHash,
mustChangePassword: dto.password ? true : undefined
}
});
if (dto.roleIds) {
await this.prisma.userRole.deleteMany({ where: { userId } });
if (dto.roleIds.length) {
await this.prisma.userRole.createMany({ data: dto.roleIds.map((roleId) => ({ userId, roleId })) });
}
}
return { ok: true };
}
async remove(tenantId: string, userId: string) {
const row = await this.prisma.user.findFirst({ where: { id: userId, tenantId } });
if (!row) throw new NotFoundException("User not found");
await this.prisma.user.delete({ where: { id: userId } });
return { ok: true };
}
async me(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: { roles: { include: { role: true } } }
});
if (!user) throw new NotFoundException("User not found");
return {
id: user.id,
tenantId: user.tenantId,
email: user.email,
fullName: user.fullName,
phone: user.phone ?? null,
addressLine1: user.addressLine1 ?? null,
addressLine2: user.addressLine2 ?? null,
city: user.city ?? null,
state: user.state ?? null,
country: user.country ?? null,
avatarUrl: user.avatarUrl ?? null,
mustChangePassword: user.mustChangePassword,
roles: user.roles.map((r) => ({ id: r.roleId, name: r.role.name }))
};
}
async updateMe(userId: string, dto: UpdateMyProfileDto) {
if (dto.email && dto.email.includes(" ")) throw new BadRequestException("Invalid email");
const user = await this.prisma.user.update({
where: { id: userId },
data: {
fullName: dto.fullName,
email: dto.email ? dto.email.toLowerCase() : undefined,
phone: dto.phone,
addressLine1: dto.addressLine1,
addressLine2: dto.addressLine2,
city: dto.city,
state: dto.state,
country: dto.country
}
});
return { ok: true, userId: user.id };
}
async setAvatar(userId: string, avatarUrl: string) {
const user = await this.prisma.user.update({
where: { id: userId },
data: { avatarUrl }
});
return { avatarUrl: user.avatarUrl ?? null };
}
}

View File

@@ -0,0 +1,51 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Req, UseGuards } from "@nestjs/common";
import type { Request } from "express";
import { WorkflowsService } from "./workflows.service";
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
@Controller("workflows")
export class WorkflowsController {
constructor(private readonly service: WorkflowsService) {}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("workflows.read")
@Get()
findAll(@Req() req: Request) {
return this.service.findAll((req.user as any).tenantId as string);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("workflows.write")
@Post()
create(@Req() req: Request, @Body() payload: Record<string, unknown>) {
return this.service.create((req.user as any).tenantId as string, payload);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("workflows.write")
@Patch(":id")
update(@Req() req: Request, @Param("id") id: string, @Body() payload: Record<string, unknown>) {
return this.service.update((req.user as any).tenantId as string, id, payload);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("workflows.write")
@Put(":id/triggers")
replaceTriggers(@Req() req: Request, @Param("id") id: string, @Body() payload: { triggers?: any[] }) {
return this.service.replaceTriggers((req.user as any).tenantId as string, id, payload.triggers ?? []);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("workflows.write")
@Put(":id/actions")
replaceActions(@Req() req: Request, @Param("id") id: string, @Body() payload: { actions?: any[] }) {
return this.service.replaceActions((req.user as any).tenantId as string, id, payload.actions ?? []);
}
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
@RequirePermissions("workflows.write")
@Delete(":id")
remove(@Req() req: Request, @Param("id") id: string) {
return this.service.remove((req.user as any).tenantId as string, id);
}
}

View File

@@ -0,0 +1,5 @@
import { Module } from "@nestjs/common";
import { WorkflowsController } from "./workflows.controller";
import { WorkflowsService } from "./workflows.service";
@Module({ controllers: [WorkflowsController], providers: [WorkflowsService], exports: [WorkflowsService] })
export class WorkflowsModule {}

View File

@@ -0,0 +1,103 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../../prisma/prisma.service";
@Injectable()
export class WorkflowsService {
constructor(private readonly prisma: PrismaService) {}
findAll(tenantId: string) {
return this.prisma.workflow.findMany({
where: { tenantId },
orderBy: { createdAt: "desc" },
include: { triggers: true, actions: { orderBy: { sortOrder: "asc" } } }
});
}
create(tenantId: string, payload: any) {
const triggers = Array.isArray(payload.triggers) ? payload.triggers : [];
const actions = Array.isArray(payload.actions) ? payload.actions : [];
return this.prisma.workflow.create({
data: {
tenantId,
name: String(payload.name ?? ""),
description: payload.description ? String(payload.description) : null,
enabled: payload.enabled === undefined ? true : Boolean(payload.enabled),
triggers: {
create: triggers.map((trigger: any) => ({
tenantId,
type: String(trigger.type ?? "manual"),
config: trigger.config ?? undefined
}))
},
actions: {
create: actions.map((action: any, index: number) => ({
tenantId,
type: String(action.type ?? "send_email"),
config: action.config ?? undefined,
sortOrder: Number(action.sortOrder ?? index)
}))
}
},
include: { triggers: true, actions: { orderBy: { sortOrder: "asc" } } }
});
}
async update(tenantId: string, id: string, payload: any) {
const row = await this.prisma.workflow.findFirst({ where: { tenantId, id } });
if (!row) throw new NotFoundException("Workflow not found");
return this.prisma.workflow.update({
where: { id },
data: {
name: payload.name === undefined ? undefined : String(payload.name),
description: payload.description === undefined ? undefined : payload.description ? String(payload.description) : null,
enabled: payload.enabled === undefined ? undefined : Boolean(payload.enabled)
},
include: { triggers: true, actions: { orderBy: { sortOrder: "asc" } } }
});
}
async replaceTriggers(tenantId: string, workflowId: string, triggers: any[]) {
const row = await this.prisma.workflow.findFirst({ where: { tenantId, id: workflowId } });
if (!row) throw new NotFoundException("Workflow not found");
await this.prisma.workflowTrigger.deleteMany({ where: { tenantId, workflowId } });
if (triggers.length) {
await this.prisma.workflowTrigger.createMany({
data: triggers.map((trigger) => ({
tenantId,
workflowId,
type: String(trigger.type ?? "manual"),
config: trigger.config ?? undefined
}))
});
}
return this.prisma.workflow.findFirst({ where: { tenantId, id: workflowId }, include: { triggers: true, actions: true } });
}
async replaceActions(tenantId: string, workflowId: string, actions: any[]) {
const row = await this.prisma.workflow.findFirst({ where: { tenantId, id: workflowId } });
if (!row) throw new NotFoundException("Workflow not found");
await this.prisma.workflowAction.deleteMany({ where: { tenantId, workflowId } });
if (actions.length) {
await this.prisma.workflowAction.createMany({
data: actions.map((action, index) => ({
tenantId,
workflowId,
type: String(action.type ?? "send_email"),
config: action.config ?? undefined,
sortOrder: Number(action.sortOrder ?? index)
}))
});
}
return this.prisma.workflow.findFirst({
where: { tenantId, id: workflowId },
include: { triggers: true, actions: { orderBy: { sortOrder: "asc" } } }
});
}
async remove(tenantId: string, id: string) {
const row = await this.prisma.workflow.findFirst({ where: { tenantId, id } });
if (!row) throw new NotFoundException("Workflow not found");
await this.prisma.workflow.delete({ where: { id } });
return { ok: true };
}
}

View File

@@ -0,0 +1,9 @@
import { Global, Module } from "@nestjs/common";
import { PrismaService } from "./prisma.service";
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService]
})
export class PrismaModule {}

View File

@@ -0,0 +1,13 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
import { PrismaClient } from "@prisma/client";
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}

16
apps/api/src/types/vendor.d.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
declare module "qrcode" {
export interface QRCodeToDataURLOptions {
errorCorrectionLevel?: string;
margin?: number;
width?: number;
}
export function toDataURL(text: string, options?: QRCodeToDataURLOptions): Promise<string>;
}
declare module "multer" {
export function diskStorage(options: {
destination?: (req: any, file: any, callback: (error: Error | null, destination: string) => void) => void;
filename?: (req: any, file: any, callback: (error: Error | null, filename: string) => void) => void;
}): any;
}

Some files were not shown because too many files have changed in this diff Show More