commit 1f1d30a9f5d01e5d6809ad7181c025344ffbf6de Author: Austin A Date: Sat Apr 25 21:02:19 2026 +0100 Productionize EventSphere platform diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9c9801f --- /dev/null +++ b/.env.example @@ -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 diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..9a121e9 --- /dev/null +++ b/.env.production.example @@ -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 +AFRICASTALKING_USERNAME=replace_me +AFRICASTALKING_API_KEY=replace_me +AFRICASTALKING_SENDER_ID=replace_me +AFRICASTALKING_WHATSAPP_URL=replace_me +PAYSTACK_SECRET_KEY=replace_me diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..45c20fe --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..f3090bb --- /dev/null +++ b/README.md @@ -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`. diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..4e3e84e --- /dev/null +++ b/apps/api/Dockerfile @@ -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"] diff --git a/apps/api/eslint.config.mjs b/apps/api/eslint.config.mjs new file mode 100644 index 0000000..097e2aa --- /dev/null +++ b/apps/api/eslint.config.mjs @@ -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" + } + } +]; diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..b8b85d4 --- /dev/null +++ b/apps/api/package.json @@ -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" + } +} diff --git a/apps/api/prisma/migrations/20260424100000_init_auth/migration.sql b/apps/api/prisma/migrations/20260424100000_init_auth/migration.sql new file mode 100644 index 0000000..067a852 --- /dev/null +++ b/apps/api/prisma/migrations/20260424100000_init_auth/migration.sql @@ -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; + diff --git a/apps/api/prisma/migrations/20260424103000_event_core/migration.sql b/apps/api/prisma/migrations/20260424103000_event_core/migration.sql new file mode 100644 index 0000000..90bf5c5 --- /dev/null +++ b/apps/api/prisma/migrations/20260424103000_event_core/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20260424112000_attendee_audit/migration.sql b/apps/api/prisma/migrations/20260424112000_attendee_audit/migration.sql new file mode 100644 index 0000000..5ef9776 --- /dev/null +++ b/apps/api/prisma/migrations/20260424112000_attendee_audit/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20260424120000_registration_public/migration.sql b/apps/api/prisma/migrations/20260424120000_registration_public/migration.sql new file mode 100644 index 0000000..ffd10be --- /dev/null +++ b/apps/api/prisma/migrations/20260424120000_registration_public/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20260424123000_invitees_rsvps_payments/migration.sql b/apps/api/prisma/migrations/20260424123000_invitees_rsvps_payments/migration.sql new file mode 100644 index 0000000..7c6421c --- /dev/null +++ b/apps/api/prisma/migrations/20260424123000_invitees_rsvps_payments/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20260424133000_settings_integrations_profile/migration.sql b/apps/api/prisma/migrations/20260424133000_settings_integrations_profile/migration.sql new file mode 100644 index 0000000..0780b87 --- /dev/null +++ b/apps/api/prisma/migrations/20260424133000_settings_integrations_profile/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20260425190000_enterprise_modules/migration.sql b/apps/api/prisma/migrations/20260425190000_enterprise_modules/migration.sql new file mode 100644 index 0000000..03c887a --- /dev/null +++ b/apps/api/prisma/migrations/20260425190000_enterprise_modules/migration.sql @@ -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; + diff --git a/apps/api/prisma/migrations/migration_lock.toml b/apps/api/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2fe25d8 --- /dev/null +++ b/apps/api/prisma/migrations/migration_lock.toml @@ -0,0 +1 @@ +provider = "postgresql" diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma new file mode 100644 index 0000000..69cb5ec --- /dev/null +++ b/apps/api/prisma/schema.prisma @@ -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]) +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts new file mode 100644 index 0000000..f657dbe --- /dev/null +++ b/apps/api/src/app.module.ts @@ -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 {} diff --git a/apps/api/src/common/filters/all-exceptions.filter.ts b/apps/api/src/common/filters/all-exceptions.filter.ts new file mode 100644 index 0000000..2531701 --- /dev/null +++ b/apps/api/src/common/filters/all-exceptions.filter.ts @@ -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 + }); + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts new file mode 100644 index 0000000..481cb07 --- /dev/null +++ b/apps/api/src/main.ts @@ -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(); diff --git a/apps/api/src/modules/attendees/attendees.controller.ts b/apps/api/src/modules/attendees/attendees.controller.ts new file mode 100644 index 0000000..a94546f --- /dev/null +++ b/apps/api/src/modules/attendees/attendees.controller.ts @@ -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); + } +} diff --git a/apps/api/src/modules/attendees/attendees.module.ts b/apps/api/src/modules/attendees/attendees.module.ts new file mode 100644 index 0000000..d7e476f --- /dev/null +++ b/apps/api/src/modules/attendees/attendees.module.ts @@ -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 {} diff --git a/apps/api/src/modules/attendees/attendees.service.ts b/apps/api/src/modules/attendees/attendees.service.ts new file mode 100644 index 0000000..932c87d --- /dev/null +++ b/apps/api/src/modules/attendees/attendees.service.ts @@ -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 }; + } +} diff --git a/apps/api/src/modules/attendees/dto.ts b/apps/api/src/modules/attendees/dto.ts new file mode 100644 index 0000000..3f10727 --- /dev/null +++ b/apps/api/src/modules/attendees/dto.ts @@ -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; +} diff --git a/apps/api/src/modules/audit/audit.module.ts b/apps/api/src/modules/audit/audit.module.ts new file mode 100644 index 0000000..ed69c7b --- /dev/null +++ b/apps/api/src/modules/audit/audit.module.ts @@ -0,0 +1,8 @@ +import { Module } from "@nestjs/common"; +import { AuditService } from "./audit.service"; + +@Module({ + providers: [AuditService], + exports: [AuditService] +}) +export class AuditModule {} diff --git a/apps/api/src/modules/audit/audit.service.ts b/apps/api/src/modules/audit/audit.service.ts new file mode 100644 index 0000000..0dc2b39 --- /dev/null +++ b/apps/api/src/modules/audit/audit.service.ts @@ -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 + } + }); + } +} diff --git a/apps/api/src/modules/auth/auth.controller.ts b/apps/api/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..6114bc8 --- /dev/null +++ b/apps/api/src/modules/auth/auth.controller.ts @@ -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 }; + } +} diff --git a/apps/api/src/modules/auth/auth.module.ts b/apps/api/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..aaa4691 --- /dev/null +++ b/apps/api/src/modules/auth/auth.module.ts @@ -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 {} diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..6ea70b4 --- /dev/null +++ b/apps/api/src/modules/auth/auth.service.ts @@ -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 { + 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 }; + } +} diff --git a/apps/api/src/modules/auth/dto.ts b/apps/api/src/modules/auth/dto.ts new file mode 100644 index 0000000..b788c61 --- /dev/null +++ b/apps/api/src/modules/auth/dto.ts @@ -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; +} diff --git a/apps/api/src/modules/auth/guards.ts b/apps/api/src/modules/auth/guards.ts new file mode 100644 index 0000000..08cd4bf --- /dev/null +++ b/apps/api/src/modules/auth/guards.ts @@ -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)); + } +} diff --git a/apps/api/src/modules/auth/jwt.strategy.ts b/apps/api/src/modules/auth/jwt.strategy.ts new file mode 100644 index 0000000..4cba3d7 --- /dev/null +++ b/apps/api/src/modules/auth/jwt.strategy.ts @@ -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; + } +} diff --git a/apps/api/src/modules/auth/types.ts b/apps/api/src/modules/auth/types.ts new file mode 100644 index 0000000..226a1e0 --- /dev/null +++ b/apps/api/src/modules/auth/types.ts @@ -0,0 +1,10 @@ +export type JwtUser = { + sub: string; + tenantId: string; + email: string; + fullName: string; + avatarUrl?: string | null; + mustChangePassword: boolean; + roleNames: string[]; + permissionKeys: string[]; +}; diff --git a/apps/api/src/modules/calendar/calendar.controller.ts b/apps/api/src/modules/calendar/calendar.controller.ts new file mode 100644 index 0000000..f12b9b2 --- /dev/null +++ b/apps/api/src/modules/calendar/calendar.controller.ts @@ -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) { + 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) { + 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) { + return this.service.createBooking((req.user as any).tenantId as string, payload); + } +} diff --git a/apps/api/src/modules/calendar/calendar.module.ts b/apps/api/src/modules/calendar/calendar.module.ts new file mode 100644 index 0000000..0f82179 --- /dev/null +++ b/apps/api/src/modules/calendar/calendar.module.ts @@ -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 {} diff --git a/apps/api/src/modules/calendar/calendar.service.ts b/apps/api/src/modules/calendar/calendar.service.ts new file mode 100644 index 0000000..b909e04 --- /dev/null +++ b/apps/api/src/modules/calendar/calendar.service.ts @@ -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 } + }); + } +} diff --git a/apps/api/src/modules/communications/communications.controller.ts b/apps/api/src/modules/communications/communications.controller.ts new file mode 100644 index 0000000..579a055 --- /dev/null +++ b/apps/api/src/modules/communications/communications.controller.ts @@ -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) { + 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); + } +} diff --git a/apps/api/src/modules/communications/communications.module.ts b/apps/api/src/modules/communications/communications.module.ts new file mode 100644 index 0000000..0b329b1 --- /dev/null +++ b/apps/api/src/modules/communications/communications.module.ts @@ -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 {} diff --git a/apps/api/src/modules/communications/communications.service.ts b/apps/api/src/modules/communications/communications.service.ts new file mode 100644 index 0000000..23a2ae6 --- /dev/null +++ b/apps/api/src/modules/communications/communications.service.ts @@ -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 }; + } +} diff --git a/apps/api/src/modules/communications/dto.ts b/apps/api/src/modules/communications/dto.ts new file mode 100644 index 0000000..9aa964b --- /dev/null +++ b/apps/api/src/modules/communications/dto.ts @@ -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; +} diff --git a/apps/api/src/modules/crm/crm.controller.ts b/apps/api/src/modules/crm/crm.controller.ts new file mode 100644 index 0000000..82a3c29 --- /dev/null +++ b/apps/api/src/modules/crm/crm.controller.ts @@ -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) { + 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) { + 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) { + 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) { + 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) { + return this.service.createActivity((req.user as any).tenantId as string, (req.user as any).sub as string, payload); + } +} diff --git a/apps/api/src/modules/crm/crm.module.ts b/apps/api/src/modules/crm/crm.module.ts new file mode 100644 index 0000000..a1b0e79 --- /dev/null +++ b/apps/api/src/modules/crm/crm.module.ts @@ -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 {} diff --git a/apps/api/src/modules/crm/crm.service.ts b/apps/api/src/modules/crm/crm.service.ts new file mode 100644 index 0000000..1c0f979 --- /dev/null +++ b/apps/api/src/modules/crm/crm.service.ts @@ -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 + } + }); + } +} diff --git a/apps/api/src/modules/events/dto.ts b/apps/api/src/modules/events/dto.ts new file mode 100644 index 0000000..ad8db30 --- /dev/null +++ b/apps/api/src/modules/events/dto.ts @@ -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; +} diff --git a/apps/api/src/modules/events/events.controller.ts b/apps/api/src/modules/events/events.controller.ts new file mode 100644 index 0000000..d42437e --- /dev/null +++ b/apps/api/src/modules/events/events.controller.ts @@ -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) { + 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) { + const tenantId = (req.user as any).tenantId as string; + return this.service.upsertPage(tenantId, id, payload); + } +} diff --git a/apps/api/src/modules/events/events.module.ts b/apps/api/src/modules/events/events.module.ts new file mode 100644 index 0000000..c107a64 --- /dev/null +++ b/apps/api/src/modules/events/events.module.ts @@ -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 {} diff --git a/apps/api/src/modules/events/events.service.ts b/apps/api/src/modules/events/events.service.ts new file mode 100644 index 0000000..e929f8f --- /dev/null +++ b/apps/api/src/modules/events/events.service.ts @@ -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) + } + }); + } +} diff --git a/apps/api/src/modules/forms/forms.controller.ts b/apps/api/src/modules/forms/forms.controller.ts new file mode 100644 index 0000000..7f5a203 --- /dev/null +++ b/apps/api/src/modules/forms/forms.controller.ts @@ -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) { + 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) { + 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) { + 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); + } +} diff --git a/apps/api/src/modules/forms/forms.module.ts b/apps/api/src/modules/forms/forms.module.ts new file mode 100644 index 0000000..df01ee2 --- /dev/null +++ b/apps/api/src/modules/forms/forms.module.ts @@ -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 {} diff --git a/apps/api/src/modules/forms/forms.service.ts b/apps/api/src/modules/forms/forms.service.ts new file mode 100644 index 0000000..4a60f77 --- /dev/null +++ b/apps/api/src/modules/forms/forms.service.ts @@ -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" + } + }); + } +} diff --git a/apps/api/src/modules/health/health.controller.ts b/apps/api/src/modules/health/health.controller.ts new file mode 100644 index 0000000..0e6d8a7 --- /dev/null +++ b/apps/api/src/modules/health/health.controller.ts @@ -0,0 +1,9 @@ +import { Controller, Get } from "@nestjs/common"; + +@Controller("health") +export class HealthController { + @Get() + health() { + return { ok: true }; + } +} diff --git a/apps/api/src/modules/health/health.module.ts b/apps/api/src/modules/health/health.module.ts new file mode 100644 index 0000000..4e544ac --- /dev/null +++ b/apps/api/src/modules/health/health.module.ts @@ -0,0 +1,5 @@ +import { Module } from "@nestjs/common"; +import { HealthController } from "./health.controller"; + +@Module({ controllers: [HealthController] }) +export class HealthModule {} diff --git a/apps/api/src/modules/integrations/dto.ts b/apps/api/src/modules/integrations/dto.ts new file mode 100644 index 0000000..0fc2b61 --- /dev/null +++ b/apps/api/src/modules/integrations/dto.ts @@ -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; +} + diff --git a/apps/api/src/modules/integrations/integrations.controller.ts b/apps/api/src/modules/integrations/integrations.controller.ts new file mode 100644 index 0000000..f0f97b5 --- /dev/null +++ b/apps/api/src/modules/integrations/integrations.controller.ts @@ -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); + } +} + diff --git a/apps/api/src/modules/integrations/integrations.module.ts b/apps/api/src/modules/integrations/integrations.module.ts new file mode 100644 index 0000000..5906013 --- /dev/null +++ b/apps/api/src/modules/integrations/integrations.module.ts @@ -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 {} + diff --git a/apps/api/src/modules/integrations/integrations.service.ts b/apps/api/src/modules/integrations/integrations.service.ts new file mode 100644 index 0000000..e95f34e --- /dev/null +++ b/apps/api/src/modules/integrations/integrations.service.ts @@ -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; + } +} + diff --git a/apps/api/src/modules/invitees/dto.ts b/apps/api/src/modules/invitees/dto.ts new file mode 100644 index 0000000..5df2d88 --- /dev/null +++ b/apps/api/src/modules/invitees/dto.ts @@ -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; +} diff --git a/apps/api/src/modules/invitees/invitees.controller.ts b/apps/api/src/modules/invitees/invitees.controller.ts new file mode 100644 index 0000000..ad21914 --- /dev/null +++ b/apps/api/src/modules/invitees/invitees.controller.ts @@ -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); + } +} + diff --git a/apps/api/src/modules/invitees/invitees.module.ts b/apps/api/src/modules/invitees/invitees.module.ts new file mode 100644 index 0000000..f1ada20 --- /dev/null +++ b/apps/api/src/modules/invitees/invitees.module.ts @@ -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 {} diff --git a/apps/api/src/modules/invitees/invitees.service.ts b/apps/api/src/modules/invitees/invitees.service.ts new file mode 100644 index 0000000..b5a698d --- /dev/null +++ b/apps/api/src/modules/invitees/invitees.service.ts @@ -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 }; + } +} diff --git a/apps/api/src/modules/invitees/public-invitees.controller.ts b/apps/api/src/modules/invitees/public-invitees.controller.ts new file mode 100644 index 0000000..4be5e19 --- /dev/null +++ b/apps/api/src/modules/invitees/public-invitees.controller.ts @@ -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); + } +} + diff --git a/apps/api/src/modules/payments/dto.ts b/apps/api/src/modules/payments/dto.ts new file mode 100644 index 0000000..318ce29 --- /dev/null +++ b/apps/api/src/modules/payments/dto.ts @@ -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; +} + diff --git a/apps/api/src/modules/payments/payments.controller.ts b/apps/api/src/modules/payments/payments.controller.ts new file mode 100644 index 0000000..3976b9b --- /dev/null +++ b/apps/api/src/modules/payments/payments.controller.ts @@ -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 }; + } +} diff --git a/apps/api/src/modules/payments/payments.module.ts b/apps/api/src/modules/payments/payments.module.ts new file mode 100644 index 0000000..0fad2b7 --- /dev/null +++ b/apps/api/src/modules/payments/payments.module.ts @@ -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 {} diff --git a/apps/api/src/modules/payments/payments.service.ts b/apps/api/src/modules/payments/payments.service.ts new file mode 100644 index 0000000..8d58f4f --- /dev/null +++ b/apps/api/src/modules/payments/payments.service.ts @@ -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) + }; + } +} diff --git a/apps/api/src/modules/qrcode/qrcode.controller.ts b/apps/api/src/modules/qrcode/qrcode.controller.ts new file mode 100644 index 0000000..4048ce2 --- /dev/null +++ b/apps/api/src/modules/qrcode/qrcode.controller.ts @@ -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 ?? "")); + } +} diff --git a/apps/api/src/modules/qrcode/qrcode.module.ts b/apps/api/src/modules/qrcode/qrcode.module.ts new file mode 100644 index 0000000..047944c --- /dev/null +++ b/apps/api/src/modules/qrcode/qrcode.module.ts @@ -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 {} diff --git a/apps/api/src/modules/qrcode/qrcode.service.ts b/apps/api/src/modules/qrcode/qrcode.service.ts new file mode 100644 index 0000000..c1e2e78 --- /dev/null +++ b/apps/api/src/modules/qrcode/qrcode.service.ts @@ -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 }; + } +} diff --git a/apps/api/src/modules/queues/queues.module.ts b/apps/api/src/modules/queues/queues.module.ts new file mode 100644 index 0000000..e750c01 --- /dev/null +++ b/apps/api/src/modules/queues/queues.module.ts @@ -0,0 +1,8 @@ +import { Module } from "@nestjs/common"; +import { QueuesService } from "./queues.service"; + +@Module({ + providers: [QueuesService], + exports: [QueuesService] +}) +export class QueuesModule {} diff --git a/apps/api/src/modules/queues/queues.service.ts b/apps/api/src/modules/queues/queues.service.ts new file mode 100644 index 0000000..b252392 --- /dev/null +++ b/apps/api/src/modules/queues/queues.service.ts @@ -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; + private readonly workers: Worker[] = []; + private readonly transporters = new Map(); + + 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 } + }); + } +} diff --git a/apps/api/src/modules/registrations/dto.ts b/apps/api/src/modules/registrations/dto.ts new file mode 100644 index 0000000..b2ca32c --- /dev/null +++ b/apps/api/src/modules/registrations/dto.ts @@ -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; +} diff --git a/apps/api/src/modules/registrations/registrations.controller.ts b/apps/api/src/modules/registrations/registrations.controller.ts new file mode 100644 index 0000000..f8163e7 --- /dev/null +++ b/apps/api/src/modules/registrations/registrations.controller.ts @@ -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); + } +} diff --git a/apps/api/src/modules/registrations/registrations.module.ts b/apps/api/src/modules/registrations/registrations.module.ts new file mode 100644 index 0000000..957f8c0 --- /dev/null +++ b/apps/api/src/modules/registrations/registrations.module.ts @@ -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 {} diff --git a/apps/api/src/modules/registrations/registrations.service.ts b/apps/api/src/modules/registrations/registrations.service.ts new file mode 100644 index 0000000..c328255 --- /dev/null +++ b/apps/api/src/modules/registrations/registrations.service.ts @@ -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 }; + } +} diff --git a/apps/api/src/modules/reports/reports.controller.ts b/apps/api/src/modules/reports/reports.controller.ts new file mode 100644 index 0000000..b5019a4 --- /dev/null +++ b/apps/api/src/modules/reports/reports.controller.ts @@ -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); + } +} diff --git a/apps/api/src/modules/reports/reports.module.ts b/apps/api/src/modules/reports/reports.module.ts new file mode 100644 index 0000000..9ec7cca --- /dev/null +++ b/apps/api/src/modules/reports/reports.module.ts @@ -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 {} + diff --git a/apps/api/src/modules/reports/reports.service.ts b/apps/api/src/modules/reports/reports.service.ts new file mode 100644 index 0000000..48f8827 --- /dev/null +++ b/apps/api/src/modules/reports/reports.service.ts @@ -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(); + 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 }; + } +} diff --git a/apps/api/src/modules/roles/dto.ts b/apps/api/src/modules/roles/dto.ts new file mode 100644 index 0000000..91a58bb --- /dev/null +++ b/apps/api/src/modules/roles/dto.ts @@ -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[]; +} + diff --git a/apps/api/src/modules/roles/roles.controller.ts b/apps/api/src/modules/roles/roles.controller.ts new file mode 100644 index 0000000..150c4dd --- /dev/null +++ b/apps/api/src/modules/roles/roles.controller.ts @@ -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); + } +} + diff --git a/apps/api/src/modules/roles/roles.module.ts b/apps/api/src/modules/roles/roles.module.ts new file mode 100644 index 0000000..099ae29 --- /dev/null +++ b/apps/api/src/modules/roles/roles.module.ts @@ -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 {} + diff --git a/apps/api/src/modules/roles/roles.service.ts b/apps/api/src/modules/roles/roles.service.ts new file mode 100644 index 0000000..389d273 --- /dev/null +++ b/apps/api/src/modules/roles/roles.service.ts @@ -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 }; + } +} + diff --git a/apps/api/src/modules/rsvps/rsvps.controller.ts b/apps/api/src/modules/rsvps/rsvps.controller.ts new file mode 100644 index 0000000..6fb3953 --- /dev/null +++ b/apps/api/src/modules/rsvps/rsvps.controller.ts @@ -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); + } +} + diff --git a/apps/api/src/modules/rsvps/rsvps.module.ts b/apps/api/src/modules/rsvps/rsvps.module.ts new file mode 100644 index 0000000..883bdc5 --- /dev/null +++ b/apps/api/src/modules/rsvps/rsvps.module.ts @@ -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 {} + diff --git a/apps/api/src/modules/rsvps/rsvps.service.ts b/apps/api/src/modules/rsvps/rsvps.service.ts new file mode 100644 index 0000000..a801f54 --- /dev/null +++ b/apps/api/src/modules/rsvps/rsvps.service.ts @@ -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 } + }); + } +} + diff --git a/apps/api/src/modules/settings/dto.ts b/apps/api/src/modules/settings/dto.ts new file mode 100644 index 0000000..56fc645 --- /dev/null +++ b/apps/api/src/modules/settings/dto.ts @@ -0,0 +1,12 @@ +import { IsObject, IsOptional, IsString } from "class-validator"; + +export class UpdateTenantSettingsDto { + @IsOptional() + @IsString() + appName?: string; + + @IsOptional() + @IsObject() + modules?: Record; +} + diff --git a/apps/api/src/modules/settings/settings.controller.ts b/apps/api/src/modules/settings/settings.controller.ts new file mode 100644 index 0000000..99bb579 --- /dev/null +++ b/apps/api/src/modules/settings/settings.controller.ts @@ -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 + }; + } +} diff --git a/apps/api/src/modules/settings/settings.module.ts b/apps/api/src/modules/settings/settings.module.ts new file mode 100644 index 0000000..b874de9 --- /dev/null +++ b/apps/api/src/modules/settings/settings.module.ts @@ -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 {} diff --git a/apps/api/src/modules/settings/settings.service.ts b/apps/api/src/modules/settings/settings.service.ts new file mode 100644 index 0000000..a3b8840 --- /dev/null +++ b/apps/api/src/modules/settings/settings.service.ts @@ -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 }; + } +} + diff --git a/apps/api/src/modules/tenants/tenants.controller.ts b/apps/api/src/modules/tenants/tenants.controller.ts new file mode 100644 index 0000000..8eb1958 --- /dev/null +++ b/apps/api/src/modules/tenants/tenants.controller.ts @@ -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); + } +} diff --git a/apps/api/src/modules/tenants/tenants.module.ts b/apps/api/src/modules/tenants/tenants.module.ts new file mode 100644 index 0000000..5930c6d --- /dev/null +++ b/apps/api/src/modules/tenants/tenants.module.ts @@ -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 {} diff --git a/apps/api/src/modules/tenants/tenants.service.ts b/apps/api/src/modules/tenants/tenants.service.ts new file mode 100644 index 0000000..878657b --- /dev/null +++ b/apps/api/src/modules/tenants/tenants.service.ts @@ -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 } }); + } +} diff --git a/apps/api/src/modules/users/dto.ts b/apps/api/src/modules/users/dto.ts new file mode 100644 index 0000000..e4358d4 --- /dev/null +++ b/apps/api/src/modules/users/dto.ts @@ -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[]; +} diff --git a/apps/api/src/modules/users/users.controller.ts b/apps/api/src/modules/users/users.controller.ts new file mode 100644 index 0000000..6775982 --- /dev/null +++ b/apps/api/src/modules/users/users.controller.ts @@ -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); + } +} diff --git a/apps/api/src/modules/users/users.module.ts b/apps/api/src/modules/users/users.module.ts new file mode 100644 index 0000000..4f6bcc7 --- /dev/null +++ b/apps/api/src/modules/users/users.module.ts @@ -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 {} + diff --git a/apps/api/src/modules/users/users.service.ts b/apps/api/src/modules/users/users.service.ts new file mode 100644 index 0000000..c24aca5 --- /dev/null +++ b/apps/api/src/modules/users/users.service.ts @@ -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 }; + } +} + diff --git a/apps/api/src/modules/workflows/workflows.controller.ts b/apps/api/src/modules/workflows/workflows.controller.ts new file mode 100644 index 0000000..53f5ae9 --- /dev/null +++ b/apps/api/src/modules/workflows/workflows.controller.ts @@ -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) { + 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) { + 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); + } +} diff --git a/apps/api/src/modules/workflows/workflows.module.ts b/apps/api/src/modules/workflows/workflows.module.ts new file mode 100644 index 0000000..2f91176 --- /dev/null +++ b/apps/api/src/modules/workflows/workflows.module.ts @@ -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 {} diff --git a/apps/api/src/modules/workflows/workflows.service.ts b/apps/api/src/modules/workflows/workflows.service.ts new file mode 100644 index 0000000..7e65b66 --- /dev/null +++ b/apps/api/src/modules/workflows/workflows.service.ts @@ -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 }; + } +} diff --git a/apps/api/src/prisma/prisma.module.ts b/apps/api/src/prisma/prisma.module.ts new file mode 100644 index 0000000..7a94e73 --- /dev/null +++ b/apps/api/src/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from "@nestjs/common"; +import { PrismaService } from "./prisma.service"; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService] +}) +export class PrismaModule {} diff --git a/apps/api/src/prisma/prisma.service.ts b/apps/api/src/prisma/prisma.service.ts new file mode 100644 index 0000000..d86f65d --- /dev/null +++ b/apps/api/src/prisma/prisma.service.ts @@ -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(); + } +} diff --git a/apps/api/src/types/vendor.d.ts b/apps/api/src/types/vendor.d.ts new file mode 100644 index 0000000..d72c6ab --- /dev/null +++ b/apps/api/src/types/vendor.d.ts @@ -0,0 +1,16 @@ +declare module "qrcode" { + export interface QRCodeToDataURLOptions { + errorCorrectionLevel?: string; + margin?: number; + width?: number; + } + + export function toDataURL(text: string, options?: QRCodeToDataURLOptions): Promise; +} + +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; +} diff --git a/apps/api/src/worker.ts b/apps/api/src/worker.ts new file mode 100644 index 0000000..04ba1ae --- /dev/null +++ b/apps/api/src/worker.ts @@ -0,0 +1,11 @@ +import { NestFactory } from "@nestjs/core"; +import { AppModule } from "./app.module"; + +async function bootstrap() { + process.env.PROCESS_QUEUES = process.env.PROCESS_QUEUES ?? "1"; + const app = await NestFactory.createApplicationContext(AppModule, { logger: ["log", "error", "warn"] }); + await app.init(); + await new Promise(() => {}); +} + +bootstrap(); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..95c9eb3 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2022", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "strict": true + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/web/.eslintrc.json b/apps/web/.eslintrc.json new file mode 100644 index 0000000..957cd15 --- /dev/null +++ b/apps/web/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals"] +} diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..70fa443 --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,21 @@ +FROM node:20-alpine + +WORKDIR /repo + +RUN corepack enable + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/web/package.json apps/web/package.json +RUN pnpm install --frozen-lockfile --filter web... + +COPY apps/web apps/web + +ARG NEXT_PUBLIC_API_URL +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL + +RUN pnpm --filter web build + +WORKDIR /repo/apps/web + +EXPOSE 3000 +CMD ["pnpm", "start"] diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 0000000..40c3d68 --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs new file mode 100644 index 0000000..224d3c2 --- /dev/null +++ b/apps/web/next.config.mjs @@ -0,0 +1,10 @@ +export default { + transpilePackages: [], + images: { + remotePatterns: [ + { protocol: "http", hostname: "localhost", port: "4000", pathname: "/uploads/**" }, + { protocol: "https", hostname: "api.event.brainshare.ng", pathname: "/uploads/**" }, + { protocol: "https", hostname: "api.event.brainshar.ng", pathname: "/uploads/**" } + ] + } +}; diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..ac090c8 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,29 @@ +{ + "name": "web", + "scripts": { + "dev": "next dev -p 3000", + "build": "next build", + "start": "next start -p 3000", + "lint": "next lint", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "next": "^14.2.15", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "lucide-react": "^0.468.0", + "recharts": "^2.13.3", + "clsx": "^2.1.1" + }, + "devDependencies": { + "eslint": "^8.57.0", + "eslint-config-next": "^14.2.15", + "typescript": "^5.6.3", + "tailwindcss": "^3.4.14", + "postcss": "^8.4.47", + "autoprefixer": "^10.4.20", + "@types/node": "^22.8.6", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1" + } +} diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs new file mode 100644 index 0000000..99417eb --- /dev/null +++ b/apps/web/postcss.config.mjs @@ -0,0 +1 @@ +export default { plugins: { tailwindcss: {}, autoprefixer: {} } }; diff --git a/apps/web/src/app/(admin)/attendees/page.tsx b/apps/web/src/app/(admin)/attendees/page.tsx new file mode 100644 index 0000000..e0a9d9b --- /dev/null +++ b/apps/web/src/app/(admin)/attendees/page.tsx @@ -0,0 +1,5 @@ +import { AdminShell } from "@/components/admin/AdminShell"; +import { AttendeesCrud } from "@/components/admin/AttendeesCrud"; +export default function Page() { + return ; +} diff --git a/apps/web/src/app/(admin)/calendar/page.tsx b/apps/web/src/app/(admin)/calendar/page.tsx new file mode 100644 index 0000000..986af13 --- /dev/null +++ b/apps/web/src/app/(admin)/calendar/page.tsx @@ -0,0 +1,5 @@ +import { AdminShell } from "@/components/admin/AdminShell"; +import { CrudPage } from "@/components/admin/CrudPage"; +export default function Page() { + return ; +} diff --git a/apps/web/src/app/(admin)/check-in/page.tsx b/apps/web/src/app/(admin)/check-in/page.tsx new file mode 100644 index 0000000..e3219f2 --- /dev/null +++ b/apps/web/src/app/(admin)/check-in/page.tsx @@ -0,0 +1,190 @@ +'use client'; + +import { AdminShell } from "@/components/admin/AdminShell"; +import { apiFetch } from "@/lib/api"; +import { useEffect, useMemo, useState } from "react"; + +function extractCode(raw: string) { + const input = raw.trim(); + if (!input) return ""; + if (input.startsWith("{") && input.endsWith("}")) { + try { + const obj = JSON.parse(input) as any; + if (obj?.code) return String(obj.code).trim().toUpperCase(); + } catch {} + } + const maybeUrlMatch = input.match(/\/public\/registrations\/([^/?#]+)/i); + if (maybeUrlMatch?.[1]) return decodeURIComponent(maybeUrlMatch[1]).trim().toUpperCase(); + return input.toUpperCase(); +} + +export default function Page() { + const [raw, setRaw] = useState(""); + const code = useMemo(() => extractCode(raw), [raw]); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [checkingIn, setCheckingIn] = useState(false); + const [registration, setRegistration] = useState(null); + const [alreadyCheckedIn, setAlreadyCheckedIn] = useState(null); + const [recent, setRecent] = useState([]); + + const loadRecent = async () => { + try { + const res = await apiFetch<{ checkins: any[] }>("/registrations/recent-checkins?limit=25"); + setRecent(res.checkins ?? []); + } catch {} + }; + + useEffect(() => { + void loadRecent(); + const id = window.setInterval(() => void loadRecent(), 5000); + return () => window.clearInterval(id); + }, []); + + const lookup = async () => { + setError(null); + setAlreadyCheckedIn(null); + setRegistration(null); + if (!code) return; + setLoading(true); + try { + const res = await apiFetch<{ registration: any }>(`/registrations/by-code/${encodeURIComponent(code)}`); + setRegistration(res.registration ?? null); + } catch { + setError("Registration not found."); + } finally { + setLoading(false); + } + }; + + const checkIn = async () => { + setError(null); + setAlreadyCheckedIn(null); + if (!code) return; + setCheckingIn(true); + try { + const res = await apiFetch<{ registration: any; alreadyCheckedIn: boolean }>( + `/registrations/by-code/${encodeURIComponent(code)}/check-in`, + { method: "POST", body: JSON.stringify({}) } + ); + setRegistration(res.registration ?? null); + setAlreadyCheckedIn(Boolean(res.alreadyCheckedIn)); + void loadRecent(); + } catch { + setError("Check-in failed."); + } finally { + setCheckingIn(false); + } + }; + + return ( + +
+
+
Check-in (Live)
+
Paste a code, QR payload JSON, or a public registration URL.
+ +
+ setRaw(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void lookup(); + }} + /> + +
+ + {error ?
{error}
: null} + + {registration ? ( +
+
+
+
{registration.attendee?.fullName ?? "Attendee"}
+
{registration.attendee?.email ?? ""}
+
+ Event: {registration.event?.name ?? "-"} +
+
+ Code: {registration.code} · Status:{" "} + {String(registration.status)} +
+
+ + +
+ + {alreadyCheckedIn === true ? ( +
+ Already checked in. +
+ ) : null} + {alreadyCheckedIn === false ? ( +
+ Checked in successfully. +
+ ) : null} +
+ ) : null} +
+ +
+
+
Recent check-ins
+ +
+
+ + + + + + + + + + + {recent.length ? ( + recent.map((r) => ( + + + + + + + )) + ) : ( + + + + )} + +
TimeAttendeeEventCode
{new Date(r.checkedInAt).toLocaleString()} +
{r.attendee?.fullName ?? "-"}
+
{r.attendee?.email ?? ""}
+
{r.event?.name ?? "-"}{r.code}
+ No check-ins yet. +
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/(admin)/communications/page.tsx b/apps/web/src/app/(admin)/communications/page.tsx new file mode 100644 index 0000000..070b7e8 --- /dev/null +++ b/apps/web/src/app/(admin)/communications/page.tsx @@ -0,0 +1,5 @@ +import { AdminShell } from "@/components/admin/AdminShell"; +import { CommunicationsConsole } from "@/components/admin/CommunicationsConsole"; +export default function Page() { + return ; +} diff --git a/apps/web/src/app/(admin)/contacts-leads/page.tsx b/apps/web/src/app/(admin)/contacts-leads/page.tsx new file mode 100644 index 0000000..ad99ae7 --- /dev/null +++ b/apps/web/src/app/(admin)/contacts-leads/page.tsx @@ -0,0 +1,5 @@ +import { AdminShell } from "@/components/admin/AdminShell"; +import { CrudPage } from "@/components/admin/CrudPage"; +export default function Page() { + return ; +} diff --git a/apps/web/src/app/(admin)/crm-pipeline/page.tsx b/apps/web/src/app/(admin)/crm-pipeline/page.tsx new file mode 100644 index 0000000..0efeb41 --- /dev/null +++ b/apps/web/src/app/(admin)/crm-pipeline/page.tsx @@ -0,0 +1,5 @@ +import { AdminShell } from "@/components/admin/AdminShell"; +import { CrudPage } from "@/components/admin/CrudPage"; +export default function Page() { + return ; +} diff --git a/apps/web/src/app/(admin)/dashboard/page.tsx b/apps/web/src/app/(admin)/dashboard/page.tsx new file mode 100644 index 0000000..c98d30e --- /dev/null +++ b/apps/web/src/app/(admin)/dashboard/page.tsx @@ -0,0 +1,5 @@ +import { AdminShell } from "@/components/admin/AdminShell"; +import { DashboardPage } from "@/components/admin/DashboardPage"; +export default function Page() { + return ; +} diff --git a/apps/web/src/app/(admin)/email-campaigns/page.tsx b/apps/web/src/app/(admin)/email-campaigns/page.tsx new file mode 100644 index 0000000..a5c9a18 --- /dev/null +++ b/apps/web/src/app/(admin)/email-campaigns/page.tsx @@ -0,0 +1,5 @@ +import { AdminShell } from "@/components/admin/AdminShell"; +import { CrudPage } from "@/components/admin/CrudPage"; +export default function Page() { + return ; +} diff --git a/apps/web/src/app/(admin)/events/page.tsx b/apps/web/src/app/(admin)/events/page.tsx new file mode 100644 index 0000000..575c829 --- /dev/null +++ b/apps/web/src/app/(admin)/events/page.tsx @@ -0,0 +1,5 @@ +import { AdminShell } from "@/components/admin/AdminShell"; +import { EventsCrud } from "@/components/admin/EventsCrud"; +export default function Page() { + return ; +} diff --git a/apps/web/src/app/(admin)/forms-workflows/page.tsx b/apps/web/src/app/(admin)/forms-workflows/page.tsx new file mode 100644 index 0000000..1bd9627 --- /dev/null +++ b/apps/web/src/app/(admin)/forms-workflows/page.tsx @@ -0,0 +1,5 @@ +import { AdminShell } from "@/components/admin/AdminShell"; +import { CrudPage } from "@/components/admin/CrudPage"; +export default function Page() { + return ; +} diff --git a/apps/web/src/app/(admin)/integrations/page.tsx b/apps/web/src/app/(admin)/integrations/page.tsx new file mode 100644 index 0000000..9cdc6a7 --- /dev/null +++ b/apps/web/src/app/(admin)/integrations/page.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { AdminShell } from "@/components/admin/AdminShell"; +import { Card } from "@/components/ui/Card"; +import { Button } from "@/components/ui/Button"; +import { apiFetch, apiPost } from "@/lib/api"; + +export default function Page() { + const [rows, setRows] = useState<{ id: string; key: string; value: string | null; isSecret: boolean }[]>([]); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + const fields = useMemo( + () => [ + { key: "paystack.secretKey", label: "Paystack Secret Key", isSecret: true }, + { key: "paystack.callbackUrl", label: "Paystack Callback URL", isSecret: false }, + { key: "africastalking.username", label: "Africa's Talking Username", isSecret: false }, + { key: "africastalking.apiKey", label: "Africa's Talking API Key", isSecret: true }, + { key: "africastalking.senderId", label: "Africa's Talking Sender ID", isSecret: false }, + { key: "smtp.host", label: "SMTP Host", isSecret: false }, + { key: "smtp.port", label: "SMTP Port", isSecret: false }, + { key: "smtp.user", label: "SMTP Username", isSecret: false }, + { key: "smtp.pass", label: "SMTP Password", isSecret: true }, + { key: "whatsapp.from", label: "WhatsApp From", isSecret: false } + ], + [] + ); + + const map = useMemo(() => { + const m = new Map(rows.map((r) => [r.key, r])); + return m; + }, [rows]); + + const [values, setValues] = useState>({}); + + const reload = useCallback(async () => { + setError(null); + setLoading(true); + try { + const data = await apiFetch<{ id: string; key: string; value: string | null; isSecret: boolean }[]>("/integrations"); + setRows(data); + const init: Record = {}; + for (const f of fields) { + const r = data.find((x) => x.key === f.key); + init[f.key] = r?.value ?? ""; + } + setValues(init); + } catch (e: any) { + setError(e?.message ?? "Failed to load integrations"); + } finally { + setLoading(false); + } + }, [fields]); + + useEffect(() => { + void reload(); + }, [reload]); + + async function save(key: string, isSecret: boolean) { + setError(null); + try { + await apiPost("/integrations", { key, value: values[key] ?? "", isSecret }); + await reload(); + } catch (e: any) { + setError(e?.message ?? "Failed to save integration"); + } + } + + return ( + + {error ?
{error}
: null} + + +
Integration Settings
+
Configure SMS, email, WhatsApp and payment providers.
+ +
+ {fields.map((f) => ( +
+
+
{f.label}
+
{map.get(f.key)?.id ? "Configured" : "Not set"}
+
+ setValues((v) => ({ ...v, [f.key]: e.target.value }))} + /> +
+ +
+
+ ))} +
+
+
+ ); +} diff --git a/apps/web/src/app/(admin)/invitees/page.tsx b/apps/web/src/app/(admin)/invitees/page.tsx new file mode 100644 index 0000000..65fec90 --- /dev/null +++ b/apps/web/src/app/(admin)/invitees/page.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { AdminShell } from "@/components/admin/AdminShell"; +import { Card } from "@/components/ui/Card"; +import { Button } from "@/components/ui/Button"; +import { apiFetch, apiPost } from "@/lib/api"; + +type EventRow = { id: string; name: string; slug: string; startsAt: string; venue: string; status: string }; +type InviteeRow = { + id: string; + fullName: string; + email: string; + phone: string | null; + status: string; + code: string; + createdAt: string; + event: EventRow; +}; + +export default function Page() { + const [rows, setRows] = useState([]); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [query, setQuery] = useState(""); + + const [open, setOpen] = useState(false); + const [eventId, setEventId] = useState(""); + const [fullName, setFullName] = useState(""); + const [email, setEmail] = useState(""); + const [phone, setPhone] = useState(""); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return rows; + return rows.filter((r) => `${r.fullName} ${r.email} ${r.event.name} ${r.code}`.toLowerCase().includes(q)); + }, [rows, query]); + + async function load() { + setError(null); + setLoading(true); + try { + const [invitees, evs] = await Promise.all([apiFetch("/invitees"), apiFetch("/events")]); + setRows(invitees); + setEvents(evs); + } catch (e: any) { + setError(e?.message ?? "Failed to load invitees"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void load(); + }, []); + + function openCreate() { + setSaveError(null); + setEventId(events[0]?.id ?? ""); + setFullName(""); + setEmail(""); + setPhone(""); + setOpen(true); + } + + async function create() { + setSaveError(null); + setSaving(true); + try { + await apiPost("/invitees", { eventId, fullName, email, phone: phone.trim().length ? phone : undefined }); + setOpen(false); + await load(); + } catch (e: any) { + setSaveError(e?.message ?? "Create failed"); + } finally { + setSaving(false); + } + } + + return ( + +
+
+
+

Invitees

+

Import, segment and invite executives, customers, partners and prospects.

+
+ +
+ + +
+ setQuery(e.target.value)} /> + +
+ + {loading ?
Loading...
: null} + {error ?
{error}
: null} + + {!loading && !error ? ( + filtered.length ? ( + + + + + + + + + + + + + {filtered.map((r) => ( + + + + + + + + + ))} + +
InviteeEmailEventStatusCodeCreated
{r.fullName}{r.email}{r.event.name}{r.status}{r.code}{new Date(r.createdAt).toLocaleString()}
+ ) : ( +
No invitees yet.
+ ) + ) : null} +
+ + {open ? ( +
setOpen(false)}> +
e.stopPropagation()}> +
+
+
Add
+
Invitee
+
+ +
+ +
+
+
Event
+ +
+ setFullName(e.target.value)} /> + setEmail(e.target.value)} /> + setPhone(e.target.value)} /> +
+ + {saveError ?
{saveError}
: null} + +
+ + +
+
+
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/app/(admin)/payments/page.tsx b/apps/web/src/app/(admin)/payments/page.tsx new file mode 100644 index 0000000..d52748f --- /dev/null +++ b/apps/web/src/app/(admin)/payments/page.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { AdminShell } from "@/components/admin/AdminShell"; +import { Card } from "@/components/ui/Card"; +import { Button } from "@/components/ui/Button"; +import { apiFetch } from "@/lib/api"; + +type EventRow = { id: string; name: string; slug: string; startsAt: string; venue: string; status: string }; +type PaymentRow = { + id: string; + email: string; + amountKobo: number; + currency: string; + provider: string; + reference: string; + status: string; + createdAt: string; + event: EventRow; +}; + +export default function Page() { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [query, setQuery] = useState(""); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return rows; + return rows.filter((r) => `${r.email} ${r.reference} ${r.status} ${r.event.name}`.toLowerCase().includes(q)); + }, [rows, query]); + + async function load() { + setError(null); + setLoading(true); + try { + const res = await apiFetch("/payments"); + setRows(res); + } catch (e: any) { + setError(e?.message ?? "Failed to load payments"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void load(); + }, []); + + return ( + +
+
+
+

Payments

+

Collect paid registrations and sponsorship invoices through Paystack.

+
+ +
+ + +
+ setQuery(e.target.value)} /> +
+ + {loading ?
Loading...
: null} + {error ?
{error}
: null} + + {!loading && !error ? ( + filtered.length ? ( + + + + + + + + + + + + + {filtered.map((r) => ( + + + + + + + + + ))} + +
ReferenceEmailEventAmountStatusCreated
{r.reference}{r.email}{r.event.name} + {(r.amountKobo / 100).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} {r.currency} + {r.status}{new Date(r.createdAt).toLocaleString()}
+ ) : ( +
No payments yet.
+ ) + ) : null} +
+
+
+ ); +} diff --git a/apps/web/src/app/(admin)/profile/page.tsx b/apps/web/src/app/(admin)/profile/page.tsx new file mode 100644 index 0000000..02fb8a0 --- /dev/null +++ b/apps/web/src/app/(admin)/profile/page.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { AdminShell } from "@/components/admin/AdminShell"; +import { Card } from "@/components/ui/Card"; +import { Button } from "@/components/ui/Button"; +import { apiFetch, apiPatch, apiPostForm } from "@/lib/api"; + +export default function Page() { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const [profile, setProfile] = useState(null); + const [avatarFile, setAvatarFile] = useState(null); + + async function reload() { + setError(null); + setSuccess(null); + setLoading(true); + try { + const p = await apiFetch("/users/me"); + setProfile(p); + } catch (e: any) { + setError(e?.message ?? "Failed to load profile"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void reload(); + }, []); + + async function save() { + setError(null); + setSuccess(null); + try { + await apiPatch("/users/me", { + fullName: profile?.fullName ?? "", + email: profile?.email ?? "", + phone: profile?.phone ?? "", + addressLine1: profile?.addressLine1 ?? "", + addressLine2: profile?.addressLine2 ?? "", + city: profile?.city ?? "", + state: profile?.state ?? "", + country: profile?.country ?? "" + }); + if (avatarFile) { + const form = new FormData(); + form.set("file", avatarFile); + await apiPostForm("/users/me/avatar", form, "PATCH"); + setAvatarFile(null); + } + setSuccess("Profile updated"); + await reload(); + } catch (e: any) { + setError(e?.message ?? "Failed to update profile"); + } + } + + return ( + + {error ?
{error}
: null} + {success ?
{success}
: null} + + +
Account
+
+ setProfile((p: any) => ({ ...p, fullName: e.target.value }))} /> + setProfile((p: any) => ({ ...p, email: e.target.value }))} /> + setProfile((p: any) => ({ ...p, phone: e.target.value }))} /> + setProfile((p: any) => ({ ...p, country: e.target.value }))} /> + setProfile((p: any) => ({ ...p, state: e.target.value }))} /> + setProfile((p: any) => ({ ...p, city: e.target.value }))} /> + setProfile((p: any) => ({ ...p, addressLine1: e.target.value }))} /> + setProfile((p: any) => ({ ...p, addressLine2: e.target.value }))} /> +
+ +
+
+
Avatar
+
Upload a profile picture.
+
+ setAvatarFile(e.target.files?.[0] ?? null)} /> +
+ +
+ +
+
+
+ ); +} + diff --git a/apps/web/src/app/(admin)/qr-codes/page.tsx b/apps/web/src/app/(admin)/qr-codes/page.tsx new file mode 100644 index 0000000..59421b2 --- /dev/null +++ b/apps/web/src/app/(admin)/qr-codes/page.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { AdminShell } from "@/components/admin/AdminShell"; +import { apiFetch } from "@/lib/api"; +import Image from "next/image"; +import { useMemo, useState } from "react"; + +function extractCode(raw: string) { + const input = raw.trim(); + if (!input) return ""; + if (input.startsWith("{") && input.endsWith("}")) { + try { + const obj = JSON.parse(input) as any; + if (obj?.code) return String(obj.code).trim().toUpperCase(); + } catch {} + } + const maybeUrlMatch = input.match(/\/public\/registrations\/([^/?#]+)/i); + if (maybeUrlMatch?.[1]) return decodeURIComponent(maybeUrlMatch[1]).trim().toUpperCase(); + return input.toUpperCase(); +} + +export default function Page() { + const [raw, setRaw] = useState(""); + const code = useMemo(() => extractCode(raw), [raw]); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [dataUrl, setDataUrl] = useState(null); + + const generate = async () => { + setError(null); + setDataUrl(null); + if (!code) return; + setLoading(true); + try { + const res = await apiFetch<{ code: string; dataUrl: string }>(`/public/registrations/${encodeURIComponent(code)}/qrcode`); + setDataUrl(res.dataUrl ?? null); + } catch { + setError("QR code not found."); + } finally { + setLoading(false); + } + }; + + return ( + +
+
+
QR Codes
+
Generate a QR code for a registration code.
+ +
+ setRaw(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void generate(); + }} + /> + +
+ + {error ?
{error}
: null} + + {dataUrl ? ( +
+
Code: {code}
+ {`QR + + Download + +
+ ) : null} +
+
+
+ ); +} diff --git a/apps/web/src/app/(admin)/registrations/page.tsx b/apps/web/src/app/(admin)/registrations/page.tsx new file mode 100644 index 0000000..6df3203 --- /dev/null +++ b/apps/web/src/app/(admin)/registrations/page.tsx @@ -0,0 +1,189 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { AdminShell } from "@/components/admin/AdminShell"; +import { Card } from "@/components/ui/Card"; +import { Button } from "@/components/ui/Button"; +import { apiFetch, apiPost } from "@/lib/api"; + +type EventRow = { id: string; name: string; slug: string; startsAt: string; venue: string; status: string }; +type AttendeeRow = { id: string; fullName: string; email: string }; +type RegistrationRow = { + id: string; + code: string; + status: string; + createdAt: string; + event: EventRow; + attendee: AttendeeRow; +}; + +export default function Page() { + const [rows, setRows] = useState([]); + const [events, setEvents] = useState([]); + const [attendees, setAttendees] = useState([]); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [query, setQuery] = useState(""); + + const [open, setOpen] = useState(false); + const [eventId, setEventId] = useState(""); + const [attendeeId, setAttendeeId] = useState(""); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return rows; + return rows.filter((r) => `${r.code} ${r.attendee.fullName} ${r.attendee.email} ${r.event.name}`.toLowerCase().includes(q)); + }, [rows, query]); + + async function load() { + setError(null); + setLoading(true); + try { + const [regs, evs, ats] = await Promise.all([ + apiFetch("/registrations"), + apiFetch("/events"), + apiFetch("/attendees") + ]); + setRows(regs); + setEvents(evs); + setAttendees(ats); + } catch (e: any) { + setError(e?.message ?? "Failed to load registrations"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void load(); + }, []); + + function openCreate() { + setSaveError(null); + setEventId(events[0]?.id ?? ""); + setAttendeeId(attendees[0]?.id ?? ""); + setOpen(true); + } + + async function create() { + setSaveError(null); + setSaving(true); + try { + await apiPost("/registrations", { eventId, attendeeId }); + setOpen(false); + await load(); + } catch (e: any) { + setSaveError(e?.message ?? "Create failed"); + } finally { + setSaving(false); + } + } + + return ( + +
+
+
+

Registrations

+

Manage live submissions, approvals, confirmations and badge issuance.

+
+ +
+ + +
+ setQuery(e.target.value)} /> + +
+ + {loading ?
Loading...
: null} + {error ?
{error}
: null} + + {!loading && !error ? ( + filtered.length ? ( + + + + + + + + + + + + {filtered.map((r) => ( + + + + + + + + ))} + +
CodeAttendeeEventStatusCreated
{r.code}{r.attendee.fullName}{r.event.name}{r.status}{new Date(r.createdAt).toLocaleString()}
+ ) : ( +
No registrations yet.
+ ) + ) : null} +
+ + {open ? ( +
setOpen(false)}> +
e.stopPropagation()}> +
+
+
Create
+
New Registration
+
+ +
+ +
+
+
Event
+ +
+
+
Attendee
+ +
+
+ + {saveError ?
{saveError}
: null} + +
+ + +
+
+
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/app/(admin)/reports/page.tsx b/apps/web/src/app/(admin)/reports/page.tsx new file mode 100644 index 0000000..08697a5 --- /dev/null +++ b/apps/web/src/app/(admin)/reports/page.tsx @@ -0,0 +1,5 @@ +import { AdminShell } from "@/components/admin/AdminShell"; +import { CrudPage } from "@/components/admin/CrudPage"; +export default function Page() { + return ; +} diff --git a/apps/web/src/app/(admin)/rsvps/page.tsx b/apps/web/src/app/(admin)/rsvps/page.tsx new file mode 100644 index 0000000..eda7913 --- /dev/null +++ b/apps/web/src/app/(admin)/rsvps/page.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { AdminShell } from "@/components/admin/AdminShell"; +import { Card } from "@/components/ui/Card"; +import { Button } from "@/components/ui/Button"; +import { apiFetch } from "@/lib/api"; + +type EventRow = { id: string; name: string; slug: string; startsAt: string; venue: string; status: string }; +type InviteeRow = { id: string; fullName: string; email: string; code: string }; +type RsvpRow = { + id: string; + response: string; + note: string | null; + createdAt: string; + event: EventRow; + invitee: InviteeRow | null; +}; + +export default function Page() { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [query, setQuery] = useState(""); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return rows; + return rows.filter((r) => `${r.response} ${r.invitee?.fullName ?? ""} ${r.invitee?.email ?? ""} ${r.event.name}`.toLowerCase().includes(q)); + }, [rows, query]); + + async function load() { + setError(null); + setLoading(true); + try { + const res = await apiFetch("/rsvps"); + setRows(res); + } catch (e: any) { + setError(e?.message ?? "Failed to load RSVPs"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void load(); + }, []); + + return ( + +
+
+
+

RSVPs

+

Track attendance intent, approvals, declines, waitlists and confirmations.

+
+ +
+ + +
+ setQuery(e.target.value)} /> +
+ + {loading ?
Loading...
: null} + {error ?
{error}
: null} + + {!loading && !error ? ( + filtered.length ? ( + + + + + + + + + + + + {filtered.map((r) => ( + + + + + + + + ))} + +
ResponseInviteeEventNoteCreated
{r.response}{r.invitee ? `${r.invitee.fullName} (${r.invitee.email})` : "-"}{r.event.name}{r.note ?? "-"}{new Date(r.createdAt).toLocaleString()}
+ ) : ( +
No RSVPs yet.
+ ) + ) : null} +
+
+
+ ); +} diff --git a/apps/web/src/app/(admin)/settings/page.tsx b/apps/web/src/app/(admin)/settings/page.tsx new file mode 100644 index 0000000..d1ece32 --- /dev/null +++ b/apps/web/src/app/(admin)/settings/page.tsx @@ -0,0 +1,322 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { AdminShell } from "@/components/admin/AdminShell"; +import { Card } from "@/components/ui/Card"; +import { Button } from "@/components/ui/Button"; +import { apiFetch, apiPatch, apiPost, apiPostForm } from "@/lib/api"; +import { clsx } from "clsx"; + +export default function Page() { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [appName, setAppName] = useState(""); + const [logoUrl, setLogoUrl] = useState(null); + const [logoFile, setLogoFile] = useState(null); + const [modules, setModules] = useState>({}); + + const [permissions, setPermissions] = useState<{ id: string; key: string }[]>([]); + const [roles, setRoles] = useState([]); + const [selectedRoleId, setSelectedRoleId] = useState(""); + const [roleName, setRoleName] = useState(""); + + const [users, setUsers] = useState([]); + const [newUser, setNewUser] = useState({ fullName: "", email: "", password: "", roleIds: [] as string[] }); + + const moduleKeys = useMemo( + () => [ + "events", + "attendees", + "registrations", + "invitees", + "rsvps", + "checkins", + "qrcodes", + "communications", + "payments", + "crm", + "workflows", + "calendar", + "reports" + ], + [] + ); + + async function reload() { + setError(null); + setLoading(true); + try { + const [settings, perms, roles, users] = await Promise.all([ + apiFetch<{ appName: string | null; logoUrl: string | null; modules: Record | null }>("/settings"), + apiFetch<{ id: string; key: string }[]>("/permissions"), + apiFetch("/roles"), + apiFetch("/users") + ]); + setAppName(settings.appName ?? ""); + setLogoUrl(settings.logoUrl ?? null); + setModules(settings.modules ?? {}); + setPermissions(perms); + setRoles(roles); + setUsers(users); + setSelectedRoleId(roles[0]?.id ?? ""); + } catch (e: any) { + setError(e?.message ?? "Failed to load settings"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void reload(); + }, []); + + async function saveBranding() { + setError(null); + try { + await apiPatch("/settings", { appName: appName.trim() || null }); + if (logoFile) { + const form = new FormData(); + form.set("file", logoFile); + const res = await apiPostForm<{ logoUrl: string | null }>("/settings/logo", form, "PATCH"); + setLogoUrl(res.logoUrl ?? null); + setLogoFile(null); + } + await reload(); + } catch (e: any) { + setError(e?.message ?? "Failed to save branding"); + } + } + + async function saveModules() { + setError(null); + try { + await apiPatch("/settings", { modules }); + await reload(); + } catch (e: any) { + setError(e?.message ?? "Failed to save modules"); + } + } + + async function createRole() { + setError(null); + try { + const r = await apiPost("/roles", { name: roleName.trim(), permissionKeys: [] }); + setRoleName(""); + setSelectedRoleId(r.id); + await reload(); + } catch (e: any) { + setError(e?.message ?? "Failed to create role"); + } + } + + const selectedRole = useMemo(() => roles.find((r) => r.id === selectedRoleId) ?? null, [roles, selectedRoleId]); + const selectedRoleKeys = useMemo(() => new Set((selectedRole?.permissions ?? []).map((rp: any) => rp.permission?.key)), [selectedRole]); + + async function toggleRolePermission(key: string) { + if (!selectedRole) return; + const next = new Set(Array.from(selectedRoleKeys)); + if (next.has(key)) next.delete(key); + else next.add(key); + setError(null); + try { + await apiFetch(`/roles/${selectedRole.id}/permissions`, { method: "PUT", body: JSON.stringify({ permissionKeys: Array.from(next) }) }); + await reload(); + } catch (e: any) { + setError(e?.message ?? "Failed to update role permissions"); + } + } + + async function deleteRole(id: string) { + setError(null); + try { + await apiFetch(`/roles/${id}`, { method: "DELETE" }); + await reload(); + } catch (e: any) { + setError(e?.message ?? "Failed to delete role"); + } + } + + async function createUser() { + setError(null); + try { + await apiPost("/users", { ...newUser, email: newUser.email.trim(), fullName: newUser.fullName.trim() }); + setNewUser({ fullName: "", email: "", password: "", roleIds: [] }); + await reload(); + } catch (e: any) { + setError(e?.message ?? "Failed to create user"); + } + } + + async function deleteUser(id: string) { + setError(null); + try { + await apiFetch(`/users/${id}`, { method: "DELETE" }); + await reload(); + } catch (e: any) { + setError(e?.message ?? "Failed to delete user"); + } + } + + return ( + + {error ?
{error}
: null} + +
+ +
Branding
+
+ setAppName(e.target.value)} /> +
+ setLogoFile(e.target.files?.[0] ?? null)} /> + {logoUrl ?
Current logo set
:
No logo uploaded
} +
+
+ +
+
+
+ + +
Modules
+
+ {moduleKeys.map((k) => ( + + ))} +
+ +
+
+
+ + +
+
+
Roles & Permissions
+
Granular access control for admin users.
+
+
+ setRoleName(e.target.value)} /> + +
+
+ +
+
+
Roles
+
+ {roles.map((r) => ( + + ))} +
+
+ +
+
Permissions
+
+ {permissions.map((p) => ( + + ))} +
+
+
+
+ + +
Users
+
+
+
Create User
+
+ setNewUser((u) => ({ ...u, fullName: e.target.value }))} /> + setNewUser((u) => ({ ...u, email: e.target.value }))} /> + setNewUser((u) => ({ ...u, password: e.target.value }))} /> +
+
Roles
+
+ {roles.map((r) => ( + + ))} +
+
+
+ +
+
+
+ +
+
Existing Users
+
+ {users.length ? users.map((u) => ( +
+
+
{u.fullName}
+
{u.email}
+
{(u.roles ?? []).map((r: any) => r.role?.name ?? r.name).join(", ")}
+
+ +
+ )) : ( +
No users yet
+ )} +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/(admin)/whatsapp-campaigns/page.tsx b/apps/web/src/app/(admin)/whatsapp-campaigns/page.tsx new file mode 100644 index 0000000..436cb3b --- /dev/null +++ b/apps/web/src/app/(admin)/whatsapp-campaigns/page.tsx @@ -0,0 +1,5 @@ +import { AdminShell } from "@/components/admin/AdminShell"; +import { CrudPage } from "@/components/admin/CrudPage"; +export default function Page() { + return ; +} diff --git a/apps/web/src/app/(public)/change-password/page.tsx b/apps/web/src/app/(public)/change-password/page.tsx new file mode 100644 index 0000000..79faf4b --- /dev/null +++ b/apps/web/src/app/(public)/change-password/page.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { PublicShell } from "@/components/public/PublicShell"; +import { Card } from "@/components/ui/Card"; +import { Button } from "@/components/ui/Button"; +import { apiPost, apiFetch } from "@/lib/api"; + +export default function Page() { + const router = useRouter(); + const [oldPassword, setOldPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function submit() { + setError(null); + setLoading(true); + try { + await apiPost("/auth/change-password", { oldPassword, newPassword }); + await apiFetch("/auth/me"); + router.push("/dashboard"); + } catch (e: any) { + setError(e?.message ?? "Password change failed"); + } finally { + setLoading(false); + } + } + + return ( + +
+ +
Security
+

Change password

+

For first login, you must change your password before continuing.

+ +
+ setOldPassword(e.target.value)} /> + setNewPassword(e.target.value)} /> +
+ + {error ?
{error}
: null} + +
+ +
+
+
+
+ ); +} + diff --git a/apps/web/src/app/(public)/confirmation/[slug]/page.tsx b/apps/web/src/app/(public)/confirmation/[slug]/page.tsx new file mode 100644 index 0000000..75b8c42 --- /dev/null +++ b/apps/web/src/app/(public)/confirmation/[slug]/page.tsx @@ -0,0 +1,44 @@ +"use client"; + +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { Suspense } from "react"; +import { FlowPage } from "@/components/public/FlowPage"; +import { Button } from "@/components/ui/Button"; + +function ConfirmationContent({ params }: { params: { slug: string } }) { + const search = useSearchParams(); + const code = search.get("code"); + + return ( + +
+
+
Access Code
+
{code ?? "-"}
+
+ +
+ Your registration is received. Save your code for check-in and e-ticket retrieval. +
+ +
+ {code ? ( + + ) : ( + + )} + +
+
+
+ ); +} + +export default function Page({ params }: { params: { slug: string } }) { + return ( + + + + ); +} diff --git a/apps/web/src/app/(public)/e-ticket/[code]/page.tsx b/apps/web/src/app/(public)/e-ticket/[code]/page.tsx new file mode 100644 index 0000000..a76c37d --- /dev/null +++ b/apps/web/src/app/(public)/e-ticket/[code]/page.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import Image from "next/image"; +import { FlowPage } from "@/components/public/FlowPage"; +import { Button } from "@/components/ui/Button"; +import { apiFetch } from "@/lib/api"; + +type TicketPayload = { + registration: { + id: string; + code: string; + status: string; + createdAt: string; + event: { id: string; name: string; slug: string; startsAt: string; venue: string }; + attendee: { id: string; fullName: string; email: string; phone: string | null }; + }; +}; + +export default function Page({ params }: { params: { code: string } }) { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [payload, setPayload] = useState(null); + const [qr, setQr] = useState(null); + + useEffect(() => { + setLoading(true); + setError(null); + void apiFetch(`/public/registrations/${params.code}`) + .then((res) => setPayload(res.registration)) + .catch((e: any) => setError(e?.message ?? "Failed to load ticket")) + .finally(() => setLoading(false)); + + void apiFetch<{ code: string; dataUrl: string }>(`/public/registrations/${params.code}/qrcode`) + .then((res) => setQr(res.dataUrl)) + .catch(() => setQr(null)); + }, [params.code]); + + return ( + + {loading ?
Loading...
: null} + {error ?
{error}
: null} + + {!loading && !error && payload ? ( +
+
+
Access Code
+
{payload.code}
+
+ {payload.event.name} · {new Date(payload.event.startsAt).toLocaleString()} · {payload.event.venue} +
+
+ {payload.attendee.fullName} · {payload.attendee.email} +
+
+ + {qr ? ( +
+ QR Code +
+ ) : ( +
QR not available.
+ )} + +
+ + +
+
+ ) : null} +
+ ); +} diff --git a/apps/web/src/app/(public)/events/[slug]/page.tsx b/apps/web/src/app/(public)/events/[slug]/page.tsx new file mode 100644 index 0000000..d2de4e3 --- /dev/null +++ b/apps/web/src/app/(public)/events/[slug]/page.tsx @@ -0,0 +1,6 @@ +import { PublicShell } from "@/components/public/PublicShell"; +import { EventLanding } from "@/components/public/EventLanding"; + +export default function Page({ params }: { params: { slug: string } }) { + return ; +} diff --git a/apps/web/src/app/(public)/invite/[code]/page.tsx b/apps/web/src/app/(public)/invite/[code]/page.tsx new file mode 100644 index 0000000..c941924 --- /dev/null +++ b/apps/web/src/app/(public)/invite/[code]/page.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { FlowPage } from "@/components/public/FlowPage"; +import { Button } from "@/components/ui/Button"; +import { apiFetch, apiPost } from "@/lib/api"; + +type InvitePayload = { + invitee: { + code: string; + fullName: string; + email: string; + phone: string | null; + status: string; + }; + event: { + id: string; + name: string; + slug: string; + startsAt: string; + venue: string; + status: string; + }; +}; + +export default function Page({ params }: { params: { code: string } }) { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [payload, setPayload] = useState(null); + + const [response, setResponse] = useState<"yes" | "no" | "maybe">("yes"); + const [note, setNote] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [submitted, setSubmitted] = useState(false); + + useEffect(() => { + setLoading(true); + setError(null); + void apiFetch(`/public/invitees/${params.code}`) + .then((res) => setPayload(res)) + .catch((e: any) => setError(e?.message ?? "Failed to load invite")) + .finally(() => setLoading(false)); + }, [params.code]); + + async function submit() { + setError(null); + setSubmitting(true); + try { + await apiPost(`/public/invitees/${params.code}/rsvp`, { + response, + note: note.trim().length ? note : undefined + }); + setSubmitted(true); + } catch (e: any) { + setError(e?.message ?? "RSVP failed"); + } finally { + setSubmitting(false); + } + } + + const title = loading ? "Loading..." : submitted ? "RSVP Received" : "RSVP"; + + return ( + + {error ?
{error}
: null} + + {!loading && payload ? ( +
+
+
Invitation
+
{payload.event.name}
+
+ {new Date(payload.event.startsAt).toLocaleString()} · {payload.event.venue} +
+
+ {payload.invitee.fullName} · {payload.invitee.email} +
+
+ + {submitted ? ( +
+
+ Thank you. Your response was recorded as {response.toUpperCase()}. +
+
+ ) : ( + <> +
+ + + +
+ +