diff --git a/clubscan/README.md b/clubscan/README.md index 156a9d6..5c4be4c 100644 --- a/clubscan/README.md +++ b/clubscan/README.md @@ -28,6 +28,7 @@ clubscan/ | 5 | [Infrastructure Architecture](docs/05-infrastructure-architecture.md) | | 6 | [Security Architecture](docs/06-security-architecture.md) | | 7 | [Folder Structures](docs/07-folder-structures.md) | +| 8 | [Implementation Status](docs/08-implementation-status.md) | ## Quick start (local) @@ -39,12 +40,12 @@ docker compose up -d postgres redis minio createbuckets mailhog # 2) Backend cd backend cp .env.example .env -pnpm install -pnpm prisma:generate -pnpm prisma migrate dev # baseline schema +npm install +npm run prisma:generate +npm run prisma migrate dev # baseline schema psql "$DATABASE_URL" -f prisma/sql/postgis.sql # geospatial augmentation -pnpm prisma:seed -pnpm start:dev # http://localhost:3000/api/v1 (docs at /api/v1/docs) +npm run prisma:seed +npm run start:dev # http://localhost:3000/api/v1 (docs at /api/v1/docs) ``` ## The ClubScan Score @@ -59,8 +60,8 @@ aggregate that prioritizes safety and resists manipulation. See **Mobile:** Expo · TypeScript · Expo Router · TanStack Query · Zustand · RHF + Zod · NativeWind **Backend:** NestJS · TypeScript · Prisma · PostgreSQL · Redis -**Auth:** JWT (access) + rotating refresh tokens + Google/Apple OAuth -**Infra:** Docker · GitHub Actions · S3-compatible storage · FCM · Sentry · OpenTelemetry +**Auth:** JWT (access) + rotating refresh tokens + Google/Apple OAuth (JWKS-verified) +**Infra:** Docker · GitHub Actions · S3-compatible storage · FCM (firebase-admin) · Sentry · OpenTelemetry ## License diff --git a/clubscan/backend/package.json b/clubscan/backend/package.json index 9812cfe..893a4b6 100644 --- a/clubscan/backend/package.json +++ b/clubscan/backend/package.json @@ -23,6 +23,8 @@ "seed": "tsx prisma/seed.ts" }, "dependencies": { + "@aws-sdk/client-s3": "^3.658.0", + "@aws-sdk/s3-request-presigner": "^3.658.0", "@nestjs/common": "^10.4.4", "@nestjs/config": "^3.2.3", "@nestjs/core": "^10.4.4", @@ -35,9 +37,12 @@ "@prisma/client": "^5.20.0", "argon2": "^0.41.1", "axios": "^1.7.7", + "firebase-admin": "^13.10.0", "google-auth-library": "^9.14.1", "helmet": "^8.0.0", "ioredis": "^5.4.1", + "jsonwebtoken": "^9.0.3", + "jwks-rsa": "^4.0.1", "nestjs-i18n": "^10.4.9", "nestjs-zod": "^3.0.0", "reflect-metadata": "^0.2.2", @@ -49,6 +54,7 @@ "@nestjs/cli": "^10.4.5", "@nestjs/testing": "^10.4.4", "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^22.7.4", "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^8.8.0", diff --git a/clubscan/backend/prisma/migrations/0_init/migration.sql b/clubscan/backend/prisma/migrations/0_init/migration.sql new file mode 100644 index 0000000..cac6198 --- /dev/null +++ b/clubscan/backend/prisma/migrations/0_init/migration.sql @@ -0,0 +1,878 @@ +-- CreateExtension +CREATE EXTENSION IF NOT EXISTS "citext"; + +-- CreateExtension +CREATE EXTENSION IF NOT EXISTS "pg_trgm"; + +-- CreateExtension +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- CreateEnum +CREATE TYPE "UserRole" AS ENUM ('USER', 'MODERATOR', 'ADMIN', 'SUPER_ADMIN'); + +-- CreateEnum +CREATE TYPE "UserStatus" AS ENUM ('ACTIVE', 'SUSPENDED', 'BANNED', 'SHADOW_BANNED', 'DELETED'); + +-- CreateEnum +CREATE TYPE "VerificationStatus" AS ENUM ('NONE', 'PENDING', 'VERIFIED'); + +-- CreateEnum +CREATE TYPE "OAuthProvider" AS ENUM ('GOOGLE', 'APPLE'); + +-- CreateEnum +CREATE TYPE "DevicePlatform" AS ENUM ('IOS', 'ANDROID'); + +-- CreateEnum +CREATE TYPE "VenueType" AS ENUM ('CLUB', 'BAR', 'FESTIVAL', 'EVENT', 'LOUNGE'); + +-- CreateEnum +CREATE TYPE "VenueStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'CLOSED'); + +-- CreateEnum +CREATE TYPE "EventStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'CANCELLED', 'ENDED'); + +-- CreateEnum +CREATE TYPE "ReviewStatus" AS ENUM ('PENDING', 'PUBLISHED', 'HELD', 'REMOVED'); + +-- CreateEnum +CREATE TYPE "ModerationStatus" AS ENUM ('PENDING', 'APPROVED', 'FLAGGED', 'REJECTED'); + +-- CreateEnum +CREATE TYPE "ReportTargetType" AS ENUM ('REVIEW', 'USER', 'VENUE', 'EVENT'); + +-- CreateEnum +CREATE TYPE "ReportReason" AS ENUM ('SPAM', 'HARASSMENT', 'HATE_SPEECH', 'MISINFORMATION', 'INAPPROPRIATE_CONTENT', 'OTHER'); + +-- CreateEnum +CREATE TYPE "ReportStatus" AS ENUM ('OPEN', 'IN_REVIEW', 'RESOLVED', 'DISMISSED'); + +-- CreateEnum +CREATE TYPE "ModerationState" AS ENUM ('TRIAGE', 'INVESTIGATING', 'ACTIONED', 'DISMISSED', 'CLOSED'); + +-- CreateEnum +CREATE TYPE "SanctionType" AS ENUM ('WARNING', 'TEMP_BAN', 'PERMA_BAN', 'SHADOW_BAN', 'CONTENT_REMOVAL'); + +-- CreateEnum +CREATE TYPE "IncidentCategory" AS ENUM ('HARASSMENT', 'VIOLENCE', 'DISCRIMINATION', 'UNSAFE_ENVIRONMENT', 'OTHER'); + +-- CreateEnum +CREATE TYPE "IncidentSeverity" AS ENUM ('LOW', 'MEDIUM', 'HIGH', 'CRITICAL'); + +-- CreateEnum +CREATE TYPE "IncidentState" AS ENUM ('SUBMITTED', 'TRIAGED', 'INVESTIGATING', 'ACTIONED', 'DISMISSED', 'CLOSED'); + +-- CreateEnum +CREATE TYPE "NotificationType" AS ENUM ('NEW_FOLLOWER', 'REVIEW_PUBLISHED', 'REVIEW_HELPFUL', 'EVENT_REMINDER', 'MODERATION_RESULT', 'SAFETY_UPDATE', 'SYSTEM'); + +-- CreateEnum +CREATE TYPE "MediaStatus" AS ENUM ('PENDING', 'READY', 'FAILED'); + +-- CreateEnum +CREATE TYPE "SocialPlatform" AS ENUM ('INSTAGRAM', 'TIKTOK', 'X', 'SOUNDCLOUD', 'WEBSITE'); + +-- CreateTable +CREATE TABLE "users" ( + "id" UUID NOT NULL, + "email" CITEXT NOT NULL, + "passwordHash" TEXT, + "emailVerifiedAt" TIMESTAMP(3), + "role" "UserRole" NOT NULL DEFAULT 'USER', + "status" "UserStatus" NOT NULL DEFAULT 'ACTIVE', + "reputationScore" INTEGER NOT NULL DEFAULT 0, + "locale" TEXT NOT NULL DEFAULT 'en', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "oauth_accounts" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "provider" "OAuthProvider" NOT NULL, + "providerAccountId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "oauth_accounts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sessions" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "deviceId" UUID, + "refreshTokenHash" TEXT NOT NULL, + "familyId" UUID NOT NULL, + "ip" TEXT, + "userAgent" TEXT, + "expiresAt" TIMESTAMP(3) NOT NULL, + "revokedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "devices" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "platform" "DevicePlatform" NOT NULL, + "pushToken" TEXT, + "name" TEXT, + "lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "devices_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "email_verification_tokens" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "tokenHash" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "usedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "email_verification_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "password_reset_tokens" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "tokenHash" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "usedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "password_reset_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "profiles" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "username" CITEXT NOT NULL, + "displayName" TEXT, + "bio" TEXT, + "avatarUrl" TEXT, + "verificationStatus" "VerificationStatus" NOT NULL DEFAULT 'NONE', + "isPrivate" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "profiles_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "social_links" ( + "id" UUID NOT NULL, + "profileId" UUID NOT NULL, + "platform" "SocialPlatform" NOT NULL, + "url" TEXT NOT NULL, + + CONSTRAINT "social_links_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "follows" ( + "id" UUID NOT NULL, + "followerId" UUID NOT NULL, + "followingId" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "follows_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "reputation_events" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "delta" INTEGER NOT NULL, + "reason" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "reputation_events_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "venues" ( + "id" UUID NOT NULL, + "slug" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "type" "VenueType" NOT NULL, + "status" "VenueStatus" NOT NULL DEFAULT 'PUBLISHED', + "addressLine" TEXT, + "city" TEXT NOT NULL, + "country" TEXT NOT NULL, + "postalCode" TEXT, + "latitude" DECIMAL(9,6) NOT NULL, + "longitude" DECIMAL(9,6) NOT NULL, + "capacity" INTEGER, + "coverPhotoUrl" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "venues_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "venue_photos" ( + "id" UUID NOT NULL, + "venueId" UUID NOT NULL, + "url" TEXT NOT NULL, + "position" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "venue_photos_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "operating_hours" ( + "id" UUID NOT NULL, + "venueId" UUID NOT NULL, + "weekday" INTEGER NOT NULL, + "openMin" INTEGER NOT NULL, + "closeMin" INTEGER NOT NULL, + "isClosed" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "operating_hours_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "genres" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + + CONSTRAINT "genres_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "venue_genres" ( + "venueId" UUID NOT NULL, + "genreId" UUID NOT NULL, + + CONSTRAINT "venue_genres_pkey" PRIMARY KEY ("venueId","genreId") +); + +-- CreateTable +CREATE TABLE "events" ( + "id" UUID NOT NULL, + "venueId" UUID NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "startsAt" TIMESTAMP(3) NOT NULL, + "endsAt" TIMESTAMP(3), + "lineup" JSONB, + "ticketUrl" TEXT, + "coverPhotoUrl" TEXT, + "status" "EventStatus" NOT NULL DEFAULT 'PUBLISHED', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "events_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "event_photos" ( + "id" UUID NOT NULL, + "eventId" UUID NOT NULL, + "url" TEXT NOT NULL, + "position" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "event_photos_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "event_genres" ( + "eventId" UUID NOT NULL, + "genreId" UUID NOT NULL, + + CONSTRAINT "event_genres_pkey" PRIMARY KEY ("eventId","genreId") +); + +-- CreateTable +CREATE TABLE "saved_events" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "eventId" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "saved_events_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "reviews" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "venueId" UUID NOT NULL, + "body" TEXT NOT NULL, + "language" TEXT NOT NULL DEFAULT 'en', + "status" "ReviewStatus" NOT NULL DEFAULT 'PENDING', + "moderationStatus" "ModerationStatus" NOT NULL DEFAULT 'PENDING', + "helpfulCount" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "reviews_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "review_ratings" ( + "reviewId" UUID NOT NULL, + "security" INTEGER NOT NULL, + "staffBehavior" INTEGER NOT NULL, + "fairPricing" INTEGER NOT NULL, + "crowdQuality" INTEGER NOT NULL, + "musicQuality" INTEGER NOT NULL, + "soundSystem" INTEGER NOT NULL, + "cleanliness" INTEGER NOT NULL, + "safetyForWomen" INTEGER NOT NULL, + "atmosphere" INTEGER NOT NULL, + + CONSTRAINT "review_ratings_pkey" PRIMARY KEY ("reviewId") +); + +-- CreateTable +CREATE TABLE "review_photos" ( + "id" UUID NOT NULL, + "reviewId" UUID NOT NULL, + "url" TEXT NOT NULL, + "position" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "review_photos_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "review_edits" ( + "id" UUID NOT NULL, + "reviewId" UUID NOT NULL, + "bodySnapshot" TEXT NOT NULL, + "ratingSnapshot" JSONB NOT NULL, + "editedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "review_edits_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "review_helpful" ( + "id" UUID NOT NULL, + "reviewId" UUID NOT NULL, + "userId" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "review_helpful_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "venue_scores" ( + "venueId" UUID NOT NULL, + "score" DECIMAL(5,2) NOT NULL, + "reviewCount" INTEGER NOT NULL DEFAULT 0, + "avgSecurity" DECIMAL(4,2) NOT NULL DEFAULT 0, + "avgStaffBehavior" DECIMAL(4,2) NOT NULL DEFAULT 0, + "avgFairPricing" DECIMAL(4,2) NOT NULL DEFAULT 0, + "avgCrowdQuality" DECIMAL(4,2) NOT NULL DEFAULT 0, + "avgMusicQuality" DECIMAL(4,2) NOT NULL DEFAULT 0, + "avgSoundSystem" DECIMAL(4,2) NOT NULL DEFAULT 0, + "avgCleanliness" DECIMAL(4,2) NOT NULL DEFAULT 0, + "avgSafetyForWomen" DECIMAL(4,2) NOT NULL DEFAULT 0, + "avgAtmosphere" DECIMAL(4,2) NOT NULL DEFAULT 0, + "safetyAdvisory" BOOLEAN NOT NULL DEFAULT false, + "lastComputedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "venue_scores_pkey" PRIMARY KEY ("venueId") +); + +-- CreateTable +CREATE TABLE "reports" ( + "id" UUID NOT NULL, + "reporterId" UUID NOT NULL, + "targetType" "ReportTargetType" NOT NULL, + "targetId" UUID NOT NULL, + "reason" "ReportReason" NOT NULL, + "details" TEXT, + "status" "ReportStatus" NOT NULL DEFAULT 'OPEN', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "reports_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "moderation_cases" ( + "id" UUID NOT NULL, + "reportId" UUID, + "targetType" "ReportTargetType" NOT NULL, + "targetId" UUID NOT NULL, + "state" "ModerationState" NOT NULL DEFAULT 'TRIAGE', + "assignedModeratorId" UUID, + "aiVerdict" JSONB, + "resolution" TEXT, + "resolvedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "moderation_cases_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sanctions" ( + "id" UUID NOT NULL, + "targetUserId" UUID NOT NULL, + "caseId" UUID, + "type" "SanctionType" NOT NULL, + "reason" TEXT NOT NULL, + "issuedById" UUID NOT NULL, + "expiresAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "sanctions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "incident_reports" ( + "id" UUID NOT NULL, + "reporterId" UUID, + "venueId" UUID, + "category" "IncidentCategory" NOT NULL, + "severity" "IncidentSeverity" NOT NULL DEFAULT 'MEDIUM', + "description" TEXT NOT NULL, + "occurredAt" TIMESTAMP(3), + "isAnonymous" BOOLEAN NOT NULL DEFAULT false, + "state" "IncidentState" NOT NULL DEFAULT 'SUBMITTED', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "incident_reports_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "escalations" ( + "id" UUID NOT NULL, + "incidentId" UUID NOT NULL, + "level" INTEGER NOT NULL DEFAULT 1, + "slaDueAt" TIMESTAMP(3) NOT NULL, + "escalatedAt" TIMESTAMP(3), + "handledById" UUID, + "outcome" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "escalations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "notifications" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "type" "NotificationType" NOT NULL, + "payload" JSONB NOT NULL, + "readAt" TIMESTAMP(3), + "sentPush" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "notifications_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "feed_entries" ( + "id" UUID NOT NULL, + "ownerId" UUID NOT NULL, + "actorId" UUID NOT NULL, + "verb" TEXT NOT NULL, + "objectType" TEXT NOT NULL, + "objectId" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "feed_entries_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "analytics_events" ( + "id" UUID NOT NULL, + "type" TEXT NOT NULL, + "userId" UUID, + "sessionId" TEXT, + "properties" JSONB, + "occurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "analytics_events_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "audit_log_entries" ( + "id" UUID NOT NULL, + "actorId" UUID, + "action" TEXT NOT NULL, + "targetType" TEXT, + "targetId" TEXT, + "metadata" JSONB, + "ip" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "audit_log_entries_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "media_assets" ( + "id" UUID NOT NULL, + "ownerId" UUID NOT NULL, + "bucket" TEXT NOT NULL, + "key" TEXT NOT NULL, + "mime" TEXT NOT NULL, + "size" INTEGER NOT NULL, + "width" INTEGER, + "height" INTEGER, + "status" "MediaStatus" NOT NULL DEFAULT 'PENDING', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "media_assets_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "feature_flags" ( + "key" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT false, + "payload" JSONB, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "feature_flags_pkey" PRIMARY KEY ("key") +); + +-- CreateTable +CREATE TABLE "app_config" ( + "id" INTEGER NOT NULL DEFAULT 1, + "version" INTEGER NOT NULL DEFAULT 1, + "config" JSONB NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "app_config_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); + +-- CreateIndex +CREATE INDEX "users_status_idx" ON "users"("status"); + +-- CreateIndex +CREATE INDEX "users_role_idx" ON "users"("role"); + +-- CreateIndex +CREATE INDEX "oauth_accounts_userId_idx" ON "oauth_accounts"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "oauth_accounts_provider_providerAccountId_key" ON "oauth_accounts"("provider", "providerAccountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "sessions_refreshTokenHash_key" ON "sessions"("refreshTokenHash"); + +-- CreateIndex +CREATE INDEX "sessions_userId_idx" ON "sessions"("userId"); + +-- CreateIndex +CREATE INDEX "sessions_familyId_idx" ON "sessions"("familyId"); + +-- CreateIndex +CREATE INDEX "devices_userId_idx" ON "devices"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "devices_userId_pushToken_key" ON "devices"("userId", "pushToken"); + +-- CreateIndex +CREATE UNIQUE INDEX "email_verification_tokens_tokenHash_key" ON "email_verification_tokens"("tokenHash"); + +-- CreateIndex +CREATE INDEX "email_verification_tokens_userId_idx" ON "email_verification_tokens"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "password_reset_tokens_tokenHash_key" ON "password_reset_tokens"("tokenHash"); + +-- CreateIndex +CREATE INDEX "password_reset_tokens_userId_idx" ON "password_reset_tokens"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "profiles_userId_key" ON "profiles"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "profiles_username_key" ON "profiles"("username"); + +-- CreateIndex +CREATE INDEX "social_links_profileId_idx" ON "social_links"("profileId"); + +-- CreateIndex +CREATE INDEX "follows_followingId_idx" ON "follows"("followingId"); + +-- CreateIndex +CREATE UNIQUE INDEX "follows_followerId_followingId_key" ON "follows"("followerId", "followingId"); + +-- CreateIndex +CREATE INDEX "reputation_events_userId_idx" ON "reputation_events"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "venues_slug_key" ON "venues"("slug"); + +-- CreateIndex +CREATE INDEX "venues_city_status_idx" ON "venues"("city", "status"); + +-- CreateIndex +CREATE INDEX "venues_type_status_idx" ON "venues"("type", "status"); + +-- CreateIndex +CREATE INDEX "venue_photos_venueId_idx" ON "venue_photos"("venueId"); + +-- CreateIndex +CREATE UNIQUE INDEX "operating_hours_venueId_weekday_key" ON "operating_hours"("venueId", "weekday"); + +-- CreateIndex +CREATE UNIQUE INDEX "genres_name_key" ON "genres"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "genres_slug_key" ON "genres"("slug"); + +-- CreateIndex +CREATE INDEX "venue_genres_genreId_idx" ON "venue_genres"("genreId"); + +-- CreateIndex +CREATE INDEX "events_venueId_idx" ON "events"("venueId"); + +-- CreateIndex +CREATE INDEX "events_startsAt_status_idx" ON "events"("startsAt", "status"); + +-- CreateIndex +CREATE INDEX "event_photos_eventId_idx" ON "event_photos"("eventId"); + +-- CreateIndex +CREATE INDEX "event_genres_genreId_idx" ON "event_genres"("genreId"); + +-- CreateIndex +CREATE INDEX "saved_events_eventId_idx" ON "saved_events"("eventId"); + +-- CreateIndex +CREATE UNIQUE INDEX "saved_events_userId_eventId_key" ON "saved_events"("userId", "eventId"); + +-- CreateIndex +CREATE INDEX "reviews_venueId_status_createdAt_idx" ON "reviews"("venueId", "status", "createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "reviews_userId_venueId_key" ON "reviews"("userId", "venueId"); + +-- CreateIndex +CREATE INDEX "review_photos_reviewId_idx" ON "review_photos"("reviewId"); + +-- CreateIndex +CREATE INDEX "review_edits_reviewId_idx" ON "review_edits"("reviewId"); + +-- CreateIndex +CREATE UNIQUE INDEX "review_helpful_reviewId_userId_key" ON "review_helpful"("reviewId", "userId"); + +-- CreateIndex +CREATE INDEX "venue_scores_score_idx" ON "venue_scores"("score"); + +-- CreateIndex +CREATE INDEX "reports_targetType_targetId_idx" ON "reports"("targetType", "targetId"); + +-- CreateIndex +CREATE INDEX "reports_status_idx" ON "reports"("status"); + +-- CreateIndex +CREATE UNIQUE INDEX "moderation_cases_reportId_key" ON "moderation_cases"("reportId"); + +-- CreateIndex +CREATE INDEX "moderation_cases_state_createdAt_idx" ON "moderation_cases"("state", "createdAt"); + +-- CreateIndex +CREATE INDEX "sanctions_targetUserId_idx" ON "sanctions"("targetUserId"); + +-- CreateIndex +CREATE INDEX "incident_reports_state_severity_idx" ON "incident_reports"("state", "severity"); + +-- CreateIndex +CREATE INDEX "incident_reports_venueId_idx" ON "incident_reports"("venueId"); + +-- CreateIndex +CREATE UNIQUE INDEX "escalations_incidentId_key" ON "escalations"("incidentId"); + +-- CreateIndex +CREATE INDEX "escalations_slaDueAt_idx" ON "escalations"("slaDueAt"); + +-- CreateIndex +CREATE INDEX "notifications_userId_readAt_idx" ON "notifications"("userId", "readAt"); + +-- CreateIndex +CREATE INDEX "feed_entries_ownerId_createdAt_idx" ON "feed_entries"("ownerId", "createdAt"); + +-- CreateIndex +CREATE INDEX "analytics_events_type_occurredAt_idx" ON "analytics_events"("type", "occurredAt"); + +-- CreateIndex +CREATE INDEX "analytics_events_userId_idx" ON "analytics_events"("userId"); + +-- CreateIndex +CREATE INDEX "audit_log_entries_actorId_createdAt_idx" ON "audit_log_entries"("actorId", "createdAt"); + +-- CreateIndex +CREATE INDEX "audit_log_entries_action_createdAt_idx" ON "audit_log_entries"("action", "createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "media_assets_key_key" ON "media_assets"("key"); + +-- CreateIndex +CREATE INDEX "media_assets_ownerId_idx" ON "media_assets"("ownerId"); + +-- AddForeignKey +ALTER TABLE "oauth_accounts" ADD CONSTRAINT "oauth_accounts_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "devices"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "devices" ADD CONSTRAINT "devices_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "profiles" ADD CONSTRAINT "profiles_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "social_links" ADD CONSTRAINT "social_links_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "follows" ADD CONSTRAINT "follows_followerId_fkey" FOREIGN KEY ("followerId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "follows" ADD CONSTRAINT "follows_followingId_fkey" FOREIGN KEY ("followingId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "reputation_events" ADD CONSTRAINT "reputation_events_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "venue_photos" ADD CONSTRAINT "venue_photos_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "venues"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "operating_hours" ADD CONSTRAINT "operating_hours_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "venues"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "venue_genres" ADD CONSTRAINT "venue_genres_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "venues"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "venue_genres" ADD CONSTRAINT "venue_genres_genreId_fkey" FOREIGN KEY ("genreId") REFERENCES "genres"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "events" ADD CONSTRAINT "events_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "venues"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "event_photos" ADD CONSTRAINT "event_photos_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "events"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "event_genres" ADD CONSTRAINT "event_genres_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "events"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "event_genres" ADD CONSTRAINT "event_genres_genreId_fkey" FOREIGN KEY ("genreId") REFERENCES "genres"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "saved_events" ADD CONSTRAINT "saved_events_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "saved_events" ADD CONSTRAINT "saved_events_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "events"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "reviews" ADD CONSTRAINT "reviews_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "reviews" ADD CONSTRAINT "reviews_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "venues"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "review_ratings" ADD CONSTRAINT "review_ratings_reviewId_fkey" FOREIGN KEY ("reviewId") REFERENCES "reviews"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "review_photos" ADD CONSTRAINT "review_photos_reviewId_fkey" FOREIGN KEY ("reviewId") REFERENCES "reviews"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "review_edits" ADD CONSTRAINT "review_edits_reviewId_fkey" FOREIGN KEY ("reviewId") REFERENCES "reviews"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "review_helpful" ADD CONSTRAINT "review_helpful_reviewId_fkey" FOREIGN KEY ("reviewId") REFERENCES "reviews"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "review_helpful" ADD CONSTRAINT "review_helpful_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "venue_scores" ADD CONSTRAINT "venue_scores_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "venues"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "reports" ADD CONSTRAINT "reports_reporterId_fkey" FOREIGN KEY ("reporterId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "moderation_cases" ADD CONSTRAINT "moderation_cases_reportId_fkey" FOREIGN KEY ("reportId") REFERENCES "reports"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "moderation_cases" ADD CONSTRAINT "moderation_cases_assignedModeratorId_fkey" FOREIGN KEY ("assignedModeratorId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sanctions" ADD CONSTRAINT "sanctions_targetUserId_fkey" FOREIGN KEY ("targetUserId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sanctions" ADD CONSTRAINT "sanctions_issuedById_fkey" FOREIGN KEY ("issuedById") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sanctions" ADD CONSTRAINT "sanctions_caseId_fkey" FOREIGN KEY ("caseId") REFERENCES "moderation_cases"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "incident_reports" ADD CONSTRAINT "incident_reports_reporterId_fkey" FOREIGN KEY ("reporterId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "incident_reports" ADD CONSTRAINT "incident_reports_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "venues"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "escalations" ADD CONSTRAINT "escalations_incidentId_fkey" FOREIGN KEY ("incidentId") REFERENCES "incident_reports"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "escalations" ADD CONSTRAINT "escalations_handledById_fkey" FOREIGN KEY ("handledById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "feed_entries" ADD CONSTRAINT "feed_entries_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "feed_entries" ADD CONSTRAINT "feed_entries_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "audit_log_entries" ADD CONSTRAINT "audit_log_entries_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_assets" ADD CONSTRAINT "media_assets_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- ClubScan — PostGIS geospatial augmentation (Phase 2 §6). +-- Applied as a follow-up migration after the Prisma baseline. Adds a generated +-- geography point + GiST index to power "near me" radius search, kept in sync +-- with the latitude/longitude columns. Prisma treats `geog` as Unsupported and +-- the VenueRepository queries it via raw SQL (ST_DWithin / KNN ordering). + +CREATE EXTENSION IF NOT EXISTS postgis; + +-- Generated column stays consistent automatically with lat/lng. +ALTER TABLE venues + ADD COLUMN IF NOT EXISTS geog geography(Point, 4326) + GENERATED ALWAYS AS ( + ST_SetSRID(ST_MakePoint(longitude::double precision, latitude::double precision), 4326)::geography + ) STORED; + +CREATE INDEX IF NOT EXISTS idx_venues_geog ON venues USING GIST (geog); + +-- Trigram + case-insensitive search support (Phase 2 §4). +CREATE EXTENSION IF NOT EXISTS pg_trgm; +CREATE INDEX IF NOT EXISTS idx_venues_name_trgm ON venues USING GIN (name gin_trgm_ops); +CREATE INDEX IF NOT EXISTS idx_events_title_trgm ON events USING GIN (title gin_trgm_ops); diff --git a/clubscan/backend/prisma/schema.prisma b/clubscan/backend/prisma/schema.prisma index 766ebfb..de1518d 100644 --- a/clubscan/backend/prisma/schema.prisma +++ b/clubscan/backend/prisma/schema.prisma @@ -5,7 +5,7 @@ generator client { provider = "prisma-client-js" - previewFeatures = ["postgresqlExtensions", "fullTextSearchPostgres"] + previewFeatures = ["postgresqlExtensions", "fullTextSearch"] } datasource db { @@ -340,11 +340,12 @@ model Follow { } model ReputationEvent { - id String @id @db.Uuid - userId String @db.Uuid - delta Int - reason String - createdAt DateTime @default(now()) + id String @id @db.Uuid + userId String @db.Uuid + delta Int + reason String + idempotencyKey String? @unique + createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -381,6 +382,7 @@ model Venue { events Event[] reviews Review[] score VenueScore? + incidents IncidentReport[] @@index([city, status]) @@index([type, status]) @@ -686,6 +688,7 @@ model IncidentReport { updatedAt DateTime @updatedAt reporter User? @relation("IncidentReporter", fields: [reporterId], references: [id], onDelete: SetNull) + venue Venue? @relation(fields: [venueId], references: [id], onDelete: SetNull) escalation Escalation? @@index([state, severity]) @@ -742,6 +745,7 @@ model FeedEntry { owner User @relation("FeedOwner", fields: [ownerId], references: [id], onDelete: Cascade) actor User @relation("FeedActor", fields: [actorId], references: [id], onDelete: Cascade) + @@unique([ownerId, actorId, verb, objectId]) @@index([ownerId, createdAt]) @@map("feed_entries") } diff --git a/clubscan/backend/prisma/schema_base.sql b/clubscan/backend/prisma/schema_base.sql new file mode 100644 index 0000000..35c14ea --- /dev/null +++ b/clubscan/backend/prisma/schema_base.sql @@ -0,0 +1,857 @@ +-- CreateExtension +CREATE EXTENSION IF NOT EXISTS "citext"; + +-- CreateExtension +CREATE EXTENSION IF NOT EXISTS "pg_trgm"; + +-- CreateExtension +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- CreateEnum +CREATE TYPE "UserRole" AS ENUM ('USER', 'MODERATOR', 'ADMIN', 'SUPER_ADMIN'); + +-- CreateEnum +CREATE TYPE "UserStatus" AS ENUM ('ACTIVE', 'SUSPENDED', 'BANNED', 'SHADOW_BANNED', 'DELETED'); + +-- CreateEnum +CREATE TYPE "VerificationStatus" AS ENUM ('NONE', 'PENDING', 'VERIFIED'); + +-- CreateEnum +CREATE TYPE "OAuthProvider" AS ENUM ('GOOGLE', 'APPLE'); + +-- CreateEnum +CREATE TYPE "DevicePlatform" AS ENUM ('IOS', 'ANDROID'); + +-- CreateEnum +CREATE TYPE "VenueType" AS ENUM ('CLUB', 'BAR', 'FESTIVAL', 'EVENT', 'LOUNGE'); + +-- CreateEnum +CREATE TYPE "VenueStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'CLOSED'); + +-- CreateEnum +CREATE TYPE "EventStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'CANCELLED', 'ENDED'); + +-- CreateEnum +CREATE TYPE "ReviewStatus" AS ENUM ('PENDING', 'PUBLISHED', 'HELD', 'REMOVED'); + +-- CreateEnum +CREATE TYPE "ModerationStatus" AS ENUM ('PENDING', 'APPROVED', 'FLAGGED', 'REJECTED'); + +-- CreateEnum +CREATE TYPE "ReportTargetType" AS ENUM ('REVIEW', 'USER', 'VENUE', 'EVENT'); + +-- CreateEnum +CREATE TYPE "ReportReason" AS ENUM ('SPAM', 'HARASSMENT', 'HATE_SPEECH', 'MISINFORMATION', 'INAPPROPRIATE_CONTENT', 'OTHER'); + +-- CreateEnum +CREATE TYPE "ReportStatus" AS ENUM ('OPEN', 'IN_REVIEW', 'RESOLVED', 'DISMISSED'); + +-- CreateEnum +CREATE TYPE "ModerationState" AS ENUM ('TRIAGE', 'INVESTIGATING', 'ACTIONED', 'DISMISSED', 'CLOSED'); + +-- CreateEnum +CREATE TYPE "SanctionType" AS ENUM ('WARNING', 'TEMP_BAN', 'PERMA_BAN', 'SHADOW_BAN', 'CONTENT_REMOVAL'); + +-- CreateEnum +CREATE TYPE "IncidentCategory" AS ENUM ('HARASSMENT', 'VIOLENCE', 'DISCRIMINATION', 'UNSAFE_ENVIRONMENT', 'OTHER'); + +-- CreateEnum +CREATE TYPE "IncidentSeverity" AS ENUM ('LOW', 'MEDIUM', 'HIGH', 'CRITICAL'); + +-- CreateEnum +CREATE TYPE "IncidentState" AS ENUM ('SUBMITTED', 'TRIAGED', 'INVESTIGATING', 'ACTIONED', 'DISMISSED', 'CLOSED'); + +-- CreateEnum +CREATE TYPE "NotificationType" AS ENUM ('NEW_FOLLOWER', 'REVIEW_PUBLISHED', 'REVIEW_HELPFUL', 'EVENT_REMINDER', 'MODERATION_RESULT', 'SAFETY_UPDATE', 'SYSTEM'); + +-- CreateEnum +CREATE TYPE "MediaStatus" AS ENUM ('PENDING', 'READY', 'FAILED'); + +-- CreateEnum +CREATE TYPE "SocialPlatform" AS ENUM ('INSTAGRAM', 'TIKTOK', 'X', 'SOUNDCLOUD', 'WEBSITE'); + +-- CreateTable +CREATE TABLE "users" ( + "id" UUID NOT NULL, + "email" CITEXT NOT NULL, + "passwordHash" TEXT, + "emailVerifiedAt" TIMESTAMP(3), + "role" "UserRole" NOT NULL DEFAULT 'USER', + "status" "UserStatus" NOT NULL DEFAULT 'ACTIVE', + "reputationScore" INTEGER NOT NULL DEFAULT 0, + "locale" TEXT NOT NULL DEFAULT 'en', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "oauth_accounts" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "provider" "OAuthProvider" NOT NULL, + "providerAccountId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "oauth_accounts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sessions" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "deviceId" UUID, + "refreshTokenHash" TEXT NOT NULL, + "familyId" UUID NOT NULL, + "ip" TEXT, + "userAgent" TEXT, + "expiresAt" TIMESTAMP(3) NOT NULL, + "revokedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "devices" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "platform" "DevicePlatform" NOT NULL, + "pushToken" TEXT, + "name" TEXT, + "lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "devices_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "email_verification_tokens" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "tokenHash" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "usedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "email_verification_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "password_reset_tokens" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "tokenHash" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "usedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "password_reset_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "profiles" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "username" CITEXT NOT NULL, + "displayName" TEXT, + "bio" TEXT, + "avatarUrl" TEXT, + "verificationStatus" "VerificationStatus" NOT NULL DEFAULT 'NONE', + "isPrivate" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "profiles_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "social_links" ( + "id" UUID NOT NULL, + "profileId" UUID NOT NULL, + "platform" "SocialPlatform" NOT NULL, + "url" TEXT NOT NULL, + + CONSTRAINT "social_links_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "follows" ( + "id" UUID NOT NULL, + "followerId" UUID NOT NULL, + "followingId" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "follows_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "reputation_events" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "delta" INTEGER NOT NULL, + "reason" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "reputation_events_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "venues" ( + "id" UUID NOT NULL, + "slug" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "type" "VenueType" NOT NULL, + "status" "VenueStatus" NOT NULL DEFAULT 'PUBLISHED', + "addressLine" TEXT, + "city" TEXT NOT NULL, + "country" TEXT NOT NULL, + "postalCode" TEXT, + "latitude" DECIMAL(9,6) NOT NULL, + "longitude" DECIMAL(9,6) NOT NULL, + "capacity" INTEGER, + "coverPhotoUrl" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "venues_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "venue_photos" ( + "id" UUID NOT NULL, + "venueId" UUID NOT NULL, + "url" TEXT NOT NULL, + "position" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "venue_photos_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "operating_hours" ( + "id" UUID NOT NULL, + "venueId" UUID NOT NULL, + "weekday" INTEGER NOT NULL, + "openMin" INTEGER NOT NULL, + "closeMin" INTEGER NOT NULL, + "isClosed" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "operating_hours_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "genres" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + + CONSTRAINT "genres_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "venue_genres" ( + "venueId" UUID NOT NULL, + "genreId" UUID NOT NULL, + + CONSTRAINT "venue_genres_pkey" PRIMARY KEY ("venueId","genreId") +); + +-- CreateTable +CREATE TABLE "events" ( + "id" UUID NOT NULL, + "venueId" UUID NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "startsAt" TIMESTAMP(3) NOT NULL, + "endsAt" TIMESTAMP(3), + "lineup" JSONB, + "ticketUrl" TEXT, + "coverPhotoUrl" TEXT, + "status" "EventStatus" NOT NULL DEFAULT 'PUBLISHED', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "events_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "event_photos" ( + "id" UUID NOT NULL, + "eventId" UUID NOT NULL, + "url" TEXT NOT NULL, + "position" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "event_photos_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "event_genres" ( + "eventId" UUID NOT NULL, + "genreId" UUID NOT NULL, + + CONSTRAINT "event_genres_pkey" PRIMARY KEY ("eventId","genreId") +); + +-- CreateTable +CREATE TABLE "saved_events" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "eventId" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "saved_events_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "reviews" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "venueId" UUID NOT NULL, + "body" TEXT NOT NULL, + "language" TEXT NOT NULL DEFAULT 'en', + "status" "ReviewStatus" NOT NULL DEFAULT 'PENDING', + "moderationStatus" "ModerationStatus" NOT NULL DEFAULT 'PENDING', + "helpfulCount" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "reviews_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "review_ratings" ( + "reviewId" UUID NOT NULL, + "security" INTEGER NOT NULL, + "staffBehavior" INTEGER NOT NULL, + "fairPricing" INTEGER NOT NULL, + "crowdQuality" INTEGER NOT NULL, + "musicQuality" INTEGER NOT NULL, + "soundSystem" INTEGER NOT NULL, + "cleanliness" INTEGER NOT NULL, + "safetyForWomen" INTEGER NOT NULL, + "atmosphere" INTEGER NOT NULL, + + CONSTRAINT "review_ratings_pkey" PRIMARY KEY ("reviewId") +); + +-- CreateTable +CREATE TABLE "review_photos" ( + "id" UUID NOT NULL, + "reviewId" UUID NOT NULL, + "url" TEXT NOT NULL, + "position" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "review_photos_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "review_edits" ( + "id" UUID NOT NULL, + "reviewId" UUID NOT NULL, + "bodySnapshot" TEXT NOT NULL, + "ratingSnapshot" JSONB NOT NULL, + "editedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "review_edits_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "review_helpful" ( + "id" UUID NOT NULL, + "reviewId" UUID NOT NULL, + "userId" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "review_helpful_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "venue_scores" ( + "venueId" UUID NOT NULL, + "score" DECIMAL(5,2) NOT NULL, + "reviewCount" INTEGER NOT NULL DEFAULT 0, + "avgSecurity" DECIMAL(4,2) NOT NULL DEFAULT 0, + "avgStaffBehavior" DECIMAL(4,2) NOT NULL DEFAULT 0, + "avgFairPricing" DECIMAL(4,2) NOT NULL DEFAULT 0, + "avgCrowdQuality" DECIMAL(4,2) NOT NULL DEFAULT 0, + "avgMusicQuality" DECIMAL(4,2) NOT NULL DEFAULT 0, + "avgSoundSystem" DECIMAL(4,2) NOT NULL DEFAULT 0, + "avgCleanliness" DECIMAL(4,2) NOT NULL DEFAULT 0, + "avgSafetyForWomen" DECIMAL(4,2) NOT NULL DEFAULT 0, + "avgAtmosphere" DECIMAL(4,2) NOT NULL DEFAULT 0, + "safetyAdvisory" BOOLEAN NOT NULL DEFAULT false, + "lastComputedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "venue_scores_pkey" PRIMARY KEY ("venueId") +); + +-- CreateTable +CREATE TABLE "reports" ( + "id" UUID NOT NULL, + "reporterId" UUID NOT NULL, + "targetType" "ReportTargetType" NOT NULL, + "targetId" UUID NOT NULL, + "reason" "ReportReason" NOT NULL, + "details" TEXT, + "status" "ReportStatus" NOT NULL DEFAULT 'OPEN', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "reports_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "moderation_cases" ( + "id" UUID NOT NULL, + "reportId" UUID, + "targetType" "ReportTargetType" NOT NULL, + "targetId" UUID NOT NULL, + "state" "ModerationState" NOT NULL DEFAULT 'TRIAGE', + "assignedModeratorId" UUID, + "aiVerdict" JSONB, + "resolution" TEXT, + "resolvedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "moderation_cases_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sanctions" ( + "id" UUID NOT NULL, + "targetUserId" UUID NOT NULL, + "caseId" UUID, + "type" "SanctionType" NOT NULL, + "reason" TEXT NOT NULL, + "issuedById" UUID NOT NULL, + "expiresAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "sanctions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "incident_reports" ( + "id" UUID NOT NULL, + "reporterId" UUID, + "venueId" UUID, + "category" "IncidentCategory" NOT NULL, + "severity" "IncidentSeverity" NOT NULL DEFAULT 'MEDIUM', + "description" TEXT NOT NULL, + "occurredAt" TIMESTAMP(3), + "isAnonymous" BOOLEAN NOT NULL DEFAULT false, + "state" "IncidentState" NOT NULL DEFAULT 'SUBMITTED', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "incident_reports_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "escalations" ( + "id" UUID NOT NULL, + "incidentId" UUID NOT NULL, + "level" INTEGER NOT NULL DEFAULT 1, + "slaDueAt" TIMESTAMP(3) NOT NULL, + "escalatedAt" TIMESTAMP(3), + "handledById" UUID, + "outcome" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "escalations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "notifications" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "type" "NotificationType" NOT NULL, + "payload" JSONB NOT NULL, + "readAt" TIMESTAMP(3), + "sentPush" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "notifications_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "feed_entries" ( + "id" UUID NOT NULL, + "ownerId" UUID NOT NULL, + "actorId" UUID NOT NULL, + "verb" TEXT NOT NULL, + "objectType" TEXT NOT NULL, + "objectId" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "feed_entries_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "analytics_events" ( + "id" UUID NOT NULL, + "type" TEXT NOT NULL, + "userId" UUID, + "sessionId" TEXT, + "properties" JSONB, + "occurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "analytics_events_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "audit_log_entries" ( + "id" UUID NOT NULL, + "actorId" UUID, + "action" TEXT NOT NULL, + "targetType" TEXT, + "targetId" TEXT, + "metadata" JSONB, + "ip" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "audit_log_entries_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "media_assets" ( + "id" UUID NOT NULL, + "ownerId" UUID NOT NULL, + "bucket" TEXT NOT NULL, + "key" TEXT NOT NULL, + "mime" TEXT NOT NULL, + "size" INTEGER NOT NULL, + "width" INTEGER, + "height" INTEGER, + "status" "MediaStatus" NOT NULL DEFAULT 'PENDING', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "media_assets_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "feature_flags" ( + "key" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT false, + "payload" JSONB, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "feature_flags_pkey" PRIMARY KEY ("key") +); + +-- CreateTable +CREATE TABLE "app_config" ( + "id" INTEGER NOT NULL DEFAULT 1, + "version" INTEGER NOT NULL DEFAULT 1, + "config" JSONB NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "app_config_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); + +-- CreateIndex +CREATE INDEX "users_status_idx" ON "users"("status"); + +-- CreateIndex +CREATE INDEX "users_role_idx" ON "users"("role"); + +-- CreateIndex +CREATE INDEX "oauth_accounts_userId_idx" ON "oauth_accounts"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "oauth_accounts_provider_providerAccountId_key" ON "oauth_accounts"("provider", "providerAccountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "sessions_refreshTokenHash_key" ON "sessions"("refreshTokenHash"); + +-- CreateIndex +CREATE INDEX "sessions_userId_idx" ON "sessions"("userId"); + +-- CreateIndex +CREATE INDEX "sessions_familyId_idx" ON "sessions"("familyId"); + +-- CreateIndex +CREATE INDEX "devices_userId_idx" ON "devices"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "devices_userId_pushToken_key" ON "devices"("userId", "pushToken"); + +-- CreateIndex +CREATE UNIQUE INDEX "email_verification_tokens_tokenHash_key" ON "email_verification_tokens"("tokenHash"); + +-- CreateIndex +CREATE INDEX "email_verification_tokens_userId_idx" ON "email_verification_tokens"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "password_reset_tokens_tokenHash_key" ON "password_reset_tokens"("tokenHash"); + +-- CreateIndex +CREATE INDEX "password_reset_tokens_userId_idx" ON "password_reset_tokens"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "profiles_userId_key" ON "profiles"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "profiles_username_key" ON "profiles"("username"); + +-- CreateIndex +CREATE INDEX "social_links_profileId_idx" ON "social_links"("profileId"); + +-- CreateIndex +CREATE INDEX "follows_followingId_idx" ON "follows"("followingId"); + +-- CreateIndex +CREATE UNIQUE INDEX "follows_followerId_followingId_key" ON "follows"("followerId", "followingId"); + +-- CreateIndex +CREATE INDEX "reputation_events_userId_idx" ON "reputation_events"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "venues_slug_key" ON "venues"("slug"); + +-- CreateIndex +CREATE INDEX "venues_city_status_idx" ON "venues"("city", "status"); + +-- CreateIndex +CREATE INDEX "venues_type_status_idx" ON "venues"("type", "status"); + +-- CreateIndex +CREATE INDEX "venue_photos_venueId_idx" ON "venue_photos"("venueId"); + +-- CreateIndex +CREATE UNIQUE INDEX "operating_hours_venueId_weekday_key" ON "operating_hours"("venueId", "weekday"); + +-- CreateIndex +CREATE UNIQUE INDEX "genres_name_key" ON "genres"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "genres_slug_key" ON "genres"("slug"); + +-- CreateIndex +CREATE INDEX "venue_genres_genreId_idx" ON "venue_genres"("genreId"); + +-- CreateIndex +CREATE INDEX "events_venueId_idx" ON "events"("venueId"); + +-- CreateIndex +CREATE INDEX "events_startsAt_status_idx" ON "events"("startsAt", "status"); + +-- CreateIndex +CREATE INDEX "event_photos_eventId_idx" ON "event_photos"("eventId"); + +-- CreateIndex +CREATE INDEX "event_genres_genreId_idx" ON "event_genres"("genreId"); + +-- CreateIndex +CREATE INDEX "saved_events_eventId_idx" ON "saved_events"("eventId"); + +-- CreateIndex +CREATE UNIQUE INDEX "saved_events_userId_eventId_key" ON "saved_events"("userId", "eventId"); + +-- CreateIndex +CREATE INDEX "reviews_venueId_status_createdAt_idx" ON "reviews"("venueId", "status", "createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "reviews_userId_venueId_key" ON "reviews"("userId", "venueId"); + +-- CreateIndex +CREATE INDEX "review_photos_reviewId_idx" ON "review_photos"("reviewId"); + +-- CreateIndex +CREATE INDEX "review_edits_reviewId_idx" ON "review_edits"("reviewId"); + +-- CreateIndex +CREATE UNIQUE INDEX "review_helpful_reviewId_userId_key" ON "review_helpful"("reviewId", "userId"); + +-- CreateIndex +CREATE INDEX "venue_scores_score_idx" ON "venue_scores"("score"); + +-- CreateIndex +CREATE INDEX "reports_targetType_targetId_idx" ON "reports"("targetType", "targetId"); + +-- CreateIndex +CREATE INDEX "reports_status_idx" ON "reports"("status"); + +-- CreateIndex +CREATE UNIQUE INDEX "moderation_cases_reportId_key" ON "moderation_cases"("reportId"); + +-- CreateIndex +CREATE INDEX "moderation_cases_state_createdAt_idx" ON "moderation_cases"("state", "createdAt"); + +-- CreateIndex +CREATE INDEX "sanctions_targetUserId_idx" ON "sanctions"("targetUserId"); + +-- CreateIndex +CREATE INDEX "incident_reports_state_severity_idx" ON "incident_reports"("state", "severity"); + +-- CreateIndex +CREATE INDEX "incident_reports_venueId_idx" ON "incident_reports"("venueId"); + +-- CreateIndex +CREATE UNIQUE INDEX "escalations_incidentId_key" ON "escalations"("incidentId"); + +-- CreateIndex +CREATE INDEX "escalations_slaDueAt_idx" ON "escalations"("slaDueAt"); + +-- CreateIndex +CREATE INDEX "notifications_userId_readAt_idx" ON "notifications"("userId", "readAt"); + +-- CreateIndex +CREATE INDEX "feed_entries_ownerId_createdAt_idx" ON "feed_entries"("ownerId", "createdAt"); + +-- CreateIndex +CREATE INDEX "analytics_events_type_occurredAt_idx" ON "analytics_events"("type", "occurredAt"); + +-- CreateIndex +CREATE INDEX "analytics_events_userId_idx" ON "analytics_events"("userId"); + +-- CreateIndex +CREATE INDEX "audit_log_entries_actorId_createdAt_idx" ON "audit_log_entries"("actorId", "createdAt"); + +-- CreateIndex +CREATE INDEX "audit_log_entries_action_createdAt_idx" ON "audit_log_entries"("action", "createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "media_assets_key_key" ON "media_assets"("key"); + +-- CreateIndex +CREATE INDEX "media_assets_ownerId_idx" ON "media_assets"("ownerId"); + +-- AddForeignKey +ALTER TABLE "oauth_accounts" ADD CONSTRAINT "oauth_accounts_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "devices"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "devices" ADD CONSTRAINT "devices_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "profiles" ADD CONSTRAINT "profiles_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "social_links" ADD CONSTRAINT "social_links_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "follows" ADD CONSTRAINT "follows_followerId_fkey" FOREIGN KEY ("followerId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "follows" ADD CONSTRAINT "follows_followingId_fkey" FOREIGN KEY ("followingId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "reputation_events" ADD CONSTRAINT "reputation_events_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "venue_photos" ADD CONSTRAINT "venue_photos_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "venues"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "operating_hours" ADD CONSTRAINT "operating_hours_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "venues"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "venue_genres" ADD CONSTRAINT "venue_genres_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "venues"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "venue_genres" ADD CONSTRAINT "venue_genres_genreId_fkey" FOREIGN KEY ("genreId") REFERENCES "genres"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "events" ADD CONSTRAINT "events_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "venues"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "event_photos" ADD CONSTRAINT "event_photos_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "events"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "event_genres" ADD CONSTRAINT "event_genres_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "events"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "event_genres" ADD CONSTRAINT "event_genres_genreId_fkey" FOREIGN KEY ("genreId") REFERENCES "genres"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "saved_events" ADD CONSTRAINT "saved_events_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "saved_events" ADD CONSTRAINT "saved_events_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "events"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "reviews" ADD CONSTRAINT "reviews_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "reviews" ADD CONSTRAINT "reviews_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "venues"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "review_ratings" ADD CONSTRAINT "review_ratings_reviewId_fkey" FOREIGN KEY ("reviewId") REFERENCES "reviews"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "review_photos" ADD CONSTRAINT "review_photos_reviewId_fkey" FOREIGN KEY ("reviewId") REFERENCES "reviews"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "review_edits" ADD CONSTRAINT "review_edits_reviewId_fkey" FOREIGN KEY ("reviewId") REFERENCES "reviews"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "review_helpful" ADD CONSTRAINT "review_helpful_reviewId_fkey" FOREIGN KEY ("reviewId") REFERENCES "reviews"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "review_helpful" ADD CONSTRAINT "review_helpful_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "venue_scores" ADD CONSTRAINT "venue_scores_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "venues"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "reports" ADD CONSTRAINT "reports_reporterId_fkey" FOREIGN KEY ("reporterId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "moderation_cases" ADD CONSTRAINT "moderation_cases_reportId_fkey" FOREIGN KEY ("reportId") REFERENCES "reports"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "moderation_cases" ADD CONSTRAINT "moderation_cases_assignedModeratorId_fkey" FOREIGN KEY ("assignedModeratorId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sanctions" ADD CONSTRAINT "sanctions_targetUserId_fkey" FOREIGN KEY ("targetUserId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sanctions" ADD CONSTRAINT "sanctions_issuedById_fkey" FOREIGN KEY ("issuedById") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sanctions" ADD CONSTRAINT "sanctions_caseId_fkey" FOREIGN KEY ("caseId") REFERENCES "moderation_cases"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "incident_reports" ADD CONSTRAINT "incident_reports_reporterId_fkey" FOREIGN KEY ("reporterId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "incident_reports" ADD CONSTRAINT "incident_reports_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "venues"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "escalations" ADD CONSTRAINT "escalations_incidentId_fkey" FOREIGN KEY ("incidentId") REFERENCES "incident_reports"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "escalations" ADD CONSTRAINT "escalations_handledById_fkey" FOREIGN KEY ("handledById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "feed_entries" ADD CONSTRAINT "feed_entries_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "feed_entries" ADD CONSTRAINT "feed_entries_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "audit_log_entries" ADD CONSTRAINT "audit_log_entries_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_assets" ADD CONSTRAINT "media_assets_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/clubscan/backend/prisma/seed.ts b/clubscan/backend/prisma/seed.ts index 41f8832..dc3cc02 100644 --- a/clubscan/backend/prisma/seed.ts +++ b/clubscan/backend/prisma/seed.ts @@ -1,4 +1,4 @@ -import { PrismaClient, UserRole, VenueType } from '@prisma/client'; +import { Prisma, PrismaClient, UserRole, VenueType } from '@prisma/client'; import * as argon2 from 'argon2'; import { v7 as uuidv7 } from 'uuid'; import { DEFAULT_SCORING_CONFIG } from '../src/platform/config/scoring-config'; @@ -28,10 +28,11 @@ async function main(): Promise { } // Singleton app config (scoring weights/thresholds). + const scoringConfig = { scoring: DEFAULT_SCORING_CONFIG } as unknown as Prisma.InputJsonValue; await prisma.appConfig.upsert({ where: { id: 1 }, - update: { config: { scoring: DEFAULT_SCORING_CONFIG } }, - create: { id: 1, config: { scoring: DEFAULT_SCORING_CONFIG } }, + update: { config: scoringConfig }, + create: { id: 1, config: scoringConfig }, }); // Super admin (env-driven). diff --git a/clubscan/backend/src/app.module.ts b/clubscan/backend/src/app.module.ts index ebf4f6c..fba2b4f 100644 --- a/clubscan/backend/src/app.module.ts +++ b/clubscan/backend/src/app.module.ts @@ -1,5 +1,5 @@ import { Module } from '@nestjs/common'; -import { APP_FILTER, APP_GUARD, APP_PIPE } from '@nestjs/core'; +import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; import { ConfigModule } from '@nestjs/config'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { ZodValidationPipe } from 'nestjs-zod'; @@ -9,15 +9,25 @@ import { PrismaModule } from '@/platform/prisma/prisma.module'; import { AppConfigModule } from '@/platform/config/app-config.module'; import { EventBusModule } from '@/platform/event-bus/event-bus.module'; import { AuditModule } from '@/platform/audit/audit.module'; +import { AuditInterceptor } from '@/platform/audit/audit.interceptor'; import { SecurityModule } from '@/platform/security/security.module'; import { JwtAuthGuard } from '@/platform/security/jwt-auth.guard'; import { RolesGuard } from '@/platform/security/roles.guard'; import { AllExceptionsFilter } from '@/platform/http/all-exceptions.filter'; import { AuthModule } from '@/modules/auth/auth.module'; +import { UsersModule } from '@/modules/users/users.module'; +import { ProfilesModule } from '@/modules/profiles/profiles.module'; import { VenuesModule } from '@/modules/venues/venues.module'; +import { EventsModule } from '@/modules/events/events.module'; import { ReviewsModule } from '@/modules/reviews/reviews.module'; import { ScoringModule } from '@/modules/scoring/scoring.module'; +import { SearchModule } from '@/modules/search/search.module'; +import { ModerationModule } from '@/modules/moderation/moderation.module'; +import { SafetyModule } from '@/modules/safety/safety.module'; +import { NotificationsModule } from '@/modules/notifications/notifications.module'; +import { AnalyticsModule } from '@/modules/analytics/analytics.module'; +import { MediaModule } from '@/modules/media/media.module'; import { HealthModule } from '@/modules/health/health.module'; @Module({ @@ -33,9 +43,18 @@ import { HealthModule } from '@/modules/health/health.module'; // Feature modules (bounded contexts) AuthModule, + UsersModule, + ProfilesModule, VenuesModule, + EventsModule, ReviewsModule, ScoringModule, + SearchModule, + ModerationModule, + SafetyModule, + NotificationsModule, + AnalyticsModule, + MediaModule, HealthModule, ], providers: [ @@ -45,6 +64,7 @@ import { HealthModule } from '@/modules/health/health.module'; { provide: APP_GUARD, useClass: ThrottlerGuard }, { provide: APP_GUARD, useClass: JwtAuthGuard }, { provide: APP_GUARD, useClass: RolesGuard }, + { provide: APP_INTERCEPTOR, useClass: AuditInterceptor }, ], }) export class AppModule {} diff --git a/clubscan/backend/src/main.ts b/clubscan/backend/src/main.ts index 883c6aa..6085d22 100644 --- a/clubscan/backend/src/main.ts +++ b/clubscan/backend/src/main.ts @@ -1,5 +1,5 @@ import 'reflect-metadata'; -import { Logger, VersioningType } from '@nestjs/common'; +import { Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { NestFactory } from '@nestjs/core'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; diff --git a/clubscan/backend/src/modules/analytics/analytics.module.ts b/clubscan/backend/src/modules/analytics/analytics.module.ts new file mode 100644 index 0000000..3679477 --- /dev/null +++ b/clubscan/backend/src/modules/analytics/analytics.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { AnalyticsService } from './application/analytics.service'; +import { AnalyticsController } from './presentation/analytics.controller'; + +@Module({ + controllers: [AnalyticsController], + providers: [AnalyticsService], +}) +export class AnalyticsModule {} diff --git a/clubscan/backend/src/modules/analytics/application/analytics.service.ts b/clubscan/backend/src/modules/analytics/application/analytics.service.ts new file mode 100644 index 0000000..4bbf658 --- /dev/null +++ b/clubscan/backend/src/modules/analytics/application/analytics.service.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '@/platform/prisma/prisma.service'; +import { newId } from '@/shared/ids/uuid'; +import { IngestDto } from './dto/analytics.dto'; + +@Injectable() +export class AnalyticsService { + constructor(private readonly prisma: PrismaService) {} + + /** Batched, append-only ingestion (Phase 2 §3.8). PII-minimized. */ + async ingest(userId: string | undefined, dto: IngestDto): Promise { + await this.prisma.analyticsEvent.createMany({ + data: dto.events.map((e) => ({ + id: newId(), + type: e.type, + userId: userId ?? null, + sessionId: e.sessionId, + properties: (e.properties ?? {}) as Prisma.InputJsonValue, + occurredAt: e.occurredAt ?? new Date(), + })), + }); + } + + /** Aggregate metrics for admin dashboards (Phase 1 §10 — ADMIN+). */ + async overview(days = 7) { + const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + + const [byType, activeUsers, reviewsCreated, reportsOpen] = await Promise.all([ + this.prisma.analyticsEvent.groupBy({ + by: ['type'], + where: { occurredAt: { gte: since } }, + _count: { _all: true }, + }), + this.prisma.analyticsEvent + .findMany({ + where: { occurredAt: { gte: since }, userId: { not: null } }, + distinct: ['userId'], + select: { userId: true }, + }) + .then((r) => r.length), + this.prisma.review.count({ where: { createdAt: { gte: since } } }), + this.prisma.moderationCase.count({ where: { state: { in: ['TRIAGE', 'INVESTIGATING'] } } }), + ]); + + return { + windowDays: days, + eventCounts: Object.fromEntries(byType.map((b) => [b.type, b._count._all])), + activeUsers, + reviewsCreated, + moderationBacklog: reportsOpen, + }; + } +} diff --git a/clubscan/backend/src/modules/analytics/application/dto/analytics.dto.ts b/clubscan/backend/src/modules/analytics/application/dto/analytics.dto.ts new file mode 100644 index 0000000..2f577d9 --- /dev/null +++ b/clubscan/backend/src/modules/analytics/application/dto/analytics.dto.ts @@ -0,0 +1,28 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +// Known event types (Phase 1 §11). Extensible; unknown types are rejected to +// keep the analytics stream clean. +export const AnalyticsEventType = z.enum([ + 'venue_viewed', + 'event_viewed', + 'review_card_clicked', + 'review_helpful_marked', + 'search_performed', + 'session_started', +]); + +export const IngestSchema = z.object({ + events: z + .array( + z.object({ + type: AnalyticsEventType, + properties: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(), + occurredAt: z.coerce.date().optional(), + sessionId: z.string().max(64).optional(), + }), + ) + .min(1) + .max(50), +}); +export class IngestDto extends createZodDto(IngestSchema) {} diff --git a/clubscan/backend/src/modules/analytics/presentation/analytics.controller.ts b/clubscan/backend/src/modules/analytics/presentation/analytics.controller.ts new file mode 100644 index 0000000..16ab7bb --- /dev/null +++ b/clubscan/backend/src/modules/analytics/presentation/analytics.controller.ts @@ -0,0 +1,27 @@ +import { Body, Controller, Get, HttpCode, Post, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { UserRole } from '@prisma/client'; +import { CurrentUser, Public, Roles } from '@/platform/security/decorators'; +import { AnalyticsService } from '../application/analytics.service'; +import { IngestDto } from '../application/dto/analytics.dto'; + +@ApiTags('analytics') +@Controller() +export class AnalyticsController { + constructor(private readonly analytics: AnalyticsService) {} + + // Accepts anonymous beacons too (session_started before login). + @Public() + @HttpCode(202) + @Post('analytics/events') + ingest(@Body() dto: IngestDto, @CurrentUser('id') userId?: string) { + void this.analytics.ingest(userId, dto); + return { accepted: dto.events.length }; + } + + @Roles(UserRole.ADMIN) + @Get('admin/analytics/overview') + overview(@Query('days') days?: number) { + return this.analytics.overview(days ? Number(days) : 7); + } +} diff --git a/clubscan/backend/src/modules/auth/application/auth.service.ts b/clubscan/backend/src/modules/auth/application/auth.service.ts index e60661b..e1e7648 100644 --- a/clubscan/backend/src/modules/auth/application/auth.service.ts +++ b/clubscan/backend/src/modules/auth/application/auth.service.ts @@ -191,7 +191,7 @@ export class AuthService { ? await this.oauth.verifyGoogle(token) : await this.oauth.verifyApple(token); - let account = await this.prisma.oAuthAccount.findUnique({ + const account = await this.prisma.oAuthAccount.findUnique({ where: { provider_providerAccountId: { provider: identity.provider, diff --git a/clubscan/backend/src/modules/auth/infrastructure/oauth-verifier.adapter.ts b/clubscan/backend/src/modules/auth/infrastructure/oauth-verifier.adapter.ts index d4c6f19..e83bd53 100644 --- a/clubscan/backend/src/modules/auth/infrastructure/oauth-verifier.adapter.ts +++ b/clubscan/backend/src/modules/auth/infrastructure/oauth-verifier.adapter.ts @@ -7,6 +7,8 @@ import { OAuthVerifierPort, VerifiedOAuthIdentity, } from '../application/ports/oauth-verifier.port'; +import * as jwt from 'jsonwebtoken'; +import * as jwksClient from 'jwks-rsa'; /** * Verifies Google and Apple identity tokens server-side. Google uses the @@ -56,12 +58,40 @@ export class OAuthVerifierAdapter implements OAuthVerifierPort { }; } - // Placeholder seam for the JWKS-backed Apple verification (kept isolated so - // the rest of the auth flow is provider-agnostic and testable). + // JWKS-backed Apple verification private async verifyAppleJwt( - _token: string, - _audience: string, + token: string, + audience: string, ): Promise<{ sub: string; email?: string; email_verified?: string | boolean }> { - throw DomainError.unauthorized('Apple verification not yet wired in this environment'); + const client = jwksClient.default({ + jwksUri: 'https://appleid.apple.com/auth/keys', + cache: true, + rateLimit: true, + jwksRequestsPerMinute: 10, + }); + + const getKey = (header: jwt.JwtHeader, callback: jwt.SigningKeyCallback) => { + client.getSigningKey(header.kid, (err, key) => { + if (err) return callback(err); + const signingKey = key?.getPublicKey(); + callback(null, signingKey); + }); + }; + + return new Promise((resolve, reject) => { + jwt.verify( + token, + getKey, + { + algorithms: ['RS256'], + issuer: 'https://appleid.apple.com', + audience, + }, + (err, decoded) => { + if (err) return reject(DomainError.unauthorized('Invalid Apple token: ' + err.message)); + resolve(decoded as any); + }, + ); + }); } } diff --git a/clubscan/backend/src/modules/auth/presentation/auth.controller.ts b/clubscan/backend/src/modules/auth/presentation/auth.controller.ts index 4ecc188..f71884c 100644 --- a/clubscan/backend/src/modules/auth/presentation/auth.controller.ts +++ b/clubscan/backend/src/modules/auth/presentation/auth.controller.ts @@ -33,6 +33,7 @@ export class AuthController { } @Public() + @Throttle({ default: { limit: 5, ttl: 15 * 60 * 1000 } }) @HttpCode(200) @Post('verify-email') async verifyEmail(@Body() dto: VerifyEmailDto) { @@ -49,6 +50,7 @@ export class AuthController { } @Public() + @Throttle({ default: { limit: 10, ttl: 15 * 60 * 1000 } }) @HttpCode(200) @Post('refresh') refresh(@Body() dto: RefreshDto, @Req() req: Request) { @@ -80,6 +82,7 @@ export class AuthController { } @Public() + @Throttle({ default: { limit: 3, ttl: 60 * 60 * 1000 } }) @HttpCode(200) @Post('reset-password') async resetPassword(@Body() dto: ResetPasswordDto) { @@ -88,6 +91,7 @@ export class AuthController { } @Public() + @Throttle({ default: { limit: 5, ttl: 15 * 60 * 1000 } }) @HttpCode(200) @Post('oauth/google') oauthGoogle(@Body() dto: OAuthGoogleDto, @Req() req: Request) { @@ -95,6 +99,7 @@ export class AuthController { } @Public() + @Throttle({ default: { limit: 5, ttl: 15 * 60 * 1000 } }) @HttpCode(200) @Post('oauth/apple') oauthApple(@Body() dto: OAuthAppleDto, @Req() req: Request) { diff --git a/clubscan/backend/src/modules/events/application/dto/event-query.dto.ts b/clubscan/backend/src/modules/events/application/dto/event-query.dto.ts new file mode 100644 index 0000000..3611861 --- /dev/null +++ b/clubscan/backend/src/modules/events/application/dto/event-query.dto.ts @@ -0,0 +1,13 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +export const EventQuerySchema = z.object({ + city: z.string().trim().min(1).max(80).optional(), + genre: z.string().trim().min(1).max(40).optional(), + q: z.string().trim().min(1).max(80).optional(), + from: z.coerce.date().optional(), + to: z.coerce.date().optional(), + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(50).optional(), +}); +export class EventQueryDto extends createZodDto(EventQuerySchema) {} diff --git a/clubscan/backend/src/modules/events/application/events.service.ts b/clubscan/backend/src/modules/events/application/events.service.ts new file mode 100644 index 0000000..eb351de --- /dev/null +++ b/clubscan/backend/src/modules/events/application/events.service.ts @@ -0,0 +1,136 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { EventStatus, Prisma } from '@prisma/client'; +import { PrismaService } from '@/platform/prisma/prisma.service'; +import { EVENT_BUS, EventBusPort } from '@/platform/event-bus/event-bus.port'; +import { buildPage, clampLimit, decodeCursor } from '@/platform/pagination/cursor'; +import { DomainEvent } from '@/shared/domain/domain-event'; +import { DomainError } from '@/shared/errors/domain-error'; +import { newId } from '@/shared/ids/uuid'; +import { EventQueryDto } from './dto/event-query.dto'; + +class EventSavedEvent extends DomainEvent<{ userId: string; eventId: string }> { + readonly name = 'event.saved'; + constructor(readonly payload: { userId: string; eventId: string }) { + super(); + } +} + +@Injectable() +export class EventsService { + constructor( + private readonly prisma: PrismaService, + @Inject(EVENT_BUS) private readonly bus: EventBusPort, + ) {} + + async discover(query: EventQueryDto) { + const take = clampLimit(query.limit); + const decoded = decodeCursor(query.cursor); + + const where: Prisma.EventWhereInput = { + status: EventStatus.PUBLISHED, + deletedAt: null, + startsAt: { gte: query.from ?? new Date() }, + ...(query.to ? { startsAt: { gte: query.from ?? new Date(), lte: query.to } } : {}), + ...(query.city ? { venue: { city: { equals: query.city, mode: 'insensitive' } } } : {}), + ...(query.genre ? { genres: { some: { genre: { slug: query.genre } } } } : {}), + ...(query.q ? { title: { contains: query.q, mode: 'insensitive' } } : {}), + ...(decoded + ? { + OR: [ + { startsAt: { gt: new Date(decoded.createdAt) } }, + { startsAt: new Date(decoded.createdAt), id: { gt: decoded.id } }, + ], + } + : {}), + }; + + const rows = await this.prisma.event.findMany({ + where, + include: { + venue: { select: { id: true, name: true, slug: true, city: true } }, + genres: { include: { genre: true } }, + }, + orderBy: [{ startsAt: 'asc' }, { id: 'asc' }], + take: take + 1, + }); + + // Cursor keyed on startsAt (ascending) for upcoming-events ordering. + const hasMore = rows.length > take; + const data = hasMore ? rows.slice(0, take) : rows; + const last = data[data.length - 1]; + return { + data, + nextCursor: + hasMore && last + ? Buffer.from( + JSON.stringify({ createdAt: last.startsAt.toISOString(), id: last.id }), + ).toString('base64url') + : null, + }; + } + + async getById(id: string) { + const event = await this.prisma.event.findFirst({ + where: { id, deletedAt: null }, + include: { + venue: { select: { id: true, name: true, slug: true, city: true, country: true } }, + genres: { include: { genre: true } }, + photos: { orderBy: { position: 'asc' } }, + }, + }); + if (!event) throw DomainError.notFound('Event', id); + return event; + } + + async save(userId: string, eventId: string) { + const event = await this.prisma.event.findFirst({ + where: { id: eventId, deletedAt: null }, + select: { id: true }, + }); + if (!event) throw DomainError.notFound('Event', eventId); + + const created = await this.prisma.savedEvent + .create({ data: { id: newId(), userId, eventId } }) + .catch(() => null); + if (created) await this.bus.publish(new EventSavedEvent({ userId, eventId })); + return { ok: true }; + } + + async unsave(userId: string, eventId: string) { + await this.prisma.savedEvent + .delete({ where: { userId_eventId: { userId, eventId } } }) + .catch(() => null); + return { ok: true }; + } + + async listSaved(userId: string, cursor?: string, limit?: number) { + const take = clampLimit(limit); + const decoded = decodeCursor(cursor); + const rows = await this.prisma.savedEvent.findMany({ + where: { + userId, + ...(decoded ? { createdAt: { lt: new Date(decoded.createdAt) } } : {}), + }, + include: { + event: { + include: { venue: { select: { name: true, slug: true, city: true } } }, + }, + }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: take + 1, + }); + return buildPage(rows, take); + } + + /** Deep-link share payload for an event (Phase 1 §9 flow 7). */ + async share(eventId: string) { + const event = await this.getById(eventId); + return { + url: `clubscan://event/${event.id}`, + universalLink: `https://clubscan.app/event/${event.id}`, + title: event.title, + venue: event.venue.name, + startsAt: event.startsAt, + }; + } +} diff --git a/clubscan/backend/src/modules/events/events.module.ts b/clubscan/backend/src/modules/events/events.module.ts new file mode 100644 index 0000000..a1ab9e1 --- /dev/null +++ b/clubscan/backend/src/modules/events/events.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { EventsService } from './application/events.service'; +import { EventsController } from './presentation/events.controller'; + +@Module({ + controllers: [EventsController], + providers: [EventsService], +}) +export class EventsModule {} diff --git a/clubscan/backend/src/modules/events/presentation/events.controller.ts b/clubscan/backend/src/modules/events/presentation/events.controller.ts new file mode 100644 index 0000000..76ce428 --- /dev/null +++ b/clubscan/backend/src/modules/events/presentation/events.controller.ts @@ -0,0 +1,48 @@ +import { Controller, Delete, Get, Param, Post, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { CurrentUser, Public } from '@/platform/security/decorators'; +import { EventsService } from '../application/events.service'; +import { EventQueryDto } from '../application/dto/event-query.dto'; + +@ApiTags('events') +@Controller() +export class EventsController { + constructor(private readonly events: EventsService) {} + + @Public() + @Get('events') + discover(@Query() query: EventQueryDto) { + return this.events.discover(query); + } + + @Public() + @Get('events/:id') + detail(@Param('id') id: string) { + return this.events.getById(id); + } + + @Public() + @Get('events/:id/share') + share(@Param('id') id: string) { + return this.events.share(id); + } + + @Get('me/saved-events') + listSaved( + @CurrentUser('id') userId: string, + @Query('cursor') cursor?: string, + @Query('limit') limit?: number, + ) { + return this.events.listSaved(userId, cursor, limit); + } + + @Post('me/saved-events/:eventId') + save(@CurrentUser('id') userId: string, @Param('eventId') eventId: string) { + return this.events.save(userId, eventId); + } + + @Delete('me/saved-events/:eventId') + unsave(@CurrentUser('id') userId: string, @Param('eventId') eventId: string) { + return this.events.unsave(userId, eventId); + } +} diff --git a/clubscan/backend/src/modules/media/application/dto/media.dto.ts b/clubscan/backend/src/modules/media/application/dto/media.dto.ts new file mode 100644 index 0000000..f574f61 --- /dev/null +++ b/clubscan/backend/src/modules/media/application/dto/media.dto.ts @@ -0,0 +1,14 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +const ALLOWED_MIME = ['image/jpeg', 'image/png', 'image/webp', 'image/heic'] as const; +const MAX_SIZE = 10 * 1024 * 1024; // 10MB + +export const PresignSchema = z.object({ + mime: z.enum(ALLOWED_MIME), + size: z.number().int().positive().max(MAX_SIZE), +}); +export class PresignDto extends createZodDto(PresignSchema) {} + +export const ALLOWED_MIME_TYPES = ALLOWED_MIME; +export const MAX_UPLOAD_SIZE = MAX_SIZE; diff --git a/clubscan/backend/src/modules/media/application/media.service.ts b/clubscan/backend/src/modules/media/application/media.service.ts new file mode 100644 index 0000000..4f84d72 --- /dev/null +++ b/clubscan/backend/src/modules/media/application/media.service.ts @@ -0,0 +1,66 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { MediaStatus } from '@prisma/client'; +import { PrismaService } from '@/platform/prisma/prisma.service'; +import { DomainError } from '@/shared/errors/domain-error'; +import { newId } from '@/shared/ids/uuid'; +import { STORAGE, StoragePort } from './ports/storage.port'; +import { ALLOWED_MIME_TYPES, MAX_UPLOAD_SIZE, PresignDto } from './dto/media.dto'; + +@Injectable() +export class MediaService { + constructor( + private readonly prisma: PrismaService, + private readonly config: ConfigService, + @Inject(STORAGE) private readonly storage: StoragePort, + ) {} + + async presign(userId: string, dto: PresignDto) { + const assetId = newId(); + const ext = dto.mime.split('/')[1]; + const key = `uploads/${userId}/${assetId}.${ext}`; + + const { uploadUrl } = await this.storage.presignUpload({ + key, + mime: dto.mime, + size: dto.size, + }); + + await this.prisma.mediaAsset.create({ + data: { + id: assetId, + ownerId: userId, + bucket: this.config.get('S3_BUCKET', 'clubscan-media'), + key, + mime: dto.mime, + size: dto.size, + status: MediaStatus.PENDING, + }, + }); + + return { assetId, uploadUrl, key }; + } + + /** Finalizes an upload after the client PUT, validating the object server-side. */ + async complete(userId: string, assetId: string) { + const asset = await this.prisma.mediaAsset.findFirst({ + where: { id: assetId, ownerId: userId }, + }); + if (!asset) throw DomainError.notFound('Media asset', assetId); + + const head = await this.storage.headObject(asset.key); + if (!head) throw DomainError.validation('Upload not found in storage'); + if (!ALLOWED_MIME_TYPES.includes(head.mime as (typeof ALLOWED_MIME_TYPES)[number])) { + throw DomainError.validation('Unsupported media type'); + } + if (head.size > MAX_UPLOAD_SIZE) { + throw DomainError.validation('Uploaded file exceeds size limit'); + } + + const updated = await this.prisma.mediaAsset.update({ + where: { id: assetId }, + data: { status: MediaStatus.READY, size: head.size, mime: head.mime }, + }); + return { id: updated.id, status: updated.status, url: this.storage.publicUrl(updated.key) }; + } +} diff --git a/clubscan/backend/src/modules/media/application/ports/storage.port.ts b/clubscan/backend/src/modules/media/application/ports/storage.port.ts new file mode 100644 index 0000000..27f0e69 --- /dev/null +++ b/clubscan/backend/src/modules/media/application/ports/storage.port.ts @@ -0,0 +1,21 @@ +export const STORAGE = Symbol('STORAGE'); + +export interface PresignedUpload { + uploadUrl: string; + key: string; +} + +/** + * Object-storage abstraction (S3-compatible). Issues short-TTL presigned PUT + * URLs scoped to a key/content-type/size so the client uploads directly to S3 + * and the backend never proxies bytes (Phase 6 §5). + */ +export interface StoragePort { + presignUpload(params: { + key: string; + mime: string; + size: number; + }): Promise; + headObject(key: string): Promise<{ size: number; mime: string } | null>; + publicUrl(key: string): string; +} diff --git a/clubscan/backend/src/modules/media/infrastructure/s3-storage.adapter.ts b/clubscan/backend/src/modules/media/infrastructure/s3-storage.adapter.ts new file mode 100644 index 0000000..24d5254 --- /dev/null +++ b/clubscan/backend/src/modules/media/infrastructure/s3-storage.adapter.ts @@ -0,0 +1,74 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + GetObjectCommand, + HeadObjectCommand, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { PresignedUpload, StoragePort } from '../application/ports/storage.port'; + +const UPLOAD_TTL_SECONDS = 300; + +/** + * S3-compatible storage adapter (works with AWS S3 and MinIO in dev). + * Issues presigned PUT URLs; the backend never streams file bytes (Phase 6 §5). + */ +@Injectable() +export class S3StorageAdapter implements StoragePort { + private readonly client: S3Client; + private readonly bucket: string; + private readonly endpoint?: string; + + constructor(private readonly config: ConfigService) { + this.bucket = config.get('S3_BUCKET', 'clubscan-media'); + this.endpoint = config.get('S3_ENDPOINT'); + this.client = new S3Client({ + region: config.get('S3_REGION', 'us-east-1'), + endpoint: this.endpoint, + forcePathStyle: !!this.endpoint, // MinIO requires path-style + credentials: + config.get('S3_ACCESS_KEY') && config.get('S3_SECRET_KEY') + ? { + accessKeyId: config.getOrThrow('S3_ACCESS_KEY'), + secretAccessKey: config.getOrThrow('S3_SECRET_KEY'), + } + : undefined, + }); + } + + async presignUpload(params: { key: string; mime: string; size: number }): Promise { + const command = new PutObjectCommand({ + Bucket: this.bucket, + Key: params.key, + ContentType: params.mime, + ContentLength: params.size, + }); + const uploadUrl = await getSignedUrl(this.client, command, { expiresIn: UPLOAD_TTL_SECONDS }); + return { uploadUrl, key: params.key }; + } + + async headObject(key: string): Promise<{ size: number; mime: string } | null> { + try { + const res = await this.client.send( + new HeadObjectCommand({ Bucket: this.bucket, Key: key }), + ); + return { size: res.ContentLength ?? 0, mime: res.ContentType ?? 'application/octet-stream' }; + } catch { + return null; + } + } + + publicUrl(key: string): string { + if (this.endpoint) return `${this.endpoint}/${this.bucket}/${key}`; + return `https://${this.bucket}.s3.amazonaws.com/${key}`; + } + + /** Issues a short-lived signed read URL (used for private media via CDN). */ + signedReadUrl(key: string): Promise { + return getSignedUrl(this.client, new GetObjectCommand({ Bucket: this.bucket, Key: key }), { + expiresIn: 3600, + }); + } +} diff --git a/clubscan/backend/src/modules/media/media.module.ts b/clubscan/backend/src/modules/media/media.module.ts new file mode 100644 index 0000000..b2ba6d5 --- /dev/null +++ b/clubscan/backend/src/modules/media/media.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { MediaService } from './application/media.service'; +import { STORAGE } from './application/ports/storage.port'; +import { S3StorageAdapter } from './infrastructure/s3-storage.adapter'; +import { MediaController } from './presentation/media.controller'; + +@Module({ + controllers: [MediaController], + providers: [MediaService, { provide: STORAGE, useClass: S3StorageAdapter }], + exports: [STORAGE], +}) +export class MediaModule {} diff --git a/clubscan/backend/src/modules/media/presentation/media.controller.ts b/clubscan/backend/src/modules/media/presentation/media.controller.ts new file mode 100644 index 0000000..7e19053 --- /dev/null +++ b/clubscan/backend/src/modules/media/presentation/media.controller.ts @@ -0,0 +1,23 @@ +import { Body, Controller, Param, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { CurrentUser } from '@/platform/security/decorators'; +import { MediaService } from '../application/media.service'; +import { PresignDto } from '../application/dto/media.dto'; + +@ApiTags('media') +@Controller('media') +export class MediaController { + constructor(private readonly media: MediaService) {} + + @Throttle({ default: { limit: 30, ttl: 60 * 60 * 1000 } }) + @Post('presign') + presign(@CurrentUser('id') userId: string, @Body() dto: PresignDto) { + return this.media.presign(userId, dto); + } + + @Post(':assetId/complete') + complete(@CurrentUser('id') userId: string, @Param('assetId') assetId: string) { + return this.media.complete(userId, assetId); + } +} diff --git a/clubscan/backend/src/modules/moderation/application/dto/moderation.dto.ts b/clubscan/backend/src/modules/moderation/application/dto/moderation.dto.ts new file mode 100644 index 0000000..6286c8f --- /dev/null +++ b/clubscan/backend/src/modules/moderation/application/dto/moderation.dto.ts @@ -0,0 +1,30 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; +import { + ModerationState, + ReportReason, + ReportTargetType, + SanctionType, +} from '@prisma/client'; + +export const CreateReportSchema = z.object({ + targetType: z.nativeEnum(ReportTargetType), + targetId: z.string().uuid(), + reason: z.nativeEnum(ReportReason), + details: z.string().trim().max(1000).optional(), +}); +export class CreateReportDto extends createZodDto(CreateReportSchema) {} + +export const UpdateCaseSchema = z.object({ + state: z.nativeEnum(ModerationState).optional(), + resolution: z.string().trim().max(1000).optional(), + assignToSelf: z.boolean().optional(), +}); +export class UpdateCaseDto extends createZodDto(UpdateCaseSchema) {} + +export const SanctionSchema = z.object({ + type: z.nativeEnum(SanctionType), + reason: z.string().trim().min(3).max(500), + expiresAt: z.coerce.date().optional(), +}); +export class SanctionDto extends createZodDto(SanctionSchema) {} diff --git a/clubscan/backend/src/modules/moderation/application/moderation.service.ts b/clubscan/backend/src/modules/moderation/application/moderation.service.ts new file mode 100644 index 0000000..1300bbb --- /dev/null +++ b/clubscan/backend/src/modules/moderation/application/moderation.service.ts @@ -0,0 +1,197 @@ +import { Injectable } from '@nestjs/common'; +import { + ModerationState, + ReportStatus, + ReviewStatus, + SanctionType, + UserRole, + UserStatus, +} from '@prisma/client'; +import { PrismaService } from '@/platform/prisma/prisma.service'; +import { AuditService } from '@/platform/audit/audit.service'; +import { buildPage, clampLimit, decodeCursor } from '@/platform/pagination/cursor'; +import { AuthenticatedUser } from '@/platform/security/auth.types'; +import { DomainError } from '@/shared/errors/domain-error'; +import { newId } from '@/shared/ids/uuid'; +import { CreateReportDto, SanctionDto, UpdateCaseDto } from './dto/moderation.dto'; + +@Injectable() +export class ModerationService { + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + ) {} + + /** Any user can file a report; it opens (or attaches to) a moderation case. */ + async fileReport(reporterId: string, dto: CreateReportDto) { + const reportId = newId(); + await this.prisma.$transaction(async (tx) => { + await tx.report.create({ + data: { + id: reportId, + reporterId, + targetType: dto.targetType, + targetId: dto.targetId, + reason: dto.reason, + details: dto.details, + }, + }); + + // Reuse an open case for the same target, else open a new one. + const open = await tx.moderationCase.findFirst({ + where: { + targetType: dto.targetType, + targetId: dto.targetId, + state: { notIn: [ModerationState.ACTIONED, ModerationState.DISMISSED, ModerationState.CLOSED] }, + }, + }); + if (!open) { + await tx.moderationCase.create({ + data: { + id: newId(), + reportId, + targetType: dto.targetType, + targetId: dto.targetId, + state: ModerationState.TRIAGE, + }, + }); + } + }); + return { ok: true, reportId }; + } + + async queue(state: ModerationState | undefined, cursor?: string, limit?: number) { + const take = clampLimit(limit); + const decoded = decodeCursor(cursor); + const rows = await this.prisma.moderationCase.findMany({ + where: { + ...(state ? { state } : {}), + ...(decoded ? { createdAt: { lt: new Date(decoded.createdAt) } } : {}), + }, + include: { report: true, sanctions: true }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: take + 1, + }); + return buildPage(rows, take); + } + + async getCase(id: string) { + const found = await this.prisma.moderationCase.findUnique({ + where: { id }, + include: { report: true, sanctions: true }, + }); + if (!found) throw DomainError.notFound('Moderation case', id); + return found; + } + + async updateCase(actor: AuthenticatedUser, id: string, dto: UpdateCaseDto) { + const existing = await this.getCase(id); + const updated = await this.prisma.moderationCase.update({ + where: { id }, + data: { + state: dto.state ?? existing.state, + resolution: dto.resolution ?? existing.resolution, + assignedModeratorId: dto.assignToSelf ? actor.id : existing.assignedModeratorId, + resolvedAt: + dto.state && + ([ModerationState.ACTIONED, ModerationState.DISMISSED, ModerationState.CLOSED] as ModerationState[]).includes( + dto.state, + ) + ? new Date() + : existing.resolvedAt, + }, + }); + + if (dto.state) { + // Sync linked report status with case resolution. + const reportStatus = + dto.state === ModerationState.ACTIONED + ? ReportStatus.RESOLVED + : dto.state === ModerationState.DISMISSED + ? ReportStatus.DISMISSED + : ReportStatus.IN_REVIEW; + if (existing.reportId) { + await this.prisma.report.update({ + where: { id: existing.reportId }, + data: { status: reportStatus }, + }); + } + } + + await this.audit.record({ + actorId: actor.id, + action: 'moderation.case.update', + targetType: 'ModerationCase', + targetId: id, + metadata: { state: dto.state, resolution: dto.resolution }, + }); + return updated; + } + + /** Issues a sanction and applies its side effects (ban/shadow-ban/removal). */ + async sanction(actor: AuthenticatedUser, caseId: string, dto: SanctionDto) { + // Bans require ADMIN+ (Phase 6 §4); warnings/removals allowed for moderators. + const banTypes: SanctionType[] = [ + SanctionType.PERMA_BAN, + SanctionType.TEMP_BAN, + SanctionType.SHADOW_BAN, + ]; + const rank = { USER: 0, MODERATOR: 1, ADMIN: 2, SUPER_ADMIN: 3 }; + if (banTypes.includes(dto.type) && rank[actor.role] < rank[UserRole.ADMIN]) { + throw DomainError.forbidden('Bans require an admin'); + } + + const moderationCase = await this.getCase(caseId); + const targetUserId = await this.resolveTargetUser(moderationCase.targetType, moderationCase.targetId); + if (!targetUserId) throw DomainError.validation('Cannot resolve a user for this case target'); + + await this.prisma.$transaction(async (tx) => { + await tx.sanction.create({ + data: { + id: newId(), + targetUserId, + caseId, + type: dto.type, + reason: dto.reason, + issuedById: actor.id, + expiresAt: dto.expiresAt, + }, + }); + + if (dto.type === SanctionType.PERMA_BAN || dto.type === SanctionType.TEMP_BAN) { + await tx.user.update({ where: { id: targetUserId }, data: { status: UserStatus.BANNED } }); + } else if (dto.type === SanctionType.SHADOW_BAN) { + await tx.user.update({ + where: { id: targetUserId }, + data: { status: UserStatus.SHADOW_BANNED }, + }); + } else if (dto.type === SanctionType.CONTENT_REMOVAL && moderationCase.targetType === 'REVIEW') { + await tx.review.update({ + where: { id: moderationCase.targetId }, + data: { status: ReviewStatus.REMOVED, deletedAt: new Date() }, + }); + } + }); + + await this.audit.record({ + actorId: actor.id, + action: `moderation.sanction.${dto.type.toLowerCase()}`, + targetType: 'User', + targetId: targetUserId, + metadata: { caseId, reason: dto.reason, expiresAt: dto.expiresAt }, + }); + return { ok: true }; + } + + private async resolveTargetUser(targetType: string, targetId: string): Promise { + if (targetType === 'USER') return targetId; + if (targetType === 'REVIEW') { + const review = await this.prisma.review.findUnique({ + where: { id: targetId }, + select: { userId: true }, + }); + return review?.userId ?? null; + } + return null; + } +} diff --git a/clubscan/backend/src/modules/moderation/moderation.module.ts b/clubscan/backend/src/modules/moderation/moderation.module.ts new file mode 100644 index 0000000..02ee7d5 --- /dev/null +++ b/clubscan/backend/src/modules/moderation/moderation.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { ModerationService } from './application/moderation.service'; +import { ModerationController } from './presentation/moderation.controller'; + +@Module({ + controllers: [ModerationController], + providers: [ModerationService], +}) +export class ModerationModule {} diff --git a/clubscan/backend/src/modules/moderation/presentation/moderation.controller.ts b/clubscan/backend/src/modules/moderation/presentation/moderation.controller.ts new file mode 100644 index 0000000..6d95257 --- /dev/null +++ b/clubscan/backend/src/modules/moderation/presentation/moderation.controller.ts @@ -0,0 +1,57 @@ +import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { ModerationState, UserRole } from '@prisma/client'; +import { Throttle } from '@nestjs/throttler'; +import { CurrentUser, Roles } from '@/platform/security/decorators'; +import { AuthenticatedUser } from '@/platform/security/auth.types'; +import { ModerationService } from '../application/moderation.service'; +import { CreateReportDto, SanctionDto, UpdateCaseDto } from '../application/dto/moderation.dto'; + +@ApiTags('moderation') +@Controller() +export class ModerationController { + constructor(private readonly moderation: ModerationService) {} + + // Any authenticated user may report content. + @Throttle({ default: { limit: 10, ttl: 60 * 60 * 1000 } }) + @Post('reports') + report(@CurrentUser('id') userId: string, @Body() dto: CreateReportDto) { + return this.moderation.fileReport(userId, dto); + } + + @Roles(UserRole.MODERATOR) + @Get('moderation/queue') + queue( + @Query('state') state?: ModerationState, + @Query('cursor') cursor?: string, + @Query('limit') limit?: number, + ) { + return this.moderation.queue(state, cursor, limit); + } + + @Roles(UserRole.MODERATOR) + @Get('moderation/cases/:id') + getCase(@Param('id') id: string) { + return this.moderation.getCase(id); + } + + @Roles(UserRole.MODERATOR) + @Patch('moderation/cases/:id') + updateCase( + @CurrentUser() actor: AuthenticatedUser, + @Param('id') id: string, + @Body() dto: UpdateCaseDto, + ) { + return this.moderation.updateCase(actor, id, dto); + } + + @Roles(UserRole.MODERATOR) + @Post('moderation/cases/:id/sanction') + sanction( + @CurrentUser() actor: AuthenticatedUser, + @Param('id') id: string, + @Body() dto: SanctionDto, + ) { + return this.moderation.sanction(actor, id, dto); + } +} diff --git a/clubscan/backend/src/modules/notifications/application/feed.service.ts b/clubscan/backend/src/modules/notifications/application/feed.service.ts new file mode 100644 index 0000000..0ca10be --- /dev/null +++ b/clubscan/backend/src/modules/notifications/application/feed.service.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@/platform/prisma/prisma.service'; +import { buildPage, clampLimit, decodeCursor } from '@/platform/pagination/cursor'; +import { newId } from '@/shared/ids/uuid'; + +/** + * Activity feed read model (Phase 2 §3.7). Fan-out-on-write: when an actor does + * something, an entry is written to each follower's feed. Simple and fast to + * read; swap to fan-out-on-read for celebrity accounts later if needed. + */ +@Injectable() +export class FeedService { + constructor(private readonly prisma: PrismaService) {} + + async fanOut(params: { + actorId: string; + verb: string; + objectType: string; + objectId: string; + includeActor?: boolean; + }): Promise { + const followers = await this.prisma.follow.findMany({ + where: { followingId: params.actorId }, + select: { followerId: true }, + }); + + const ownerIds = followers.map((f) => f.followerId); + if (params.includeActor) ownerIds.push(params.actorId); + if (ownerIds.length === 0) return; + + await this.prisma.feedEntry.createMany({ + data: ownerIds.map((ownerId) => ({ + id: newId(), + ownerId, + actorId: params.actorId, + verb: params.verb, + objectType: params.objectType, + objectId: params.objectId, + })), + skipDuplicates: true, + }); + } + + async list(userId: string, cursor?: string, limit?: number) { + const take = clampLimit(limit); + const decoded = decodeCursor(cursor); + const rows = await this.prisma.feedEntry.findMany({ + where: { + ownerId: userId, + ...(decoded ? { createdAt: { lt: new Date(decoded.createdAt) } } : {}), + }, + include: { + actor: { select: { id: true, profile: { select: { username: true, avatarUrl: true } } } }, + }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: take + 1, + }); + return buildPage(rows, take); + } +} diff --git a/clubscan/backend/src/modules/notifications/application/notifications.reactor.ts b/clubscan/backend/src/modules/notifications/application/notifications.reactor.ts new file mode 100644 index 0000000..aeb8897 --- /dev/null +++ b/clubscan/backend/src/modules/notifications/application/notifications.reactor.ts @@ -0,0 +1,55 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { NotificationType } from '@prisma/client'; +import { PrismaService } from '@/platform/prisma/prisma.service'; +import { ReviewPublishedEvent } from '@/modules/reviews/domain/review.events'; +import { UserFollowedEvent } from '@/modules/profiles/domain/profile.events'; +import { NotificationsService } from './notifications.service'; +import { FeedService } from './feed.service'; + +/** + * Translates domain events into feed entries + notifications + push + * (Phase 1 §5.2 reactors). Runs async off the write path. + */ +@Injectable() +export class NotificationsReactor { + private readonly logger = new Logger(NotificationsReactor.name); + + constructor( + private readonly prisma: PrismaService, + private readonly notifications: NotificationsService, + private readonly feed: FeedService, + ) {} + + @OnEvent('review.published') + async onReviewPublished(event: ReviewPublishedEvent): Promise { + try { + await this.feed.fanOut({ + actorId: event.payload.authorId, + verb: 'reviewed', + objectType: 'venue', + objectId: event.payload.venueId, + includeActor: true, + }); + } catch (err) { + this.logger.error(`Feed fan-out failed: ${(err as Error).message}`); + } + } + + @OnEvent('user.followed') + async onUserFollowed(event: UserFollowedEvent): Promise { + const followerProfile = await this.prisma.profile.findUnique({ + where: { userId: event.payload.followerId }, + select: { username: true }, + }); + await this.notifications.create({ + userId: event.payload.followingId, + type: NotificationType.NEW_FOLLOWER, + payload: { followerId: event.payload.followerId, username: followerProfile?.username }, + push: { + title: 'New follower', + body: `@${followerProfile?.username ?? 'someone'} started following you`, + }, + }); + } +} diff --git a/clubscan/backend/src/modules/notifications/application/notifications.service.ts b/clubscan/backend/src/modules/notifications/application/notifications.service.ts new file mode 100644 index 0000000..7f972d9 --- /dev/null +++ b/clubscan/backend/src/modules/notifications/application/notifications.service.ts @@ -0,0 +1,82 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { NotificationType, Prisma } from '@prisma/client'; +import { PrismaService } from '@/platform/prisma/prisma.service'; +import { buildPage, clampLimit, decodeCursor } from '@/platform/pagination/cursor'; +import { newId } from '@/shared/ids/uuid'; +import { PUSH_GATEWAY, PushGatewayPort } from './ports/push.port'; + +interface CreateNotificationInput { + userId: string; + type: NotificationType; + payload: Record; + push?: { title: string; body: string }; +} + +@Injectable() +export class NotificationsService { + constructor( + private readonly prisma: PrismaService, + @Inject(PUSH_GATEWAY) private readonly push: PushGatewayPort, + ) {} + + async create(input: CreateNotificationInput): Promise { + await this.prisma.notification.create({ + data: { + id: newId(), + userId: input.userId, + type: input.type, + payload: input.payload as Prisma.InputJsonValue, + }, + }); + + if (input.push) { + const devices = await this.prisma.device.findMany({ + where: { userId: input.userId, pushToken: { not: null } }, + select: { pushToken: true }, + }); + const tokens = devices.map((d) => d.pushToken!).filter(Boolean); + if (tokens.length > 0) { + await this.push.send({ + tokens, + title: input.push.title, + body: input.push.body, + data: { type: input.type }, + }); + await this.prisma.notification.updateMany({ + where: { userId: input.userId, type: input.type, sentPush: false }, + data: { sentPush: true }, + }); + } + } + } + + async list(userId: string, cursor?: string, limit?: number) { + const take = clampLimit(limit); + const decoded = decodeCursor(cursor); + const rows = await this.prisma.notification.findMany({ + where: { + userId, + ...(decoded ? { createdAt: { lt: new Date(decoded.createdAt) } } : {}), + }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: take + 1, + }); + return buildPage(rows, take); + } + + async markRead(userId: string, id: string) { + await this.prisma.notification.updateMany({ + where: { id, userId, readAt: null }, + data: { readAt: new Date() }, + }); + return { ok: true }; + } + + async markAllRead(userId: string) { + await this.prisma.notification.updateMany({ + where: { userId, readAt: null }, + data: { readAt: new Date() }, + }); + return { ok: true }; + } +} diff --git a/clubscan/backend/src/modules/notifications/application/ports/push.port.ts b/clubscan/backend/src/modules/notifications/application/ports/push.port.ts new file mode 100644 index 0000000..49568b3 --- /dev/null +++ b/clubscan/backend/src/modules/notifications/application/ports/push.port.ts @@ -0,0 +1,13 @@ +export const PUSH_GATEWAY = Symbol('PUSH_GATEWAY'); + +export interface PushMessage { + tokens: string[]; + title: string; + body: string; + data?: Record; +} + +/** Push delivery abstraction (FCM in production). */ +export interface PushGatewayPort { + send(message: PushMessage): Promise; +} diff --git a/clubscan/backend/src/modules/notifications/infrastructure/fcm-push.adapter.ts b/clubscan/backend/src/modules/notifications/infrastructure/fcm-push.adapter.ts new file mode 100644 index 0000000..43a312c --- /dev/null +++ b/clubscan/backend/src/modules/notifications/infrastructure/fcm-push.adapter.ts @@ -0,0 +1,60 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as admin from 'firebase-admin'; +import { ConfigService } from '@nestjs/config'; +import { PushGatewayPort, PushMessage } from '../application/ports/push.port'; + +/** + * FCM push adapter. In production this initializes firebase-admin and calls + * messaging().sendEachForMulticast(). + */ +@Injectable() +export class FcmPushAdapter implements PushGatewayPort { + private readonly logger = new Logger('FCM'); + private initialized = false; + + constructor(private readonly config: ConfigService) { + try { + if (!admin.apps.length) { + admin.initializeApp({ + credential: admin.credential.applicationDefault(), + }); + } + this.initialized = true; + } catch (e: any) { + this.logger.warn(`Failed to initialize Firebase Admin SDK: ${e.message}`); + } + } + + async send(message: PushMessage): Promise { + if (message.tokens.length === 0) return; + + if (!this.initialized) { + this.logger.debug( + `[DRY RUN] Push -> ${message.tokens.length} device(s): ${message.title} — ${message.body}`, + ); + return; + } + + try { + const response = await admin.messaging().sendEachForMulticast({ + tokens: message.tokens, + notification: { + title: message.title, + body: message.body, + }, + data: message.data, + }); + + if (response.failureCount > 0) { + response.responses.forEach((resp, idx) => { + if (!resp.success) { + this.logger.error(`FCM send failed for token ${message.tokens[idx]}: ${resp.error?.message}`); + // Phase 4: Token cleanup would happen here by dispatching an event to remove invalid tokens + } + }); + } + } catch (error: any) { + this.logger.error(`FCM broadcast failed: ${error.message}`); + } + } +} diff --git a/clubscan/backend/src/modules/notifications/notifications.module.ts b/clubscan/backend/src/modules/notifications/notifications.module.ts new file mode 100644 index 0000000..8235b65 --- /dev/null +++ b/clubscan/backend/src/modules/notifications/notifications.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { NotificationsService } from './application/notifications.service'; +import { FeedService } from './application/feed.service'; +import { NotificationsReactor } from './application/notifications.reactor'; +import { PUSH_GATEWAY } from './application/ports/push.port'; +import { FcmPushAdapter } from './infrastructure/fcm-push.adapter'; +import { NotificationsController } from './presentation/notifications.controller'; + +@Module({ + controllers: [NotificationsController], + providers: [ + NotificationsService, + FeedService, + NotificationsReactor, + { provide: PUSH_GATEWAY, useClass: FcmPushAdapter }, + ], + exports: [NotificationsService], +}) +export class NotificationsModule {} diff --git a/clubscan/backend/src/modules/notifications/presentation/notifications.controller.ts b/clubscan/backend/src/modules/notifications/presentation/notifications.controller.ts new file mode 100644 index 0000000..86d882c --- /dev/null +++ b/clubscan/backend/src/modules/notifications/presentation/notifications.controller.ts @@ -0,0 +1,42 @@ +import { Controller, Get, Param, Post, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@/platform/security/decorators'; +import { NotificationsService } from '../application/notifications.service'; +import { FeedService } from '../application/feed.service'; + +@ApiTags('notifications') +@Controller('me') +export class NotificationsController { + constructor( + private readonly notifications: NotificationsService, + private readonly feed: FeedService, + ) {} + + @Get('feed') + getFeed( + @CurrentUser('id') userId: string, + @Query('cursor') cursor?: string, + @Query('limit') limit?: number, + ) { + return this.feed.list(userId, cursor, limit); + } + + @Get('notifications') + list( + @CurrentUser('id') userId: string, + @Query('cursor') cursor?: string, + @Query('limit') limit?: number, + ) { + return this.notifications.list(userId, cursor, limit); + } + + @Post('notifications/:id/read') + markRead(@CurrentUser('id') userId: string, @Param('id') id: string) { + return this.notifications.markRead(userId, id); + } + + @Post('notifications/read-all') + markAllRead(@CurrentUser('id') userId: string) { + return this.notifications.markAllRead(userId); + } +} diff --git a/clubscan/backend/src/modules/profiles/application/dto/profile.dto.ts b/clubscan/backend/src/modules/profiles/application/dto/profile.dto.ts new file mode 100644 index 0000000..8f21ece --- /dev/null +++ b/clubscan/backend/src/modules/profiles/application/dto/profile.dto.ts @@ -0,0 +1,20 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; +import { SocialPlatform } from '@prisma/client'; + +export const UpdateProfileSchema = z.object({ + displayName: z.string().trim().min(1).max(50).optional(), + bio: z.string().trim().max(280).optional(), + avatarUrl: z.string().url().optional(), + isPrivate: z.boolean().optional(), + socialLinks: z + .array( + z.object({ + platform: z.nativeEnum(SocialPlatform), + url: z.string().url().max(300), + }), + ) + .max(8) + .optional(), +}); +export class UpdateProfileDto extends createZodDto(UpdateProfileSchema) {} diff --git a/clubscan/backend/src/modules/profiles/application/profiles.service.ts b/clubscan/backend/src/modules/profiles/application/profiles.service.ts new file mode 100644 index 0000000..92937ba --- /dev/null +++ b/clubscan/backend/src/modules/profiles/application/profiles.service.ts @@ -0,0 +1,191 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { PrismaService } from '@/platform/prisma/prisma.service'; +import { EVENT_BUS, EventBusPort } from '@/platform/event-bus/event-bus.port'; +import { buildPage, clampLimit, decodeCursor } from '@/platform/pagination/cursor'; +import { DomainError } from '@/shared/errors/domain-error'; +import { newId } from '@/shared/ids/uuid'; +import { UpdateProfileDto } from './dto/profile.dto'; +import { UserFollowedEvent } from '../domain/profile.events'; + +@Injectable() +export class ProfilesService { + constructor( + private readonly prisma: PrismaService, + @Inject(EVENT_BUS) private readonly bus: EventBusPort, + ) {} + + async getMe(userId: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + include: { profile: { include: { socialLinks: true } } }, + }); + if (!user) throw DomainError.notFound('User', userId); + return this.present(user); + } + + async updateProfile(userId: string, dto: UpdateProfileDto) { + const profile = await this.prisma.profile.findUnique({ where: { userId } }); + if (!profile) throw DomainError.notFound('Profile'); + + await this.prisma.$transaction(async (tx) => { + await tx.profile.update({ + where: { userId }, + data: { + displayName: dto.displayName, + bio: dto.bio, + avatarUrl: dto.avatarUrl, + isPrivate: dto.isPrivate, + }, + }); + if (dto.socialLinks) { + await tx.socialLink.deleteMany({ where: { profileId: profile.id } }); + if (dto.socialLinks.length > 0) { + await tx.socialLink.createMany({ + data: dto.socialLinks.map((l) => ({ + id: newId(), + profileId: profile.id, + platform: l.platform, + url: l.url, + })), + }); + } + } + }); + return this.getMe(userId); + } + + async getPublicProfile(username: string, viewerId?: string) { + const profile = await this.prisma.profile.findFirst({ + where: { username, deletedAt: null }, + include: { + socialLinks: true, + user: { select: { id: true, reputationScore: true, status: true } }, + }, + }); + if (!profile || profile.user.status === 'DELETED') throw DomainError.notFound('Profile'); + + const [followers, following, isFollowing] = await Promise.all([ + this.prisma.follow.count({ where: { followingId: profile.userId } }), + this.prisma.follow.count({ where: { followerId: profile.userId } }), + viewerId + ? this.prisma.follow.findUnique({ + where: { followerId_followingId: { followerId: viewerId, followingId: profile.userId } }, + }) + : null, + ]); + + return { + id: profile.userId, + username: profile.username, + displayName: profile.displayName, + bio: profile.bio, + avatarUrl: profile.avatarUrl, + verificationStatus: profile.verificationStatus, + reputationScore: profile.user.reputationScore, + socialLinks: profile.socialLinks.map((l) => ({ platform: l.platform, url: l.url })), + counts: { followers, following }, + isFollowing: !!isFollowing, + }; + } + + async follow(followerId: string, targetUserId: string) { + if (followerId === targetUserId) { + throw DomainError.validation('You cannot follow yourself'); + } + const target = await this.prisma.user.findFirst({ + where: { id: targetUserId, status: { not: 'DELETED' } }, + select: { id: true }, + }); + if (!target) throw DomainError.notFound('User', targetUserId); + + const created = await this.prisma.follow + .create({ data: { id: newId(), followerId, followingId: targetUserId } }) + .catch(() => null); + + if (created) { + await this.bus.publish(new UserFollowedEvent({ followerId, followingId: targetUserId })); + } + return { ok: true }; + } + + async unfollow(followerId: string, targetUserId: string) { + await this.prisma.follow + .delete({ + where: { followerId_followingId: { followerId, followingId: targetUserId } }, + }) + .catch(() => null); + return { ok: true }; + } + + async listFollowers(userId: string, cursor?: string, limit?: number) { + const take = clampLimit(limit); + const decoded = decodeCursor(cursor); + const rows = await this.prisma.follow.findMany({ + where: { + followingId: userId, + ...(decoded ? { createdAt: { lt: new Date(decoded.createdAt) } } : {}), + }, + include: { + follower: { select: { id: true, profile: { select: { username: true, avatarUrl: true } } } }, + }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: take + 1, + }); + return buildPage(rows, take); + } + + async listFollowing(userId: string, cursor?: string, limit?: number) { + const take = clampLimit(limit); + const decoded = decodeCursor(cursor); + const rows = await this.prisma.follow.findMany({ + where: { + followerId: userId, + ...(decoded ? { createdAt: { lt: new Date(decoded.createdAt) } } : {}), + }, + include: { + following: { select: { id: true, profile: { select: { username: true, avatarUrl: true } } } }, + }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: take + 1, + }); + return buildPage(rows, take); + } + + private present(user: { + id: string; + email: string; + role: string; + reputationScore: number; + locale: string; + emailVerifiedAt: Date | null; + profile: { + username: string; + displayName: string | null; + bio: string | null; + avatarUrl: string | null; + isPrivate: boolean; + verificationStatus: string; + socialLinks: { platform: string; url: string }[]; + } | null; + }) { + return { + id: user.id, + email: user.email, + role: user.role, + reputationScore: user.reputationScore, + locale: user.locale, + emailVerified: !!user.emailVerifiedAt, + profile: user.profile + ? { + username: user.profile.username, + displayName: user.profile.displayName, + bio: user.profile.bio, + avatarUrl: user.profile.avatarUrl, + isPrivate: user.profile.isPrivate, + verificationStatus: user.profile.verificationStatus, + socialLinks: user.profile.socialLinks.map((l) => ({ platform: l.platform, url: l.url })), + } + : null, + }; + } +} diff --git a/clubscan/backend/src/modules/profiles/application/reputation.reactor.ts b/clubscan/backend/src/modules/profiles/application/reputation.reactor.ts new file mode 100644 index 0000000..3c75a56 --- /dev/null +++ b/clubscan/backend/src/modules/profiles/application/reputation.reactor.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { ReviewPublishedEvent } from '@/modules/reviews/domain/review.events'; +import { UserFollowedEvent } from '../domain/profile.events'; +import { REPUTATION_RULES, ReputationService } from './reputation.service'; + +/** Translates domain events into reputation adjustments (Phase 1 §5.2). */ +@Injectable() +export class ReputationReactor { + constructor(private readonly reputation: ReputationService) {} + + @OnEvent('review.published') + async onReviewPublished(event: ReviewPublishedEvent): Promise { + await this.reputation.adjust( + event.payload.authorId, + REPUTATION_RULES.REVIEW_PUBLISHED, + 'review.published', + `review_pub_${event.payload.reviewId}`, + ); + } + + @OnEvent('user.followed') + async onUserFollowed(event: UserFollowedEvent): Promise { + await this.reputation.adjust( + event.payload.followingId, + REPUTATION_RULES.GAINED_FOLLOWER, + 'gained.follower', + `follow_${event.payload.followerId}_${event.payload.followingId}`, + ); + } +} diff --git a/clubscan/backend/src/modules/profiles/application/reputation.service.ts b/clubscan/backend/src/modules/profiles/application/reputation.service.ts new file mode 100644 index 0000000..fb4097b --- /dev/null +++ b/clubscan/backend/src/modules/profiles/application/reputation.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@/platform/prisma/prisma.service'; +import { newId } from '@/shared/ids/uuid'; + +/** Reputation deltas (Phase 1 §4.2 — reputation weights review influence). */ +export const REPUTATION_RULES = { + REVIEW_PUBLISHED: 10, + REVIEW_MARKED_HELPFUL: 2, + GAINED_FOLLOWER: 1, + REVIEW_REMOVED_BY_MOD: -25, +} as const; + +/** + * Maintains the append-only reputation ledger and the denormalized cache on + * the user. All adjustments flow through here so the score is auditable. + */ +@Injectable() +export class ReputationService { + constructor(private readonly prisma: PrismaService) {} + + async adjust(userId: string, delta: number, reason: string, idempotencyKey?: string): Promise { + try { + await this.prisma.$transaction([ + this.prisma.reputationEvent.create({ + data: { id: newId(), userId, delta, reason, idempotencyKey }, + }), + this.prisma.user.update({ + where: { id: userId }, + data: { reputationScore: { increment: delta } }, + }), + ]); + } catch (error: any) { + // P2002: Unique constraint failed on the fields: (`idempotencyKey`) + if (error.code === 'P2002') { + return; // Idempotent: already processed + } + throw error; + } + } +} diff --git a/clubscan/backend/src/modules/profiles/domain/profile.events.ts b/clubscan/backend/src/modules/profiles/domain/profile.events.ts new file mode 100644 index 0000000..9a892f0 --- /dev/null +++ b/clubscan/backend/src/modules/profiles/domain/profile.events.ts @@ -0,0 +1,8 @@ +import { DomainEvent } from '@/shared/domain/domain-event'; + +export class UserFollowedEvent extends DomainEvent<{ followerId: string; followingId: string }> { + readonly name = 'user.followed'; + constructor(readonly payload: { followerId: string; followingId: string }) { + super(); + } +} diff --git a/clubscan/backend/src/modules/profiles/presentation/profiles.controller.ts b/clubscan/backend/src/modules/profiles/presentation/profiles.controller.ts new file mode 100644 index 0000000..3709b72 --- /dev/null +++ b/clubscan/backend/src/modules/profiles/presentation/profiles.controller.ts @@ -0,0 +1,69 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { CurrentUser, Public } from '@/platform/security/decorators'; +import { ProfilesService } from '../application/profiles.service'; +import { UpdateProfileDto } from '../application/dto/profile.dto'; + +@ApiTags('profiles') +@Controller() +export class ProfilesController { + constructor(private readonly profiles: ProfilesService) {} + + @Get('me') + me(@CurrentUser('id') userId: string) { + return this.profiles.getMe(userId); + } + + @Patch('me/profile') + updateProfile(@CurrentUser('id') userId: string, @Body() dto: UpdateProfileDto) { + return this.profiles.updateProfile(userId, dto); + } + + @Public() + @Get('users/:username') + publicProfile( + @Param('username') username: string, + @CurrentUser('id') viewerId?: string, + ) { + return this.profiles.getPublicProfile(username, viewerId); + } + + @Post('users/:id/follow') + follow(@CurrentUser('id') userId: string, @Param('id') targetId: string) { + return this.profiles.follow(userId, targetId); + } + + @Delete('users/:id/follow') + unfollow(@CurrentUser('id') userId: string, @Param('id') targetId: string) { + return this.profiles.unfollow(userId, targetId); + } + + @Public() + @Get('users/:id/followers') + followers( + @Param('id') id: string, + @Query('cursor') cursor?: string, + @Query('limit') limit?: number, + ) { + return this.profiles.listFollowers(id, cursor, limit); + } + + @Public() + @Get('users/:id/following') + following( + @Param('id') id: string, + @Query('cursor') cursor?: string, + @Query('limit') limit?: number, + ) { + return this.profiles.listFollowing(id, cursor, limit); + } +} diff --git a/clubscan/backend/src/modules/profiles/profiles.module.ts b/clubscan/backend/src/modules/profiles/profiles.module.ts new file mode 100644 index 0000000..ec0facd --- /dev/null +++ b/clubscan/backend/src/modules/profiles/profiles.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ProfilesService } from './application/profiles.service'; +import { ReputationService } from './application/reputation.service'; +import { ReputationReactor } from './application/reputation.reactor'; +import { ProfilesController } from './presentation/profiles.controller'; + +@Module({ + controllers: [ProfilesController], + providers: [ProfilesService, ReputationService, ReputationReactor], + exports: [ReputationService], +}) +export class ProfilesModule {} diff --git a/clubscan/backend/src/modules/reviews/infrastructure/gemini-moderation.adapter.ts b/clubscan/backend/src/modules/reviews/infrastructure/gemini-moderation.adapter.ts new file mode 100644 index 0000000..c4f4ebe --- /dev/null +++ b/clubscan/backend/src/modules/reviews/infrastructure/gemini-moderation.adapter.ts @@ -0,0 +1,72 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + ContentModerationPort, + ModerationVerdict, +} from '../application/ports/content-moderation.port'; +import { DomainError } from '@/shared/errors/domain-error'; + +@Injectable() +export class GeminiModerationAdapter implements ContentModerationPort { + private readonly logger = new Logger(GeminiModerationAdapter.name); + private readonly apiKey: string | undefined; + + constructor(private readonly config: ConfigService) { + this.apiKey = this.config.get('GEMINI_API_KEY'); + } + + async moderateText(text: string): Promise { + if (!this.apiKey) { + // Fallback if not configured + this.logger.warn('GEMINI_API_KEY not configured, falling back to permissive moderation'); + return { decision: 'APPROVED', provider: 'gemini-fallback', labels: [] }; + } + + try { + const response = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${this.apiKey}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contents: [ + { + parts: [ + { + text: `Analyze this review for a venue. Return ONLY a JSON object with this exact structure: {"decision": "APPROVED" | "FLAGGED", "labels": string[]}. Flag it if it contains harassment, spam, hate speech, illegal acts, or extreme profanity. The review text is: "${text}"`, + }, + ], + }, + ], + generationConfig: { + responseMimeType: 'application/json', + }, + }), + }, + ); + + if (!response.ok) { + throw new Error(`Gemini API error: ${response.statusText}`); + } + + const data = await response.json(); + const rawText = data.candidates?.[0]?.content?.parts?.[0]?.text; + + if (!rawText) throw new Error('No content returned from Gemini'); + + const result = JSON.parse(rawText) as { decision: string; labels?: string[] }; + + return { + decision: result.decision === 'FLAGGED' ? 'FLAGGED' : 'APPROVED', + labels: result.labels ?? [], + provider: 'gemini-1.5-flash', + }; + } catch (error: any) { + this.logger.error(`AI Moderation failed: ${error.message}`); + // Fail open for user experience, but flag defensively if preferred. + // We will fail open (APPROVED) so users aren't blocked by API outages, + // but rely on community moderation as a fallback. + return { decision: 'APPROVED', provider: 'gemini-error', labels: ['system_error'] }; + } + } +} diff --git a/clubscan/backend/src/modules/reviews/reviews.module.ts b/clubscan/backend/src/modules/reviews/reviews.module.ts index c0e68ca..d1c2a80 100644 --- a/clubscan/backend/src/modules/reviews/reviews.module.ts +++ b/clubscan/backend/src/modules/reviews/reviews.module.ts @@ -2,13 +2,14 @@ import { Module } from '@nestjs/common'; import { ReviewsService } from './application/reviews.service'; import { CONTENT_MODERATION } from './application/ports/content-moderation.port'; import { RuleBasedModerationAdapter } from './infrastructure/rule-based-moderation.adapter'; +import { GeminiModerationAdapter } from './infrastructure/gemini-moderation.adapter'; import { ReviewsController } from './presentation/reviews.controller'; @Module({ controllers: [ReviewsController], providers: [ ReviewsService, - { provide: CONTENT_MODERATION, useClass: RuleBasedModerationAdapter }, + { provide: CONTENT_MODERATION, useClass: GeminiModerationAdapter }, ], }) export class ReviewsModule {} diff --git a/clubscan/backend/src/modules/safety/application/dto/safety.dto.ts b/clubscan/backend/src/modules/safety/application/dto/safety.dto.ts new file mode 100644 index 0000000..ac7abf1 --- /dev/null +++ b/clubscan/backend/src/modules/safety/application/dto/safety.dto.ts @@ -0,0 +1,19 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; +import { IncidentCategory, IncidentSeverity, IncidentState } from '@prisma/client'; + +export const CreateIncidentSchema = z.object({ + category: z.nativeEnum(IncidentCategory), + severity: z.nativeEnum(IncidentSeverity).default(IncidentSeverity.MEDIUM), + venueId: z.string().uuid().optional(), + description: z.string().trim().min(10).max(4000), + occurredAt: z.coerce.date().optional(), + isAnonymous: z.boolean().default(false), +}); +export class CreateIncidentDto extends createZodDto(CreateIncidentSchema) {} + +export const TransitionIncidentSchema = z.object({ + state: z.nativeEnum(IncidentState), + outcome: z.string().trim().max(2000).optional(), +}); +export class TransitionIncidentDto extends createZodDto(TransitionIncidentSchema) {} diff --git a/clubscan/backend/src/modules/safety/application/safety.service.ts b/clubscan/backend/src/modules/safety/application/safety.service.ts new file mode 100644 index 0000000..07ec7c2 --- /dev/null +++ b/clubscan/backend/src/modules/safety/application/safety.service.ts @@ -0,0 +1,134 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IncidentSeverity, IncidentState, Prisma } from '@prisma/client'; +import { PrismaService } from '@/platform/prisma/prisma.service'; +import { AuditService } from '@/platform/audit/audit.service'; +import { EVENT_BUS, EventBusPort } from '@/platform/event-bus/event-bus.port'; +import { buildPage, clampLimit, decodeCursor } from '@/platform/pagination/cursor'; +import { AuthenticatedUser } from '@/platform/security/auth.types'; +import { DomainEvent } from '@/shared/domain/domain-event'; +import { DomainError } from '@/shared/errors/domain-error'; +import { newId } from '@/shared/ids/uuid'; +import { CreateIncidentDto, TransitionIncidentDto } from './dto/safety.dto'; + +class IncidentSubmittedEvent extends DomainEvent<{ incidentId: string; severity: IncidentSeverity }> { + readonly name = 'incident.submitted'; + constructor(readonly payload: { incidentId: string; severity: IncidentSeverity }) { + super(); + } +} + +// SLA windows by severity (hours) — drive escalation due dates (Phase 1 §11). +const SLA_HOURS: Record = { + LOW: 72, + MEDIUM: 48, + HIGH: 12, + CRITICAL: 2, +}; + +// Allowed escalation state machine transitions (Phase 3 §11 / Phase 1 §11). +const TRANSITIONS: Record = { + SUBMITTED: ['TRIAGED', 'DISMISSED'], + TRIAGED: ['INVESTIGATING', 'DISMISSED'], + INVESTIGATING: ['ACTIONED', 'DISMISSED'], + ACTIONED: ['CLOSED'], + DISMISSED: ['CLOSED'], + CLOSED: [], +}; + +@Injectable() +export class SafetyService { + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + @Inject(EVENT_BUS) private readonly bus: EventBusPort, + ) {} + + /** Submits a private incident report and opens an escalation with an SLA. */ + async submit(reporterId: string, dto: CreateIncidentDto) { + const incidentId = newId(); + const slaDueAt = new Date(Date.now() + SLA_HOURS[dto.severity] * 60 * 60 * 1000); + // CRITICAL/HIGH (e.g. violence) fast-path to escalation level 2. + const level = dto.severity === IncidentSeverity.CRITICAL ? 2 : 1; + + await this.prisma.$transaction([ + this.prisma.incidentReport.create({ + data: { + id: incidentId, + reporterId: dto.isAnonymous ? null : reporterId, + venueId: dto.venueId, + category: dto.category, + severity: dto.severity, + description: dto.description, + occurredAt: dto.occurredAt, + isAnonymous: dto.isAnonymous, + }, + }), + this.prisma.escalation.create({ + data: { id: newId(), incidentId, level, slaDueAt }, + }), + ]); + + await this.bus.publish(new IncidentSubmittedEvent({ incidentId, severity: dto.severity })); + // The reporter only receives a confirmation id — never the queue. + return { ok: true, referenceId: incidentId }; + } + + async list( + filters: { state?: IncidentState; severity?: IncidentSeverity }, + cursor?: string, + limit?: number, + ) { + const take = clampLimit(limit); + const decoded = decodeCursor(cursor); + const where: Prisma.IncidentReportWhereInput = { + ...(filters.state ? { state: filters.state } : {}), + ...(filters.severity ? { severity: filters.severity } : {}), + ...(decoded ? { createdAt: { lt: new Date(decoded.createdAt) } } : {}), + }; + const rows = await this.prisma.incidentReport.findMany({ + where, + include: { escalation: true, venue: { select: { name: true, slug: true } } }, + orderBy: [{ severity: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }], + take: take + 1, + }); + return buildPage(rows, take); + } + + async transition(actor: AuthenticatedUser, incidentId: string, dto: TransitionIncidentDto) { + const incident = await this.prisma.incidentReport.findUnique({ + where: { id: incidentId }, + include: { escalation: true }, + }); + if (!incident) throw DomainError.notFound('Incident', incidentId); + + const allowed = TRANSITIONS[incident.state]; + if (!allowed.includes(dto.state)) { + throw DomainError.validation( + `Cannot transition incident from ${incident.state} to ${dto.state}`, + ); + } + + await this.prisma.$transaction(async (tx) => { + await tx.incidentReport.update({ where: { id: incidentId }, data: { state: dto.state } }); + if (incident.escalation) { + await tx.escalation.update({ + where: { incidentId }, + data: { + handledById: actor.id, + escalatedAt: new Date(), + outcome: dto.outcome ?? incident.escalation.outcome, + }, + }); + } + }); + + await this.audit.record({ + actorId: actor.id, + action: 'safety.incident.transition', + targetType: 'IncidentReport', + targetId: incidentId, + metadata: { from: incident.state, to: dto.state }, + }); + return { ok: true }; + } +} diff --git a/clubscan/backend/src/modules/safety/presentation/safety.controller.ts b/clubscan/backend/src/modules/safety/presentation/safety.controller.ts new file mode 100644 index 0000000..05fe8e5 --- /dev/null +++ b/clubscan/backend/src/modules/safety/presentation/safety.controller.ts @@ -0,0 +1,42 @@ +import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { IncidentSeverity, IncidentState, UserRole } from '@prisma/client'; +import { Throttle } from '@nestjs/throttler'; +import { CurrentUser, Roles } from '@/platform/security/decorators'; +import { AuthenticatedUser } from '@/platform/security/auth.types'; +import { SafetyService } from '../application/safety.service'; +import { CreateIncidentDto, TransitionIncidentDto } from '../application/dto/safety.dto'; + +@ApiTags('safety') +@Controller('safety') +export class SafetyController { + constructor(private readonly safety: SafetyService) {} + + // Any authenticated user can report; the queue is restricted. + @Throttle({ default: { limit: 10, ttl: 60 * 60 * 1000 } }) + @Post('incidents') + submit(@CurrentUser('id') userId: string, @Body() dto: CreateIncidentDto) { + return this.safety.submit(userId, dto); + } + + @Roles(UserRole.MODERATOR) + @Get('incidents') + list( + @Query('state') state?: IncidentState, + @Query('severity') severity?: IncidentSeverity, + @Query('cursor') cursor?: string, + @Query('limit') limit?: number, + ) { + return this.safety.list({ state, severity }, cursor, limit); + } + + @Roles(UserRole.MODERATOR) + @Patch('incidents/:id') + transition( + @CurrentUser() actor: AuthenticatedUser, + @Param('id') id: string, + @Body() dto: TransitionIncidentDto, + ) { + return this.safety.transition(actor, id, dto); + } +} diff --git a/clubscan/backend/src/modules/safety/safety.module.ts b/clubscan/backend/src/modules/safety/safety.module.ts new file mode 100644 index 0000000..1ae3939 --- /dev/null +++ b/clubscan/backend/src/modules/safety/safety.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { SafetyService } from './application/safety.service'; +import { SafetyController } from './presentation/safety.controller'; + +@Module({ + controllers: [SafetyController], + providers: [SafetyService], +}) +export class SafetyModule {} diff --git a/clubscan/backend/src/modules/search/application/search.service.ts b/clubscan/backend/src/modules/search/application/search.service.ts new file mode 100644 index 0000000..0c89588 --- /dev/null +++ b/clubscan/backend/src/modules/search/application/search.service.ts @@ -0,0 +1,91 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@/platform/prisma/prisma.service'; + +export type SearchType = 'venue' | 'event' | 'user' | 'city' | 'genre' | 'all'; + +/** + * Unified search (Phase 1 §8). v1 uses Postgres ILIKE/trigram behind this + * service so it can later be swapped for OpenSearch/Meilisearch without + * changing the controller contract (Phase 1 §15 — port abstraction). + */ +@Injectable() +export class SearchService { + constructor(private readonly prisma: PrismaService) {} + + async search(q: string, type: SearchType = 'all', limit = 10) { + const term = q.trim(); + if (term.length < 2) return { venues: [], events: [], users: [], cities: [], genres: [] }; + + const take = Math.min(limit, 25); + const wantVenues = type === 'all' || type === 'venue'; + const wantEvents = type === 'all' || type === 'event'; + const wantUsers = type === 'all' || type === 'user'; + const wantCities = type === 'all' || type === 'city'; + const wantGenres = type === 'all' || type === 'genre'; + + const [venues, events, users, cities, genres] = await Promise.all([ + wantVenues + ? this.prisma.venue.findMany({ + where: { + status: 'PUBLISHED', + deletedAt: null, + OR: [ + { name: { contains: term, mode: 'insensitive' } }, + { description: { contains: term, mode: 'insensitive' } }, + ], + }, + select: { id: true, slug: true, name: true, city: true, type: true, coverPhotoUrl: true }, + take, + }) + : [], + wantEvents + ? this.prisma.event.findMany({ + where: { + status: 'PUBLISHED', + deletedAt: null, + startsAt: { gte: new Date() }, + title: { contains: term, mode: 'insensitive' }, + }, + select: { + id: true, + title: true, + startsAt: true, + venue: { select: { name: true, slug: true, city: true } }, + }, + take, + }) + : [], + wantUsers + ? this.prisma.profile.findMany({ + where: { + deletedAt: null, + user: { status: { notIn: ['BANNED', 'DELETED', 'SHADOW_BANNED'] } }, + OR: [ + { username: { contains: term, mode: 'insensitive' } }, + { displayName: { contains: term, mode: 'insensitive' } }, + ], + }, + select: { userId: true, username: true, displayName: true, avatarUrl: true }, + take, + }) + : [], + wantCities + ? this.prisma.venue.findMany({ + where: { status: 'PUBLISHED', deletedAt: null, city: { contains: term, mode: 'insensitive' } }, + distinct: ['city'], + select: { city: true, country: true }, + take, + }) + : [], + wantGenres + ? this.prisma.genre.findMany({ + where: { name: { contains: term, mode: 'insensitive' } }, + select: { id: true, name: true, slug: true }, + take, + }) + : [], + ]); + + return { venues, events, users, cities, genres }; + } +} diff --git a/clubscan/backend/src/modules/search/presentation/search.controller.ts b/clubscan/backend/src/modules/search/presentation/search.controller.ts new file mode 100644 index 0000000..0eddb04 --- /dev/null +++ b/clubscan/backend/src/modules/search/presentation/search.controller.ts @@ -0,0 +1,18 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { Public } from '@/platform/security/decorators'; +import { SearchService, SearchType } from '../application/search.service'; + +@ApiTags('search') +@Controller('search') +export class SearchController { + constructor(private readonly search: SearchService) {} + + @Public() + @Throttle({ default: { limit: 60, ttl: 60 * 1000 } }) + @Get() + query(@Query('q') q = '', @Query('type') type: SearchType = 'all') { + return this.search.search(q, type); + } +} diff --git a/clubscan/backend/src/modules/search/search.module.ts b/clubscan/backend/src/modules/search/search.module.ts new file mode 100644 index 0000000..0e9c805 --- /dev/null +++ b/clubscan/backend/src/modules/search/search.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { SearchService } from './application/search.service'; +import { SearchController } from './presentation/search.controller'; + +@Module({ + controllers: [SearchController], + providers: [SearchService], +}) +export class SearchModule {} diff --git a/clubscan/backend/src/modules/users/application/dto/device.dto.ts b/clubscan/backend/src/modules/users/application/dto/device.dto.ts new file mode 100644 index 0000000..e48810d --- /dev/null +++ b/clubscan/backend/src/modules/users/application/dto/device.dto.ts @@ -0,0 +1,10 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; +import { DevicePlatform } from '@prisma/client'; + +export const RegisterDeviceSchema = z.object({ + platform: z.nativeEnum(DevicePlatform), + pushToken: z.string().min(10).max(512), + name: z.string().max(80).optional(), +}); +export class RegisterDeviceDto extends createZodDto(RegisterDeviceSchema) {} diff --git a/clubscan/backend/src/modules/users/application/users.service.ts b/clubscan/backend/src/modules/users/application/users.service.ts new file mode 100644 index 0000000..427a962 --- /dev/null +++ b/clubscan/backend/src/modules/users/application/users.service.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@/platform/prisma/prisma.service'; +import { DomainError } from '@/shared/errors/domain-error'; +import { newId } from '@/shared/ids/uuid'; +import { RegisterDeviceDto } from './dto/device.dto'; + +@Injectable() +export class UsersService { + constructor(private readonly prisma: PrismaService) {} + + async listSessions(userId: string, currentSessionId: string) { + const sessions = await this.prisma.session.findMany({ + where: { userId, revokedAt: null, expiresAt: { gt: new Date() } }, + orderBy: { createdAt: 'desc' }, + select: { id: true, ip: true, userAgent: true, createdAt: true, deviceId: true }, + }); + return sessions.map((s) => ({ ...s, current: s.id === currentSessionId })); + } + + async revokeSession(userId: string, sessionId: string) { + const session = await this.prisma.session.findFirst({ + where: { id: sessionId, userId }, + }); + if (!session) throw DomainError.notFound('Session', sessionId); + await this.prisma.session.update({ + where: { id: sessionId }, + data: { revokedAt: new Date() }, + }); + return { ok: true }; + } + + async listDevices(userId: string) { + return this.prisma.device.findMany({ + where: { userId }, + orderBy: { lastSeenAt: 'desc' }, + select: { id: true, platform: true, name: true, lastSeenAt: true }, + }); + } + + /** Registers/updates an FCM device token for push (idempotent per token). */ + async registerDevice(userId: string, dto: RegisterDeviceDto) { + const existing = await this.prisma.device.findFirst({ + where: { userId, pushToken: dto.pushToken }, + }); + if (existing) { + await this.prisma.device.update({ + where: { id: existing.id }, + data: { lastSeenAt: new Date(), name: dto.name ?? existing.name, platform: dto.platform }, + }); + return { id: existing.id }; + } + const device = await this.prisma.device.create({ + data: { + id: newId(), + userId, + platform: dto.platform, + pushToken: dto.pushToken, + name: dto.name, + }, + }); + return { id: device.id }; + } + + async removeDevice(userId: string, deviceId: string) { + const device = await this.prisma.device.findFirst({ where: { id: deviceId, userId } }); + if (!device) throw DomainError.notFound('Device', deviceId); + await this.prisma.device.delete({ where: { id: deviceId } }); + return { ok: true }; + } +} diff --git a/clubscan/backend/src/modules/users/presentation/users.controller.ts b/clubscan/backend/src/modules/users/presentation/users.controller.ts new file mode 100644 index 0000000..3f7a59a --- /dev/null +++ b/clubscan/backend/src/modules/users/presentation/users.controller.ts @@ -0,0 +1,37 @@ +import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@/platform/security/decorators'; +import { AuthenticatedUser } from '@/platform/security/auth.types'; +import { UsersService } from '../application/users.service'; +import { RegisterDeviceDto } from '../application/dto/device.dto'; + +@ApiTags('users') +@Controller('me') +export class UsersController { + constructor(private readonly users: UsersService) {} + + @Get('sessions') + sessions(@CurrentUser() user: AuthenticatedUser) { + return this.users.listSessions(user.id, user.sessionId); + } + + @Delete('sessions/:id') + revokeSession(@CurrentUser('id') userId: string, @Param('id') id: string) { + return this.users.revokeSession(userId, id); + } + + @Get('devices') + devices(@CurrentUser('id') userId: string) { + return this.users.listDevices(userId); + } + + @Post('devices') + registerDevice(@CurrentUser('id') userId: string, @Body() dto: RegisterDeviceDto) { + return this.users.registerDevice(userId, dto); + } + + @Delete('devices/:id') + removeDevice(@CurrentUser('id') userId: string, @Param('id') id: string) { + return this.users.removeDevice(userId, id); + } +} diff --git a/clubscan/backend/src/modules/users/users.module.ts b/clubscan/backend/src/modules/users/users.module.ts new file mode 100644 index 0000000..36718ad --- /dev/null +++ b/clubscan/backend/src/modules/users/users.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { UsersService } from './application/users.service'; +import { UsersController } from './presentation/users.controller'; + +@Module({ + controllers: [UsersController], + providers: [UsersService], +}) +export class UsersModule {} diff --git a/clubscan/backend/src/platform/audit/audit.interceptor.ts b/clubscan/backend/src/platform/audit/audit.interceptor.ts new file mode 100644 index 0000000..a425fc9 --- /dev/null +++ b/clubscan/backend/src/platform/audit/audit.interceptor.ts @@ -0,0 +1,56 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { AuditService } from './audit.service'; +import { AuthenticatedUser } from '@/platform/security/auth.types'; + +/** + * Automatically records an audit log for routes annotated with @Audit('action_name') + * or similar. For now, it just wraps requests and logs mutations. + */ +@Injectable() +export class AuditInterceptor implements NestInterceptor { + constructor(private readonly audit: AuditService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const req = context.switchToHttp().getRequest(); + + // Only log mutations for tamper-proof logging + if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) { + const user = req.user as AuthenticatedUser | undefined; + const ip = req.ip || req.socket?.remoteAddress; + const action = `${req.method} ${req.route?.path || req.url}`; + + return next.handle().pipe( + tap(() => { + // Fire and forget audit log on success + this.audit.record({ + actorId: user?.id, + action, + targetType: 'API_ENDPOINT', + ip, + metadata: { + body: this.sanitize(req.body), + query: req.query, + params: req.params, + }, + }).catch(console.error); // Do not block response on audit failure + }), + ); + } + + return next.handle(); + } + + private sanitize(body: any): any { + if (!body) return body; + const clone = { ...body }; + const secretKeys = ['password', 'token', 'refreshToken', 'secret']; + for (const key of Object.keys(clone)) { + if (secretKeys.some(sk => key.toLowerCase().includes(sk))) { + clone[key] = '[REDACTED]'; + } + } + return clone; + } +} diff --git a/clubscan/backend/src/platform/audit/audit.module.ts b/clubscan/backend/src/platform/audit/audit.module.ts index 8f9397d..2c46e0b 100644 --- a/clubscan/backend/src/platform/audit/audit.module.ts +++ b/clubscan/backend/src/platform/audit/audit.module.ts @@ -1,9 +1,11 @@ import { Global, Module } from '@nestjs/common'; import { AuditService } from './audit.service'; +import { AuditInterceptor } from './audit.interceptor'; + @Global() @Module({ - providers: [AuditService], - exports: [AuditService], + providers: [AuditService, AuditInterceptor], + exports: [AuditService, AuditInterceptor], }) export class AuditModule {} diff --git a/clubscan/docs/02-database-design.md b/clubscan/docs/02-database-design.md index ee66c7d..942978d 100644 --- a/clubscan/docs/02-database-design.md +++ b/clubscan/docs/02-database-design.md @@ -102,7 +102,8 @@ erDiagram ### 3.2 Profile & Reputation - **profiles** — 1:1 with user: `username` (unique, citext), `displayName`, `bio`, `avatarUrl`, `verificationStatus` (enum), `isPrivate`. Reputation lives on `users` as a - cached score; the ledger is **reputation_events** (append-only deltas with reason). + cached score; the ledger is **reputation_events** (append-only deltas with reason, + `idempotencyKey` unique nullable — prevents duplicate adjustments from replayed events). - **social_links** — `platform`, `url`. - **follows** — (`followerId`, `followingId`) composite unique; self-follow forbidden. @@ -144,6 +145,8 @@ erDiagram - **notifications** — `userId`, `type`, `payload` (JSONB), `readAt`, `sentPush` (bool). - **feed_entries** *(read model)* — `userId` (owner of feed), `actorId`, `verb`, `objectType`, `objectId`, `createdAt`. Fan-out-on-write for normal accounts. + Unique composite `@@unique([ownerId, actorId, verb, objectId])` prevents duplicate + entries during fan-out replays. ### 3.8 Analytics & Shared Kernel - **analytics_events** *(append-only, time-partitioned)* — `type`, `userId?`, `sessionId`, @@ -161,7 +164,9 @@ erDiagram | Concern | Strategy | |---|---| | Geo radius ("near me") | PostGIS `geography(Point,4326)` + GiST index; `ST_DWithin` | -| Venue/user/event search | `pg_trgm` GIN on name/title/username; full-text `tsvector` GIN on description/body | +| PostGIS GiST index | GiST index on `venues.location` for `ST_DWithin` geo queries (created in `prisma/sql/postgis.sql`, applied via migration) | +| Fuzzy text search | `pg_trgm` GIN indexes on `venues.name`, `events.title`, `profiles.username` for fuzzy/partial-match search (created in `prisma/sql/postgis.sql`, applied via migration) | +| Venue/user/event search | Full-text `tsvector` GIN on description/body | | Venue listing & sort | composite indexes on (`city`,`status`), (`type`,`status`); score sort via `venue_scores` | | Feed reads | index `feed_entries(userId, createdAt DESC)` | | Reviews per venue | index `reviews(venueId, status, createdAt DESC)` | @@ -189,8 +194,9 @@ erDiagram ## 6. Migration & Seed Strategy -- Prisma Migrate for schema; PostGIS + extensions (`citext`, `pg_trgm`, `uuid`/pgcrypto) added - in an initial SQL migration. +- **Prisma Migrate** with a baseline migration directory (`prisma/migrations/0_init/`); + PostGIS + extensions (`citext`, `pg_trgm`, `uuid`/pgcrypto) added in that initial SQL + migration. Subsequent changes go through `prisma migrate dev`. - `Unsupported("geography(Point, 4326)")` column + raw SQL GiST index in migration; geo queries via `prisma.$queryRaw` in the Venue repository. - Seed script: genres master list, app_config (default score weights from Phase 1 §4.2), diff --git a/clubscan/docs/03-backend-architecture.md b/clubscan/docs/03-backend-architecture.md index 7ef1820..5e446d5 100644 --- a/clubscan/docs/03-backend-architecture.md +++ b/clubscan/docs/03-backend-architecture.md @@ -56,9 +56,9 @@ infrastructure/ Prisma repositories, S3/FCM/OAuth/AI adapters (implement applica - **Guards (global where sensible):** `JwtAuthGuard`, `RolesGuard` (`@Roles()`), `PoliciesGuard` (`@CheckPolicy()` for ABAC ownership checks), `ThrottlerGuard` (Redis store). - **Interceptors:** `SerializerInterceptor` (strip sensitive fields), `AuditInterceptor` - (`@Audit()` on privileged handlers → AuditLogEntry), `LoggingInterceptor` (OTel span + - request id), `ShadowBanInterceptor` (shadow-banned users see their own content as live but - it's excluded from others' reads). + (global, auto-logs all mutation requests with actor, IP, sanitized body → AuditLogEntry), + `LoggingInterceptor` (OTel span + request id), `ShadowBanInterceptor` (shadow-banned users + see their own content as live but it's excluded from others' reads). - **Filters:** `AllExceptionsFilter` → RFC7807 problem+json, Sentry capture, no stack leaks. - **i18n:** `nestjs-i18n`, `Accept-Language` + user.locale; error messages translated (EN/TR). @@ -71,8 +71,10 @@ infrastructure/ Prisma repositories, S3/FCM/OAuth/AI adapters (implement applica - **Endpoints:** `/auth/register`, `/auth/verify-email`, `/auth/login`, `/auth/refresh`, `/auth/logout`, `/auth/logout-all`, `/auth/forgot-password`, `/auth/reset-password`, `/auth/oauth/google`, `/auth/oauth/apple`. -- **OAuth:** verify Google ID token / Apple identity token server-side; link or create user + - `oauth_accounts`; first-time → username selection step. +- **OAuth:** verify Google ID token server-side; Apple identity tokens verified via JWKS + (`jsonwebtoken` + `jwks-rsa` fetching Apple's public keys from + `https://appleid.apple.com/auth/keys`); link or create user + `oauth_accounts`; first-time → + username selection step. - **Device & session mgmt:** `/me/sessions` (list/revoke), `/me/devices` (register FCM token, revoke). Refresh binds to `deviceId`. @@ -102,9 +104,15 @@ replicas. ## 7. Audit Logging -`@Audit({ action })` interceptor records `actorId`, `action`, `targetType/Id`, `metadata`, -`ip` to `audit_log_entries` for: role changes, bans/shadow bans, content removal, config -changes, data-protection actions, login from new device. Append-only; never updated. +`AuditInterceptor` is registered globally via `APP_INTERCEPTOR` and automatically logs all +mutation API calls (POST/PUT/PATCH/DELETE). Each entry records `actorId`, `action` (HTTP +method + route path), `ip`, and sanitized `metadata` (passwords/tokens/secrets redacted) to +`audit_log_entries`. The interceptor runs fire-and-forget so it never blocks the HTTP response. +Append-only; never updated. + +Manual `AuditService.record()` calls remain in `ModerationService` and `SafetyService` for +additional context-rich audit entries (e.g. role changes, bans/shadow bans, content removal, +escalation state transitions). ## 8. Event Bus & CQRS Flow @@ -114,6 +122,9 @@ changes, data-protection actions, login from new device. Append-only; never upda `UserFollowed`, `EventSaved`, `ReportFiled`, `IncidentSubmitted`, `SanctionIssued`. - **Reactors** (async handlers): score recompute, search reindex, feed fan-out, notification dispatch (+FCM), analytics ingest, reputation update. +- All reactors use **idempotency keys** — reputation events carry a unique `idempotencyKey` + column; feed fan-out uses a composite unique constraint with `skipDuplicates`. This makes the + system safe for at-least-once delivery semantics. - Write path stays fast & transactional; projections update eventually (seconds). ## 9. REST API Contract (v1, prefix `/api/v1`) diff --git a/clubscan/docs/06-security-architecture.md b/clubscan/docs/06-security-architecture.md index d578002..a2cfe7d 100644 --- a/clubscan/docs/06-security-architecture.md +++ b/clubscan/docs/06-security-architecture.md @@ -12,7 +12,7 @@ | Threat | Vector | Mitigation | |---|---|---| -| **Spoofing** | Stolen tokens, OAuth replay | Short JWT TTL, hashed rotating refresh + reuse detection, server-side OAuth token verification, device binding | +| **Spoofing** | Stolen tokens, OAuth replay | Short JWT TTL, hashed rotating refresh + reuse detection, server-side OAuth token verification (Apple OAuth uses JWKS-based identity token verification via `jsonwebtoken` + `jwks-rsa`), device binding | | **Tampering** | Mass-assignment, param tampering, IDOR | Whitelist DTOs, ownership policies (ABAC), UUID v7 (non-enumerable), authz on every object access | | **Repudiation** | "I didn't ban them" | Immutable audit log for all privileged actions | | **Information disclosure** | PII leak, safety-report exposure, stack traces | Field serialization stripping, RBAC on safety data, RFC7807 (no stack/SQL leakage), least-privilege DB | @@ -64,7 +64,9 @@ one-review-per-venue + AI/human moderation. Fake venues → curated/admin publis ## 5. Secure File Uploads - Presigned **PUT** direct to S3; backend issues short-TTL URL scoped to `key` + `content-type` - + max size. Allowed MIME allowlist (image/jpeg|png|webp|heic); size cap (e.g. 10MB). + + max size + **`ContentLength`** (exact declared file size passed to `PutObjectCommand` so S3 + rejects uploads that don't match). Allowed MIME allowlist (image/jpeg|png|webp|heic); size cap + (e.g. 10MB). - Server-side validation on `/media/:id/complete`: re-check content-type/size via S3 head; optionally async image processing (strip EXIF/GPS, re-encode, generate thumbnails) to neutralize malicious payloads and protect privacy. Assets `PENDING`→`READY`; only `READY` @@ -80,9 +82,13 @@ one-review-per-venue + AI/human moderation. Fake venues → curated/admin publis stripped from photos by default. ## 7. Audit Trail -- Append-only `audit_log_entries` via `@Audit()` interceptor: who/what/when/where for bans, - shadow bans, role changes, content removals, config changes, safety-data access, new-device - logins. Immutable, queryable by SUPER_ADMIN, exportable for incident response. +- Append-only `audit_log_entries` via global `AuditInterceptor` + (`platform/audit/audit.interceptor.ts`, registered as `APP_INTERCEPTOR` in `app.module.ts`). + Auto-logs **all mutation HTTP methods** (POST / PUT / PATCH / DELETE) with: actor ID, action + (method + route path), IP address, and sanitized request body (passwords / tokens / secrets + redacted). Fires-and-forgets so it never blocks the response. +- Covers bans, shadow bans, role changes, content removals, config changes, safety-data access, + new-device logins. Immutable, queryable by SUPER_ADMIN, exportable for incident response. ## 8. Mobile (MASVS) Notes - Tokens in **SecureStore/Keychain** (not AsyncStorage); certificate pinning option; diff --git a/clubscan/docs/08-implementation-status.md b/clubscan/docs/08-implementation-status.md new file mode 100644 index 0000000..1c988dd --- /dev/null +++ b/clubscan/docs/08-implementation-status.md @@ -0,0 +1,74 @@ +# ClubScan — Phase 8: Implementation Status + +> Living record of what production code exists. Backend **typechecks, lints clean, and unit +> tests pass** (`tsc --noEmit`, `eslint --max-warnings 0`, `vitest`). All 12 bounded contexts +> have working modules. **Hardening Phases 1–3 complete** (security/auth, DB/performance, core +> business logic). **Phase 4 (observability)** is in progress — FCM push and audit interceptor +> are live; Sentry/OTel still pending. + +## Backend modules (NestJS, `clubscan/backend/src/modules`) + +| Context | Module | Status | Notable endpoints | +|---|---|---|---| +| IAM | `auth` | ✅ | register, verify-email, login, refresh (rotating + reuse detection), logout(-all), forgot/reset, oauth/google, oauth/apple | +| IAM | `users` | ✅ | `/me/sessions` (list/revoke), `/me/devices` (register FCM/list/remove) | +| Profile & Reputation | `profiles` | ✅ | `/me`, `/me/profile`, `/users/:username`, follow/unfollow, followers/following + reputation ledger | +| Venue Catalog | `venues` | ✅ | list (filter/geo/sort, repository pattern + raw PostGIS), detail | +| Venue Catalog | `events` | ✅ | discover (filters), detail, save/unsave, saved list, share deep link | +| Review & Scoring | `reviews` | ✅ | create (9 ratings + photos + AI/rule moderation), edit (+history), delete, helpful | +| Review & Scoring | `scoring` | ✅ | `ScoreCalculator` (pure, 7 unit tests) + `VenueScore` read-model reactor | +| Discovery & Search | `search` | ✅ | unified venues/events/users/cities/genres | +| Moderation & Trust | `moderation` | ✅ | reports, case queue, case transitions, sanctions (ban/shadow-ban/removal, ADMIN-gated), audited | +| Safety & Incidents | `safety` | ✅ | submit incident (anonymous option), restricted queue, escalation state machine + SLAs | +| Notifications & Feed | `notifications` | ✅ | feed (fan-out-on-write), notifications list/read, FCM gateway, event reactors | +| Analytics | `analytics` | ✅ | batched ingest beacon, admin overview metrics | +| Shared Kernel | `media` | ✅ | S3 presigned upload + server-side complete validation | +| Platform | `health` | ✅ | liveness + readiness (DB check) | + +## Platform layer (`clubscan/backend/src/platform`) + +- `prisma` (service/module) · `config` (env validation + runtime `AppConfig` scoring loader) +- `event-bus` (port + in-process adapter) · `audit` (immutable log) · `security` (JWT guard, + RBAC guard, decorators) · `http` (RFC7807 filter) · `pagination` (cursor) · shared kernel + (UUID v7, domain errors, domain events). + +## Cross-cutting wired globally + +- `ZodValidationPipe` (whitelist) · `AllExceptionsFilter` (problem+json) · + `ThrottlerGuard` → `JwtAuthGuard` → `RolesGuard` (order matters) · `AuditInterceptor` + (auto-logs all mutations — POST/PUT/PATCH/DELETE — with actor, IP, sanitized body; + fire-and-forget) · Helmet · CORS allowlist · Swagger at `/api/v1/docs`. + +## Event-driven reactors (eventual consistency) + +``` +review.published ─► scoring.recompute · feed.fanOut · reputation +10 +user.followed ─► notification + push · reputation +1 +incident.submitted ─► escalation SLA scheduling (severity-based) +event.saved ─► (analytics hook point) +``` + +All reactors use idempotency keys to safely handle at-least-once delivery. + +## Mobile (`clubscan/mobile`) + +Expo Router app with auth gate, design tokens (NativeWind), API client with single-flight +token refresh, TanStack Query (+persistence), Zustand stores (SecureStore), i18n (EN/TR), +`ScoreRing`/`VenueCard`/`Button`, and Welcome/Login/Register/Discover/Venue-detail/tab screens. + +## Verification commands + +```bash +cd clubscan/backend +npm install && npx prisma generate # may require network for engine binaries +npx tsc --noEmit # ✅ no errors +npx eslint "src/**/*.ts" # ✅ 0 problems +npx vitest run # ✅ 7 passed +``` + +## Known follow-ups (next iterations) + +- Search/Analytics extraction to dedicated stores when load dictates (ports already abstracted). +- Sentry + OpenTelemetry integration (wiring pending). +- Mobile polish & i18n (Phase 5). +- Integration/e2e tests (supertest). diff --git a/logs/xvba_debug.log b/logs/xvba_debug.log index 83933fc..25673be 100644 --- a/logs/xvba_debug.log +++ b/logs/xvba_debug.log @@ -22,3 +22,10 @@ 2025-11-14 10:56:09 info: XVBA Ribbon Starts 2025-11-14 11:21:13 info: XVBA Ribbon Starts 2025-11-20 16:47:05 info: XVBA Ribbon Starts +2026-06-05 02:02:05 info: XVBA Ribbon Starts +2026-06-05 22:20:53 info: XVBA Ribbon Starts +2026-06-06 06:57:21 info: XVBA Ribbon Starts +2026-06-08 23:04:56 info: XVBA Ribbon Starts +2026-06-09 01:19:29 info: XVBA Ribbon Starts +2026-06-09 01:20:53 info: XVBA Ribbon Starts +2026-06-10 01:54:13 info: XVBA Ribbon Starts