Productionize EventSphere platform
This commit is contained in:
20
apps/api/Dockerfile
Normal file
20
apps/api/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /repo
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY apps/api/package.json apps/api/package.json
|
||||
RUN pnpm install --frozen-lockfile --filter api...
|
||||
|
||||
COPY apps/api apps/api
|
||||
RUN pnpm --filter api prisma:generate
|
||||
RUN pnpm --filter api build
|
||||
|
||||
WORKDIR /repo/apps/api
|
||||
|
||||
ENV PORT=4000
|
||||
EXPOSE 4000
|
||||
|
||||
CMD ["pnpm", "start"]
|
||||
24
apps/api/eslint.config.mjs
Normal file
24
apps/api/eslint.config.mjs
Normal file
@@ -0,0 +1,24 @@
|
||||
import js from "@eslint/js";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default [
|
||||
{ ignores: ["dist/**", "node_modules/**"] },
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ["**/*.ts"],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: "./tsconfig.json",
|
||||
tsconfigRootDir: __dirname
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "off"
|
||||
}
|
||||
}
|
||||
];
|
||||
55
apps/api/package.json
Normal file
55
apps/api/package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "api",
|
||||
"scripts": {
|
||||
"dev": "nest start --watch",
|
||||
"build": "nest build",
|
||||
"start": "prisma migrate deploy --schema prisma/schema.prisma && node dist/main.js",
|
||||
"worker": "node dist/worker.js",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"prisma:generate": "prisma generate --schema prisma/schema.prisma",
|
||||
"prisma:migrate:dev": "prisma migrate dev --schema prisma/schema.prisma",
|
||||
"prisma:migrate:deploy": "prisma migrate deploy --schema prisma/schema.prisma"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.4.6",
|
||||
"@nestjs/core": "^10.4.6",
|
||||
"@nestjs/platform-express": "^10.4.6",
|
||||
"@nestjs/config": "^3.2.3",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/swagger": "^7.4.2",
|
||||
"@nestjs/throttler": "^6.4.0",
|
||||
"@prisma/client": "^6.16.2",
|
||||
"bullmq": "^5.58.3",
|
||||
"bcrypt": "^5.1.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"ioredis": "^5.6.1",
|
||||
"multer": "^2.0.2",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"nodemailer": "^6.9.15",
|
||||
"qrcode": "^1.5.4",
|
||||
"axios": "^1.7.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.5",
|
||||
"@eslint/js": "^9.22.0",
|
||||
"@types/node": "^22.8.6",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cookie-parser": "^1.4.9",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/swagger-ui-express": "^4.1.8",
|
||||
"eslint": "^9.22.0",
|
||||
"prisma": "^6.16.2",
|
||||
"typescript-eslint": "^8.26.1",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
1
apps/api/prisma/migrations/migration_lock.toml
Normal file
1
apps/api/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1 @@
|
||||
provider = "postgresql"
|
||||
782
apps/api/prisma/schema.prisma
Normal file
782
apps/api/prisma/schema.prisma
Normal file
@@ -0,0 +1,782 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Tenant {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
slug String @unique
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
users User[]
|
||||
roles Role[]
|
||||
events Event[]
|
||||
attendees Attendee[]
|
||||
registrations Registration[]
|
||||
invitees Invitee[]
|
||||
rsvps RSVP[]
|
||||
payments PaymentTransaction[]
|
||||
settings TenantSetting?
|
||||
integrations IntegrationSetting[]
|
||||
eventPages EventPage[]
|
||||
ticketTypes TicketType[]
|
||||
forms Form[]
|
||||
formSubmissions FormSubmission[]
|
||||
qrCodes QRCode[]
|
||||
checkIns CheckIn[]
|
||||
communicationTemplates CommunicationTemplate[]
|
||||
communicationLogs CommunicationLog[]
|
||||
paystackWebhookEvents PaystackWebhookEvent[]
|
||||
crmLeads CRMLead[]
|
||||
crmDeals CRMDeal[]
|
||||
crmActivities CRMActivity[]
|
||||
workflows Workflow[]
|
||||
workflowTriggers WorkflowTrigger[]
|
||||
workflowActions WorkflowAction[]
|
||||
calendarRoutingForms CalendarRoutingForm[]
|
||||
calendarSlots CalendarSlot[]
|
||||
bookings Booking[]
|
||||
formFields FormField[]
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
email String
|
||||
fullName String
|
||||
phone String?
|
||||
addressLine1 String?
|
||||
addressLine2 String?
|
||||
city String?
|
||||
state String?
|
||||
country String?
|
||||
avatarUrl String?
|
||||
mustChangePassword Boolean @default(false)
|
||||
passwordHash String
|
||||
refreshTokenId String? @unique
|
||||
refreshTokenHash String?
|
||||
refreshTokenExpiresAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
roles UserRole[]
|
||||
performedCheckIns CheckIn[] @relation("CheckInUser")
|
||||
crmActivities CRMActivity[] @relation("CRMActivityUser")
|
||||
|
||||
@@unique([tenantId, email])
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
model TenantSetting {
|
||||
id String @id @default(cuid())
|
||||
tenantId String @unique
|
||||
appName String?
|
||||
logoUrl String?
|
||||
modules Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model IntegrationSetting {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
key String
|
||||
value String?
|
||||
isSecret Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([tenantId, key])
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
model Role {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
users UserRole[]
|
||||
permissions RolePermission[]
|
||||
|
||||
@@unique([tenantId, name])
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
model Permission {
|
||||
id String @id @default(cuid())
|
||||
key String @unique
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
roles RolePermission[]
|
||||
}
|
||||
|
||||
model UserRole {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
roleId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, roleId])
|
||||
}
|
||||
|
||||
model RolePermission {
|
||||
id String @id @default(cuid())
|
||||
roleId String
|
||||
permissionId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([roleId, permissionId])
|
||||
}
|
||||
|
||||
enum EventStatus {
|
||||
draft
|
||||
active
|
||||
closed
|
||||
}
|
||||
|
||||
model Event {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
name String
|
||||
slug String
|
||||
status EventStatus @default(draft)
|
||||
startsAt DateTime
|
||||
venue String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
attendees Attendee[]
|
||||
registrations Registration[]
|
||||
invitees Invitee[]
|
||||
rsvps RSVP[]
|
||||
payments PaymentTransaction[]
|
||||
eventPage EventPage?
|
||||
ticketTypes TicketType[]
|
||||
forms Form[]
|
||||
formSubmissions FormSubmission[]
|
||||
checkIns CheckIn[]
|
||||
crmLeads CRMLead[]
|
||||
crmDeals CRMDeal[]
|
||||
calendarRoutingForms CalendarRoutingForm[]
|
||||
bookings Booking[]
|
||||
|
||||
@@unique([tenantId, slug])
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, status])
|
||||
}
|
||||
|
||||
enum RegistrationStatus {
|
||||
pending
|
||||
confirmed
|
||||
cancelled
|
||||
}
|
||||
|
||||
model Registration {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
eventId String
|
||||
attendeeId String
|
||||
ticketTypeId String?
|
||||
status RegistrationStatus @default(pending)
|
||||
code String @unique
|
||||
source String @default("admin")
|
||||
checkedInAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
attendee Attendee @relation(fields: [attendeeId], references: [id], onDelete: Cascade)
|
||||
ticketType TicketType? @relation(fields: [ticketTypeId], references: [id], onDelete: SetNull)
|
||||
qrCode QRCode?
|
||||
checkIns CheckIn[]
|
||||
formSubmissions FormSubmission[]
|
||||
communicationLogs CommunicationLog[]
|
||||
payments PaymentTransaction[]
|
||||
|
||||
@@unique([tenantId, eventId, attendeeId])
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, eventId])
|
||||
@@index([eventId])
|
||||
@@index([ticketTypeId])
|
||||
}
|
||||
|
||||
enum InviteeStatus {
|
||||
invited
|
||||
delivered
|
||||
opened
|
||||
rsvped
|
||||
bounced
|
||||
}
|
||||
|
||||
model Invitee {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
eventId String
|
||||
fullName String
|
||||
email String
|
||||
phone String?
|
||||
status InviteeStatus @default(invited)
|
||||
code String @unique
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
rsvps RSVP[]
|
||||
communicationLogs CommunicationLog[]
|
||||
|
||||
@@unique([tenantId, eventId, email])
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, eventId])
|
||||
}
|
||||
|
||||
enum RSVPResponse {
|
||||
yes
|
||||
no
|
||||
maybe
|
||||
}
|
||||
|
||||
model RSVP {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
eventId String
|
||||
inviteeId String?
|
||||
response RSVPResponse
|
||||
note String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
invitee Invitee? @relation(fields: [inviteeId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, eventId])
|
||||
@@index([inviteeId])
|
||||
}
|
||||
|
||||
enum PaymentStatus {
|
||||
initialized
|
||||
success
|
||||
failed
|
||||
}
|
||||
|
||||
model PaymentTransaction {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
eventId String
|
||||
registrationId String?
|
||||
ticketTypeId String?
|
||||
email String
|
||||
amountKobo Int
|
||||
currency String @default("NGN")
|
||||
provider String @default("paystack")
|
||||
reference String @unique
|
||||
status PaymentStatus @default(initialized)
|
||||
raw Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull)
|
||||
ticketType TicketType? @relation(fields: [ticketTypeId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, eventId])
|
||||
@@index([eventId])
|
||||
@@index([registrationId])
|
||||
@@index([ticketTypeId])
|
||||
}
|
||||
|
||||
model Attendee {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
eventId String?
|
||||
fullName String
|
||||
email String
|
||||
phone String?
|
||||
tags String[] @default([])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull)
|
||||
registrations Registration[]
|
||||
formSubmissions FormSubmission[]
|
||||
checkIns CheckIn[]
|
||||
crmLeads CRMLead[]
|
||||
bookings Booking[]
|
||||
|
||||
@@unique([tenantId, email])
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, eventId])
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
tenantId String?
|
||||
actorUserId String?
|
||||
action String
|
||||
entityType String?
|
||||
entityId String?
|
||||
ip String?
|
||||
userAgent String?
|
||||
metadata Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([actorUserId])
|
||||
@@index([entityType, entityId])
|
||||
}
|
||||
|
||||
model EventPage {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
eventId String @unique
|
||||
title String
|
||||
heroTitle String?
|
||||
description String?
|
||||
content Json?
|
||||
theme Json?
|
||||
publishedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
model TicketType {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
eventId String
|
||||
name String
|
||||
description String?
|
||||
priceKobo Int @default(0)
|
||||
currency String @default("NGN")
|
||||
capacity Int?
|
||||
soldCount Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
registrations Registration[]
|
||||
payments PaymentTransaction[]
|
||||
|
||||
@@unique([tenantId, eventId, name])
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, eventId])
|
||||
}
|
||||
|
||||
model Form {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
eventId String?
|
||||
name String
|
||||
slug String
|
||||
description String?
|
||||
isPublic Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull)
|
||||
fields FormField[]
|
||||
submissions FormSubmission[]
|
||||
|
||||
@@unique([tenantId, slug])
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, eventId])
|
||||
}
|
||||
|
||||
model FormField {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
formId String
|
||||
label String
|
||||
key String
|
||||
type String
|
||||
required Boolean @default(false)
|
||||
options Json?
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([formId, key])
|
||||
@@index([tenantId])
|
||||
@@index([formId])
|
||||
}
|
||||
|
||||
model FormSubmission {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
formId String
|
||||
eventId String?
|
||||
attendeeId String?
|
||||
registrationId String?
|
||||
data Json
|
||||
source String @default("admin")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull)
|
||||
attendee Attendee? @relation(fields: [attendeeId], references: [id], onDelete: SetNull)
|
||||
registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([formId])
|
||||
@@index([eventId])
|
||||
@@index([attendeeId])
|
||||
@@index([registrationId])
|
||||
}
|
||||
|
||||
model QRCode {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
registrationId String @unique
|
||||
code String @unique
|
||||
payload Json
|
||||
status QRCodeStatus @default(active)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
registration Registration @relation(fields: [registrationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
enum QRCodeStatus {
|
||||
active
|
||||
revoked
|
||||
}
|
||||
|
||||
model CheckIn {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
eventId String
|
||||
registrationId String
|
||||
attendeeId String?
|
||||
checkedInByUserId String?
|
||||
code String
|
||||
result CheckInResult @default(checked_in)
|
||||
note String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
registration Registration @relation(fields: [registrationId], references: [id], onDelete: Cascade)
|
||||
attendee Attendee? @relation(fields: [attendeeId], references: [id], onDelete: SetNull)
|
||||
checkedInBy User? @relation("CheckInUser", fields: [checkedInByUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, eventId])
|
||||
@@index([registrationId])
|
||||
@@index([attendeeId])
|
||||
}
|
||||
|
||||
enum CheckInResult {
|
||||
checked_in
|
||||
duplicate
|
||||
rejected
|
||||
}
|
||||
|
||||
model CommunicationTemplate {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
name String
|
||||
channel CommunicationChannel
|
||||
subject String?
|
||||
body String
|
||||
variables Json?
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
logs CommunicationLog[]
|
||||
|
||||
@@unique([tenantId, name, channel])
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
model CommunicationLog {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
templateId String?
|
||||
inviteeId String?
|
||||
registrationId String?
|
||||
channel CommunicationChannel
|
||||
to String
|
||||
subject String?
|
||||
body String
|
||||
status CommunicationStatus @default(queued)
|
||||
providerMessageId String?
|
||||
error String?
|
||||
raw Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
template CommunicationTemplate? @relation(fields: [templateId], references: [id], onDelete: SetNull)
|
||||
invitee Invitee? @relation(fields: [inviteeId], references: [id], onDelete: SetNull)
|
||||
registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([templateId])
|
||||
@@index([inviteeId])
|
||||
@@index([registrationId])
|
||||
}
|
||||
|
||||
enum CommunicationChannel {
|
||||
email
|
||||
sms
|
||||
whatsapp
|
||||
}
|
||||
|
||||
enum CommunicationStatus {
|
||||
queued
|
||||
sent
|
||||
failed
|
||||
}
|
||||
|
||||
model PaystackWebhookEvent {
|
||||
id String @id @default(cuid())
|
||||
tenantId String?
|
||||
event String
|
||||
reference String?
|
||||
status String @default("received")
|
||||
raw Json
|
||||
receivedAt DateTime @default(now())
|
||||
processedAt DateTime?
|
||||
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([reference])
|
||||
}
|
||||
|
||||
model CRMLead {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
eventId String?
|
||||
attendeeId String?
|
||||
fullName String
|
||||
email String
|
||||
phone String?
|
||||
source String @default("manual")
|
||||
status String @default("new")
|
||||
valueKobo Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull)
|
||||
attendee Attendee? @relation(fields: [attendeeId], references: [id], onDelete: SetNull)
|
||||
deals CRMDeal[]
|
||||
activities CRMActivity[]
|
||||
|
||||
@@unique([tenantId, email])
|
||||
@@index([tenantId])
|
||||
@@index([eventId])
|
||||
@@index([attendeeId])
|
||||
}
|
||||
|
||||
model CRMDeal {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
leadId String?
|
||||
eventId String?
|
||||
title String
|
||||
stage String @default("new")
|
||||
valueKobo Int @default(0)
|
||||
probability Int @default(0)
|
||||
ownerUserId String?
|
||||
expectedCloseAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
lead CRMLead? @relation(fields: [leadId], references: [id], onDelete: SetNull)
|
||||
event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull)
|
||||
activities CRMActivity[]
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([leadId])
|
||||
@@index([eventId])
|
||||
}
|
||||
|
||||
model CRMActivity {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
leadId String?
|
||||
dealId String?
|
||||
userId String?
|
||||
type String @default("note")
|
||||
note String
|
||||
dueAt DateTime?
|
||||
completedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
lead CRMLead? @relation(fields: [leadId], references: [id], onDelete: SetNull)
|
||||
deal CRMDeal? @relation(fields: [dealId], references: [id], onDelete: SetNull)
|
||||
user User? @relation("CRMActivityUser", fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([leadId])
|
||||
@@index([dealId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model Workflow {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
name String
|
||||
description String?
|
||||
enabled Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
triggers WorkflowTrigger[]
|
||||
actions WorkflowAction[]
|
||||
|
||||
@@unique([tenantId, name])
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
model WorkflowTrigger {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
workflowId String
|
||||
type String
|
||||
config Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([workflowId])
|
||||
}
|
||||
|
||||
model WorkflowAction {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
workflowId String
|
||||
type String
|
||||
config Json?
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([workflowId])
|
||||
}
|
||||
|
||||
model CalendarRoutingForm {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
eventId String?
|
||||
name String
|
||||
slug String
|
||||
description String?
|
||||
durationMinutes Int @default(30)
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull)
|
||||
slots CalendarSlot[]
|
||||
bookings Booking[]
|
||||
|
||||
@@unique([tenantId, slug])
|
||||
@@index([tenantId])
|
||||
@@index([eventId])
|
||||
}
|
||||
|
||||
model CalendarSlot {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
routingFormId String
|
||||
startsAt DateTime
|
||||
endsAt DateTime
|
||||
capacity Int @default(1)
|
||||
bookedCount Int @default(0)
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
routingForm CalendarRoutingForm @relation(fields: [routingFormId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([routingFormId])
|
||||
@@index([startsAt])
|
||||
}
|
||||
|
||||
model Booking {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
routingFormId String
|
||||
eventId String?
|
||||
attendeeId String?
|
||||
fullName String
|
||||
email String
|
||||
phone String?
|
||||
startsAt DateTime
|
||||
endsAt DateTime
|
||||
status String @default("booked")
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
routingForm CalendarRoutingForm @relation(fields: [routingFormId], references: [id], onDelete: Cascade)
|
||||
event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull)
|
||||
attendee Attendee? @relation(fields: [attendeeId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([routingFormId])
|
||||
@@index([eventId])
|
||||
@@index([attendeeId])
|
||||
@@index([startsAt])
|
||||
}
|
||||
69
apps/api/src/app.module.ts
Normal file
69
apps/api/src/app.module.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { PrismaModule } from "./prisma/prisma.module";
|
||||
import { EventsModule } from "./modules/events/events.module";
|
||||
import { AttendeesModule } from "./modules/attendees/attendees.module";
|
||||
import { RegistrationsModule } from "./modules/registrations/registrations.module";
|
||||
import { InviteesModule } from "./modules/invitees/invitees.module";
|
||||
import { RsvpsModule } from "./modules/rsvps/rsvps.module";
|
||||
import { CommunicationsModule } from "./modules/communications/communications.module";
|
||||
import { PaymentsModule } from "./modules/payments/payments.module";
|
||||
import { CrmModule } from "./modules/crm/crm.module";
|
||||
import { WorkflowsModule } from "./modules/workflows/workflows.module";
|
||||
import { QrcodeModule } from "./modules/qrcode/qrcode.module";
|
||||
import { AuthModule } from "./modules/auth/auth.module";
|
||||
import { TenantsModule } from "./modules/tenants/tenants.module";
|
||||
import { HealthModule } from "./modules/health/health.module";
|
||||
import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
|
||||
import { APP_GUARD } from "@nestjs/core";
|
||||
import { AuditModule } from "./modules/audit/audit.module";
|
||||
import { QueuesModule } from "./modules/queues/queues.module";
|
||||
import { ReportsModule } from "./modules/reports/reports.module";
|
||||
import { SettingsModule } from "./modules/settings/settings.module";
|
||||
import { IntegrationsModule } from "./modules/integrations/integrations.module";
|
||||
import { UsersModule } from "./modules/users/users.module";
|
||||
import { RolesModule } from "./modules/roles/roles.module";
|
||||
import { FormsModule } from "./modules/forms/forms.module";
|
||||
import { CalendarModule } from "./modules/calendar/calendar.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
ttl: 60_000,
|
||||
limit: 120
|
||||
}
|
||||
]),
|
||||
PrismaModule,
|
||||
AuthModule,
|
||||
AuditModule,
|
||||
QueuesModule,
|
||||
TenantsModule,
|
||||
HealthModule,
|
||||
EventsModule,
|
||||
AttendeesModule,
|
||||
RegistrationsModule,
|
||||
InviteesModule,
|
||||
RsvpsModule,
|
||||
CommunicationsModule,
|
||||
PaymentsModule,
|
||||
CrmModule,
|
||||
WorkflowsModule,
|
||||
QrcodeModule,
|
||||
ReportsModule,
|
||||
SettingsModule,
|
||||
IntegrationsModule,
|
||||
UsersModule,
|
||||
RolesModule,
|
||||
FormsModule,
|
||||
CalendarModule
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: ThrottlerGuard
|
||||
}
|
||||
]
|
||||
})
|
||||
export class AppModule {}
|
||||
29
apps/api/src/common/filters/all-exceptions.filter.ts
Normal file
29
apps/api/src/common/filters/all-exceptions.filter.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ArgumentsHost, Catch, HttpException, HttpStatus } from "@nestjs/common";
|
||||
import type { ExceptionFilter } from "@nestjs/common";
|
||||
|
||||
@Catch()
|
||||
export class AllExceptionsFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const res = ctx.getResponse();
|
||||
const req = ctx.getRequest();
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const path = req?.originalUrl ?? req?.url;
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
const statusCode = exception.getStatus();
|
||||
const response = exception.getResponse() as any;
|
||||
const message = Array.isArray(response?.message) ? response.message.join(", ") : response?.message ?? exception.message;
|
||||
res.status(statusCode).json({ statusCode, message, path, timestamp });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
|
||||
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
message: "Internal server error",
|
||||
path,
|
||||
timestamp
|
||||
});
|
||||
}
|
||||
}
|
||||
46
apps/api/src/main.ts
Normal file
46
apps/api/src/main.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { AppModule } from "./app.module";
|
||||
import { ValidationPipe } from "@nestjs/common";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||
import { AllExceptionsFilter } from "./common/filters/all-exceptions.filter";
|
||||
import express from "express";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, { bodyParser: false });
|
||||
const corsOrigins = (process.env.CORS_ORIGINS ?? process.env.PUBLIC_WEB_URL ?? "")
|
||||
.split(",")
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean);
|
||||
app.enableCors({ origin: corsOrigins.length ? corsOrigins : true, credentials: true });
|
||||
app.setGlobalPrefix("api/v1");
|
||||
app.use(
|
||||
express.json({
|
||||
verify: (req: any, _res, buf) => {
|
||||
req.rawBody = buf;
|
||||
}
|
||||
})
|
||||
);
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
const uploadsDir = process.env.UPLOADS_DIR ?? path.join(process.cwd(), "uploads");
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
app.use("/uploads", express.static(uploadsDir));
|
||||
app.use(cookieParser());
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true
|
||||
})
|
||||
);
|
||||
app.useGlobalFilters(new AllExceptionsFilter());
|
||||
|
||||
const config = new DocumentBuilder().setTitle("EventSphere API").setVersion("v1").build();
|
||||
const doc = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup("api", app, doc);
|
||||
|
||||
await app.listen(process.env.PORT || 4000);
|
||||
}
|
||||
bootstrap();
|
||||
50
apps/api/src/modules/attendees/attendees.controller.ts
Normal file
50
apps/api/src/modules/attendees/attendees.controller.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { AttendeesService } from "./attendees.service";
|
||||
import { CreateAttendeeDto, UpdateAttendeeDto } from "./dto";
|
||||
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
|
||||
|
||||
@Controller("attendees")
|
||||
export class AttendeesController {
|
||||
constructor(private readonly service: AttendeesService) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("attendees.read")
|
||||
@Get()
|
||||
findAll(@Req() req: Request) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.findAll(tenantId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("attendees.write")
|
||||
@Post()
|
||||
create(@Req() req: Request, @Body() dto: CreateAttendeeDto) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.create(tenantId, dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("attendees.read")
|
||||
@Get(":id")
|
||||
findOne(@Req() req: Request, @Param("id") id: string) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.findOne(tenantId, id);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("attendees.write")
|
||||
@Patch(":id")
|
||||
update(@Req() req: Request, @Param("id") id: string, @Body() dto: UpdateAttendeeDto) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.update(tenantId, id, dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("attendees.write")
|
||||
@Delete(":id")
|
||||
remove(@Req() req: Request, @Param("id") id: string) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.remove(tenantId, id);
|
||||
}
|
||||
}
|
||||
5
apps/api/src/modules/attendees/attendees.module.ts
Normal file
5
apps/api/src/modules/attendees/attendees.module.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AttendeesController } from "./attendees.controller";
|
||||
import { AttendeesService } from "./attendees.service";
|
||||
@Module({ controllers: [AttendeesController], providers: [AttendeesService], exports: [AttendeesService] })
|
||||
export class AttendeesModule {}
|
||||
47
apps/api/src/modules/attendees/attendees.service.ts
Normal file
47
apps/api/src/modules/attendees/attendees.service.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import type { CreateAttendeeDto, UpdateAttendeeDto } from "./dto";
|
||||
|
||||
@Injectable()
|
||||
export class AttendeesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findAll(tenantId: string) {
|
||||
return this.prisma.attendee.findMany({ where: { tenantId }, orderBy: { createdAt: "desc" } });
|
||||
}
|
||||
|
||||
async findOne(tenantId: string, id: string) {
|
||||
const row = await this.prisma.attendee.findFirst({ where: { tenantId, id } });
|
||||
if (!row) throw new NotFoundException("Attendee not found");
|
||||
return row;
|
||||
}
|
||||
|
||||
async create(tenantId: string, dto: CreateAttendeeDto) {
|
||||
try {
|
||||
return await this.prisma.attendee.create({
|
||||
data: { tenantId, fullName: dto.fullName, email: dto.email.toLowerCase(), phone: dto.phone }
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (String(e?.code) === "P2002") throw new BadRequestException("Attendee email already exists");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async update(tenantId: string, id: string, dto: UpdateAttendeeDto) {
|
||||
await this.findOne(tenantId, id);
|
||||
return this.prisma.attendee.update({
|
||||
where: { id },
|
||||
data: {
|
||||
fullName: dto.fullName,
|
||||
email: dto.email ? dto.email.toLowerCase() : undefined,
|
||||
phone: dto.phone
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async remove(tenantId: string, id: string) {
|
||||
await this.findOne(tenantId, id);
|
||||
await this.prisma.attendee.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
29
apps/api/src/modules/attendees/dto.ts
Normal file
29
apps/api/src/modules/attendees/dto.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { IsEmail, IsNotEmpty, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class CreateAttendeeDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
fullName!: string;
|
||||
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export class UpdateAttendeeDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
fullName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
}
|
||||
8
apps/api/src/modules/audit/audit.module.ts
Normal file
8
apps/api/src/modules/audit/audit.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuditService } from "./audit.service";
|
||||
|
||||
@Module({
|
||||
providers: [AuditService],
|
||||
exports: [AuditService]
|
||||
})
|
||||
export class AuditModule {}
|
||||
29
apps/api/src/modules/audit/audit.service.ts
Normal file
29
apps/api/src/modules/audit/audit.service.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async log(action: string, req?: Request, options?: { tenantId?: string; actorUserId?: string; entityType?: string; entityId?: string; metadata?: any }) {
|
||||
const ip =
|
||||
(req?.headers?.["x-forwarded-for"] as string | undefined)?.split(",")[0]?.trim() ??
|
||||
(req as any)?.ip ??
|
||||
undefined;
|
||||
const userAgent = (req?.headers?.["user-agent"] as string | undefined) ?? undefined;
|
||||
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
action,
|
||||
tenantId: options?.tenantId,
|
||||
actorUserId: options?.actorUserId,
|
||||
entityType: options?.entityType,
|
||||
entityId: options?.entityId,
|
||||
metadata: options?.metadata,
|
||||
ip,
|
||||
userAgent
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
71
apps/api/src/modules/auth/auth.controller.ts
Normal file
71
apps/api/src/modules/auth/auth.controller.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Body, Controller, Get, Post, Req, Res, UseGuards } from "@nestjs/common";
|
||||
import type { Request, Response } from "express";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { BootstrapDto, ChangePasswordDto, LoginDto, RefreshDto } from "./dto";
|
||||
import { JwtAuthGuard } from "./guards";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
|
||||
@Controller("auth")
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly service: AuthService,
|
||||
private readonly prisma: PrismaService
|
||||
) {}
|
||||
|
||||
@Post("bootstrap")
|
||||
async bootstrap(@Body() dto: BootstrapDto, @Res({ passthrough: true }) res: Response) {
|
||||
const result = await this.service.bootstrap(dto);
|
||||
this.service.setRefreshCookie(res, result.refreshToken);
|
||||
return { accessToken: result.accessToken, user: result.user, tenant: result.tenant };
|
||||
}
|
||||
|
||||
@Post("login")
|
||||
async login(@Req() req: Request, @Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
|
||||
const result = await this.service.login(dto, req);
|
||||
this.service.setRefreshCookie(res, result.refreshToken);
|
||||
return { accessToken: result.accessToken, user: result.user, tenant: result.tenant };
|
||||
}
|
||||
|
||||
@Post("refresh")
|
||||
async refresh(@Body() dto: RefreshDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
const token = dto.refreshToken ?? req.cookies?.refreshToken;
|
||||
const result = await this.service.refresh(token);
|
||||
this.service.setRefreshCookie(res, result.refreshToken);
|
||||
return { accessToken: result.accessToken };
|
||||
}
|
||||
|
||||
@Post("logout")
|
||||
async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
const token = req.cookies?.refreshToken;
|
||||
await this.service.logout(token, req);
|
||||
res.clearCookie("refreshToken");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get("me")
|
||||
async me(@Req() req: Request) {
|
||||
const jwtUser = req.user as any;
|
||||
const userId = String(jwtUser?.sub ?? "");
|
||||
if (!userId) return { user: req.user };
|
||||
const dbUser = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!dbUser) return { user: req.user };
|
||||
return {
|
||||
user: {
|
||||
...jwtUser,
|
||||
email: dbUser.email,
|
||||
fullName: dbUser.fullName,
|
||||
avatarUrl: dbUser.avatarUrl ?? null,
|
||||
mustChangePassword: dbUser.mustChangePassword
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post("change-password")
|
||||
async changePassword(@Req() req: Request, @Body() dto: ChangePasswordDto) {
|
||||
const userId = (req.user as any).sub as string;
|
||||
await this.service.changePassword(userId, dto.oldPassword, dto.newPassword, req);
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
17
apps/api/src/modules/auth/auth.module.ts
Normal file
17
apps/api/src/modules/auth/auth.module.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { PassportModule } from "@nestjs/passport";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { JwtStrategy } from "./jwt.strategy";
|
||||
import { JwtAuthGuard, PermissionsGuard, TenantGuard } from "./guards";
|
||||
import { AuditModule } from "../audit/audit.module";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [PassportModule, JwtModule.register({}), AuditModule],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy, JwtAuthGuard, TenantGuard, PermissionsGuard],
|
||||
exports: [AuthService, JwtAuthGuard, TenantGuard, PermissionsGuard]
|
||||
})
|
||||
export class AuthModule {}
|
||||
332
apps/api/src/modules/auth/auth.service.ts
Normal file
332
apps/api/src/modules/auth/auth.service.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import { BadRequestException, Injectable, UnauthorizedException, type OnModuleInit } from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import bcrypt from "bcrypt";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { BootstrapDto, LoginDto } from "./dto";
|
||||
import type { Request, Response } from "express";
|
||||
import type { JwtUser } from "./types";
|
||||
import { AuditService } from "../audit/audit.service";
|
||||
|
||||
const PERMISSION_KEYS = [
|
||||
"events.read",
|
||||
"events.write",
|
||||
"attendees.read",
|
||||
"attendees.write",
|
||||
"communications.read",
|
||||
"communications.write",
|
||||
"invitees.read",
|
||||
"invitees.write",
|
||||
"rsvps.read",
|
||||
"rsvps.write",
|
||||
"registrations.read",
|
||||
"registrations.write",
|
||||
"payments.read",
|
||||
"payments.write",
|
||||
"reports.read",
|
||||
"settings.read",
|
||||
"settings.write",
|
||||
"integrations.read",
|
||||
"integrations.write",
|
||||
"users.read",
|
||||
"users.write",
|
||||
"roles.read",
|
||||
"roles.write",
|
||||
"forms.read",
|
||||
"forms.write",
|
||||
"workflows.read",
|
||||
"workflows.write",
|
||||
"qrcodes.read",
|
||||
"qrcodes.write",
|
||||
"calendar.read",
|
||||
"calendar.write",
|
||||
"crm.read",
|
||||
"crm.write",
|
||||
"checkins.read",
|
||||
"checkins.write"
|
||||
];
|
||||
|
||||
function nowPlusSeconds(seconds: number) {
|
||||
return new Date(Date.now() + seconds * 1000);
|
||||
}
|
||||
|
||||
function toSeconds(input: string | undefined, fallbackSeconds: number) {
|
||||
if (!input) return fallbackSeconds;
|
||||
const match = input.match(/^(\d+)(s|m|h|d)$/);
|
||||
if (!match) return fallbackSeconds;
|
||||
const value = Number(match[1]);
|
||||
const unit = match[2];
|
||||
if (unit === "s") return value;
|
||||
if (unit === "m") return value * 60;
|
||||
if (unit === "h") return value * 3600;
|
||||
return value * 86400;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService implements OnModuleInit {
|
||||
private readonly accessTtlSeconds: number;
|
||||
private readonly refreshTtlSeconds: number;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly audit: AuditService
|
||||
) {
|
||||
this.accessTtlSeconds = toSeconds(process.env.JWT_ACCESS_TTL, 15 * 60);
|
||||
this.refreshTtlSeconds = toSeconds(process.env.JWT_REFRESH_TTL, 30 * 24 * 3600);
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.ensureSystemPermissions();
|
||||
|
||||
const enabled = process.env.AUTO_BOOTSTRAP === "1" || process.env.AUTO_BOOTSTRAP === "true";
|
||||
if (!enabled) return;
|
||||
|
||||
const tenants = await this.prisma.tenant.count();
|
||||
if (tenants > 0) return;
|
||||
|
||||
const tenantName = process.env.DEFAULT_TENANT_NAME;
|
||||
const tenantSlug = process.env.DEFAULT_TENANT_SLUG;
|
||||
const adminFullName = process.env.DEFAULT_SUPERADMIN_FULL_NAME;
|
||||
const adminEmail = process.env.DEFAULT_SUPERADMIN_EMAIL;
|
||||
const adminPassword = process.env.DEFAULT_SUPERADMIN_PASSWORD;
|
||||
|
||||
if (!tenantName || !tenantSlug || !adminFullName || !adminEmail || !adminPassword) {
|
||||
throw new Error("AUTO_BOOTSTRAP is enabled but DEFAULT_* environment variables are missing");
|
||||
}
|
||||
|
||||
const dto: BootstrapDto = {
|
||||
tenantName,
|
||||
tenantSlug,
|
||||
adminFullName,
|
||||
adminEmail,
|
||||
adminPassword
|
||||
};
|
||||
await this.bootstrap(dto);
|
||||
}
|
||||
|
||||
async ensureSystemPermissions() {
|
||||
await this.prisma.permission.createMany({
|
||||
data: PERMISSION_KEYS.map((key) => ({ key })),
|
||||
skipDuplicates: true
|
||||
});
|
||||
|
||||
const [superAdminRoles, permissions] = await Promise.all([
|
||||
this.prisma.role.findMany({ where: { name: "super_admin" } }),
|
||||
this.prisma.permission.findMany({ where: { key: { in: PERMISSION_KEYS } } })
|
||||
]);
|
||||
|
||||
if (!superAdminRoles.length || !permissions.length) return;
|
||||
await this.prisma.rolePermission.createMany({
|
||||
data: superAdminRoles.flatMap((role) => permissions.map((permission) => ({ roleId: role.id, permissionId: permission.id }))),
|
||||
skipDuplicates: true
|
||||
});
|
||||
}
|
||||
|
||||
setRefreshCookie(res: Response, refreshToken: string) {
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
res.cookie("refreshToken", refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: isProd,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: this.refreshTtlSeconds * 1000
|
||||
});
|
||||
}
|
||||
|
||||
async bootstrap(dto: BootstrapDto, req?: Request) {
|
||||
const tenantCount = await this.prisma.tenant.count();
|
||||
if (tenantCount > 0 && process.env.ALLOW_PUBLIC_SIGNUP !== "1" && process.env.ALLOW_PUBLIC_SIGNUP !== "true") {
|
||||
throw new BadRequestException("Tenant setup is disabled after initial bootstrap");
|
||||
}
|
||||
|
||||
const existing = await this.prisma.tenant.findUnique({ where: { slug: dto.tenantSlug } });
|
||||
if (existing) throw new BadRequestException("Tenant already exists");
|
||||
|
||||
const passwordHash = await bcrypt.hash(dto.adminPassword, 12);
|
||||
|
||||
const tenant = await this.prisma.tenant.create({
|
||||
data: {
|
||||
name: dto.tenantName,
|
||||
slug: dto.tenantSlug,
|
||||
roles: {
|
||||
create: [
|
||||
{ name: "super_admin" },
|
||||
{ name: "admin" },
|
||||
{ name: "manager" }
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await this.prisma.permission.createMany({
|
||||
data: PERMISSION_KEYS.map((key) => ({ key })),
|
||||
skipDuplicates: true
|
||||
});
|
||||
|
||||
const [superAdminRole, permissions] = await Promise.all([
|
||||
this.prisma.role.findFirst({ where: { tenantId: tenant.id, name: "super_admin" } }),
|
||||
this.prisma.permission.findMany({ where: { key: { in: PERMISSION_KEYS } } })
|
||||
]);
|
||||
|
||||
if (!superAdminRole) throw new BadRequestException("Bootstrap role missing");
|
||||
|
||||
await this.prisma.rolePermission.createMany({
|
||||
data: permissions.map((p) => ({ roleId: superAdminRole.id, permissionId: p.id })),
|
||||
skipDuplicates: true
|
||||
});
|
||||
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
email: dto.adminEmail.toLowerCase(),
|
||||
fullName: dto.adminFullName,
|
||||
mustChangePassword: true,
|
||||
passwordHash,
|
||||
roles: {
|
||||
create: [{ roleId: superAdminRole.id }]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const tokens = await this.issueTokens(user.id);
|
||||
await this.audit.log("auth.bootstrap", req, { tenantId: tenant.id, actorUserId: user.id });
|
||||
return {
|
||||
...tokens,
|
||||
user: { id: user.id, email: user.email, fullName: user.fullName, tenantId: user.tenantId, mustChangePassword: user.mustChangePassword },
|
||||
tenant: { id: tenant.id, name: tenant.name, slug: tenant.slug }
|
||||
};
|
||||
}
|
||||
|
||||
async login(dto: LoginDto, req?: Request) {
|
||||
const tenant = await this.prisma.tenant.findUnique({ where: { slug: dto.tenantSlug } });
|
||||
if (!tenant) throw new UnauthorizedException("Invalid credentials");
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { tenantId_email: { tenantId: tenant.id, email: dto.email.toLowerCase() } }
|
||||
});
|
||||
if (!user) throw new UnauthorizedException("Invalid credentials");
|
||||
|
||||
const ok = await bcrypt.compare(dto.password, user.passwordHash);
|
||||
if (!ok) throw new UnauthorizedException("Invalid credentials");
|
||||
|
||||
const tokens = await this.issueTokens(user.id);
|
||||
await this.audit.log("auth.login", req, { tenantId: tenant.id, actorUserId: user.id });
|
||||
return {
|
||||
...tokens,
|
||||
user: { id: user.id, email: user.email, fullName: user.fullName, tenantId: user.tenantId, mustChangePassword: user.mustChangePassword },
|
||||
tenant: { id: tenant.id, name: tenant.name, slug: tenant.slug }
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(refreshToken: string | undefined) {
|
||||
if (!refreshToken) throw new UnauthorizedException("Missing refresh token");
|
||||
let payload: any;
|
||||
try {
|
||||
payload = await this.jwt.verifyAsync(refreshToken, { secret: process.env.JWT_SECRET });
|
||||
} catch {
|
||||
throw new UnauthorizedException("Invalid refresh token");
|
||||
}
|
||||
|
||||
if (payload?.type !== "refresh") throw new UnauthorizedException("Invalid refresh token");
|
||||
|
||||
const userId = String(payload.sub);
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user || !user.refreshTokenHash || !user.refreshTokenId || !user.refreshTokenExpiresAt) {
|
||||
throw new UnauthorizedException("Invalid refresh token");
|
||||
}
|
||||
|
||||
if (user.refreshTokenExpiresAt.getTime() < Date.now()) throw new UnauthorizedException("Refresh token expired");
|
||||
if (String(payload.rid) !== user.refreshTokenId) throw new UnauthorizedException("Invalid refresh token");
|
||||
|
||||
const ok = await bcrypt.compare(refreshToken, user.refreshTokenHash);
|
||||
if (!ok) throw new UnauthorizedException("Invalid refresh token");
|
||||
|
||||
return this.issueTokens(user.id);
|
||||
}
|
||||
|
||||
async logout(refreshToken: string | undefined, req?: Request) {
|
||||
if (!refreshToken) return;
|
||||
let payload: any;
|
||||
try {
|
||||
payload = await this.jwt.verifyAsync(refreshToken, { secret: process.env.JWT_SECRET });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (payload?.type !== "refresh") return;
|
||||
await this.prisma.user.update({
|
||||
where: { id: String(payload.sub) },
|
||||
data: { refreshTokenId: null, refreshTokenHash: null, refreshTokenExpiresAt: null }
|
||||
});
|
||||
await this.audit.log("auth.logout", req, { tenantId: String(payload.tenantId), actorUserId: String(payload.sub) });
|
||||
}
|
||||
|
||||
async buildJwtUser(userId: string): Promise<JwtUser> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: { include: { permission: true } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!user) throw new UnauthorizedException("User not found");
|
||||
|
||||
const roleNames = user.roles.map((ur) => ur.role.name);
|
||||
const permissionKeys = Array.from(new Set(user.roles.flatMap((ur) => ur.role.permissions.map((rp) => rp.permission.key))));
|
||||
|
||||
return {
|
||||
sub: user.id,
|
||||
tenantId: user.tenantId,
|
||||
email: user.email,
|
||||
fullName: user.fullName,
|
||||
avatarUrl: user.avatarUrl ?? null,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
roleNames,
|
||||
permissionKeys
|
||||
};
|
||||
}
|
||||
|
||||
async changePassword(userId: string, oldPassword: string, newPassword: string, req?: Request) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new UnauthorizedException("User not found");
|
||||
|
||||
const ok = await bcrypt.compare(oldPassword, user.passwordHash);
|
||||
if (!ok) throw new UnauthorizedException("Invalid password");
|
||||
|
||||
const passwordHash = await bcrypt.hash(newPassword, 12);
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { passwordHash, mustChangePassword: false, refreshTokenId: null, refreshTokenHash: null, refreshTokenExpiresAt: null }
|
||||
});
|
||||
await this.audit.log("auth.changePassword", req, { tenantId: user.tenantId, actorUserId: user.id });
|
||||
}
|
||||
|
||||
private async issueTokens(userId: string) {
|
||||
const jwtUser = await this.buildJwtUser(userId);
|
||||
const accessToken = await this.jwt.signAsync(
|
||||
{ ...jwtUser, type: "access" },
|
||||
{ secret: process.env.JWT_SECRET, expiresIn: this.accessTtlSeconds }
|
||||
);
|
||||
|
||||
const rid = randomBytes(16).toString("hex");
|
||||
const refreshToken = await this.jwt.signAsync(
|
||||
{ sub: jwtUser.sub, tenantId: jwtUser.tenantId, type: "refresh", rid },
|
||||
{ secret: process.env.JWT_SECRET, expiresIn: this.refreshTtlSeconds }
|
||||
);
|
||||
|
||||
const refreshTokenHash = await bcrypt.hash(refreshToken, 12);
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { refreshTokenId: rid, refreshTokenHash, refreshTokenExpiresAt: nowPlusSeconds(this.refreshTtlSeconds) }
|
||||
});
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
}
|
||||
51
apps/api/src/modules/auth/dto.ts
Normal file
51
apps/api/src/modules/auth/dto.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { IsEmail, IsNotEmpty, IsOptional, IsString, Matches, MinLength } from "class-validator";
|
||||
|
||||
export class BootstrapDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
tenantName!: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9-]+$/)
|
||||
tenantSlug!: string;
|
||||
|
||||
@IsEmail()
|
||||
adminEmail!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
adminPassword!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
adminFullName!: string;
|
||||
}
|
||||
|
||||
export class LoginDto {
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9-]+$/)
|
||||
tenantSlug!: string;
|
||||
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
}
|
||||
|
||||
export class RefreshDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
refreshToken?: string;
|
||||
}
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
oldPassword!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
newPassword!: string;
|
||||
}
|
||||
37
apps/api/src/modules/auth/guards.ts
Normal file
37
apps/api/src/modules/auth/guards.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, SetMetadata } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
import type { JwtUser } from "./types";
|
||||
|
||||
export const REQUIRE_PERMISSIONS_KEY = "requirePermissions";
|
||||
export const RequirePermissions = (...permissions: string[]) => SetMetadata(REQUIRE_PERMISSIONS_KEY, permissions);
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard("jwt") {}
|
||||
|
||||
@Injectable()
|
||||
export class TenantGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext) {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const user = req.user as JwtUser | undefined;
|
||||
return Boolean(user?.tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PermissionsGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
const handler = context.getHandler();
|
||||
const cls = context.getClass();
|
||||
const required: string[] =
|
||||
this.reflector.getAllAndOverride(REQUIRE_PERMISSIONS_KEY, [handler, cls]) ?? [];
|
||||
if (!required.length) return true;
|
||||
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const user = req.user as JwtUser | undefined;
|
||||
const keys = new Set(user?.permissionKeys ?? []);
|
||||
return required.every((k) => keys.has(k));
|
||||
}
|
||||
}
|
||||
22
apps/api/src/modules/auth/jwt.strategy.ts
Normal file
22
apps/api/src/modules/auth/jwt.strategy.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { ExtractJwt, Strategy } from "passport-jwt";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(private readonly prisma: PrismaService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: process.env.JWT_SECRET
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: any) {
|
||||
if (!payload || payload.type !== "access") throw new UnauthorizedException("Invalid token");
|
||||
const user = await this.prisma.user.findUnique({ where: { id: String(payload.sub) } });
|
||||
if (!user) throw new UnauthorizedException("Invalid token");
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
10
apps/api/src/modules/auth/types.ts
Normal file
10
apps/api/src/modules/auth/types.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export type JwtUser = {
|
||||
sub: string;
|
||||
tenantId: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
avatarUrl?: string | null;
|
||||
mustChangePassword: boolean;
|
||||
roleNames: string[];
|
||||
permissionKeys: string[];
|
||||
};
|
||||
51
apps/api/src/modules/calendar/calendar.controller.ts
Normal file
51
apps/api/src/modules/calendar/calendar.controller.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Body, Controller, Get, Post, Query, Req, UseGuards } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
|
||||
import { CalendarService } from "./calendar.service";
|
||||
|
||||
@Controller("calendar")
|
||||
export class CalendarController {
|
||||
constructor(private readonly service: CalendarService) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("calendar.read")
|
||||
@Get("routing-forms")
|
||||
routingForms(@Req() req: Request) {
|
||||
return this.service.routingForms((req.user as any).tenantId as string);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("calendar.write")
|
||||
@Post("routing-forms")
|
||||
createRoutingForm(@Req() req: Request, @Body() payload: Record<string, unknown>) {
|
||||
return this.service.createRoutingForm((req.user as any).tenantId as string, payload);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("calendar.read")
|
||||
@Get("slots")
|
||||
slots(@Req() req: Request, @Query("routingFormId") routingFormId?: string) {
|
||||
return this.service.slots((req.user as any).tenantId as string, routingFormId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("calendar.write")
|
||||
@Post("slots")
|
||||
createSlot(@Req() req: Request, @Body() payload: Record<string, unknown>) {
|
||||
return this.service.createSlot((req.user as any).tenantId as string, payload);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("calendar.read")
|
||||
@Get("bookings")
|
||||
bookings(@Req() req: Request) {
|
||||
return this.service.bookings((req.user as any).tenantId as string);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("calendar.write")
|
||||
@Post("bookings")
|
||||
createBooking(@Req() req: Request, @Body() payload: Record<string, unknown>) {
|
||||
return this.service.createBooking((req.user as any).tenantId as string, payload);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/modules/calendar/calendar.module.ts
Normal file
9
apps/api/src/modules/calendar/calendar.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { CalendarController } from "./calendar.controller";
|
||||
import { CalendarService } from "./calendar.service";
|
||||
|
||||
@Module({
|
||||
controllers: [CalendarController],
|
||||
providers: [CalendarService]
|
||||
})
|
||||
export class CalendarModule {}
|
||||
92
apps/api/src/modules/calendar/calendar.service.ts
Normal file
92
apps/api/src/modules/calendar/calendar.service.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class CalendarService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
routingForms(tenantId: string) {
|
||||
return this.prisma.calendarRoutingForm.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { event: true, _count: { select: { slots: true, bookings: true } } }
|
||||
});
|
||||
}
|
||||
|
||||
createRoutingForm(tenantId: string, payload: any) {
|
||||
return this.prisma.calendarRoutingForm.create({
|
||||
data: {
|
||||
tenantId,
|
||||
eventId: payload.eventId || null,
|
||||
name: String(payload.name ?? ""),
|
||||
slug: String(payload.slug ?? "").toLowerCase().trim(),
|
||||
description: payload.description ? String(payload.description) : null,
|
||||
durationMinutes: Number(payload.durationMinutes ?? 30),
|
||||
active: payload.active === undefined ? true : Boolean(payload.active)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
slots(tenantId: string, routingFormId?: string) {
|
||||
return this.prisma.calendarSlot.findMany({
|
||||
where: { tenantId, ...(routingFormId ? { routingFormId } : {}) },
|
||||
orderBy: { startsAt: "asc" },
|
||||
include: { routingForm: true }
|
||||
});
|
||||
}
|
||||
|
||||
async createSlot(tenantId: string, payload: any) {
|
||||
const routingForm = await this.prisma.calendarRoutingForm.findFirst({ where: { tenantId, id: String(payload.routingFormId ?? "") } });
|
||||
if (!routingForm) throw new NotFoundException("Routing form not found");
|
||||
const startsAt = new Date(String(payload.startsAt ?? ""));
|
||||
const endsAt = new Date(String(payload.endsAt ?? ""));
|
||||
if (!Number.isFinite(startsAt.getTime()) || !Number.isFinite(endsAt.getTime()) || endsAt <= startsAt) {
|
||||
throw new BadRequestException("Invalid slot time");
|
||||
}
|
||||
return this.prisma.calendarSlot.create({
|
||||
data: {
|
||||
tenantId,
|
||||
routingFormId: routingForm.id,
|
||||
startsAt,
|
||||
endsAt,
|
||||
capacity: Number(payload.capacity ?? 1),
|
||||
active: payload.active === undefined ? true : Boolean(payload.active)
|
||||
},
|
||||
include: { routingForm: true }
|
||||
});
|
||||
}
|
||||
|
||||
bookings(tenantId: string) {
|
||||
return this.prisma.booking.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { startsAt: "desc" },
|
||||
include: { routingForm: true, event: true, attendee: true }
|
||||
});
|
||||
}
|
||||
|
||||
async createBooking(tenantId: string, payload: any) {
|
||||
const routingForm = await this.prisma.calendarRoutingForm.findFirst({ where: { tenantId, id: String(payload.routingFormId ?? "") } });
|
||||
if (!routingForm) throw new NotFoundException("Routing form not found");
|
||||
const startsAt = new Date(String(payload.startsAt ?? ""));
|
||||
const endsAt = new Date(String(payload.endsAt ?? ""));
|
||||
if (!Number.isFinite(startsAt.getTime()) || !Number.isFinite(endsAt.getTime()) || endsAt <= startsAt) {
|
||||
throw new BadRequestException("Invalid booking time");
|
||||
}
|
||||
return this.prisma.booking.create({
|
||||
data: {
|
||||
tenantId,
|
||||
routingFormId: routingForm.id,
|
||||
eventId: payload.eventId || routingForm.eventId || null,
|
||||
attendeeId: payload.attendeeId || null,
|
||||
fullName: String(payload.fullName ?? ""),
|
||||
email: String(payload.email ?? "").toLowerCase().trim(),
|
||||
phone: payload.phone ? String(payload.phone) : null,
|
||||
startsAt,
|
||||
endsAt,
|
||||
status: payload.status ? String(payload.status) : "booked",
|
||||
notes: payload.notes ? String(payload.notes) : null
|
||||
},
|
||||
include: { routingForm: true, event: true, attendee: true }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Body, Controller, Get, Post, Req, UseGuards } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { CommunicationsService } from "./communications.service";
|
||||
import { SendEmailDto, SendSmsDto, SendWhatsappDto } from "./dto";
|
||||
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
|
||||
@Controller("communications")
|
||||
export class CommunicationsController {
|
||||
constructor(private readonly service: CommunicationsService) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("communications.read")
|
||||
@Get()
|
||||
findAll(@Req() req: Request) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.findAll(tenantId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("communications.read")
|
||||
@Get("templates")
|
||||
templates(@Req() req: Request) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.templates(tenantId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("communications.write")
|
||||
@Post("templates")
|
||||
createTemplate(@Req() req: Request, @Body() payload: Record<string, unknown>) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.createTemplate(tenantId, payload);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("communications.write")
|
||||
@Post("email")
|
||||
sendEmail(@Req() req: Request, @Body() dto: SendEmailDto) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
const actorUserId = (req.user as any).sub as string;
|
||||
return this.service.sendEmail(tenantId, actorUserId, dto, req);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("communications.write")
|
||||
@Post("sms")
|
||||
sendSms(@Req() req: Request, @Body() dto: SendSmsDto) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
const actorUserId = (req.user as any).sub as string;
|
||||
return this.service.sendSms(tenantId, actorUserId, dto, req);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("communications.write")
|
||||
@Post("whatsapp")
|
||||
sendWhatsapp(@Req() req: Request, @Body() dto: SendWhatsappDto) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
const actorUserId = (req.user as any).sub as string;
|
||||
return this.service.sendWhatsapp(tenantId, actorUserId, dto, req);
|
||||
}
|
||||
}
|
||||
13
apps/api/src/modules/communications/communications.module.ts
Normal file
13
apps/api/src/modules/communications/communications.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { CommunicationsController } from "./communications.controller";
|
||||
import { CommunicationsService } from "./communications.service";
|
||||
import { QueuesModule } from "../queues/queues.module";
|
||||
import { AuditModule } from "../audit/audit.module";
|
||||
|
||||
@Module({
|
||||
imports: [QueuesModule, AuditModule],
|
||||
controllers: [CommunicationsController],
|
||||
providers: [CommunicationsService],
|
||||
exports: [CommunicationsService]
|
||||
})
|
||||
export class CommunicationsModule {}
|
||||
132
apps/api/src/modules/communications/communications.service.ts
Normal file
132
apps/api/src/modules/communications/communications.service.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { QueuesService } from "../queues/queues.service";
|
||||
import { AuditService } from "../audit/audit.service";
|
||||
import type { SendEmailDto, SendSmsDto, SendWhatsappDto } from "./dto";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class CommunicationsService {
|
||||
constructor(
|
||||
private readonly queues: QueuesService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly prisma: PrismaService
|
||||
) {}
|
||||
|
||||
findAll(tenantId: string) {
|
||||
return this.prisma.communicationLog.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { template: true, invitee: true, registration: true }
|
||||
});
|
||||
}
|
||||
|
||||
templates(tenantId: string) {
|
||||
return this.prisma.communicationTemplate.findMany({ where: { tenantId }, orderBy: { createdAt: "desc" } });
|
||||
}
|
||||
|
||||
createTemplate(tenantId: string, payload: any) {
|
||||
return this.prisma.communicationTemplate.create({
|
||||
data: {
|
||||
tenantId,
|
||||
name: String(payload.name ?? ""),
|
||||
channel: payload.channel ?? "email",
|
||||
subject: payload.subject ? String(payload.subject) : null,
|
||||
body: String(payload.body ?? ""),
|
||||
variables: payload.variables ?? undefined,
|
||||
isActive: payload.isActive === undefined ? true : Boolean(payload.isActive)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async sendEmail(tenantId: string, actorUserId: string, dto: SendEmailDto, req: Request) {
|
||||
const log = await this.prisma.communicationLog.create({
|
||||
data: {
|
||||
tenantId,
|
||||
channel: "email",
|
||||
to: dto.to,
|
||||
subject: dto.subject,
|
||||
body: dto.html ?? dto.text ?? "",
|
||||
status: "queued"
|
||||
}
|
||||
});
|
||||
|
||||
await this.queues.queue("communications").add("sendEmail", {
|
||||
tenantId,
|
||||
actorUserId,
|
||||
communicationLogId: log.id,
|
||||
to: dto.to,
|
||||
subject: dto.subject,
|
||||
text: dto.text,
|
||||
html: dto.html
|
||||
});
|
||||
|
||||
await this.audit.log("communications.sendEmail.queued", req, {
|
||||
tenantId,
|
||||
actorUserId,
|
||||
entityType: "email",
|
||||
metadata: { to: dto.to, subject: dto.subject }
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async sendSms(tenantId: string, actorUserId: string, dto: SendSmsDto, req: Request) {
|
||||
const log = await this.prisma.communicationLog.create({
|
||||
data: {
|
||||
tenantId,
|
||||
channel: "sms",
|
||||
to: dto.to,
|
||||
body: dto.message,
|
||||
status: "queued"
|
||||
}
|
||||
});
|
||||
|
||||
await this.queues.queue("communications").add("sendSms", {
|
||||
tenantId,
|
||||
actorUserId,
|
||||
communicationLogId: log.id,
|
||||
to: dto.to,
|
||||
message: dto.message,
|
||||
from: dto.from
|
||||
});
|
||||
|
||||
await this.audit.log("communications.sendSms.queued", req, {
|
||||
tenantId,
|
||||
actorUserId,
|
||||
entityType: "sms",
|
||||
metadata: { to: dto.to }
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async sendWhatsapp(tenantId: string, actorUserId: string, dto: SendWhatsappDto, req: Request) {
|
||||
const log = await this.prisma.communicationLog.create({
|
||||
data: {
|
||||
tenantId,
|
||||
channel: "whatsapp",
|
||||
to: dto.to,
|
||||
body: dto.message,
|
||||
status: "queued"
|
||||
}
|
||||
});
|
||||
|
||||
await this.queues.queue("communications").add("sendWhatsapp", {
|
||||
tenantId,
|
||||
actorUserId,
|
||||
communicationLogId: log.id,
|
||||
to: dto.to,
|
||||
message: dto.message
|
||||
});
|
||||
|
||||
await this.audit.log("communications.sendWhatsapp.queued", req, {
|
||||
tenantId,
|
||||
actorUserId,
|
||||
entityType: "whatsapp",
|
||||
metadata: { to: dto.to }
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
42
apps/api/src/modules/communications/dto.ts
Normal file
42
apps/api/src/modules/communications/dto.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { IsEmail, IsNotEmpty, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class SendEmailDto {
|
||||
@IsEmail()
|
||||
to!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
subject!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
text?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
html?: string;
|
||||
}
|
||||
|
||||
export class SendSmsDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
to!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
message!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
from?: string;
|
||||
}
|
||||
|
||||
export class SendWhatsappDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
to!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
message!: string;
|
||||
}
|
||||
87
apps/api/src/modules/crm/crm.controller.ts
Normal file
87
apps/api/src/modules/crm/crm.controller.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { CrmService } from "./crm.service";
|
||||
|
||||
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
|
||||
|
||||
@Controller("crm")
|
||||
export class CrmController {
|
||||
constructor(private readonly service: CrmService) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("crm.read")
|
||||
@Get("summary")
|
||||
summary(@Req() req: Request) {
|
||||
return this.service.summary((req.user as any).tenantId as string);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("crm.read")
|
||||
@Get("leads")
|
||||
leads(@Req() req: Request) {
|
||||
return this.service.leads((req.user as any).tenantId as string);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("crm.write")
|
||||
@Post("leads")
|
||||
createLead(@Req() req: Request, @Body() payload: Record<string, unknown>) {
|
||||
return this.service.createLead((req.user as any).tenantId as string, payload);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("crm.write")
|
||||
@Patch("leads/:id")
|
||||
updateLead(@Req() req: Request, @Param("id") id: string, @Body() payload: Record<string, unknown>) {
|
||||
return this.service.updateLead((req.user as any).tenantId as string, id, payload);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("crm.write")
|
||||
@Delete("leads/:id")
|
||||
removeLead(@Req() req: Request, @Param("id") id: string) {
|
||||
return this.service.removeLead((req.user as any).tenantId as string, id);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("crm.read")
|
||||
@Get("deals")
|
||||
deals(@Req() req: Request) {
|
||||
return this.service.deals((req.user as any).tenantId as string);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("crm.write")
|
||||
@Post("deals")
|
||||
createDeal(@Req() req: Request, @Body() payload: Record<string, unknown>) {
|
||||
return this.service.createDeal((req.user as any).tenantId as string, payload);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("crm.write")
|
||||
@Patch("deals/:id")
|
||||
updateDeal(@Req() req: Request, @Param("id") id: string, @Body() payload: Record<string, unknown>) {
|
||||
return this.service.updateDeal((req.user as any).tenantId as string, id, payload);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("crm.write")
|
||||
@Delete("deals/:id")
|
||||
removeDeal(@Req() req: Request, @Param("id") id: string) {
|
||||
return this.service.removeDeal((req.user as any).tenantId as string, id);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("crm.read")
|
||||
@Get("activities")
|
||||
activities(@Req() req: Request) {
|
||||
return this.service.activities((req.user as any).tenantId as string);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("crm.write")
|
||||
@Post("activities")
|
||||
createActivity(@Req() req: Request, @Body() payload: Record<string, unknown>) {
|
||||
return this.service.createActivity((req.user as any).tenantId as string, (req.user as any).sub as string, payload);
|
||||
}
|
||||
}
|
||||
5
apps/api/src/modules/crm/crm.module.ts
Normal file
5
apps/api/src/modules/crm/crm.module.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { CrmController } from "./crm.controller";
|
||||
import { CrmService } from "./crm.service";
|
||||
@Module({ controllers: [CrmController], providers: [CrmService], exports: [CrmService] })
|
||||
export class CrmModule {}
|
||||
141
apps/api/src/modules/crm/crm.service.ts
Normal file
141
apps/api/src/modules/crm/crm.service.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class CrmService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async summary(tenantId: string) {
|
||||
const [leadCount, dealCount, wonDeals, openDeals, leadsByStatus] = await Promise.all([
|
||||
this.prisma.cRMLead.count({ where: { tenantId } }),
|
||||
this.prisma.cRMDeal.count({ where: { tenantId } }),
|
||||
this.prisma.cRMDeal.aggregate({ where: { tenantId, stage: "closed_won" }, _sum: { valueKobo: true } }),
|
||||
this.prisma.cRMDeal.aggregate({ where: { tenantId, stage: { notIn: ["closed_won", "closed_lost"] } }, _sum: { valueKobo: true } }),
|
||||
this.prisma.cRMLead.groupBy({ by: ["status"], where: { tenantId }, _count: { _all: true } })
|
||||
]);
|
||||
|
||||
return {
|
||||
leadCount,
|
||||
dealCount,
|
||||
wonValueKobo: wonDeals._sum.valueKobo ?? 0,
|
||||
openValueKobo: openDeals._sum.valueKobo ?? 0,
|
||||
leadsByStatus: leadsByStatus.map((row) => ({ status: row.status, count: row._count._all }))
|
||||
};
|
||||
}
|
||||
|
||||
leads(tenantId: string) {
|
||||
return this.prisma.cRMLead.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { event: true, attendee: true, deals: true }
|
||||
});
|
||||
}
|
||||
|
||||
createLead(tenantId: string, payload: any) {
|
||||
return this.prisma.cRMLead.create({
|
||||
data: {
|
||||
tenantId,
|
||||
eventId: payload.eventId || null,
|
||||
attendeeId: payload.attendeeId || null,
|
||||
fullName: String(payload.fullName ?? ""),
|
||||
email: String(payload.email ?? "").toLowerCase().trim(),
|
||||
phone: payload.phone ? String(payload.phone) : null,
|
||||
source: payload.source ? String(payload.source) : "manual",
|
||||
status: payload.status ? String(payload.status) : "new",
|
||||
valueKobo: Number(payload.valueKobo ?? 0)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async updateLead(tenantId: string, id: string, payload: any) {
|
||||
const row = await this.prisma.cRMLead.findFirst({ where: { tenantId, id } });
|
||||
if (!row) throw new NotFoundException("Lead not found");
|
||||
return this.prisma.cRMLead.update({
|
||||
where: { id },
|
||||
data: {
|
||||
fullName: payload.fullName === undefined ? undefined : String(payload.fullName),
|
||||
email: payload.email === undefined ? undefined : String(payload.email).toLowerCase().trim(),
|
||||
phone: payload.phone === undefined ? undefined : payload.phone ? String(payload.phone) : null,
|
||||
source: payload.source === undefined ? undefined : String(payload.source),
|
||||
status: payload.status === undefined ? undefined : String(payload.status),
|
||||
valueKobo: payload.valueKobo === undefined ? undefined : Number(payload.valueKobo)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async removeLead(tenantId: string, id: string) {
|
||||
const row = await this.prisma.cRMLead.findFirst({ where: { tenantId, id } });
|
||||
if (!row) throw new NotFoundException("Lead not found");
|
||||
await this.prisma.cRMLead.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
deals(tenantId: string) {
|
||||
return this.prisma.cRMDeal.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
include: { lead: true, event: true, activities: true }
|
||||
});
|
||||
}
|
||||
|
||||
createDeal(tenantId: string, payload: any) {
|
||||
return this.prisma.cRMDeal.create({
|
||||
data: {
|
||||
tenantId,
|
||||
leadId: payload.leadId || null,
|
||||
eventId: payload.eventId || null,
|
||||
title: String(payload.title ?? ""),
|
||||
stage: payload.stage ? String(payload.stage) : "new",
|
||||
valueKobo: Number(payload.valueKobo ?? 0),
|
||||
probability: Number(payload.probability ?? 0),
|
||||
ownerUserId: payload.ownerUserId || null,
|
||||
expectedCloseAt: payload.expectedCloseAt ? new Date(payload.expectedCloseAt) : null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async updateDeal(tenantId: string, id: string, payload: any) {
|
||||
const row = await this.prisma.cRMDeal.findFirst({ where: { tenantId, id } });
|
||||
if (!row) throw new NotFoundException("Deal not found");
|
||||
return this.prisma.cRMDeal.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title: payload.title === undefined ? undefined : String(payload.title),
|
||||
stage: payload.stage === undefined ? undefined : String(payload.stage),
|
||||
valueKobo: payload.valueKobo === undefined ? undefined : Number(payload.valueKobo),
|
||||
probability: payload.probability === undefined ? undefined : Number(payload.probability),
|
||||
expectedCloseAt: payload.expectedCloseAt === undefined ? undefined : payload.expectedCloseAt ? new Date(payload.expectedCloseAt) : null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async removeDeal(tenantId: string, id: string) {
|
||||
const row = await this.prisma.cRMDeal.findFirst({ where: { tenantId, id } });
|
||||
if (!row) throw new NotFoundException("Deal not found");
|
||||
await this.prisma.cRMDeal.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
activities(tenantId: string) {
|
||||
return this.prisma.cRMActivity.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { lead: true, deal: true }
|
||||
});
|
||||
}
|
||||
|
||||
createActivity(tenantId: string, userId: string, payload: any) {
|
||||
return this.prisma.cRMActivity.create({
|
||||
data: {
|
||||
tenantId,
|
||||
userId,
|
||||
leadId: payload.leadId || null,
|
||||
dealId: payload.dealId || null,
|
||||
type: payload.type ? String(payload.type) : "note",
|
||||
note: String(payload.note ?? ""),
|
||||
dueAt: payload.dueAt ? new Date(payload.dueAt) : null,
|
||||
completedAt: payload.completedAt ? new Date(payload.completedAt) : null
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
47
apps/api/src/modules/events/dto.ts
Normal file
47
apps/api/src/modules/events/dto.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { IsDateString, IsIn, IsNotEmpty, IsOptional, IsString, Matches } from "class-validator";
|
||||
|
||||
export class CreateEventDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9-]+$/)
|
||||
slug!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(["draft", "active", "closed"])
|
||||
status?: "draft" | "active" | "closed";
|
||||
|
||||
@IsDateString()
|
||||
startsAt!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
venue!: string;
|
||||
}
|
||||
|
||||
export class UpdateEventDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9-]+$/)
|
||||
slug?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(["draft", "active", "closed"])
|
||||
status?: "draft" | "active" | "closed";
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
startsAt?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
venue?: string;
|
||||
}
|
||||
74
apps/api/src/modules/events/events.controller.ts
Normal file
74
apps/api/src/modules/events/events.controller.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { EventsService } from "./events.service";
|
||||
import { CreateEventDto, UpdateEventDto } from "./dto";
|
||||
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
|
||||
|
||||
@Controller("events")
|
||||
export class EventsController {
|
||||
constructor(private readonly service: EventsService) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("events.read")
|
||||
@Get()
|
||||
findAll(@Req() req: Request) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.findAll(tenantId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("events.write")
|
||||
@Post()
|
||||
create(@Req() req: Request, @Body() dto: CreateEventDto) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.create(tenantId, dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("events.read")
|
||||
@Get(":id")
|
||||
findOne(@Req() req: Request, @Param("id") id: string) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.findOne(tenantId, id);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("events.read")
|
||||
@Get(":id/details")
|
||||
findDetails(@Req() req: Request, @Param("id") id: string) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.findDetails(tenantId, id);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("events.write")
|
||||
@Patch(":id")
|
||||
update(@Req() req: Request, @Param("id") id: string, @Body() dto: UpdateEventDto) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.update(tenantId, id, dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("events.write")
|
||||
@Delete(":id")
|
||||
remove(@Req() req: Request, @Param("id") id: string) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.remove(tenantId, id);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("events.write")
|
||||
@Post(":id/ticket-types")
|
||||
createTicketType(@Req() req: Request, @Param("id") id: string, @Body() payload: Record<string, unknown>) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.createTicketType(tenantId, id, payload);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("events.write")
|
||||
@Patch(":id/page")
|
||||
upsertPage(@Req() req: Request, @Param("id") id: string, @Body() payload: Record<string, unknown>) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.upsertPage(tenantId, id, payload);
|
||||
}
|
||||
}
|
||||
5
apps/api/src/modules/events/events.module.ts
Normal file
5
apps/api/src/modules/events/events.module.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { EventsController } from "./events.controller";
|
||||
import { EventsService } from "./events.service";
|
||||
@Module({ controllers: [EventsController], providers: [EventsService], exports: [EventsService] })
|
||||
export class EventsModule {}
|
||||
110
apps/api/src/modules/events/events.service.ts
Normal file
110
apps/api/src/modules/events/events.service.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import type { CreateEventDto, UpdateEventDto } from "./dto";
|
||||
|
||||
@Injectable()
|
||||
export class EventsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findAll(tenantId: string) {
|
||||
return this.prisma.event.findMany({ where: { tenantId }, orderBy: { createdAt: "desc" } });
|
||||
}
|
||||
|
||||
async findOne(tenantId: string, id: string) {
|
||||
const row = await this.prisma.event.findFirst({ where: { tenantId, id } });
|
||||
if (!row) throw new NotFoundException("Event not found");
|
||||
return row;
|
||||
}
|
||||
|
||||
async findDetails(tenantId: string, id: string) {
|
||||
const row = await this.prisma.event.findFirst({
|
||||
where: { tenantId, id },
|
||||
include: {
|
||||
eventPage: true,
|
||||
ticketTypes: { orderBy: { createdAt: "asc" } },
|
||||
_count: { select: { registrations: true, invitees: true, rsvps: true, checkIns: true } }
|
||||
}
|
||||
});
|
||||
if (!row) throw new NotFoundException("Event not found");
|
||||
return row;
|
||||
}
|
||||
|
||||
async create(tenantId: string, dto: CreateEventDto) {
|
||||
try {
|
||||
return await this.prisma.event.create({
|
||||
data: {
|
||||
tenantId,
|
||||
name: dto.name,
|
||||
slug: dto.slug,
|
||||
status: dto.status ?? "draft",
|
||||
startsAt: new Date(dto.startsAt),
|
||||
venue: dto.venue
|
||||
}
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (String(e?.code) === "P2002") throw new BadRequestException("Event slug already exists");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async update(tenantId: string, id: string, dto: UpdateEventDto) {
|
||||
await this.findOne(tenantId, id);
|
||||
return this.prisma.event.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: dto.name,
|
||||
slug: dto.slug,
|
||||
status: dto.status,
|
||||
startsAt: dto.startsAt ? new Date(dto.startsAt) : undefined,
|
||||
venue: dto.venue
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async remove(tenantId: string, id: string) {
|
||||
await this.findOne(tenantId, id);
|
||||
await this.prisma.event.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async upsertPage(tenantId: string, eventId: string, payload: any) {
|
||||
const event = await this.findOne(tenantId, eventId);
|
||||
return this.prisma.eventPage.upsert({
|
||||
where: { eventId: event.id },
|
||||
create: {
|
||||
tenantId,
|
||||
eventId: event.id,
|
||||
title: String(payload.title ?? event.name),
|
||||
heroTitle: payload.heroTitle ? String(payload.heroTitle) : event.name,
|
||||
description: payload.description ? String(payload.description) : null,
|
||||
content: payload.content ?? undefined,
|
||||
theme: payload.theme ?? undefined,
|
||||
publishedAt: payload.published ? new Date() : null
|
||||
},
|
||||
update: {
|
||||
title: payload.title === undefined ? undefined : String(payload.title),
|
||||
heroTitle: payload.heroTitle === undefined ? undefined : payload.heroTitle ? String(payload.heroTitle) : null,
|
||||
description: payload.description === undefined ? undefined : payload.description ? String(payload.description) : null,
|
||||
content: payload.content === undefined ? undefined : payload.content,
|
||||
theme: payload.theme === undefined ? undefined : payload.theme,
|
||||
publishedAt: payload.published === undefined ? undefined : payload.published ? new Date() : null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async createTicketType(tenantId: string, eventId: string, payload: any) {
|
||||
const event = await this.findOne(tenantId, eventId);
|
||||
return this.prisma.ticketType.create({
|
||||
data: {
|
||||
tenantId,
|
||||
eventId: event.id,
|
||||
name: String(payload.name ?? ""),
|
||||
description: payload.description ? String(payload.description) : null,
|
||||
priceKobo: Number(payload.priceKobo ?? Math.round(Number(payload.priceNaira ?? 0) * 100)),
|
||||
currency: payload.currency ? String(payload.currency) : "NGN",
|
||||
capacity: payload.capacity === undefined || payload.capacity === null || payload.capacity === "" ? null : Number(payload.capacity),
|
||||
isActive: payload.isActive === undefined ? true : Boolean(payload.isActive)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
65
apps/api/src/modules/forms/forms.controller.ts
Normal file
65
apps/api/src/modules/forms/forms.controller.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, Req, UseGuards } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
|
||||
import { FormsService } from "./forms.service";
|
||||
|
||||
@Controller("forms")
|
||||
export class FormsController {
|
||||
constructor(private readonly service: FormsService) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("forms.read")
|
||||
@Get()
|
||||
findAll(@Req() req: Request) {
|
||||
return this.service.findAll((req.user as any).tenantId as string);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("forms.write")
|
||||
@Post()
|
||||
create(@Req() req: Request, @Body() payload: Record<string, unknown>) {
|
||||
return this.service.create((req.user as any).tenantId as string, payload);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("forms.read")
|
||||
@Get("submissions")
|
||||
submissions(@Req() req: Request, @Query("formId") formId?: string) {
|
||||
return this.service.submissions((req.user as any).tenantId as string, formId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("forms.read")
|
||||
@Get(":id")
|
||||
findOne(@Req() req: Request, @Param("id") id: string) {
|
||||
return this.service.findOne((req.user as any).tenantId as string, id);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("forms.write")
|
||||
@Patch(":id")
|
||||
update(@Req() req: Request, @Param("id") id: string, @Body() payload: Record<string, unknown>) {
|
||||
return this.service.update((req.user as any).tenantId as string, id, payload);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("forms.write")
|
||||
@Put(":id/fields")
|
||||
replaceFields(@Req() req: Request, @Param("id") id: string, @Body() payload: { fields?: any[] }) {
|
||||
return this.service.replaceFields((req.user as any).tenantId as string, id, payload.fields ?? []);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("forms.write")
|
||||
@Post(":id/submissions")
|
||||
submit(@Req() req: Request, @Param("id") id: string, @Body() payload: Record<string, unknown>) {
|
||||
return this.service.submit((req.user as any).tenantId as string, id, payload);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("forms.write")
|
||||
@Delete(":id")
|
||||
remove(@Req() req: Request, @Param("id") id: string) {
|
||||
return this.service.remove((req.user as any).tenantId as string, id);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/modules/forms/forms.module.ts
Normal file
9
apps/api/src/modules/forms/forms.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { FormsController } from "./forms.controller";
|
||||
import { FormsService } from "./forms.service";
|
||||
|
||||
@Module({
|
||||
controllers: [FormsController],
|
||||
providers: [FormsService]
|
||||
})
|
||||
export class FormsModule {}
|
||||
123
apps/api/src/modules/forms/forms.service.ts
Normal file
123
apps/api/src/modules/forms/forms.service.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class FormsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findAll(tenantId: string) {
|
||||
return this.prisma.form.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
event: true,
|
||||
fields: { orderBy: { sortOrder: "asc" } },
|
||||
_count: { select: { submissions: true } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(tenantId: string, id: string) {
|
||||
const row = await this.prisma.form.findFirst({
|
||||
where: { tenantId, id },
|
||||
include: {
|
||||
event: true,
|
||||
fields: { orderBy: { sortOrder: "asc" } },
|
||||
submissions: { orderBy: { createdAt: "desc" } }
|
||||
}
|
||||
});
|
||||
if (!row) throw new NotFoundException("Form not found");
|
||||
return row;
|
||||
}
|
||||
|
||||
create(tenantId: string, payload: any) {
|
||||
const fields = Array.isArray(payload.fields) ? payload.fields : [];
|
||||
return this.prisma.form.create({
|
||||
data: {
|
||||
tenantId,
|
||||
eventId: payload.eventId || null,
|
||||
name: String(payload.name ?? ""),
|
||||
slug: String(payload.slug ?? "").toLowerCase().trim(),
|
||||
description: payload.description ? String(payload.description) : null,
|
||||
isPublic: Boolean(payload.isPublic ?? false),
|
||||
fields: {
|
||||
create: fields.map((field: any, index: number) => ({
|
||||
tenantId,
|
||||
label: String(field.label ?? ""),
|
||||
key: String(field.key ?? "").trim(),
|
||||
type: String(field.type ?? "text"),
|
||||
required: Boolean(field.required ?? false),
|
||||
options: field.options ?? undefined,
|
||||
sortOrder: Number(field.sortOrder ?? index)
|
||||
}))
|
||||
}
|
||||
},
|
||||
include: { fields: { orderBy: { sortOrder: "asc" } }, event: true }
|
||||
});
|
||||
}
|
||||
|
||||
async update(tenantId: string, id: string, payload: any) {
|
||||
await this.findOne(tenantId, id);
|
||||
return this.prisma.form.update({
|
||||
where: { id },
|
||||
data: {
|
||||
eventId: payload.eventId === undefined ? undefined : payload.eventId || null,
|
||||
name: payload.name === undefined ? undefined : String(payload.name),
|
||||
slug: payload.slug === undefined ? undefined : String(payload.slug).toLowerCase().trim(),
|
||||
description: payload.description === undefined ? undefined : payload.description ? String(payload.description) : null,
|
||||
isPublic: payload.isPublic === undefined ? undefined : Boolean(payload.isPublic)
|
||||
},
|
||||
include: { fields: { orderBy: { sortOrder: "asc" } }, event: true }
|
||||
});
|
||||
}
|
||||
|
||||
async replaceFields(tenantId: string, formId: string, fields: any[]) {
|
||||
await this.findOne(tenantId, formId);
|
||||
await this.prisma.formField.deleteMany({ where: { tenantId, formId } });
|
||||
if (fields.length) {
|
||||
await this.prisma.formField.createMany({
|
||||
data: fields.map((field, index) => ({
|
||||
tenantId,
|
||||
formId,
|
||||
label: String(field.label ?? ""),
|
||||
key: String(field.key ?? "").trim(),
|
||||
type: String(field.type ?? "text"),
|
||||
required: Boolean(field.required ?? false),
|
||||
options: field.options ?? undefined,
|
||||
sortOrder: Number(field.sortOrder ?? index)
|
||||
}))
|
||||
});
|
||||
}
|
||||
return this.findOne(tenantId, formId);
|
||||
}
|
||||
|
||||
async remove(tenantId: string, id: string) {
|
||||
await this.findOne(tenantId, id);
|
||||
await this.prisma.form.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
submissions(tenantId: string, formId?: string) {
|
||||
return this.prisma.formSubmission.findMany({
|
||||
where: { tenantId, ...(formId ? { formId } : {}) },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { form: true, event: true, attendee: true, registration: true }
|
||||
});
|
||||
}
|
||||
|
||||
async submit(tenantId: string, formId: string, payload: any) {
|
||||
const form = await this.prisma.form.findFirst({ where: { tenantId, id: formId } });
|
||||
if (!form) throw new NotFoundException("Form not found");
|
||||
return this.prisma.formSubmission.create({
|
||||
data: {
|
||||
tenantId,
|
||||
formId,
|
||||
eventId: payload.eventId || form.eventId || null,
|
||||
attendeeId: payload.attendeeId || null,
|
||||
registrationId: payload.registrationId || null,
|
||||
data: payload.data ?? {},
|
||||
source: payload.source ? String(payload.source) : "admin"
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
9
apps/api/src/modules/health/health.controller.ts
Normal file
9
apps/api/src/modules/health/health.controller.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
|
||||
@Controller("health")
|
||||
export class HealthController {
|
||||
@Get()
|
||||
health() {
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
5
apps/api/src/modules/health/health.module.ts
Normal file
5
apps/api/src/modules/health/health.module.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { HealthController } from "./health.controller";
|
||||
|
||||
@Module({ controllers: [HealthController] })
|
||||
export class HealthModule {}
|
||||
16
apps/api/src/modules/integrations/dto.ts
Normal file
16
apps/api/src/modules/integrations/dto.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { IsBoolean, IsNotEmpty, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class UpsertIntegrationSettingDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
key!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
value?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isSecret?: boolean;
|
||||
}
|
||||
|
||||
27
apps/api/src/modules/integrations/integrations.controller.ts
Normal file
27
apps/api/src/modules/integrations/integrations.controller.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Body, Controller, Get, Post, Req, UseGuards } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
|
||||
import { UpsertIntegrationSettingDto } from "./dto";
|
||||
import { IntegrationsService } from "./integrations.service";
|
||||
|
||||
@Controller("integrations")
|
||||
export class IntegrationsController {
|
||||
constructor(private readonly service: IntegrationsService) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("integrations.read")
|
||||
@Get()
|
||||
findAll(@Req() req: Request) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.findAll(tenantId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("integrations.write")
|
||||
@Post()
|
||||
upsert(@Req() req: Request, @Body() dto: UpsertIntegrationSettingDto) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.upsert(tenantId, dto);
|
||||
}
|
||||
}
|
||||
|
||||
11
apps/api/src/modules/integrations/integrations.module.ts
Normal file
11
apps/api/src/modules/integrations/integrations.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { IntegrationsController } from "./integrations.controller";
|
||||
import { IntegrationsService } from "./integrations.service";
|
||||
|
||||
@Module({
|
||||
controllers: [IntegrationsController],
|
||||
providers: [IntegrationsService],
|
||||
exports: [IntegrationsService]
|
||||
})
|
||||
export class IntegrationsModule {}
|
||||
|
||||
22
apps/api/src/modules/integrations/integrations.service.ts
Normal file
22
apps/api/src/modules/integrations/integrations.service.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import type { UpsertIntegrationSettingDto } from "./dto";
|
||||
|
||||
@Injectable()
|
||||
export class IntegrationsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findAll(tenantId: string) {
|
||||
return this.prisma.integrationSetting.findMany({ where: { tenantId }, orderBy: { key: "asc" } });
|
||||
}
|
||||
|
||||
async upsert(tenantId: string, dto: UpsertIntegrationSettingDto) {
|
||||
const row = await this.prisma.integrationSetting.upsert({
|
||||
where: { tenantId_key: { tenantId, key: dto.key } },
|
||||
create: { tenantId, key: dto.key, value: dto.value ?? null, isSecret: dto.isSecret ?? false },
|
||||
update: { value: dto.value ?? null, isSecret: dto.isSecret ?? false }
|
||||
});
|
||||
return row;
|
||||
}
|
||||
}
|
||||
|
||||
45
apps/api/src/modules/invitees/dto.ts
Normal file
45
apps/api/src/modules/invitees/dto.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { IsEmail, IsIn, IsOptional, IsString, MinLength } from "class-validator";
|
||||
|
||||
export class CreateInviteeDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
fullName!: string;
|
||||
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsString()
|
||||
eventId!: string;
|
||||
}
|
||||
|
||||
export class UpdateInviteeDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
fullName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(["invited", "delivered", "opened", "rsvped", "bounced"])
|
||||
status?: "invited" | "delivered" | "opened" | "rsvped" | "bounced";
|
||||
}
|
||||
|
||||
export class PublicRsvpDto {
|
||||
@IsIn(["yes", "no", "maybe"])
|
||||
response!: "yes" | "no" | "maybe";
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
51
apps/api/src/modules/invitees/invitees.controller.ts
Normal file
51
apps/api/src/modules/invitees/invitees.controller.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
|
||||
import { CreateInviteeDto, UpdateInviteeDto } from "./dto";
|
||||
import { InviteesService } from "./invitees.service";
|
||||
|
||||
@Controller("invitees")
|
||||
export class InviteesController {
|
||||
constructor(private readonly service: InviteesService) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("invitees.read")
|
||||
@Get()
|
||||
findAll(@Req() req: Request) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.findAll(tenantId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("invitees.write")
|
||||
@Post()
|
||||
create(@Req() req: Request, @Body() dto: CreateInviteeDto) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.create(tenantId, dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("invitees.read")
|
||||
@Get(":id")
|
||||
findOne(@Req() req: Request, @Param("id") id: string) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.findOne(tenantId, id);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("invitees.write")
|
||||
@Patch(":id")
|
||||
update(@Req() req: Request, @Param("id") id: string, @Body() dto: UpdateInviteeDto) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.update(tenantId, id, dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("invitees.write")
|
||||
@Delete(":id")
|
||||
remove(@Req() req: Request, @Param("id") id: string) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.remove(tenantId, id);
|
||||
}
|
||||
}
|
||||
|
||||
14
apps/api/src/modules/invitees/invitees.module.ts
Normal file
14
apps/api/src/modules/invitees/invitees.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuditModule } from "../audit/audit.module";
|
||||
import { QueuesModule } from "../queues/queues.module";
|
||||
import { InviteesController } from "./invitees.controller";
|
||||
import { InviteesService } from "./invitees.service";
|
||||
import { PublicInviteesController } from "./public-invitees.controller";
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, QueuesModule],
|
||||
controllers: [InviteesController, PublicInviteesController],
|
||||
providers: [InviteesService],
|
||||
exports: [InviteesService]
|
||||
})
|
||||
export class InviteesModule {}
|
||||
136
apps/api/src/modules/invitees/invitees.service.ts
Normal file
136
apps/api/src/modules/invitees/invitees.service.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { randomBytes } from "crypto";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { AuditService } from "../audit/audit.service";
|
||||
import { QueuesService } from "../queues/queues.service";
|
||||
import { CreateInviteeDto, PublicRsvpDto, UpdateInviteeDto } from "./dto";
|
||||
|
||||
@Injectable()
|
||||
export class InviteesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly queues: QueuesService
|
||||
) {}
|
||||
|
||||
async findAll(tenantId: string) {
|
||||
return this.prisma.invitee.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { event: true }
|
||||
});
|
||||
}
|
||||
|
||||
async create(tenantId: string, dto: CreateInviteeDto) {
|
||||
const email = dto.email.toLowerCase().trim();
|
||||
const event = await this.prisma.event.findFirst({ where: { id: dto.eventId, tenantId } });
|
||||
if (!event) throw new NotFoundException("Event not found");
|
||||
|
||||
const code = randomBytes(10).toString("hex").toUpperCase();
|
||||
try {
|
||||
const invitee = await this.prisma.invitee.create({
|
||||
data: {
|
||||
tenantId,
|
||||
eventId: dto.eventId,
|
||||
fullName: dto.fullName,
|
||||
email,
|
||||
phone: dto.phone?.trim().length ? dto.phone : null,
|
||||
code
|
||||
}
|
||||
});
|
||||
const baseUrl = (process.env.PUBLIC_WEB_URL ?? "http://localhost:3000").replace(/\/$/, "");
|
||||
const subject = `Invitation: ${event.name}`;
|
||||
const text = `Hello ${invitee.fullName},\n\nYou are invited to ${event.name}.\nRSVP here: ${baseUrl}/invite/${invitee.code}\n\nThank you.`;
|
||||
await this.queues.queue("communications").add("sendEmail", {
|
||||
tenantId,
|
||||
to: invitee.email,
|
||||
subject,
|
||||
text,
|
||||
inviteeId: invitee.id
|
||||
});
|
||||
await this.audit.log("invitees.create", undefined, { tenantId, entityType: "Invitee", entityId: invitee.id, metadata: { eventId: dto.eventId } });
|
||||
return invitee;
|
||||
} catch (e: any) {
|
||||
if (typeof e?.message === "string" && e.message.includes("Invitee_tenantId_eventId_email_key")) {
|
||||
throw new BadRequestException("Invitee already exists for this event");
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async findOne(tenantId: string, id: string) {
|
||||
const invitee = await this.prisma.invitee.findFirst({
|
||||
where: { id, tenantId },
|
||||
include: { event: true, rsvps: { orderBy: { createdAt: "desc" } } }
|
||||
});
|
||||
if (!invitee) throw new NotFoundException("Invitee not found");
|
||||
return invitee;
|
||||
}
|
||||
|
||||
async update(tenantId: string, id: string, dto: UpdateInviteeDto) {
|
||||
await this.findOne(tenantId, id);
|
||||
const invitee = await this.prisma.invitee.update({
|
||||
where: { id },
|
||||
data: {
|
||||
fullName: dto.fullName,
|
||||
email: dto.email ? dto.email.toLowerCase().trim() : undefined,
|
||||
phone: dto.phone === undefined ? undefined : dto.phone?.trim().length ? dto.phone : null,
|
||||
status: dto.status
|
||||
}
|
||||
});
|
||||
await this.audit.log("invitees.update", undefined, { tenantId, entityType: "Invitee", entityId: id });
|
||||
return invitee;
|
||||
}
|
||||
|
||||
async remove(tenantId: string, id: string) {
|
||||
await this.findOne(tenantId, id);
|
||||
await this.prisma.invitee.delete({ where: { id } });
|
||||
await this.audit.log("invitees.delete", undefined, { tenantId, entityType: "Invitee", entityId: id });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async publicGetInviteeByCode(code: string) {
|
||||
const invitee = await this.prisma.invitee.findFirst({
|
||||
where: { code },
|
||||
include: { event: true }
|
||||
});
|
||||
if (!invitee) throw new NotFoundException("Invite not found");
|
||||
return {
|
||||
invitee: {
|
||||
code: invitee.code,
|
||||
fullName: invitee.fullName,
|
||||
email: invitee.email,
|
||||
phone: invitee.phone,
|
||||
status: invitee.status
|
||||
},
|
||||
event: {
|
||||
id: invitee.event.id,
|
||||
name: invitee.event.name,
|
||||
slug: invitee.event.slug,
|
||||
startsAt: invitee.event.startsAt,
|
||||
venue: invitee.event.venue,
|
||||
status: invitee.event.status
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async publicRsvp(code: string, dto: PublicRsvpDto) {
|
||||
const invitee = await this.prisma.invitee.findFirst({ where: { code } });
|
||||
if (!invitee) throw new NotFoundException("Invite not found");
|
||||
|
||||
const rsvp = await this.prisma.rSVP.create({
|
||||
data: {
|
||||
tenantId: invitee.tenantId,
|
||||
eventId: invitee.eventId,
|
||||
inviteeId: invitee.id,
|
||||
response: dto.response,
|
||||
note: dto.note?.trim().length ? dto.note : null
|
||||
}
|
||||
});
|
||||
|
||||
await this.prisma.invitee.update({ where: { id: invitee.id }, data: { status: "rsvped" } });
|
||||
await this.audit.log("rsvps.public", undefined, { tenantId: invitee.tenantId, entityType: "RSVP", entityId: rsvp.id, metadata: { inviteeId: invitee.id, response: dto.response } });
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
19
apps/api/src/modules/invitees/public-invitees.controller.ts
Normal file
19
apps/api/src/modules/invitees/public-invitees.controller.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Body, Controller, Get, Param, Post } from "@nestjs/common";
|
||||
import { PublicRsvpDto } from "./dto";
|
||||
import { InviteesService } from "./invitees.service";
|
||||
|
||||
@Controller("public/invitees")
|
||||
export class PublicInviteesController {
|
||||
constructor(private readonly service: InviteesService) {}
|
||||
|
||||
@Get(":code")
|
||||
getInvite(@Param("code") code: string) {
|
||||
return this.service.publicGetInviteeByCode(code);
|
||||
}
|
||||
|
||||
@Post(":code/rsvp")
|
||||
rsvp(@Param("code") code: string, @Body() dto: PublicRsvpDto) {
|
||||
return this.service.publicRsvp(code, dto);
|
||||
}
|
||||
}
|
||||
|
||||
15
apps/api/src/modules/payments/dto.ts
Normal file
15
apps/api/src/modules/payments/dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { IsEmail, IsNumber, IsOptional, IsString, Min } from "class-validator";
|
||||
|
||||
export class PublicInitializePaystackDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
amountNaira!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
tenantSlug?: string;
|
||||
}
|
||||
|
||||
65
apps/api/src/modules/payments/payments.controller.ts
Normal file
65
apps/api/src/modules/payments/payments.controller.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Body, Controller, Get, Headers, Param, Post, Query, Req, UnauthorizedException, UseGuards } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { createHmac } from "node:crypto";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
|
||||
import { PublicInitializePaystackDto } from "./dto";
|
||||
import { QueuesService } from "../queues/queues.service";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
@Controller("payments")
|
||||
export class PaymentsController {
|
||||
constructor(private readonly service: PaymentsService) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("payments.read")
|
||||
@Get()
|
||||
findAll(@Req() req: Request) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.findAll(tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller("public/payments")
|
||||
export class PublicPaymentsController {
|
||||
constructor(private readonly service: PaymentsService) {}
|
||||
|
||||
@Post("events/:slug/paystack/initialize")
|
||||
initialize(@Param("slug") slug: string, @Body() dto: PublicInitializePaystackDto) {
|
||||
return this.service.initializePaystackPublic(slug, dto.email, dto.amountNaira, dto.tenantSlug);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller("public/webhooks/paystack")
|
||||
export class PaystackWebhooksController {
|
||||
constructor(
|
||||
private readonly queues: QueuesService,
|
||||
private readonly prisma: PrismaService
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
async handle(
|
||||
@Req() req: Request & { rawBody?: Buffer },
|
||||
@Headers("x-paystack-signature") signature?: string,
|
||||
@Body() body?: any,
|
||||
@Query("tenantSlug") tenantSlug?: string
|
||||
) {
|
||||
let secret = process.env.PAYSTACK_SECRET_KEY;
|
||||
if (tenantSlug) {
|
||||
const tenant = await this.prisma.tenant.findUnique({ where: { slug: tenantSlug } });
|
||||
if (tenant) {
|
||||
const integrationSecret = await this.prisma.integrationSetting.findUnique({
|
||||
where: { tenantId_key: { tenantId: tenant.id, key: "paystack.secretKey" } }
|
||||
});
|
||||
secret = integrationSecret?.value ?? secret;
|
||||
}
|
||||
}
|
||||
if (!secret) throw new UnauthorizedException();
|
||||
|
||||
const raw = req.rawBody ?? Buffer.from(JSON.stringify(body ?? {}));
|
||||
const computed = createHmac("sha512", secret).update(raw).digest("hex");
|
||||
if (!signature || computed !== signature) throw new UnauthorizedException();
|
||||
|
||||
await this.queues.queue("webhooks").add("paystack", { body, tenantSlug, receivedAt: new Date().toISOString() });
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
7
apps/api/src/modules/payments/payments.module.ts
Normal file
7
apps/api/src/modules/payments/payments.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PaymentsController, PaystackWebhooksController, PublicPaymentsController } from "./payments.controller";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
import { AuditModule } from "../audit/audit.module";
|
||||
import { QueuesModule } from "../queues/queues.module";
|
||||
@Module({ imports: [AuditModule, QueuesModule], controllers: [PaymentsController, PublicPaymentsController, PaystackWebhooksController], providers: [PaymentsService], exports: [PaymentsService] })
|
||||
export class PaymentsModule {}
|
||||
82
apps/api/src/modules/payments/payments.service.ts
Normal file
82
apps/api/src/modules/payments/payments.service.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import axios from "axios";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { AuditService } from "../audit/audit.service";
|
||||
|
||||
@Injectable()
|
||||
export class PaymentsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService
|
||||
) {}
|
||||
|
||||
findAll(tenantId: string) {
|
||||
return this.prisma.paymentTransaction.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { event: true }
|
||||
});
|
||||
}
|
||||
|
||||
async initializePaystackPublic(eventSlug: string, email: string, amountNaira: number, tenantSlug?: string) {
|
||||
const resolvedTenantSlug = tenantSlug ?? process.env.DEFAULT_TENANT_SLUG ?? "";
|
||||
if (!resolvedTenantSlug) throw new BadRequestException("Missing tenantSlug");
|
||||
|
||||
const tenant = await this.prisma.tenant.findUnique({ where: { slug: resolvedTenantSlug } });
|
||||
if (!tenant) throw new NotFoundException("Tenant not found");
|
||||
|
||||
const event = await this.prisma.event.findFirst({ where: { tenantId: tenant.id, slug: eventSlug } });
|
||||
if (!event) throw new NotFoundException("Event not found");
|
||||
|
||||
const amount = Number.isFinite(amountNaira) ? Math.round(amountNaira * 100) : NaN;
|
||||
if (!amount || amount < 100) throw new BadRequestException("Invalid amount");
|
||||
|
||||
const secret =
|
||||
(await this.prisma.integrationSetting
|
||||
.findUnique({ where: { tenantId_key: { tenantId: tenant.id, key: "paystack.secretKey" } } })
|
||||
.then((r) => r?.value ?? null)) ??
|
||||
process.env.PAYSTACK_SECRET_KEY;
|
||||
if (!secret) throw new BadRequestException("PAYSTACK_SECRET_KEY is not configured");
|
||||
|
||||
const reference = `EVT-${randomBytes(10).toString("hex").toUpperCase()}`;
|
||||
const row = await this.prisma.paymentTransaction.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
eventId: event.id,
|
||||
email: email.toLowerCase().trim(),
|
||||
amountKobo: amount,
|
||||
reference,
|
||||
status: "initialized",
|
||||
raw: Prisma.JsonNull
|
||||
}
|
||||
});
|
||||
|
||||
const callbackUrl =
|
||||
(await this.prisma.integrationSetting
|
||||
.findUnique({ where: { tenantId_key: { tenantId: tenant.id, key: "paystack.callbackUrl" } } })
|
||||
.then((r) => r?.value ?? null)) ??
|
||||
process.env.PAYSTACK_CALLBACK_URL ??
|
||||
undefined;
|
||||
|
||||
const res = await axios.post(
|
||||
"https://api.paystack.co/transaction/initialize",
|
||||
{ email: row.email, amount: row.amountKobo, reference, callback_url: callbackUrl },
|
||||
{ headers: { Authorization: `Bearer ${secret}`, "Content-Type": "application/json" } }
|
||||
);
|
||||
|
||||
const payload = res.data as any;
|
||||
if (!payload?.status || !payload?.data?.authorization_url) {
|
||||
throw new BadRequestException("Paystack initialization failed");
|
||||
}
|
||||
|
||||
await this.prisma.paymentTransaction.update({ where: { id: row.id }, data: { raw: payload } });
|
||||
await this.audit.log("payments.paystack.initialize", undefined, { tenantId: tenant.id, entityType: "PaymentTransaction", entityId: row.id, metadata: { eventId: event.id } });
|
||||
|
||||
return {
|
||||
reference,
|
||||
authorizationUrl: String(payload.data.authorization_url)
|
||||
};
|
||||
}
|
||||
}
|
||||
25
apps/api/src/modules/qrcode/qrcode.controller.ts
Normal file
25
apps/api/src/modules/qrcode/qrcode.controller.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Body, Controller, Get, Post, Req, UseGuards } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { QrcodeService } from "./qrcode.service";
|
||||
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
|
||||
|
||||
@Controller("qrcode")
|
||||
export class QrcodeController {
|
||||
constructor(private readonly service: QrcodeService) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("qrcodes.read")
|
||||
@Get()
|
||||
findAll(@Req() req: Request) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.findAll(tenantId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("qrcodes.write")
|
||||
@Post()
|
||||
create(@Req() req: Request, @Body() payload: { registrationId?: string }) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.createForRegistration(tenantId, String(payload.registrationId ?? ""));
|
||||
}
|
||||
}
|
||||
5
apps/api/src/modules/qrcode/qrcode.module.ts
Normal file
5
apps/api/src/modules/qrcode/qrcode.module.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { QrcodeController } from "./qrcode.controller";
|
||||
import { QrcodeService } from "./qrcode.service";
|
||||
@Module({ controllers: [QrcodeController], providers: [QrcodeService], exports: [QrcodeService] })
|
||||
export class QrcodeModule {}
|
||||
52
apps/api/src/modules/qrcode/qrcode.service.ts
Normal file
52
apps/api/src/modules/qrcode/qrcode.service.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import QR from "qrcode";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class QrcodeService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findAll(tenantId: string) {
|
||||
return this.prisma.qRCode.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
registration: { include: { attendee: true, event: true } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async createForRegistration(tenantId: string, registrationId: string) {
|
||||
const registration = await this.prisma.registration.findFirst({
|
||||
where: { id: registrationId, tenantId },
|
||||
include: { attendee: true, event: true }
|
||||
});
|
||||
if (!registration) throw new NotFoundException("Registration not found");
|
||||
|
||||
const payload = {
|
||||
code: registration.code,
|
||||
eventId: registration.eventId,
|
||||
tenantId: registration.tenantId,
|
||||
registrationId: registration.id
|
||||
};
|
||||
|
||||
const qrCode = await this.prisma.qRCode.upsert({
|
||||
where: { registrationId: registration.id },
|
||||
create: {
|
||||
tenantId,
|
||||
registrationId: registration.id,
|
||||
code: registration.code,
|
||||
payload
|
||||
},
|
||||
update: {
|
||||
code: registration.code,
|
||||
payload,
|
||||
status: "active"
|
||||
},
|
||||
include: { registration: { include: { attendee: true, event: true } } }
|
||||
});
|
||||
|
||||
const dataUrl = await QR.toDataURL(JSON.stringify(payload), { errorCorrectionLevel: "M", margin: 1, width: 320 });
|
||||
return { qrCode, dataUrl };
|
||||
}
|
||||
}
|
||||
8
apps/api/src/modules/queues/queues.module.ts
Normal file
8
apps/api/src/modules/queues/queues.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { QueuesService } from "./queues.service";
|
||||
|
||||
@Module({
|
||||
providers: [QueuesService],
|
||||
exports: [QueuesService]
|
||||
})
|
||||
export class QueuesModule {}
|
||||
279
apps/api/src/modules/queues/queues.service.ts
Normal file
279
apps/api/src/modules/queues/queues.service.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
|
||||
import { Queue, Worker } from "bullmq";
|
||||
import Redis from "ioredis";
|
||||
import nodemailer from "nodemailer";
|
||||
import axios from "axios";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
|
||||
type QueueName = "communications" | "qrcode" | "workflows" | "webhooks" | "reports";
|
||||
|
||||
@Injectable()
|
||||
export class QueuesService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly connection: Redis;
|
||||
private readonly queues: Record<QueueName, Queue>;
|
||||
private readonly workers: Worker[] = [];
|
||||
private readonly transporters = new Map<string, nodemailer.Transporter>();
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {
|
||||
const url = process.env.REDIS_URL ?? "redis://localhost:6379";
|
||||
this.connection = new Redis(url, { maxRetriesPerRequest: null });
|
||||
this.queues = {
|
||||
communications: new Queue("communications", { connection: this.connection }),
|
||||
qrcode: new Queue("qrcode", { connection: this.connection }),
|
||||
workflows: new Queue("workflows", { connection: this.connection }),
|
||||
webhooks: new Queue("webhooks", { connection: this.connection }),
|
||||
reports: new Queue("reports", { connection: this.connection })
|
||||
};
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
if (process.env.PROCESS_QUEUES !== "1") return;
|
||||
|
||||
this.workers.push(
|
||||
new Worker(
|
||||
"communications",
|
||||
async (job) => {
|
||||
if (job.name === "sendEmail") {
|
||||
const tenantId = String((job.data as any).tenantId ?? "");
|
||||
const host = (await this.getIntegrationValue(tenantId, "smtp.host")) ?? process.env.SMTP_HOST;
|
||||
const portRaw = (await this.getIntegrationValue(tenantId, "smtp.port")) ?? process.env.SMTP_PORT ?? "587";
|
||||
const user = (await this.getIntegrationValue(tenantId, "smtp.user")) ?? process.env.SMTP_USER;
|
||||
const pass = (await this.getIntegrationValue(tenantId, "smtp.pass")) ?? process.env.SMTP_PASS;
|
||||
const from = (await this.getIntegrationValue(tenantId, "smtp.from")) ?? process.env.SMTP_FROM ?? user;
|
||||
|
||||
const port = Number(portRaw);
|
||||
if (!host || !port || !user || !pass || !from) throw new Error("SMTP is not configured");
|
||||
|
||||
const transporterKey = `${host}:${port}:${user}`;
|
||||
const transporter =
|
||||
this.transporters.get(transporterKey) ??
|
||||
nodemailer.createTransport({
|
||||
host,
|
||||
port,
|
||||
secure: port === 465,
|
||||
auth: { user, pass }
|
||||
});
|
||||
this.transporters.set(transporterKey, transporter);
|
||||
|
||||
const to = String((job.data as any).to ?? "");
|
||||
const subject = String((job.data as any).subject ?? "");
|
||||
const text = (job.data as any).text ? String((job.data as any).text) : undefined;
|
||||
const html = (job.data as any).html ? String((job.data as any).html) : undefined;
|
||||
const communicationLogId = (job.data as any).communicationLogId ? String((job.data as any).communicationLogId) : undefined;
|
||||
|
||||
if (!to || !subject) throw new Error("Invalid email payload");
|
||||
|
||||
try {
|
||||
const sent = await transporter.sendMail({ from, to, subject, text, html });
|
||||
const inviteeId = (job.data as any).inviteeId ? String((job.data as any).inviteeId) : undefined;
|
||||
if (inviteeId) {
|
||||
await this.prisma.invitee.updateMany({ where: { id: inviteeId }, data: { status: "delivered" } });
|
||||
}
|
||||
await this.markCommunicationLog(communicationLogId, "sent", sent?.messageId ? String(sent.messageId) : undefined);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
await this.markCommunicationLog(communicationLogId, "failed", undefined, e?.message ? String(e.message) : "Delivery failed");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
if (job.name === "sendSms" || job.name === "sendWhatsapp") {
|
||||
const tenantId = String((job.data as any).tenantId ?? "");
|
||||
const username =
|
||||
(await this.getIntegrationValue(tenantId, "africastalking.username")) ??
|
||||
process.env.AFRICASTALKING_USERNAME ??
|
||||
process.env.AT_USERNAME;
|
||||
const apiKey =
|
||||
(await this.getIntegrationValue(tenantId, "africastalking.apiKey")) ??
|
||||
process.env.AFRICASTALKING_API_KEY ??
|
||||
process.env.AT_API_KEY;
|
||||
if (!username || !apiKey) throw new Error("Africa's Talking is not configured");
|
||||
|
||||
const to = String((job.data as any).to ?? "");
|
||||
const message = String((job.data as any).message ?? "");
|
||||
const communicationLogId = (job.data as any).communicationLogId ? String((job.data as any).communicationLogId) : undefined;
|
||||
if (!to || !message) throw new Error("Invalid message payload");
|
||||
|
||||
try {
|
||||
let providerMessageId: string | undefined;
|
||||
if (job.name === "sendSms") {
|
||||
const from =
|
||||
(job.data as any).from
|
||||
? String((job.data as any).from)
|
||||
: (await this.getIntegrationValue(tenantId, "africastalking.senderId")) ??
|
||||
process.env.AFRICASTALKING_SENDER_ID ??
|
||||
process.env.AT_SMS_FROM;
|
||||
const body = new URLSearchParams();
|
||||
body.set("username", username);
|
||||
body.set("to", to);
|
||||
body.set("message", message);
|
||||
if (from) body.set("from", from);
|
||||
|
||||
const res = await axios.post("https://api.africastalking.com/version1/messaging", body.toString(), {
|
||||
headers: { apikey: apiKey, "Content-Type": "application/x-www-form-urlencoded" }
|
||||
});
|
||||
providerMessageId = String((res.data as any)?.SMSMessageData?.Recipients?.[0]?.messageId ?? "");
|
||||
} else {
|
||||
const url =
|
||||
(await this.getIntegrationValue(tenantId, "africastalking.whatsappUrl")) ??
|
||||
process.env.AFRICASTALKING_WHATSAPP_URL ??
|
||||
process.env.AT_WHATSAPP_URL;
|
||||
if (!url) throw new Error("AT_WHATSAPP_URL is not configured");
|
||||
const res = await axios.post(
|
||||
url,
|
||||
{ username, to, message },
|
||||
{ headers: { apikey: apiKey, "Content-Type": "application/json" } }
|
||||
);
|
||||
providerMessageId = String((res.data as any)?.id ?? "");
|
||||
}
|
||||
|
||||
const inviteeId = (job.data as any).inviteeId ? String((job.data as any).inviteeId) : undefined;
|
||||
if (inviteeId) {
|
||||
await this.prisma.invitee.updateMany({ where: { id: inviteeId }, data: { status: "delivered" } });
|
||||
}
|
||||
|
||||
await this.markCommunicationLog(communicationLogId, "sent", providerMessageId || undefined);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
await this.markCommunicationLog(communicationLogId, "failed", undefined, e?.message ? String(e.message) : "Delivery failed");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
},
|
||||
{ connection: this.connection }
|
||||
)
|
||||
);
|
||||
|
||||
this.workers.push(
|
||||
new Worker(
|
||||
"qrcode",
|
||||
async () => {
|
||||
return { ok: true };
|
||||
},
|
||||
{ connection: this.connection }
|
||||
)
|
||||
);
|
||||
|
||||
this.workers.push(
|
||||
new Worker(
|
||||
"workflows",
|
||||
async () => {
|
||||
return { ok: true };
|
||||
},
|
||||
{ connection: this.connection }
|
||||
)
|
||||
);
|
||||
|
||||
this.workers.push(
|
||||
new Worker(
|
||||
"webhooks",
|
||||
async (job) => {
|
||||
if (job.name === "paystack") {
|
||||
const payload = (job.data as any)?.body;
|
||||
const event = String(payload?.event ?? "");
|
||||
const reference = String(payload?.data?.reference ?? "");
|
||||
const tenantSlug = (job.data as any)?.tenantSlug ? String((job.data as any).tenantSlug) : undefined;
|
||||
const tenant = tenantSlug ? await this.prisma.tenant.findUnique({ where: { slug: tenantSlug } }) : null;
|
||||
|
||||
const webhook = await this.prisma.paystackWebhookEvent.create({
|
||||
data: {
|
||||
tenantId: tenant?.id ?? null,
|
||||
event,
|
||||
reference: reference || null,
|
||||
raw: payload ?? {},
|
||||
status: "received"
|
||||
}
|
||||
});
|
||||
|
||||
if (reference) {
|
||||
const nextStatus =
|
||||
event === "charge.success" ? "success" :
|
||||
event === "charge.failed" ? "failed" :
|
||||
undefined;
|
||||
|
||||
if (nextStatus) {
|
||||
await this.prisma.paymentTransaction.updateMany({
|
||||
where: { reference, ...(tenant?.id ? { tenantId: tenant.id } : {}) },
|
||||
data: { status: nextStatus, raw: payload }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.paystackWebhookEvent.update({
|
||||
where: { id: webhook.id },
|
||||
data: { status: "processed", processedAt: new Date() }
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
},
|
||||
{ connection: this.connection }
|
||||
)
|
||||
);
|
||||
|
||||
this.workers.push(
|
||||
new Worker(
|
||||
"reports",
|
||||
async (job) => {
|
||||
if (job.name === "registrationsCsv") {
|
||||
const tenantId = String((job.data as any).tenantId ?? "");
|
||||
const eventId = (job.data as any).eventId ? String((job.data as any).eventId) : undefined;
|
||||
if (!tenantId) throw new Error("Missing tenantId");
|
||||
|
||||
const rows = await this.prisma.registration.findMany({
|
||||
where: { tenantId, ...(eventId ? { eventId } : {}) },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { attendee: true, event: true }
|
||||
});
|
||||
|
||||
const header = ["code", "status", "createdAt", "attendeeFullName", "attendeeEmail", "eventName"].join(",");
|
||||
const lines = rows.map((r) => {
|
||||
const fields = [
|
||||
r.code,
|
||||
r.status,
|
||||
r.createdAt.toISOString(),
|
||||
r.attendee.fullName,
|
||||
r.attendee.email,
|
||||
r.event.name
|
||||
].map((v) => `"${String(v).replaceAll('"', '""')}"`);
|
||||
return fields.join(",");
|
||||
});
|
||||
|
||||
return { csv: [header, ...lines].join("\n") };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
},
|
||||
{ connection: this.connection }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await Promise.allSettled(this.workers.map((w) => w.close()));
|
||||
await Promise.allSettled(Object.values(this.queues).map((q) => q.close()));
|
||||
await this.connection.quit();
|
||||
}
|
||||
|
||||
queue(name: QueueName) {
|
||||
return this.queues[name];
|
||||
}
|
||||
|
||||
private async getIntegrationValue(tenantId: string, key: string) {
|
||||
if (!tenantId) return null;
|
||||
const row = await this.prisma.integrationSetting.findUnique({ where: { tenantId_key: { tenantId, key } } });
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
private async markCommunicationLog(id: string | undefined, status: "sent" | "failed", providerMessageId?: string, error?: string) {
|
||||
if (!id) return;
|
||||
await this.prisma.communicationLog.updateMany({
|
||||
where: { id },
|
||||
data: { status, providerMessageId, error }
|
||||
});
|
||||
}
|
||||
}
|
||||
24
apps/api/src/modules/registrations/dto.ts
Normal file
24
apps/api/src/modules/registrations/dto.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { IsEmail, IsNotEmpty, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class CreateRegistrationDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
eventId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
attendeeId!: string;
|
||||
}
|
||||
|
||||
export class PublicRegisterDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
fullName!: string;
|
||||
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
}
|
||||
115
apps/api/src/modules/registrations/registrations.controller.ts
Normal file
115
apps/api/src/modules/registrations/registrations.controller.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, Req, UseGuards } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { RegistrationsService } from "./registrations.service";
|
||||
import { CreateRegistrationDto, PublicRegisterDto } from "./dto";
|
||||
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
|
||||
@Controller("registrations")
|
||||
export class RegistrationsController {
|
||||
constructor(private readonly service: RegistrationsService) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("registrations.read")
|
||||
@Get()
|
||||
findAll(@Req() req: Request) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.findAll(tenantId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("registrations.write")
|
||||
@Post()
|
||||
create(@Req() req: Request, @Body() dto: CreateRegistrationDto) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.create(tenantId, dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("registrations.read")
|
||||
@Get("by-code/:code")
|
||||
async byCode(@Req() req: Request, @Param("code") code: string) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
const row = await this.service.registrationByCode(tenantId, code);
|
||||
return {
|
||||
registration: {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
status: row.status,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
event: { id: row.event.id, name: row.event.name, slug: row.event.slug, startsAt: row.event.startsAt, venue: row.event.venue },
|
||||
attendee: { id: row.attendee.id, fullName: row.attendee.fullName, email: row.attendee.email, phone: row.attendee.phone }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("registrations.read")
|
||||
@Get("recent-checkins")
|
||||
async recentCheckins(@Req() req: Request, @Query("limit") limit?: string) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
const rows = await this.service.recentCheckins(tenantId, limit ? Number(limit) : 25);
|
||||
return {
|
||||
checkins: rows.map((r) => ({
|
||||
id: r.id,
|
||||
code: r.code,
|
||||
status: r.registration.status,
|
||||
checkedInAt: r.createdAt,
|
||||
result: r.result,
|
||||
event: { id: r.event.id, name: r.event.name },
|
||||
attendee: r.attendee ? { id: r.attendee.id, fullName: r.attendee.fullName, email: r.attendee.email } : null
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("registrations.write")
|
||||
@Post("by-code/:code/check-in")
|
||||
async checkIn(@Req() req: Request, @Param("code") code: string) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
const actorUserId = (req.user as any).sub as string;
|
||||
return this.service.checkInByCode(tenantId, actorUserId, code, req);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller("public/events")
|
||||
export class PublicRegistrationsController {
|
||||
constructor(private readonly service: RegistrationsService) {}
|
||||
|
||||
@Get(":slug")
|
||||
async getEvent(@Param("slug") slug: string, @Query("tenantSlug") tenantSlug?: string) {
|
||||
const resolved = tenantSlug ?? process.env.DEFAULT_TENANT_SLUG;
|
||||
if (!resolved) return { message: "Missing tenantSlug" };
|
||||
const { event } = await this.service.publicEventBySlug(resolved, slug);
|
||||
return { event };
|
||||
}
|
||||
|
||||
@Post(":slug/register")
|
||||
async register(@Param("slug") slug: string, @Body() dto: PublicRegisterDto, @Query("tenantSlug") tenantSlug?: string) {
|
||||
return this.service.publicRegister(slug, dto, tenantSlug);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller("public/registrations")
|
||||
export class PublicRegistrationLookupController {
|
||||
constructor(private readonly service: RegistrationsService) {}
|
||||
|
||||
@Get(":code")
|
||||
async byCode(@Param("code") code: string) {
|
||||
const row = await this.service.publicRegistrationByCode(code);
|
||||
return {
|
||||
registration: {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
status: row.status,
|
||||
createdAt: row.createdAt,
|
||||
event: { id: row.event.id, name: row.event.name, slug: row.event.slug, startsAt: row.event.startsAt, venue: row.event.venue },
|
||||
attendee: { id: row.attendee.id, fullName: row.attendee.fullName, email: row.attendee.email, phone: row.attendee.phone }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Get(":code/qrcode")
|
||||
async qr(@Param("code") code: string) {
|
||||
return this.service.publicRegistrationQr(code);
|
||||
}
|
||||
}
|
||||
11
apps/api/src/modules/registrations/registrations.module.ts
Normal file
11
apps/api/src/modules/registrations/registrations.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PublicRegistrationLookupController, PublicRegistrationsController, RegistrationsController } from "./registrations.controller";
|
||||
import { RegistrationsService } from "./registrations.service";
|
||||
import { AuditModule } from "../audit/audit.module";
|
||||
@Module({
|
||||
imports: [AuditModule],
|
||||
controllers: [RegistrationsController, PublicRegistrationsController, PublicRegistrationLookupController],
|
||||
providers: [RegistrationsService],
|
||||
exports: [RegistrationsService]
|
||||
})
|
||||
export class RegistrationsModule {}
|
||||
207
apps/api/src/modules/registrations/registrations.service.ts
Normal file
207
apps/api/src/modules/registrations/registrations.service.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { AuditService } from "../audit/audit.service";
|
||||
import type { CreateRegistrationDto, PublicRegisterDto } from "./dto";
|
||||
import QRCode from "qrcode";
|
||||
|
||||
@Injectable()
|
||||
export class RegistrationsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService
|
||||
) {}
|
||||
|
||||
findAll(tenantId: string) {
|
||||
return this.prisma.registration.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { attendee: true, event: true }
|
||||
});
|
||||
}
|
||||
|
||||
async create(tenantId: string, dto: CreateRegistrationDto) {
|
||||
const event = await this.prisma.event.findFirst({ where: { id: dto.eventId, tenantId } });
|
||||
if (!event) throw new NotFoundException("Event not found");
|
||||
const attendee = await this.prisma.attendee.findFirst({ where: { id: dto.attendeeId, tenantId } });
|
||||
if (!attendee) throw new NotFoundException("Attendee not found");
|
||||
|
||||
const code = randomBytes(5).toString("hex").toUpperCase();
|
||||
try {
|
||||
return await this.prisma.registration.create({
|
||||
data: { tenantId, eventId: event.id, attendeeId: attendee.id, code, status: "pending", source: "admin" },
|
||||
include: { attendee: true, event: true }
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (String(e?.code) === "P2002") throw new BadRequestException("Registration already exists");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async publicEventBySlug(tenantSlug: string, eventSlug: string) {
|
||||
const tenant = await this.prisma.tenant.findUnique({ where: { slug: tenantSlug } });
|
||||
if (!tenant) throw new NotFoundException("Tenant not found");
|
||||
const event = await this.prisma.event.findFirst({
|
||||
where: { tenantId: tenant.id, slug: eventSlug },
|
||||
include: {
|
||||
eventPage: true,
|
||||
ticketTypes: { where: { isActive: true }, orderBy: { createdAt: "asc" } }
|
||||
}
|
||||
});
|
||||
if (!event) throw new NotFoundException("Event not found");
|
||||
return { tenant, event };
|
||||
}
|
||||
|
||||
async publicRegister(eventSlug: string, dto: PublicRegisterDto, tenantSlug?: string) {
|
||||
const resolvedTenantSlug = tenantSlug ?? process.env.DEFAULT_TENANT_SLUG ?? "";
|
||||
if (!resolvedTenantSlug) throw new BadRequestException("Missing tenant slug");
|
||||
|
||||
const { tenant, event } = await this.publicEventBySlug(resolvedTenantSlug, eventSlug);
|
||||
|
||||
const attendee = await this.prisma.attendee.upsert({
|
||||
where: { tenantId_email: { tenantId: tenant.id, email: dto.email.toLowerCase() } },
|
||||
update: { fullName: dto.fullName, phone: dto.phone },
|
||||
create: { tenantId: tenant.id, eventId: event.id, fullName: dto.fullName, email: dto.email.toLowerCase(), phone: dto.phone }
|
||||
});
|
||||
|
||||
const code = randomBytes(5).toString("hex").toUpperCase();
|
||||
try {
|
||||
const registration = await this.prisma.registration.create({
|
||||
data: { tenantId: tenant.id, eventId: event.id, attendeeId: attendee.id, code, status: "pending", source: "landing_page" }
|
||||
});
|
||||
await this.prisma.cRMLead.upsert({
|
||||
where: { tenantId_email: { tenantId: tenant.id, email: attendee.email } },
|
||||
create: {
|
||||
tenantId: tenant.id,
|
||||
eventId: event.id,
|
||||
attendeeId: attendee.id,
|
||||
fullName: attendee.fullName,
|
||||
email: attendee.email,
|
||||
phone: attendee.phone,
|
||||
source: "registration",
|
||||
status: "new"
|
||||
},
|
||||
update: {
|
||||
eventId: event.id,
|
||||
attendeeId: attendee.id,
|
||||
fullName: attendee.fullName,
|
||||
phone: attendee.phone,
|
||||
source: "registration"
|
||||
}
|
||||
});
|
||||
return { registrationId: registration.id, code };
|
||||
} catch (e: any) {
|
||||
if (String(e?.code) === "P2002") throw new BadRequestException("Already registered");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async publicRegistrationByCode(code: string) {
|
||||
const row = await this.prisma.registration.findUnique({
|
||||
where: { code: code.toUpperCase() },
|
||||
include: { attendee: true, event: true, tenant: true }
|
||||
});
|
||||
if (!row) throw new NotFoundException("Registration not found");
|
||||
return row;
|
||||
}
|
||||
|
||||
async publicRegistrationQr(code: string) {
|
||||
const row = await this.publicRegistrationByCode(code);
|
||||
const payload = JSON.stringify({ code: row.code, eventId: row.eventId, tenantId: row.tenantId });
|
||||
await this.prisma.qRCode.upsert({
|
||||
where: { registrationId: row.id },
|
||||
create: {
|
||||
tenantId: row.tenantId,
|
||||
registrationId: row.id,
|
||||
code: row.code,
|
||||
payload: { code: row.code, eventId: row.eventId, tenantId: row.tenantId, registrationId: row.id }
|
||||
},
|
||||
update: {
|
||||
code: row.code,
|
||||
payload: { code: row.code, eventId: row.eventId, tenantId: row.tenantId, registrationId: row.id },
|
||||
status: "active"
|
||||
}
|
||||
});
|
||||
const dataUrl = await QRCode.toDataURL(payload, { errorCorrectionLevel: "M", margin: 1, width: 320 });
|
||||
return { code: row.code, dataUrl };
|
||||
}
|
||||
|
||||
async registrationByCode(tenantId: string, code: string) {
|
||||
const row = await this.prisma.registration.findFirst({
|
||||
where: { tenantId, code: code.toUpperCase() },
|
||||
include: { attendee: true, event: true }
|
||||
});
|
||||
if (!row) throw new NotFoundException("Registration not found");
|
||||
return row;
|
||||
}
|
||||
|
||||
async recentCheckins(tenantId: string, limit = 25) {
|
||||
const take = Math.min(Math.max(Number(limit || 25), 1), 100);
|
||||
return this.prisma.checkIn.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take,
|
||||
include: { registration: true, attendee: true, event: true }
|
||||
});
|
||||
}
|
||||
|
||||
async checkInByCode(tenantId: string, actorUserId: string, code: string, req?: Request) {
|
||||
const row = await this.prisma.registration.findFirst({
|
||||
where: { tenantId, code: code.toUpperCase() },
|
||||
include: { attendee: true, event: true }
|
||||
});
|
||||
if (!row) throw new NotFoundException("Registration not found");
|
||||
|
||||
if (row.status === "confirmed") {
|
||||
await this.prisma.checkIn.create({
|
||||
data: {
|
||||
tenantId,
|
||||
eventId: row.eventId,
|
||||
registrationId: row.id,
|
||||
attendeeId: row.attendeeId,
|
||||
checkedInByUserId: actorUserId,
|
||||
code: row.code,
|
||||
result: "duplicate",
|
||||
note: "Already checked in"
|
||||
}
|
||||
});
|
||||
await this.audit.log("registrations.checkin.duplicate", req, {
|
||||
tenantId,
|
||||
actorUserId,
|
||||
entityType: "registration",
|
||||
entityId: row.id,
|
||||
metadata: { code: row.code, eventId: row.eventId, attendeeId: row.attendeeId }
|
||||
});
|
||||
return { registration: row, alreadyCheckedIn: true };
|
||||
}
|
||||
|
||||
const updated = await this.prisma.registration.update({
|
||||
where: { id: row.id },
|
||||
data: { status: "confirmed", checkedInAt: new Date() },
|
||||
include: { attendee: true, event: true }
|
||||
});
|
||||
|
||||
await this.prisma.checkIn.create({
|
||||
data: {
|
||||
tenantId,
|
||||
eventId: updated.eventId,
|
||||
registrationId: updated.id,
|
||||
attendeeId: updated.attendeeId,
|
||||
checkedInByUserId: actorUserId,
|
||||
code: updated.code,
|
||||
result: "checked_in"
|
||||
}
|
||||
});
|
||||
|
||||
await this.audit.log("registrations.checkin", req, {
|
||||
tenantId,
|
||||
actorUserId,
|
||||
entityType: "registration",
|
||||
entityId: updated.id,
|
||||
metadata: { code: updated.code, eventId: updated.eventId, attendeeId: updated.attendeeId }
|
||||
});
|
||||
|
||||
return { registration: updated, alreadyCheckedIn: false };
|
||||
}
|
||||
}
|
||||
32
apps/api/src/modules/reports/reports.controller.ts
Normal file
32
apps/api/src/modules/reports/reports.controller.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Controller, Get, Param, Post, Query, Req, UseGuards } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
|
||||
import { ReportsService } from "./reports.service";
|
||||
|
||||
@Controller("reports")
|
||||
export class ReportsController {
|
||||
constructor(private readonly service: ReportsService) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("reports.read")
|
||||
@Get("summary")
|
||||
summary(@Req() req: Request) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.summary(tenantId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("reports.read")
|
||||
@Post("registrations-csv")
|
||||
queueRegistrations(@Req() req: Request, @Query("eventId") eventId?: string) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.queueRegistrationsCsv(tenantId, eventId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("reports.read")
|
||||
@Get("jobs/:id")
|
||||
getJob(@Param("id") id: string) {
|
||||
return this.service.getJob(id);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/modules/reports/reports.module.ts
Normal file
12
apps/api/src/modules/reports/reports.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { QueuesModule } from "../queues/queues.module";
|
||||
import { ReportsController } from "./reports.controller";
|
||||
import { ReportsService } from "./reports.service";
|
||||
|
||||
@Module({
|
||||
imports: [QueuesModule],
|
||||
controllers: [ReportsController],
|
||||
providers: [ReportsService]
|
||||
})
|
||||
export class ReportsModule {}
|
||||
|
||||
105
apps/api/src/modules/reports/reports.service.ts
Normal file
105
apps/api/src/modules/reports/reports.service.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Job } from "bullmq";
|
||||
import { QueuesService } from "../queues/queues.service";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class ReportsService {
|
||||
constructor(
|
||||
private readonly queues: QueuesService,
|
||||
private readonly prisma: PrismaService
|
||||
) {}
|
||||
|
||||
async summary(tenantId: string) {
|
||||
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
const [attendees, registrations, confirmed, checkedIns, revenue, events, recentRegistrations, rsvpGroups, sourceGroups, activeEvents] =
|
||||
await Promise.all([
|
||||
this.prisma.attendee.count({ where: { tenantId } }),
|
||||
this.prisma.registration.findMany({
|
||||
where: { tenantId, createdAt: { gte: since } },
|
||||
select: { createdAt: true, status: true, source: true }
|
||||
}),
|
||||
this.prisma.registration.count({ where: { tenantId, status: "confirmed" } }),
|
||||
this.prisma.checkIn.count({ where: { tenantId, result: "checked_in" } }),
|
||||
this.prisma.paymentTransaction.aggregate({ where: { tenantId, status: "success" }, _sum: { amountKobo: true } }),
|
||||
this.prisma.event.count({ where: { tenantId } }),
|
||||
this.prisma.registration.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 5,
|
||||
include: { attendee: true, event: true }
|
||||
}),
|
||||
this.prisma.rSVP.groupBy({ by: ["response"], where: { tenantId }, _count: { _all: true } }),
|
||||
this.prisma.registration.groupBy({ by: ["source"], where: { tenantId }, _count: { _all: true } }),
|
||||
this.prisma.event.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { startsAt: "asc" },
|
||||
include: { registrations: true, payments: true },
|
||||
take: 8
|
||||
})
|
||||
]);
|
||||
|
||||
const totalRecent = registrations.length;
|
||||
const confirmedRecent = registrations.filter((row) => row.status === "confirmed").length;
|
||||
const conversionRate = totalRecent ? Math.round((confirmedRecent / totalRecent) * 1000) / 10 : 0;
|
||||
|
||||
const chartMap = new Map<string, { name: string; registrations: number; confirmed: number }>();
|
||||
for (let i = 29; i >= 0; i -= 1) {
|
||||
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
|
||||
const key = d.toISOString().slice(0, 10);
|
||||
chartMap.set(key, { name: key, registrations: 0, confirmed: 0 });
|
||||
}
|
||||
for (const row of registrations) {
|
||||
const key = row.createdAt.toISOString().slice(0, 10);
|
||||
const bucket = chartMap.get(key);
|
||||
if (!bucket) continue;
|
||||
bucket.registrations += 1;
|
||||
if (row.status === "confirmed") bucket.confirmed += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
metrics: [
|
||||
{ label: "Total Attendees", value: attendees, change: "live" },
|
||||
{ label: "Confirmed", value: confirmed, change: "live" },
|
||||
{ label: "Checked-in", value: checkedIns, change: "live" },
|
||||
{ label: "Revenue", value: revenue._sum.amountKobo ?? 0, change: "live", currency: "NGN" },
|
||||
{ label: "Events", value: events, change: "live" },
|
||||
{ label: "Conversion Rate", value: conversionRate, change: "last 30 days", suffix: "%" }
|
||||
],
|
||||
chartData: Array.from(chartMap.values()),
|
||||
recentRegistrations: recentRegistrations.map((row) => ({
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
status: row.status,
|
||||
createdAt: row.createdAt,
|
||||
attendee: row.attendee,
|
||||
event: row.event
|
||||
})),
|
||||
topEvents: activeEvents.map((event) => ({
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
slug: event.slug,
|
||||
status: event.status,
|
||||
startsAt: event.startsAt,
|
||||
venue: event.venue,
|
||||
registrations: event.registrations.length,
|
||||
revenueKobo: event.payments.filter((payment) => payment.status === "success").reduce((sum, payment) => sum + payment.amountKobo, 0)
|
||||
})),
|
||||
rsvpStatus: rsvpGroups.map((row) => ({ response: row.response, count: row._count._all })),
|
||||
registrationSources: sourceGroups.map((row) => ({ source: row.source, count: row._count._all }))
|
||||
};
|
||||
}
|
||||
|
||||
async queueRegistrationsCsv(tenantId: string, eventId?: string) {
|
||||
const job = await this.queues.queue("reports").add("registrationsCsv", { tenantId, eventId });
|
||||
return { jobId: job.id };
|
||||
}
|
||||
|
||||
async getJob(jobId: string) {
|
||||
const job = await Job.fromId(this.queues.queue("reports"), jobId);
|
||||
if (!job) return { state: "not_found" as const };
|
||||
const state = await job.getState();
|
||||
const result = (job.returnvalue as any) ?? null;
|
||||
return { state, result };
|
||||
}
|
||||
}
|
||||
18
apps/api/src/modules/roles/dto.ts
Normal file
18
apps/api/src/modules/roles/dto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ArrayNotEmpty, IsArray, IsNotEmpty, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class CreateRoleDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
permissionKeys?: string[];
|
||||
}
|
||||
|
||||
export class SetRolePermissionsDto {
|
||||
@IsArray()
|
||||
permissionKeys!: string[];
|
||||
}
|
||||
|
||||
55
apps/api/src/modules/roles/roles.controller.ts
Normal file
55
apps/api/src/modules/roles/roles.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Put, Req, UseGuards } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
|
||||
import { CreateRoleDto, SetRolePermissionsDto } from "./dto";
|
||||
import { RolesService } from "./roles.service";
|
||||
|
||||
@Controller("permissions")
|
||||
export class PermissionsController {
|
||||
constructor(private readonly service: RolesService) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("roles.read")
|
||||
@Get()
|
||||
list() {
|
||||
return this.service.listPermissions();
|
||||
}
|
||||
}
|
||||
|
||||
@Controller("roles")
|
||||
export class RolesController {
|
||||
constructor(private readonly service: RolesService) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("roles.read")
|
||||
@Get()
|
||||
findAll(@Req() req: Request) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.findAll(tenantId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("roles.write")
|
||||
@Post()
|
||||
create(@Req() req: Request, @Body() dto: CreateRoleDto) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.create(tenantId, dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("roles.write")
|
||||
@Put(":id/permissions")
|
||||
setPermissions(@Req() req: Request, @Param("id") id: string, @Body() dto: SetRolePermissionsDto) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.setPermissions(tenantId, id, dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("roles.write")
|
||||
@Delete(":id")
|
||||
remove(@Req() req: Request, @Param("id") id: string) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.remove(tenantId, id);
|
||||
}
|
||||
}
|
||||
|
||||
10
apps/api/src/modules/roles/roles.module.ts
Normal file
10
apps/api/src/modules/roles/roles.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PermissionsController, RolesController } from "./roles.controller";
|
||||
import { RolesService } from "./roles.service";
|
||||
|
||||
@Module({
|
||||
controllers: [RolesController, PermissionsController],
|
||||
providers: [RolesService]
|
||||
})
|
||||
export class RolesModule {}
|
||||
|
||||
65
apps/api/src/modules/roles/roles.service.ts
Normal file
65
apps/api/src/modules/roles/roles.service.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import type { CreateRoleDto, SetRolePermissionsDto } from "./dto";
|
||||
|
||||
@Injectable()
|
||||
export class RolesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
listPermissions() {
|
||||
return this.prisma.permission.findMany({ orderBy: { key: "asc" } });
|
||||
}
|
||||
|
||||
findAll(tenantId: string) {
|
||||
return this.prisma.role.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { name: "asc" },
|
||||
include: {
|
||||
permissions: { include: { permission: true } },
|
||||
users: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async create(tenantId: string, dto: CreateRoleDto) {
|
||||
const role = await this.prisma.role.create({
|
||||
data: {
|
||||
tenantId,
|
||||
name: dto.name,
|
||||
permissions: dto.permissionKeys?.length
|
||||
? {
|
||||
create: dto.permissionKeys.map((key) => ({
|
||||
permission: { connect: { key } }
|
||||
}))
|
||||
}
|
||||
: undefined
|
||||
},
|
||||
include: { permissions: { include: { permission: true } } }
|
||||
});
|
||||
return role;
|
||||
}
|
||||
|
||||
async setPermissions(tenantId: string, roleId: string, dto: SetRolePermissionsDto) {
|
||||
const role = await this.prisma.role.findFirst({ where: { id: roleId, tenantId } });
|
||||
if (!role) throw new NotFoundException("Role not found");
|
||||
|
||||
const keys = Array.from(new Set(dto.permissionKeys.map((k) => String(k))));
|
||||
const perms = await this.prisma.permission.findMany({ where: { key: { in: keys } } });
|
||||
if (perms.length !== keys.length) throw new BadRequestException("Unknown permission key");
|
||||
|
||||
await this.prisma.rolePermission.deleteMany({ where: { roleId } });
|
||||
await this.prisma.rolePermission.createMany({
|
||||
data: perms.map((p) => ({ roleId, permissionId: p.id })),
|
||||
skipDuplicates: true
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async remove(tenantId: string, roleId: string) {
|
||||
const role = await this.prisma.role.findFirst({ where: { id: roleId, tenantId } });
|
||||
if (!role) throw new NotFoundException("Role not found");
|
||||
await this.prisma.role.delete({ where: { id: roleId } });
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
18
apps/api/src/modules/rsvps/rsvps.controller.ts
Normal file
18
apps/api/src/modules/rsvps/rsvps.controller.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Controller, Get, Req, UseGuards } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
|
||||
import { RsvpsService } from "./rsvps.service";
|
||||
|
||||
@Controller("rsvps")
|
||||
export class RsvpsController {
|
||||
constructor(private readonly service: RsvpsService) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("rsvps.read")
|
||||
@Get()
|
||||
findAll(@Req() req: Request) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.findAll(tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
10
apps/api/src/modules/rsvps/rsvps.module.ts
Normal file
10
apps/api/src/modules/rsvps/rsvps.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { RsvpsController } from "./rsvps.controller";
|
||||
import { RsvpsService } from "./rsvps.service";
|
||||
|
||||
@Module({
|
||||
controllers: [RsvpsController],
|
||||
providers: [RsvpsService]
|
||||
})
|
||||
export class RsvpsModule {}
|
||||
|
||||
16
apps/api/src/modules/rsvps/rsvps.service.ts
Normal file
16
apps/api/src/modules/rsvps/rsvps.service.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class RsvpsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findAll(tenantId: string) {
|
||||
return this.prisma.rSVP.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { event: true, invitee: true }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
12
apps/api/src/modules/settings/dto.ts
Normal file
12
apps/api/src/modules/settings/dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { IsObject, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class UpdateTenantSettingsDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
appName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
modules?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
82
apps/api/src/modules/settings/settings.controller.ts
Normal file
82
apps/api/src/modules/settings/settings.controller.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { BadRequestException, Body, Controller, Get, Patch, Query, Req, UploadedFile, UseGuards, UseInterceptors } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { diskStorage } from "multer";
|
||||
import path from "node:path";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
|
||||
import { UpdateTenantSettingsDto } from "./dto";
|
||||
import { SettingsService } from "./settings.service";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
|
||||
@Controller("settings")
|
||||
export class SettingsController {
|
||||
constructor(
|
||||
private readonly service: SettingsService,
|
||||
private readonly prisma: PrismaService
|
||||
) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("settings.read")
|
||||
@Get()
|
||||
get(@Req() req: Request) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.get(tenantId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("settings.write")
|
||||
@Patch()
|
||||
update(@Req() req: Request, @Body() dto: UpdateTenantSettingsDto) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.update(tenantId, dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("settings.write")
|
||||
@Patch("logo")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
storage: diskStorage({
|
||||
destination: (_req, _file, cb) => {
|
||||
const dir = process.env.UPLOADS_DIR ?? path.join(process.cwd(), "uploads");
|
||||
cb(null, dir);
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname || "").slice(0, 10);
|
||||
cb(null, `logo-${Date.now()}-${randomBytes(6).toString("hex")}${ext}`);
|
||||
}
|
||||
}),
|
||||
limits: { fileSize: 5 * 1024 * 1024 }
|
||||
})
|
||||
)
|
||||
async uploadLogo(@Req() req: Request, @UploadedFile() file?: any) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
if (!file) throw new BadRequestException("Missing file");
|
||||
const base = process.env.PUBLIC_API_URL ?? "";
|
||||
const url = `${base.replace(/\/$/, "")}/uploads/${encodeURIComponent(file.filename)}`;
|
||||
return this.service.setLogo(tenantId, url);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller("public/branding")
|
||||
export class PublicBrandingController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Get()
|
||||
async get(@Query("tenantSlug") tenantSlug?: string) {
|
||||
const slug = tenantSlug ?? process.env.DEFAULT_TENANT_SLUG ?? "";
|
||||
if (!slug) throw new BadRequestException("Missing tenantSlug");
|
||||
const tenant = await this.prisma.tenant.findUnique({ where: { slug } });
|
||||
if (!tenant) throw new BadRequestException("Tenant not found");
|
||||
const settings = await this.prisma.tenantSetting.findUnique({ where: { tenantId: tenant.id } });
|
||||
return {
|
||||
tenant: { id: tenant.id, slug: tenant.slug, name: tenant.name },
|
||||
branding: {
|
||||
appName: settings?.appName ?? tenant.name,
|
||||
logoUrl: settings?.logoUrl ?? null
|
||||
},
|
||||
modules: (settings?.modules as any) ?? null
|
||||
};
|
||||
}
|
||||
}
|
||||
10
apps/api/src/modules/settings/settings.module.ts
Normal file
10
apps/api/src/modules/settings/settings.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PublicBrandingController, SettingsController } from "./settings.controller";
|
||||
import { SettingsService } from "./settings.service";
|
||||
|
||||
@Module({
|
||||
controllers: [SettingsController, PublicBrandingController],
|
||||
providers: [SettingsService],
|
||||
exports: [SettingsService]
|
||||
})
|
||||
export class SettingsModule {}
|
||||
40
apps/api/src/modules/settings/settings.service.ts
Normal file
40
apps/api/src/modules/settings/settings.service.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import type { UpdateTenantSettingsDto } from "./dto";
|
||||
|
||||
@Injectable()
|
||||
export class SettingsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async get(tenantId: string) {
|
||||
const row = await this.prisma.tenantSetting.findUnique({ where: { tenantId } });
|
||||
return {
|
||||
appName: row?.appName ?? null,
|
||||
logoUrl: row?.logoUrl ?? null,
|
||||
modules: (row?.modules as any) ?? null
|
||||
};
|
||||
}
|
||||
|
||||
async update(tenantId: string, dto: UpdateTenantSettingsDto) {
|
||||
const row = await this.prisma.tenantSetting.upsert({
|
||||
where: { tenantId },
|
||||
create: { tenantId, appName: dto.appName, modules: dto.modules ?? undefined },
|
||||
update: { appName: dto.appName, modules: dto.modules ?? undefined }
|
||||
});
|
||||
return {
|
||||
appName: row.appName ?? null,
|
||||
logoUrl: row.logoUrl ?? null,
|
||||
modules: (row.modules as any) ?? null
|
||||
};
|
||||
}
|
||||
|
||||
async setLogo(tenantId: string, logoUrl: string) {
|
||||
const row = await this.prisma.tenantSetting.upsert({
|
||||
where: { tenantId },
|
||||
create: { tenantId, logoUrl },
|
||||
update: { logoUrl }
|
||||
});
|
||||
return { logoUrl: row.logoUrl ?? null };
|
||||
}
|
||||
}
|
||||
|
||||
12
apps/api/src/modules/tenants/tenants.controller.ts
Normal file
12
apps/api/src/modules/tenants/tenants.controller.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get, Param } from "@nestjs/common";
|
||||
import { TenantsService } from "./tenants.service";
|
||||
|
||||
@Controller("tenants")
|
||||
export class TenantsController {
|
||||
constructor(private readonly service: TenantsService) {}
|
||||
|
||||
@Get(":slug")
|
||||
findOneBySlug(@Param("slug") slug: string) {
|
||||
return this.service.findBySlug(slug);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/modules/tenants/tenants.module.ts
Normal file
10
apps/api/src/modules/tenants/tenants.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TenantsController } from "./tenants.controller";
|
||||
import { TenantsService } from "./tenants.service";
|
||||
|
||||
@Module({
|
||||
controllers: [TenantsController],
|
||||
providers: [TenantsService],
|
||||
exports: [TenantsService]
|
||||
})
|
||||
export class TenantsModule {}
|
||||
11
apps/api/src/modules/tenants/tenants.service.ts
Normal file
11
apps/api/src/modules/tenants/tenants.service.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class TenantsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findBySlug(slug: string) {
|
||||
return this.prisma.tenant.findUnique({ where: { slug } });
|
||||
}
|
||||
}
|
||||
96
apps/api/src/modules/users/dto.ts
Normal file
96
apps/api/src/modules/users/dto.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { ArrayNotEmpty, IsArray, IsEmail, IsNotEmpty, IsOptional, IsString, MinLength } from "class-validator";
|
||||
|
||||
export class UpdateMyProfileDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
fullName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
addressLine1?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
addressLine2?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
city?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
state?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
country?: string;
|
||||
}
|
||||
|
||||
export class CreateUserDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
fullName!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
roleIds?: string[];
|
||||
}
|
||||
|
||||
export class UpdateUserDto {
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
fullName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
addressLine1?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
addressLine2?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
city?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
state?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
country?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
roleIds?: string[];
|
||||
}
|
||||
85
apps/api/src/modules/users/users.controller.ts
Normal file
85
apps/api/src/modules/users/users.controller.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { BadRequestException, Body, Controller, Delete, Get, Param, Patch, Post, Req, UploadedFile, UseGuards, UseInterceptors } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { diskStorage } from "multer";
|
||||
import path from "node:path";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
|
||||
import { CreateUserDto, UpdateMyProfileDto, UpdateUserDto } from "./dto";
|
||||
import { UsersService } from "./users.service";
|
||||
|
||||
@Controller("users")
|
||||
export class UsersController {
|
||||
constructor(private readonly service: UsersService) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("users.read")
|
||||
@Get()
|
||||
findAll(@Req() req: Request) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.findAll(tenantId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("users.write")
|
||||
@Post()
|
||||
create(@Req() req: Request, @Body() dto: CreateUserDto) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.create(tenantId, dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard)
|
||||
@Patch("me")
|
||||
updateMeProfile(@Req() req: Request, @Body() dto: UpdateMyProfileDto) {
|
||||
const userId = (req.user as any).sub as string;
|
||||
return this.service.updateMe(userId, dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("users.write")
|
||||
@Patch(":id")
|
||||
update(@Req() req: Request, @Param("id") id: string, @Body() dto: UpdateUserDto) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.update(tenantId, id, dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("users.write")
|
||||
@Delete(":id")
|
||||
remove(@Req() req: Request, @Param("id") id: string) {
|
||||
const tenantId = (req.user as any).tenantId as string;
|
||||
return this.service.remove(tenantId, id);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard)
|
||||
@Get("me")
|
||||
me(@Req() req: Request) {
|
||||
const userId = (req.user as any).sub as string;
|
||||
return this.service.me(userId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard)
|
||||
@Patch("me/avatar")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
storage: diskStorage({
|
||||
destination: (_req, _file, cb) => {
|
||||
const dir = process.env.UPLOADS_DIR ?? path.join(process.cwd(), "uploads");
|
||||
cb(null, dir);
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname || "").slice(0, 10);
|
||||
cb(null, `avatar-${Date.now()}-${randomBytes(6).toString("hex")}${ext}`);
|
||||
}
|
||||
}),
|
||||
limits: { fileSize: 5 * 1024 * 1024 }
|
||||
})
|
||||
)
|
||||
async uploadAvatar(@Req() req: Request, @UploadedFile() file?: any) {
|
||||
const userId = (req.user as any).sub as string;
|
||||
if (!file) throw new BadRequestException("Missing file");
|
||||
const base = process.env.PUBLIC_API_URL ?? "";
|
||||
const url = `${base.replace(/\/$/, "")}/uploads/${encodeURIComponent(file.filename)}`;
|
||||
return this.service.setAvatar(userId, url);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/modules/users/users.module.ts
Normal file
10
apps/api/src/modules/users/users.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { UsersController } from "./users.controller";
|
||||
import { UsersService } from "./users.service";
|
||||
|
||||
@Module({
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService]
|
||||
})
|
||||
export class UsersModule {}
|
||||
|
||||
118
apps/api/src/modules/users/users.service.ts
Normal file
118
apps/api/src/modules/users/users.service.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import bcrypt from "bcrypt";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import type { CreateUserDto, UpdateMyProfileDto, UpdateUserDto } from "./dto";
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findAll(tenantId: string) {
|
||||
return this.prisma.user.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { roles: { include: { role: true } } }
|
||||
});
|
||||
}
|
||||
|
||||
async create(tenantId: string, dto: CreateUserDto) {
|
||||
const passwordHash = await bcrypt.hash(dto.password, 12);
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
tenantId,
|
||||
email: dto.email.toLowerCase(),
|
||||
fullName: dto.fullName,
|
||||
mustChangePassword: true,
|
||||
passwordHash,
|
||||
roles: dto.roleIds?.length ? { create: dto.roleIds.map((roleId) => ({ roleId })) } : undefined
|
||||
}
|
||||
});
|
||||
return { id: user.id };
|
||||
}
|
||||
|
||||
async update(tenantId: string, userId: string, dto: UpdateUserDto) {
|
||||
const row = await this.prisma.user.findFirst({ where: { id: userId, tenantId } });
|
||||
if (!row) throw new NotFoundException("User not found");
|
||||
const passwordHash = dto.password ? await bcrypt.hash(dto.password, 12) : undefined;
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
email: dto.email ? dto.email.toLowerCase() : undefined,
|
||||
fullName: dto.fullName,
|
||||
phone: dto.phone,
|
||||
addressLine1: dto.addressLine1,
|
||||
addressLine2: dto.addressLine2,
|
||||
city: dto.city,
|
||||
state: dto.state,
|
||||
country: dto.country,
|
||||
passwordHash,
|
||||
mustChangePassword: dto.password ? true : undefined
|
||||
}
|
||||
});
|
||||
|
||||
if (dto.roleIds) {
|
||||
await this.prisma.userRole.deleteMany({ where: { userId } });
|
||||
if (dto.roleIds.length) {
|
||||
await this.prisma.userRole.createMany({ data: dto.roleIds.map((roleId) => ({ userId, roleId })) });
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async remove(tenantId: string, userId: string) {
|
||||
const row = await this.prisma.user.findFirst({ where: { id: userId, tenantId } });
|
||||
if (!row) throw new NotFoundException("User not found");
|
||||
await this.prisma.user.delete({ where: { id: userId } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async me(userId: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: { roles: { include: { role: true } } }
|
||||
});
|
||||
if (!user) throw new NotFoundException("User not found");
|
||||
return {
|
||||
id: user.id,
|
||||
tenantId: user.tenantId,
|
||||
email: user.email,
|
||||
fullName: user.fullName,
|
||||
phone: user.phone ?? null,
|
||||
addressLine1: user.addressLine1 ?? null,
|
||||
addressLine2: user.addressLine2 ?? null,
|
||||
city: user.city ?? null,
|
||||
state: user.state ?? null,
|
||||
country: user.country ?? null,
|
||||
avatarUrl: user.avatarUrl ?? null,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
roles: user.roles.map((r) => ({ id: r.roleId, name: r.role.name }))
|
||||
};
|
||||
}
|
||||
|
||||
async updateMe(userId: string, dto: UpdateMyProfileDto) {
|
||||
if (dto.email && dto.email.includes(" ")) throw new BadRequestException("Invalid email");
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
fullName: dto.fullName,
|
||||
email: dto.email ? dto.email.toLowerCase() : undefined,
|
||||
phone: dto.phone,
|
||||
addressLine1: dto.addressLine1,
|
||||
addressLine2: dto.addressLine2,
|
||||
city: dto.city,
|
||||
state: dto.state,
|
||||
country: dto.country
|
||||
}
|
||||
});
|
||||
return { ok: true, userId: user.id };
|
||||
}
|
||||
|
||||
async setAvatar(userId: string, avatarUrl: string) {
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { avatarUrl }
|
||||
});
|
||||
return { avatarUrl: user.avatarUrl ?? null };
|
||||
}
|
||||
}
|
||||
|
||||
51
apps/api/src/modules/workflows/workflows.controller.ts
Normal file
51
apps/api/src/modules/workflows/workflows.controller.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Req, UseGuards } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { WorkflowsService } from "./workflows.service";
|
||||
import { JwtAuthGuard, PermissionsGuard, RequirePermissions, TenantGuard } from "../auth/guards";
|
||||
|
||||
@Controller("workflows")
|
||||
export class WorkflowsController {
|
||||
constructor(private readonly service: WorkflowsService) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("workflows.read")
|
||||
@Get()
|
||||
findAll(@Req() req: Request) {
|
||||
return this.service.findAll((req.user as any).tenantId as string);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("workflows.write")
|
||||
@Post()
|
||||
create(@Req() req: Request, @Body() payload: Record<string, unknown>) {
|
||||
return this.service.create((req.user as any).tenantId as string, payload);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("workflows.write")
|
||||
@Patch(":id")
|
||||
update(@Req() req: Request, @Param("id") id: string, @Body() payload: Record<string, unknown>) {
|
||||
return this.service.update((req.user as any).tenantId as string, id, payload);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("workflows.write")
|
||||
@Put(":id/triggers")
|
||||
replaceTriggers(@Req() req: Request, @Param("id") id: string, @Body() payload: { triggers?: any[] }) {
|
||||
return this.service.replaceTriggers((req.user as any).tenantId as string, id, payload.triggers ?? []);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("workflows.write")
|
||||
@Put(":id/actions")
|
||||
replaceActions(@Req() req: Request, @Param("id") id: string, @Body() payload: { actions?: any[] }) {
|
||||
return this.service.replaceActions((req.user as any).tenantId as string, id, payload.actions ?? []);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, TenantGuard, PermissionsGuard)
|
||||
@RequirePermissions("workflows.write")
|
||||
@Delete(":id")
|
||||
remove(@Req() req: Request, @Param("id") id: string) {
|
||||
return this.service.remove((req.user as any).tenantId as string, id);
|
||||
}
|
||||
}
|
||||
5
apps/api/src/modules/workflows/workflows.module.ts
Normal file
5
apps/api/src/modules/workflows/workflows.module.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { WorkflowsController } from "./workflows.controller";
|
||||
import { WorkflowsService } from "./workflows.service";
|
||||
@Module({ controllers: [WorkflowsController], providers: [WorkflowsService], exports: [WorkflowsService] })
|
||||
export class WorkflowsModule {}
|
||||
103
apps/api/src/modules/workflows/workflows.service.ts
Normal file
103
apps/api/src/modules/workflows/workflows.service.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class WorkflowsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findAll(tenantId: string) {
|
||||
return this.prisma.workflow.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { triggers: true, actions: { orderBy: { sortOrder: "asc" } } }
|
||||
});
|
||||
}
|
||||
|
||||
create(tenantId: string, payload: any) {
|
||||
const triggers = Array.isArray(payload.triggers) ? payload.triggers : [];
|
||||
const actions = Array.isArray(payload.actions) ? payload.actions : [];
|
||||
return this.prisma.workflow.create({
|
||||
data: {
|
||||
tenantId,
|
||||
name: String(payload.name ?? ""),
|
||||
description: payload.description ? String(payload.description) : null,
|
||||
enabled: payload.enabled === undefined ? true : Boolean(payload.enabled),
|
||||
triggers: {
|
||||
create: triggers.map((trigger: any) => ({
|
||||
tenantId,
|
||||
type: String(trigger.type ?? "manual"),
|
||||
config: trigger.config ?? undefined
|
||||
}))
|
||||
},
|
||||
actions: {
|
||||
create: actions.map((action: any, index: number) => ({
|
||||
tenantId,
|
||||
type: String(action.type ?? "send_email"),
|
||||
config: action.config ?? undefined,
|
||||
sortOrder: Number(action.sortOrder ?? index)
|
||||
}))
|
||||
}
|
||||
},
|
||||
include: { triggers: true, actions: { orderBy: { sortOrder: "asc" } } }
|
||||
});
|
||||
}
|
||||
|
||||
async update(tenantId: string, id: string, payload: any) {
|
||||
const row = await this.prisma.workflow.findFirst({ where: { tenantId, id } });
|
||||
if (!row) throw new NotFoundException("Workflow not found");
|
||||
return this.prisma.workflow.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: payload.name === undefined ? undefined : String(payload.name),
|
||||
description: payload.description === undefined ? undefined : payload.description ? String(payload.description) : null,
|
||||
enabled: payload.enabled === undefined ? undefined : Boolean(payload.enabled)
|
||||
},
|
||||
include: { triggers: true, actions: { orderBy: { sortOrder: "asc" } } }
|
||||
});
|
||||
}
|
||||
|
||||
async replaceTriggers(tenantId: string, workflowId: string, triggers: any[]) {
|
||||
const row = await this.prisma.workflow.findFirst({ where: { tenantId, id: workflowId } });
|
||||
if (!row) throw new NotFoundException("Workflow not found");
|
||||
await this.prisma.workflowTrigger.deleteMany({ where: { tenantId, workflowId } });
|
||||
if (triggers.length) {
|
||||
await this.prisma.workflowTrigger.createMany({
|
||||
data: triggers.map((trigger) => ({
|
||||
tenantId,
|
||||
workflowId,
|
||||
type: String(trigger.type ?? "manual"),
|
||||
config: trigger.config ?? undefined
|
||||
}))
|
||||
});
|
||||
}
|
||||
return this.prisma.workflow.findFirst({ where: { tenantId, id: workflowId }, include: { triggers: true, actions: true } });
|
||||
}
|
||||
|
||||
async replaceActions(tenantId: string, workflowId: string, actions: any[]) {
|
||||
const row = await this.prisma.workflow.findFirst({ where: { tenantId, id: workflowId } });
|
||||
if (!row) throw new NotFoundException("Workflow not found");
|
||||
await this.prisma.workflowAction.deleteMany({ where: { tenantId, workflowId } });
|
||||
if (actions.length) {
|
||||
await this.prisma.workflowAction.createMany({
|
||||
data: actions.map((action, index) => ({
|
||||
tenantId,
|
||||
workflowId,
|
||||
type: String(action.type ?? "send_email"),
|
||||
config: action.config ?? undefined,
|
||||
sortOrder: Number(action.sortOrder ?? index)
|
||||
}))
|
||||
});
|
||||
}
|
||||
return this.prisma.workflow.findFirst({
|
||||
where: { tenantId, id: workflowId },
|
||||
include: { triggers: true, actions: { orderBy: { sortOrder: "asc" } } }
|
||||
});
|
||||
}
|
||||
|
||||
async remove(tenantId: string, id: string) {
|
||||
const row = await this.prisma.workflow.findFirst({ where: { tenantId, id } });
|
||||
if (!row) throw new NotFoundException("Workflow not found");
|
||||
await this.prisma.workflow.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
9
apps/api/src/prisma/prisma.module.ts
Normal file
9
apps/api/src/prisma/prisma.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { PrismaService } from "./prisma.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService]
|
||||
})
|
||||
export class PrismaModule {}
|
||||
13
apps/api/src/prisma/prisma.service.ts
Normal file
13
apps/api/src/prisma/prisma.service.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
16
apps/api/src/types/vendor.d.ts
vendored
Normal file
16
apps/api/src/types/vendor.d.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
declare module "qrcode" {
|
||||
export interface QRCodeToDataURLOptions {
|
||||
errorCorrectionLevel?: string;
|
||||
margin?: number;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export function toDataURL(text: string, options?: QRCodeToDataURLOptions): Promise<string>;
|
||||
}
|
||||
|
||||
declare module "multer" {
|
||||
export function diskStorage(options: {
|
||||
destination?: (req: any, file: any, callback: (error: Error | null, destination: string) => void) => void;
|
||||
filename?: (req: any, file: any, callback: (error: Error | null, filename: string) => void) => void;
|
||||
}): any;
|
||||
}
|
||||
11
apps/api/src/worker.ts
Normal file
11
apps/api/src/worker.ts
Normal file
@@ -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();
|
||||
17
apps/api/tsconfig.json
Normal file
17
apps/api/tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
3
apps/web/.eslintrc.json
Normal file
3
apps/web/.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals"]
|
||||
}
|
||||
21
apps/web/Dockerfile
Normal file
21
apps/web/Dockerfile
Normal file
@@ -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"]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user