From 29a902f2313f62e2146e25dc711ee88e2b4ae1b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 19:36:05 +0000 Subject: [PATCH 1/8] =?UTF-8?q?docs(clubscan):=20Phase=201=20=E2=80=94=20p?= =?UTF-8?q?roduct=20architecture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- clubscan/docs/01-product-architecture.md | 400 +++++++++++++++++++++++ 1 file changed, 400 insertions(+) create mode 100644 clubscan/docs/01-product-architecture.md diff --git a/clubscan/docs/01-product-architecture.md b/clubscan/docs/01-product-architecture.md new file mode 100644 index 0000000..85d764d --- /dev/null +++ b/clubscan/docs/01-product-architecture.md @@ -0,0 +1,400 @@ +# ClubScan — Phase 1: Product Architecture + +> **Status:** Phase 1 of 8 · **Owner:** Platform Architecture · **Last updated:** 2026-06-04 +> +> This document defines the product vision, scope, domain model, system topology, and +> architectural principles for ClubScan. It is the source of truth that every later phase +> (database, backend, frontend, infra, security, code) must conform to. No code is written +> in this phase — this is the contract. + +--- + +## 1. Mission & Product Thesis + +**Mission:** Create a safer, more transparent nightlife ecosystem where users can discover +clubs, evaluate venues, share experiences, and make informed decisions. + +**Thesis:** Nightlife is a high-trust, high-risk social activity that is currently served by +generic review apps (Google Maps, Yelp) and event aggregators (RA, DICE) that were never +designed around **safety** and **structured, category-level trust signals**. ClubScan's +defensible wedge is a **weighted, multi-dimensional scoring system** (with an explicit +*Safety-for-Women* signal) combined with **first-class moderation and safety reporting**. +This is a community-and-data product, not a booking product — we win on trust, coverage, and +signal quality. + +### 1.1 Brand Personality (drives every UX & content decision) + +| Trait | Implication | +|---|---| +| **Trustworthy** | Transparent scores, visible moderation, no pay-to-rank. Verified badges. | +| **Modern** | Premium dark-first aesthetic, motion, fast perceived performance. | +| **Data-driven** | Every venue surfaces structured metrics, not just stars. | +| **Community-first** | Reputation, follows, feed; contributors are recognized. | + +### 1.2 Visual North Star + +Spotify (dark, content-dense, media-forward) × Resident Advisor (event-centric, editorial) × +Apple/Linear/Notion (restraint, typography, spacing discipline). See Phase 4. + +--- + +## 2. Target Users & Personas + +| Persona | Goal | Primary jobs-to-be-done | +|---|---|---| +| **The Explorer** (core consumer) | Find a good, safe night out tonight/this weekend | Discover venues & events near me, filter by genre/safety, read trustworthy reviews | +| **The Contributor** (power user) | Build reputation, help others | Write structured reviews, upload photos, follow people, earn reputation | +| **The Safety-Conscious User** | Avoid unsafe venues | Read *Safety-for-Women* / *Security* scores, report incidents | +| **Venue Owner/Manager** (later phase) | Manage venue presence | Claim venue, respond to reviews, view analytics (read-only in v1) | +| **Moderator / Admin** (internal) | Keep the platform safe & clean | Triage reports, action content/users, audit | + +> **v1 scope note:** Venue owners are **out of scope for write access** in the first release. +> Venues are platform-curated + community-suggested. Owner claiming is a Phase-2 product epic. + +--- + +## 3. Scope: Feature Epics → Capabilities + +The 12 core feature areas map to **bounded contexts** (Section 5). Below is the capability +inventory used to drive the backlog and the API surface. + +| # | Epic | Key capabilities (v1) | +|---|---|---| +| E1 | **Identity & Auth** | Email signup + verification, password reset, Google & Apple OAuth, JWT access + rotating refresh tokens, multi-device session management, device revocation | +| E2 | **User Profiles** | Username, bio, avatar, social links, verification status, reputation score | +| E3 | **Venues** | Clubs, bars, festivals, events, lounges; rich metadata, photos, hours, genres, capacity, geo, aggregate metrics | +| E4 | **Scoring** | 9 structured rating categories, weighted algorithm, recency + reputation weighting, Bayesian shrinkage | +| E5 | **Reviews** | CRUD, image upload, edit history, AI moderation, reporting, admin review | +| E6 | **Community** | Follow/unfollow, followers/following, activity feed, notifications | +| E7 | **Event Discovery** | Browse, filter, save, share events | +| E8 | **Search** | Venues, events, cities, genres, users; geo + full-text | +| E9 | **Moderation** | Roles, bans, shadow bans, content review, report queues | +| E10 | **Analytics** | Venue/event views, CTR, review engagement, retention | +| E11 | **Safety** | Structured incident reports (harassment/violence/discrimination/unsafe), escalation workflows | +| E12 | **i18n** | English + Turkish, locale-aware formatting, server-driven + client catalogs | + +--- + +## 4. The ClubScan Scoring System (product spec) + +This is the product's core IP. Detailed algorithm + storage land in Phase 2/3; the **product +rules** are fixed here. + +### 4.1 Rating categories (1–5 each, per review) + +1. Security +2. Staff behavior +3. Fair pricing +4. Crowd quality +5. Music quality +6. Sound system +7. Cleanliness +8. **Safety for women** *(elevated, see weighting)* +9. Overall atmosphere + +### 4.2 Composite "ClubScan Score" (0–100) — design principles + +The displayed venue score is **not** a naive average. It must resist manipulation, reward +trust, and prioritize safety. The algorithm (formalized in Phase 3) follows these rules: + +- **Category weights** (sum = 1.0), tunable via config, default: + Safety for women `0.18`, Security `0.16`, Staff behavior `0.12`, Crowd quality `0.10`, + Fair pricing `0.10`, Cleanliness `0.10`, Music quality `0.10`, Sound system `0.08`, + Overall atmosphere `0.06`. +- **Bayesian shrinkage** toward the global mean for low-volume venues (prior weight `m`), + so 1 glowing review can't crown a venue. +- **Reviewer reputation weighting:** higher-reputation, verified users count more + (capped to prevent oligarchy). +- **Recency decay:** exponential half-life (default 180 days) — a venue's score reflects its + *current* state. +- **Anti-abuse:** one scored review per user per venue (editable); shadow-banned & flagged + reviews excluded from aggregates; rate limits + velocity checks. +- **Transparency:** category sub-scores and review count always shown next to the composite. + +### 4.3 Safety as a first-class citizen + +- *Safety for women* and *Security* carry the highest weights. +- A venue with safety incident reports above a threshold gets a **safety advisory** surfaced + in the UI (not auto-defamation — threshold + moderation gated). +- Safety reports (E11) are a **separate pipeline** from reviews (E5): they are private, + escalation-driven, and never publicly attributed. + +--- + +## 5. Domain Model — Bounded Contexts (DDD) + +ClubScan is decomposed into bounded contexts. v1 ships as a **modular monolith** (NestJS +modules = contexts) with strict module boundaries so contexts can later be extracted into +services without rewriting domain logic. This is the pragmatic path to "scale to millions" +without premature microservice tax. + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ ClubScan Domain Map │ +├───────────────┬───────────────┬───────────────┬──────────────────────────┤ +│ Identity & │ Profile & │ Venue │ Review & │ +│ Access (IAM) │ Reputation │ Catalog │ Scoring │ +│ │ │ │ │ +│ users, creds, │ profiles, │ venues, events,│ reviews, ratings, │ +│ sessions, │ follows, │ photos, hours, │ aggregates, score calc │ +│ devices, │ reputation │ genres, geo │ (CQRS read models) │ +│ oauth │ │ │ │ +├───────────────┼───────────────┼───────────────┼──────────────────────────┤ +│ Discovery & │ Moderation & │ Safety & │ Notifications & │ +│ Search │ Trust │ Incidents │ Activity Feed │ +│ │ │ │ │ +│ search index, │ roles, bans, │ incident │ feed events, push (FCM), │ +│ filters, feed │ shadow bans, │ reports, │ in-app notifications │ +│ ranking │ report queues, │ escalation │ │ +│ │ AI moderation │ workflows │ │ +├───────────────┴───────────────┴───────────────┴──────────────────────────┤ +│ Analytics & Telemetry (event ingestion, metrics, retention) │ +├────────────────────────────────────────────────────────────────────────── │ +│ Platform / Shared Kernel (audit log, media/storage, i18n, config) │ +└────────────────────────────────────────────────────────────────────────────┘ +``` + +### 5.1 Context responsibilities & ubiquitous language + +| Context | Aggregate roots | Owns | Talks to | +|---|---|---|---| +| **IAM** | `User` (credentials), `Session`, `Device` | Auth, tokens, OAuth, devices | everything (issues identity) | +| **Profile & Reputation** | `Profile`, `Follow` | Public profile, social graph, reputation score | IAM, Review (reputation inputs) | +| **Venue Catalog** | `Venue`, `Event` | Venue/event master data, media, geo, hours | Search, Review | +| **Review & Scoring** | `Review`, `VenueScore` (read model) | Reviews, ratings, score aggregation (CQRS) | Venue, Profile, Moderation | +| **Discovery & Search** | (read models) | Search index, ranking, event discovery, feed ranking | Venue, Review, Profile | +| **Moderation & Trust** | `Report`, `ModerationCase`, `Sanction` | Reports, bans, shadow bans, queues, AI verdicts | every content context | +| **Safety & Incidents** | `IncidentReport`, `Escalation` | Safety reporting + escalation workflow | Moderation, Notifications | +| **Notifications & Feed** | `Notification`, `FeedEntry` | Push (FCM), in-app notifications, activity feed | all (consumes domain events) | +| **Analytics** | `AnalyticsEvent` (append-only) | View/CTR/engagement/retention | all (consumes events) | +| **Shared Kernel** | `AuditLogEntry`, `MediaAsset` | Audit trail, media/S3, i18n catalogs, feature flags | all | + +### 5.2 Inter-context communication + +- **Synchronous** (in-process within the modular monolith): query interfaces / application + services exposed by each module. No cross-module Prisma access — modules talk via + application-layer ports (Repository/Service interfaces). +- **Asynchronous** (domain events): a lightweight internal event bus (NestJS `EventEmitter` + in v1, swappable to a real broker — Redis Streams / SQS / Kafka — behind the same port). + Events drive **Notifications**, **Analytics**, **Search indexing**, and **Score + recomputation** so the write path stays fast and the read path is eventually consistent. + +Example event flow: +``` +ReviewPublished ─► [Scoring] recompute VenueScore read model + ─► [Search] reindex venue + ─► [Feed] fan-out to followers' feeds + ─► [Analytics] record engagement event + ─► [Reputation] adjust author reputation +``` + +--- + +## 6. Architectural Principles (binding for all phases) + +1. **Clean Architecture** — dependencies point inward. Layering per module: + `domain` (entities, value objects, domain services — zero framework deps) → + `application` (use cases, ports, DTOs, CQRS handlers) → + `infrastructure` (Prisma repos, S3, FCM, OAuth adapters) → + `presentation` (NestJS controllers, guards, interceptors). +2. **Domain-Driven Design** — bounded contexts (Section 5), ubiquitous language, aggregates + enforce invariants. No anemic leakage of business rules into controllers. +3. **SOLID** — especially DIP: application depends on **ports** (interfaces); infrastructure + provides adapters wired by NestJS DI. +4. **Repository Pattern** — Prisma is hidden behind repository interfaces in `application`. + Domain never imports `@prisma/client`. +5. **CQRS where appropriate** — split for **Scoring** and **Discovery/Search/Feed**: writes + go through command handlers and emit events; reads come from denormalized read models + (`VenueScore`, search index, feed) optimized for query performance. Simple CRUD contexts + (Profile edits, etc.) stay command/query-merged to avoid ceremony. +6. **Eventual consistency at the edges** — aggregates are read instantly; aggregate *scores*, + feeds, and search may lag by seconds. UI is designed to tolerate this. +7. **Everything auditable** — privileged actions write immutable `AuditLogEntry`. +8. **Config over code** — score weights, rate limits, thresholds, feature flags are config. +9. **API-first** — REST contract (Phase 3) is the boundary; the mobile app is one consumer. + +--- + +## 7. System Topology (logical) + +``` + ┌───────────────────────────┐ + │ Mobile App (Expo) │ + │ RN · TS · Expo Router · │ + │ React Query · Zustand · │ + │ RHF+Zod · NativeWind │ + └─────────────┬─────────────┘ + │ HTTPS (REST/JSON, JWT) + ▼ + ┌───────────────────────────┐ + │ Edge / CDN + WAF + TLS │ + │ (rate limit, DDoS, CDN) │ + └─────────────┬─────────────┘ + ▼ + ┌──────────────────────────────────────────────────────┐ + │ NestJS API (modular monolith) │ + │ Auth · Profile · Venue · Review/Scoring · Search · │ + │ Moderation · Safety · Notifications · Analytics │ + │ ── guards, rate limiter, validation, audit, i18n ── │ + └───┬───────────┬──────────┬─────────┬────────┬─────────┘ + │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ + ┌───────────┐ ┌──────────┐ ┌───────┐ ┌──────┐ ┌──────────────┐ + │PostgreSQL │ │ Redis │ │ S3 │ │ FCM │ │ External APIs │ + │ (Prisma) │ │ cache / │ │object │ │ push │ │ Google Maps · │ + │ primary + │ │ rate-lim │ │storage│ │ │ │ OAuth · AI │ + │ replicas │ │ /queues │ │ +CDN │ │ │ │ moderation │ + └───────────┘ └──────────┘ └───────┘ └──────┘ └──────────────┘ + + Cross-cutting observability: Sentry (errors) · OpenTelemetry + (traces/metrics/logs) → collector → backend (e.g. Grafana/Tempo/Prom). +``` + +### 7.1 Why a modular monolith first + +- One deployable, one DB, transactional integrity where it matters (reviews ↔ scores). +- Bounded-context module boundaries make extraction to services a **mechanical** later step. +- Cheapest path to correctness and velocity at <10M users; horizontal scale via stateless + API replicas + read replicas + caching covers the early growth curve. + +### 7.2 Scaling path (when metrics demand it) + +1. Stateless API → horizontal autoscale behind LB. +2. Postgres read replicas for read-heavy contexts (Discovery, Venue, Scoring read models). +3. Redis for hot caches (venue pages, scores, sessions) + rate limiting + async queues. +4. Extract **Search** to a dedicated engine (OpenSearch/Meilisearch) — interface already + abstracted. +5. Extract **Analytics** ingestion to a stream + columnar store. +6. Promote the in-process event bus to a real broker; peel off the highest-load contexts + (Notifications, Analytics, Search) into services. + +--- + +## 8. Non-Functional Requirements (NFRs) + +| Category | Target (v1 → scale) | +|---|---| +| **Availability** | 99.9% API; graceful degradation (cached venue pages survive DB blips) | +| **Latency** | p95 < 300ms for read endpoints (cached), < 800ms for search; perceived instant via optimistic UI + skeletons | +| **Scalability** | Stateless API; design assumes 10M+ users, 100k+ venues, 10M+ reviews | +| **Consistency** | Strong for writes within an aggregate; eventual for scores/feed/search (seconds) | +| **Security** | OWASP ASVS L2 target; see Phase 6 | +| **Privacy/Compliance** | GDPR/KVKK (Turkey): data export & deletion, consent, data minimization; safety reports access-controlled | +| **Observability** | Every request traced (OTel), errors to Sentry, audit log for privileged actions | +| **Accessibility** | WCAG 2.1 AA intent on mobile (contrast, dynamic type, screen reader labels) | +| **i18n** | EN + TR at launch; all user-facing strings externalized | + +--- + +## 9. Key End-to-End User Flows (v1) + +These flows are the acceptance lens for the API and UI design in later phases. + +1. **Onboard & verify** — signup → email verification → profile setup → location permission → + personalized discovery. +2. **OAuth onboard** — Continue with Google/Apple → (first time) choose username → discovery. +3. **Discover tonight** — open app → geo + "open now" venues + nearby events → filter by + genre/safety → venue detail. +4. **Evaluate a venue** — venue detail shows ClubScan Score, category breakdown, photos, + hours, reviews → user decides. +5. **Contribute a review** — rate 9 categories → write text → attach photos → submit → + AI moderation → published (or held) → score recomputes → followers' feeds update. +6. **Follow & feed** — follow a contributor → their new reviews/photos appear in activity feed + + push notification. +7. **Save & share an event** — discover event → save → receive reminder → share deep link. +8. **Report unsafe behavior** — venue/review → "Report safety concern" → structured incident + form → escalation workflow → moderator triage (private). +9. **Moderation triage** — moderator opens report queue → reviews flagged content/AI verdict + → action (dismiss / remove / warn / ban / shadow ban) → audit logged → reporter notified + of resolution (without exposing target identity for safety reports). + +--- + +## 10. Roles & Authorization Model (product-level) + +| Role | Capabilities | +|---|---| +| **User** | CRUD own profile/reviews, follow, save, report, view public content | +| **Moderator** | All User + view report queues, action reviews/comments, issue warnings, temporary bans, resolve reports | +| **Admin** | All Moderator + manage venues/events, permanent bans, shadow bans, manage moderators, view analytics dashboards | +| **Super Admin** | All Admin + role management, platform config (weights/flags), access full audit log, data-protection actions | + +Enforced via RBAC + policy guards (Phase 3) and reflected in moderation tooling (Phase 3/6). +Sensitive transitions (ban, role change, config change) are **always audit-logged**. + +--- + +## 11. Analytics & Safety as Product Surfaces + +- **Analytics (E10)** is event-sourced: the app and API emit typed events + (`venue_viewed`, `event_viewed`, `review_card_clicked`, `review_helpful_marked`, sessions). + These power CTR, engagement, and retention cohorts. PII-minimized, consent-aware. +- **Safety (E11)** runs an **escalation state machine**: + `SUBMITTED → TRIAGED → INVESTIGATING → ACTIONED|DISMISSED → CLOSED`, with severity-based + SLAs and auto-escalation if unactioned. Violence/credible-threat reports fast-path to Admin. + Detailed in Phase 3 (workflow) and Phase 6 (access control). + +--- + +## 12. Technology Stack — Rationale Summary + +| Layer | Choice | Why | +|---|---|---| +| Mobile | Expo (RN) + TS + Expo Router | Cross-platform, OTA updates, file-based routing, fast iteration | +| Data/Server state | TanStack Query | Caching, background refetch, pagination, optimistic updates | +| Client state | Zustand | Minimal, ergonomic global UI/session state | +| Forms | React Hook Form + Zod | Performant forms + shared schema validation (client & server reuse) | +| Styling | NativeWind | Tailwind ergonomics → consistent design system on RN | +| API | NestJS + TS | First-class DI, modular architecture, guards/interceptors, DDD-friendly | +| DB | PostgreSQL | Relational integrity, PostGIS-class geo, JSONB flexibility, full-text | +| ORM | Prisma | Type-safe, migrations, fits Repository pattern behind interfaces | +| Auth | JWT + rotating refresh + OAuth (Google/Apple) | Stateless access, secure long sessions, social onboarding | +| Storage | S3-compatible | Durable media, presigned uploads, CDN fronting | +| Push | FCM | Cross-platform push | +| Maps | Google Maps | Mature places/geo on mobile | +| Infra | Docker + GitHub Actions CI/CD | Reproducible builds, automated pipelines | +| Observability | Sentry + OpenTelemetry | Errors + distributed tracing/metrics/logs | + +--- + +## 13. Out of Scope for v1 (explicit) + +- Venue owner self-service write access / claiming (Phase-2 epic). +- Ticketing / payments / reservations. +- DMs / real-time chat. +- Web client (API is web-ready; client is mobile-first v1). +- ML-trained reputation/ranking models (heuristic + config-driven first). + +--- + +## 14. Phase Map & Deliverables + +| Phase | Deliverable | Artifact location | +|---|---|---| +| **1. Product architecture** ✅ | This document | `clubscan/docs/01-product-architecture.md` | +| 2. Database design | ER diagram, schema, Prisma models | `clubscan/docs/02-*`, `clubscan/backend/prisma/` | +| 3. Backend architecture | Modules, REST API, DTOs, validation, authz, rate limiting, audit, scoring algorithm | `clubscan/docs/03-*`, `clubscan/backend/` | +| 4. Frontend architecture | Design system, theme, navigation, state, API client | `clubscan/docs/04-*`, `clubscan/mobile/` | +| 5. Infrastructure | Docker, CI/CD, deploy plan, observability | `clubscan/docs/05-*`, compose/CI files | +| 6. Security | Threat model, OWASP controls, authz policies, file upload, audit | `clubscan/docs/06-*` | +| 7. Folder structures | Definitive backend + mobile trees | `clubscan/docs/07-*` | +| 8. Production code | Incremental, working code | `clubscan/backend/`, `clubscan/mobile/` | + +--- + +## 15. Open Decisions to Confirm Before Phase 2 + +1. **Deployment target** for infra phase (e.g., AWS ECS/Fargate + RDS + S3, or Fly.io/Render, + or self-hosted Docker Compose). Affects Phase 5 specifics. *(Default assumption: AWS-style + containerized + managed Postgres + S3-compatible.)* +2. **AI moderation provider** (e.g., OpenAI moderation, AWS Rekognition/Comprehend, or a + pluggable port with a stub). *(Default: define a `ContentModerationPort` with a provider + adapter; ship a deterministic rule-based + pluggable AI adapter.)* +3. **Search engine** — Postgres FTS + trigram + PostGIS for v1, abstract behind a port for a + later OpenSearch/Meilisearch swap. *(Default: Postgres-native for v1.)* + +Unless you direct otherwise, Phase 2 will proceed on the **default assumptions** above. + +--- + +*End of Phase 1. Awaiting confirmation to proceed to **Phase 2: Database Design**.* From e4d63172136f8de79fdfe1ab40ae3a2d94cfa865 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 19:39:01 +0000 Subject: [PATCH 2/8] =?UTF-8?q?docs(clubscan):=20Phase=202=20=E2=80=94=20d?= =?UTF-8?q?atabase=20design=20+=20canonical=20Prisma=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- clubscan/backend/prisma/schema.prisma | 817 ++++++++++++++++++++++++++ clubscan/docs/02-database-design.md | 206 +++++++ 2 files changed, 1023 insertions(+) create mode 100644 clubscan/backend/prisma/schema.prisma create mode 100644 clubscan/docs/02-database-design.md diff --git a/clubscan/backend/prisma/schema.prisma b/clubscan/backend/prisma/schema.prisma new file mode 100644 index 0000000..766ebfb --- /dev/null +++ b/clubscan/backend/prisma/schema.prisma @@ -0,0 +1,817 @@ +// ClubScan — Canonical Prisma Schema (Phase 2 / Phase 8) +// PostgreSQL 16. UUID v7 PKs are generated in the application layer (time-ordered). +// Geospatial point columns are added via raw SQL migration (PostGIS) — see migrations. +// Module boundaries (Phase 1 bounded contexts) are noted in section comments. + +generator client { + provider = "prisma-client-js" + previewFeatures = ["postgresqlExtensions", "fullTextSearchPostgres"] +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") + extensions = [citext, pg_trgm, pgcrypto] +} + +// ============================================================================ +// ENUMS +// ============================================================================ + +enum UserRole { + USER + MODERATOR + ADMIN + SUPER_ADMIN +} + +enum UserStatus { + ACTIVE + SUSPENDED + BANNED + SHADOW_BANNED + DELETED +} + +enum VerificationStatus { + NONE + PENDING + VERIFIED +} + +enum OAuthProvider { + GOOGLE + APPLE +} + +enum DevicePlatform { + IOS + ANDROID +} + +enum VenueType { + CLUB + BAR + FESTIVAL + EVENT + LOUNGE +} + +enum VenueStatus { + DRAFT + PUBLISHED + CLOSED +} + +enum EventStatus { + DRAFT + PUBLISHED + CANCELLED + ENDED +} + +enum ReviewStatus { + PENDING + PUBLISHED + HELD + REMOVED +} + +enum ModerationStatus { + PENDING + APPROVED + FLAGGED + REJECTED +} + +enum ReportTargetType { + REVIEW + USER + VENUE + EVENT +} + +enum ReportReason { + SPAM + HARASSMENT + HATE_SPEECH + MISINFORMATION + INAPPROPRIATE_CONTENT + OTHER +} + +enum ReportStatus { + OPEN + IN_REVIEW + RESOLVED + DISMISSED +} + +enum ModerationState { + TRIAGE + INVESTIGATING + ACTIONED + DISMISSED + CLOSED +} + +enum SanctionType { + WARNING + TEMP_BAN + PERMA_BAN + SHADOW_BAN + CONTENT_REMOVAL +} + +enum IncidentCategory { + HARASSMENT + VIOLENCE + DISCRIMINATION + UNSAFE_ENVIRONMENT + OTHER +} + +enum IncidentSeverity { + LOW + MEDIUM + HIGH + CRITICAL +} + +enum IncidentState { + SUBMITTED + TRIAGED + INVESTIGATING + ACTIONED + DISMISSED + CLOSED +} + +enum NotificationType { + NEW_FOLLOWER + REVIEW_PUBLISHED + REVIEW_HELPFUL + EVENT_REMINDER + MODERATION_RESULT + SAFETY_UPDATE + SYSTEM +} + +enum MediaStatus { + PENDING + READY + FAILED +} + +enum SocialPlatform { + INSTAGRAM + TIKTOK + X + SOUNDCLOUD + WEBSITE +} + +// ============================================================================ +// CONTEXT: IAM (Identity & Access) +// ============================================================================ + +model User { + id String @id @db.Uuid + email String @unique @db.Citext + passwordHash String? // null for OAuth-only accounts + emailVerifiedAt DateTime? + role UserRole @default(USER) + status UserStatus @default(ACTIVE) + reputationScore Int @default(0) // denormalized cache; ledger = ReputationEvent + locale String @default("en") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + profile Profile? + oauthAccounts OAuthAccount[] + sessions Session[] + devices Device[] + reviews Review[] + reviewHelpfuls ReviewHelpful[] + savedEvents SavedEvent[] + followers Follow[] @relation("Following") + following Follow[] @relation("Follower") + reputationEvents ReputationEvent[] + reportsFiled Report[] @relation("Reporter") + incidentReports IncidentReport[] @relation("IncidentReporter") + sanctions Sanction[] @relation("SanctionTarget") + sanctionsIssued Sanction[] @relation("SanctionIssuer") + notifications Notification[] + feedEntries FeedEntry[] @relation("FeedOwner") + feedActorEntries FeedEntry[] @relation("FeedActor") + auditLogs AuditLogEntry[] + mediaAssets MediaAsset[] + assignedCases ModerationCase[] @relation("CaseAssignee") + handledEscalation Escalation[] @relation("EscalationHandler") + + @@index([status]) + @@index([role]) + @@map("users") +} + +model OAuthAccount { + id String @id @db.Uuid + userId String @db.Uuid + provider OAuthProvider + providerAccountId String + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([provider, providerAccountId]) + @@index([userId]) + @@map("oauth_accounts") +} + +model Session { + id String @id @db.Uuid + userId String @db.Uuid + deviceId String? @db.Uuid + refreshTokenHash String @unique // SHA-256 of refresh token; rotated on use + familyId String @db.Uuid // token family for reuse detection + ip String? + userAgent String? + expiresAt DateTime + revokedAt DateTime? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + device Device? @relation(fields: [deviceId], references: [id], onDelete: SetNull) + + @@index([userId]) + @@index([familyId]) + @@map("sessions") +} + +model Device { + id String @id @db.Uuid + userId String @db.Uuid + platform DevicePlatform + pushToken String? // FCM token + name String? + lastSeenAt DateTime @default(now()) + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + sessions Session[] + + @@unique([userId, pushToken]) + @@index([userId]) + @@map("devices") +} + +model EmailVerificationToken { + id String @id @db.Uuid + userId String @db.Uuid + tokenHash String @unique + expiresAt DateTime + usedAt DateTime? + createdAt DateTime @default(now()) + + @@index([userId]) + @@map("email_verification_tokens") +} + +model PasswordResetToken { + id String @id @db.Uuid + userId String @db.Uuid + tokenHash String @unique + expiresAt DateTime + usedAt DateTime? + createdAt DateTime @default(now()) + + @@index([userId]) + @@map("password_reset_tokens") +} + +// ============================================================================ +// CONTEXT: Profile & Reputation +// ============================================================================ + +model Profile { + id String @id @db.Uuid + userId String @unique @db.Uuid + username String @unique @db.Citext + displayName String? + bio String? + avatarUrl String? + verificationStatus VerificationStatus @default(NONE) + isPrivate Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + socialLinks SocialLink[] + + @@map("profiles") +} + +model SocialLink { + id String @id @db.Uuid + profileId String @db.Uuid + platform SocialPlatform + url String + + profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade) + + @@index([profileId]) + @@map("social_links") +} + +model Follow { + id String @id @db.Uuid + followerId String @db.Uuid + followingId String @db.Uuid + createdAt DateTime @default(now()) + + follower User @relation("Follower", fields: [followerId], references: [id], onDelete: Cascade) + following User @relation("Following", fields: [followingId], references: [id], onDelete: Cascade) + + @@unique([followerId, followingId]) + @@index([followingId]) + @@map("follows") +} + +model ReputationEvent { + id String @id @db.Uuid + userId String @db.Uuid + delta Int + reason String + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("reputation_events") +} + +// ============================================================================ +// CONTEXT: Venue Catalog +// ============================================================================ + +model Venue { + id String @id @db.Uuid + slug String @unique + name String + description String? + type VenueType + status VenueStatus @default(PUBLISHED) + addressLine String? + city String + country String + postalCode String? + latitude Decimal @db.Decimal(9, 6) + longitude Decimal @db.Decimal(9, 6) + capacity Int? + coverPhotoUrl String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + photos VenuePhoto[] + hours OperatingHour[] + genres VenueGenre[] + events Event[] + reviews Review[] + score VenueScore? + + @@index([city, status]) + @@index([type, status]) + @@map("venues") +} + +model VenuePhoto { + id String @id @db.Uuid + venueId String @db.Uuid + url String + position Int @default(0) + createdAt DateTime @default(now()) + + venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade) + + @@index([venueId]) + @@map("venue_photos") +} + +model OperatingHour { + id String @id @db.Uuid + venueId String @db.Uuid + weekday Int // 0=Sunday..6=Saturday + openMin Int // minutes from midnight + closeMin Int // may exceed 1440 for overnight + isClosed Boolean @default(false) + + venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade) + + @@unique([venueId, weekday]) + @@map("operating_hours") +} + +model Genre { + id String @id @db.Uuid + name String @unique + slug String @unique + venues VenueGenre[] + events EventGenre[] + + @@map("genres") +} + +model VenueGenre { + venueId String @db.Uuid + genreId String @db.Uuid + + venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade) + genre Genre @relation(fields: [genreId], references: [id], onDelete: Cascade) + + @@id([venueId, genreId]) + @@index([genreId]) + @@map("venue_genres") +} + +model Event { + id String @id @db.Uuid + venueId String @db.Uuid + title String + description String? + startsAt DateTime + endsAt DateTime? + lineup Json? // [{ artist, time }] + ticketUrl String? + coverPhotoUrl String? + status EventStatus @default(PUBLISHED) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade) + genres EventGenre[] + photos EventPhoto[] + savedEvents SavedEvent[] + + @@index([venueId]) + @@index([startsAt, status]) + @@map("events") +} + +model EventPhoto { + id String @id @db.Uuid + eventId String @db.Uuid + url String + position Int @default(0) + + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + + @@index([eventId]) + @@map("event_photos") +} + +model EventGenre { + eventId String @db.Uuid + genreId String @db.Uuid + + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + genre Genre @relation(fields: [genreId], references: [id], onDelete: Cascade) + + @@id([eventId, genreId]) + @@index([genreId]) + @@map("event_genres") +} + +model SavedEvent { + id String @id @db.Uuid + userId String @db.Uuid + eventId String @db.Uuid + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + + @@unique([userId, eventId]) + @@index([eventId]) + @@map("saved_events") +} + +// ============================================================================ +// CONTEXT: Review & Scoring +// ============================================================================ + +model Review { + id String @id @db.Uuid + userId String @db.Uuid + venueId String @db.Uuid + body String + language String @default("en") + status ReviewStatus @default(PENDING) + moderationStatus ModerationStatus @default(PENDING) + helpfulCount Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade) + rating ReviewRating? + photos ReviewPhoto[] + edits ReviewEdit[] + helpfuls ReviewHelpful[] + + @@unique([userId, venueId]) // one editable review per user per venue + @@index([venueId, status, createdAt]) + @@map("reviews") +} + +model ReviewRating { + reviewId String @id @db.Uuid + security Int // 1..5 + staffBehavior Int + fairPricing Int + crowdQuality Int + musicQuality Int + soundSystem Int + cleanliness Int + safetyForWomen Int + atmosphere Int + + review Review @relation(fields: [reviewId], references: [id], onDelete: Cascade) + + @@map("review_ratings") +} + +model ReviewPhoto { + id String @id @db.Uuid + reviewId String @db.Uuid + url String + position Int @default(0) + createdAt DateTime @default(now()) + + review Review @relation(fields: [reviewId], references: [id], onDelete: Cascade) + + @@index([reviewId]) + @@map("review_photos") +} + +model ReviewEdit { + id String @id @db.Uuid + reviewId String @db.Uuid + bodySnapshot String + ratingSnapshot Json + editedAt DateTime @default(now()) + + review Review @relation(fields: [reviewId], references: [id], onDelete: Cascade) + + @@index([reviewId]) + @@map("review_edits") +} + +model ReviewHelpful { + id String @id @db.Uuid + reviewId String @db.Uuid + userId String @db.Uuid + createdAt DateTime @default(now()) + + review Review @relation(fields: [reviewId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([reviewId, userId]) + @@map("review_helpful") +} + +// CQRS read model — rebuilt from ReviewPublished/Removed/Edited events +model VenueScore { + venueId String @id @db.Uuid + score Decimal @db.Decimal(5, 2) // composite 0..100 + reviewCount Int @default(0) + avgSecurity Decimal @db.Decimal(4, 2) @default(0) + avgStaffBehavior Decimal @db.Decimal(4, 2) @default(0) + avgFairPricing Decimal @db.Decimal(4, 2) @default(0) + avgCrowdQuality Decimal @db.Decimal(4, 2) @default(0) + avgMusicQuality Decimal @db.Decimal(4, 2) @default(0) + avgSoundSystem Decimal @db.Decimal(4, 2) @default(0) + avgCleanliness Decimal @db.Decimal(4, 2) @default(0) + avgSafetyForWomen Decimal @db.Decimal(4, 2) @default(0) + avgAtmosphere Decimal @db.Decimal(4, 2) @default(0) + safetyAdvisory Boolean @default(false) + lastComputedAt DateTime @default(now()) + + venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade) + + @@index([score]) + @@map("venue_scores") +} + +// ============================================================================ +// CONTEXT: Moderation & Trust +// ============================================================================ + +model Report { + id String @id @db.Uuid + reporterId String @db.Uuid + targetType ReportTargetType + targetId String @db.Uuid + reason ReportReason + details String? + status ReportStatus @default(OPEN) + createdAt DateTime @default(now()) + + reporter User @relation("Reporter", fields: [reporterId], references: [id], onDelete: Cascade) + case ModerationCase? + + @@index([targetType, targetId]) + @@index([status]) + @@map("reports") +} + +model ModerationCase { + id String @id @db.Uuid + reportId String? @unique @db.Uuid + targetType ReportTargetType + targetId String @db.Uuid + state ModerationState @default(TRIAGE) + assignedModeratorId String? @db.Uuid + aiVerdict Json? // { provider, scores, labels } + resolution String? + resolvedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + report Report? @relation(fields: [reportId], references: [id], onDelete: SetNull) + assignee User? @relation("CaseAssignee", fields: [assignedModeratorId], references: [id], onDelete: SetNull) + sanctions Sanction[] + + @@index([state, createdAt]) + @@map("moderation_cases") +} + +model Sanction { + id String @id @db.Uuid + targetUserId String @db.Uuid + caseId String? @db.Uuid + type SanctionType + reason String + issuedById String @db.Uuid + expiresAt DateTime? + createdAt DateTime @default(now()) + + targetUser User @relation("SanctionTarget", fields: [targetUserId], references: [id], onDelete: Cascade) + issuedBy User @relation("SanctionIssuer", fields: [issuedById], references: [id], onDelete: Restrict) + case ModerationCase? @relation(fields: [caseId], references: [id], onDelete: SetNull) + + @@index([targetUserId]) + @@map("sanctions") +} + +// ============================================================================ +// CONTEXT: Safety & Incidents (access-restricted) +// ============================================================================ + +model IncidentReport { + id String @id @db.Uuid + reporterId String? @db.Uuid // null if anonymous + venueId String? @db.Uuid + category IncidentCategory + severity IncidentSeverity @default(MEDIUM) + description String + occurredAt DateTime? + isAnonymous Boolean @default(false) + state IncidentState @default(SUBMITTED) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + reporter User? @relation("IncidentReporter", fields: [reporterId], references: [id], onDelete: SetNull) + escalation Escalation? + + @@index([state, severity]) + @@index([venueId]) + @@map("incident_reports") +} + +model Escalation { + id String @id @db.Uuid + incidentId String @unique @db.Uuid + level Int @default(1) + slaDueAt DateTime + escalatedAt DateTime? + handledById String? @db.Uuid + outcome String? + createdAt DateTime @default(now()) + + incident IncidentReport @relation(fields: [incidentId], references: [id], onDelete: Cascade) + handledBy User? @relation("EscalationHandler", fields: [handledById], references: [id], onDelete: SetNull) + + @@index([slaDueAt]) + @@map("escalations") +} + +// ============================================================================ +// CONTEXT: Notifications & Feed +// ============================================================================ + +model Notification { + id String @id @db.Uuid + userId String @db.Uuid + type NotificationType + payload Json + readAt DateTime? + sentPush Boolean @default(false) + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, readAt]) + @@map("notifications") +} + +// CQRS read model — fan-out-on-write activity feed +model FeedEntry { + id String @id @db.Uuid + ownerId String @db.Uuid // whose feed this entry belongs to + actorId String @db.Uuid + verb String // e.g. "reviewed", "followed", "saved" + objectType String + objectId String @db.Uuid + createdAt DateTime @default(now()) + + owner User @relation("FeedOwner", fields: [ownerId], references: [id], onDelete: Cascade) + actor User @relation("FeedActor", fields: [actorId], references: [id], onDelete: Cascade) + + @@index([ownerId, createdAt]) + @@map("feed_entries") +} + +// ============================================================================ +// CONTEXT: Analytics & Shared Kernel (append-only / platform) +// ============================================================================ + +model AnalyticsEvent { + id String @id @db.Uuid + type String + userId String? @db.Uuid + sessionId String? + properties Json? + occurredAt DateTime @default(now()) + + @@index([type, occurredAt]) + @@index([userId]) + @@map("analytics_events") +} + +model AuditLogEntry { + id String @id @db.Uuid + actorId String? @db.Uuid + action String + targetType String? + targetId String? + metadata Json? + ip String? + createdAt DateTime @default(now()) + + actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull) + + @@index([actorId, createdAt]) + @@index([action, createdAt]) + @@map("audit_log_entries") +} + +model MediaAsset { + id String @id @db.Uuid + ownerId String @db.Uuid + bucket String + key String @unique + mime String + size Int + width Int? + height Int? + status MediaStatus @default(PENDING) + createdAt DateTime @default(now()) + + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + + @@index([ownerId]) + @@map("media_assets") +} + +model FeatureFlag { + key String @id + enabled Boolean @default(false) + payload Json? + updatedAt DateTime @updatedAt + + @@map("feature_flags") +} + +model AppConfig { + id Int @id @default(1) // singleton row + version Int @default(1) + config Json // score weights, thresholds, rate limits + updatedAt DateTime @updatedAt + + @@map("app_config") +} diff --git a/clubscan/docs/02-database-design.md b/clubscan/docs/02-database-design.md new file mode 100644 index 0000000..ee66c7d --- /dev/null +++ b/clubscan/docs/02-database-design.md @@ -0,0 +1,206 @@ +# ClubScan — Phase 2: Database Design + +> **Status:** Phase 2 of 8 · **Depends on:** Phase 1 · **DB:** PostgreSQL 16 · **ORM:** Prisma +> +> Defines the relational model, ER diagram, indexing/partitioning strategy, and the canonical +> Prisma schema. The Prisma schema in `clubscan/backend/prisma/schema.prisma` is the +> machine-readable source of truth; this document is its rationale. + +--- + +## 1. Modeling Principles + +1. **Bounded contexts → schema regions.** Tables are grouped by the contexts from Phase 1. + In a modular monolith they share one Postgres database; module code only touches its own + tables through repository interfaces (no cross-module joins in app code — joins that cross + contexts happen in dedicated read models). +2. **UUID v7 primary keys** (`@db.Uuid`, app-generated, time-ordered) — globally unique, safe + to expose, index-friendly, shard-ready. Avoid leaking sequential counts. +3. **Soft deletes** (`deletedAt`) for user-generated content (reviews, profiles) to support + moderation, audit, and GDPR workflows; hard delete on data-protection request. +4. **Timestamps everywhere** (`createdAt`, `updatedAt`) with DB defaults. +5. **Money/scores never floated for storage of truth.** Ratings are small ints (1–5); + composite scores stored as `Decimal` in read models. +6. **Append-only tables** for `AuditLogEntry` and `AnalyticsEvent` (immutable, partitioned by + time at scale). +7. **JSONB for open-ended/extensible** attributes (social links, notification payloads, AI + verdict details) — but anything queried/filtered gets a real column + index. +8. **Geospatial:** store `latitude`/`longitude` as `Decimal`; add PostGIS `geography(Point)` + via migration for radius search (Prisma `Unsupported` type + raw SQL for `ST_DWithin`). +9. **Enums in DB** for closed sets (roles, statuses, venue types) for integrity + clarity. +10. **CQRS read models** (`VenueScore`, `FeedEntry`, search projections) are denormalized and + rebuilt from domain events — never the write-side source of truth. + +--- + +## 2. Entity-Relationship Diagram + +```mermaid +erDiagram + USER ||--|| PROFILE : has + USER ||--o{ SESSION : has + USER ||--o{ DEVICE : registers + USER ||--o{ OAUTH_ACCOUNT : links + USER ||--o{ REVIEW : writes + USER ||--o{ FOLLOW : "follower" + USER ||--o{ FOLLOW : "following" + USER ||--o{ SAVED_EVENT : saves + USER ||--o{ REPORT : files + USER ||--o{ INCIDENT_REPORT : files + USER ||--o{ SANCTION : "target of" + USER ||--o{ NOTIFICATION : receives + USER ||--o{ AUDIT_LOG_ENTRY : actor + + PROFILE ||--o{ SOCIAL_LINK : has + + VENUE ||--o{ VENUE_PHOTO : has + VENUE ||--o{ OPERATING_HOUR : has + VENUE ||--o{ VENUE_GENRE : tagged + GENRE ||--o{ VENUE_GENRE : tags + VENUE ||--o{ EVENT : hosts + VENUE ||--o{ REVIEW : receives + VENUE ||--|| VENUE_SCORE : "aggregate (read model)" + + EVENT ||--o{ EVENT_GENRE : tagged + GENRE ||--o{ EVENT_GENRE : tags + EVENT ||--o{ SAVED_EVENT : "saved as" + EVENT ||--o{ EVENT_PHOTO : has + + REVIEW ||--|| REVIEW_RATING : "category scores" + REVIEW ||--o{ REVIEW_PHOTO : has + REVIEW ||--o{ REVIEW_EDIT : "edit history" + REVIEW ||--o{ REVIEW_HELPFUL : "marked helpful" + REVIEW ||--o{ REPORT : "reported via" + REVIEW ||--o{ MODERATION_CASE : "moderated via" + + REPORT ||--|| MODERATION_CASE : "opens" + MODERATION_CASE ||--o{ SANCTION : "results in" + MODERATION_CASE ||--o{ AUDIT_LOG_ENTRY : "audited by" + + INCIDENT_REPORT ||--|| ESCALATION : triggers + + USER ||--o{ FEED_ENTRY : "feed of (read model)" +``` + +> A rendered PNG/SVG is generated in CI from this Mermaid block (Phase 5). The Prisma schema +> is authoritative for exact columns. + +--- + +## 3. Table Inventory by Context + +### 3.1 IAM +- **users** — auth identity: `email` (unique, citext), `passwordHash` (nullable for OAuth-only), + `emailVerifiedAt`, `role` (enum), `status` (enum: ACTIVE/SUSPENDED/BANNED/SHADOW_BANNED/DELETED), + `reputationScore` (denormalized cache from Reputation), timestamps, `deletedAt`. +- **oauth_accounts** — `provider` (GOOGLE/APPLE), `providerAccountId`, unique (provider, providerAccountId). +- **sessions** — refresh-token sessions: `refreshTokenHash`, `deviceId`, `expiresAt`, + `revokedAt`, `ip`, `userAgent`. Rotation: each refresh replaces the hash (reuse detection). +- **devices** — `pushToken` (FCM), `platform` (IOS/ANDROID), `lastSeenAt`, `name`. +- **email_verification_tokens**, **password_reset_tokens** — single-use, hashed, TTL. + +### 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). +- **social_links** — `platform`, `url`. +- **follows** — (`followerId`, `followingId`) composite unique; self-follow forbidden. + +### 3.3 Venue Catalog +- **venues** — `name`, `slug` (unique), `description`, `type` (enum CLUB/BAR/FESTIVAL/EVENT/LOUNGE), + address fields, `city`, `country`, `latitude`, `longitude`, `capacity`, `status` + (DRAFT/PUBLISHED/CLOSED), `coverPhotoUrl`. PostGIS point added via migration. +- **venue_photos**, **operating_hours** (per weekday open/close, supports overnight), + **genres** (master), **venue_genres** (M:N). +- **events** — belongs to venue: `title`, `description`, `startsAt`, `endsAt`, `lineup` (JSONB), + `ticketUrl`, `coverPhotoUrl`, `status`. **event_photos**, **event_genres**. +- **saved_events** — (`userId`, `eventId`) unique. + +### 3.4 Review & Scoring +- **reviews** — (`userId`, `venueId`) **unique** (one editable review per user/venue), + `body`, `status` (PENDING/PUBLISHED/HELD/REMOVED), `moderationStatus`, `language`, + `helpfulCount` (cache), `deletedAt`. +- **review_ratings** — 1:1 with review, the 9 category small-ints (1–5). +- **review_photos**, **review_edits** (immutable snapshots for edit history), + **review_helpful** ((`userId`,`reviewId`) unique). +- **venue_scores** *(CQRS read model)* — per venue: composite `score` (Decimal 0–100), + per-category weighted averages, `reviewCount`, `lastComputedAt`. Rebuilt from events. + +### 3.5 Moderation & Trust +- **reports** — polymorphic target (`targetType` enum REVIEW/USER/VENUE/EVENT, `targetId`), + `reason` (enum), `details`, `reporterId`, `status`. +- **moderation_cases** — opened from reports/AI flags: `state`, `assignedModeratorId`, + `aiVerdict` (JSONB), `resolution`, `resolvedAt`. +- **sanctions** — `targetUserId`, `type` (WARNING/TEMP_BAN/PERMA_BAN/SHADOW_BAN/CONTENT_REMOVAL), + `reason`, `expiresAt`, `issuedById`, `caseId`. + +### 3.6 Safety & Incidents +- **incident_reports** — `reporterId`, `venueId?`, `category` (HARASSMENT/VIOLENCE/ + DISCRIMINATION/UNSAFE_ENVIRONMENT/OTHER), `severity`, `description`, `occurredAt`, + `isAnonymous`, `state`. **Access-restricted** (moderation/safety roles only). +- **escalations** — `incidentId`, `level`, `slaDueAt`, `escalatedAt`, `handledById`, `outcome`. + +### 3.7 Notifications & Feed +- **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. + +### 3.8 Analytics & Shared Kernel +- **analytics_events** *(append-only, time-partitioned)* — `type`, `userId?`, `sessionId`, + `properties` (JSONB), `occurredAt`. Source for CTR/engagement/retention. +- **audit_log_entries** *(append-only)* — `actorId`, `action`, `targetType`, `targetId`, + `metadata` (JSONB), `ip`, `createdAt`. Written for every privileged action. +- **media_assets** — `ownerId`, `bucket`, `key`, `mime`, `size`, `status` (PENDING/READY), + `width`, `height`. Photos reference assets; direct-to-S3 presigned uploads. +- **feature_flags**, **app_config** (score weights, thresholds — single JSONB row, versioned). + +--- + +## 4. Indexing & Performance Strategy + +| 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 | +| 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)` | +| One review per user/venue | unique (`userId`,`venueId`) | +| Follows graph | unique (`followerId`,`followingId`); index both directions | +| Sessions lookup | index `sessions(userId)`, unique active `refreshTokenHash` | +| Reports queue | index `moderation_cases(state, createdAt)` | +| Append-only growth | range partition `analytics_events`/`audit_log_entries` by month at scale | +| Hot reads | Redis cache for venue detail + `venue_scores`, sessions, rate-limit counters | + +--- + +## 5. Data Integrity & Lifecycle + +- **Referential integrity** via FKs; `ON DELETE` chosen per relation (e.g. cascade + review_ratings with review; restrict venue delete if it has events). +- **Soft delete** for reviews/profiles → excluded from public reads via repository default + scope; retained for audit; purged on GDPR/KVKK erasure with audit entry. +- **Score recompute** is idempotent and event-driven; `venue_scores.lastComputedAt` guards + stale writes. +- **Token hygiene:** verification/reset/refresh tokens stored hashed (never plaintext), TTL'd, + single-use; refresh rotation with reuse detection revokes the whole session family. + +--- + +## 6. Migration & Seed Strategy + +- Prisma Migrate for schema; PostGIS + extensions (`citext`, `pg_trgm`, `uuid`/pgcrypto) added + in an initial SQL migration. +- `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), + a super-admin (env-driven), demo venues/events for dev only. + +--- + +## 7. Prisma Schema + +The canonical schema is committed at **`clubscan/backend/prisma/schema.prisma`** (this phase). +It implements every table above with enums, relations, indexes, and CQRS read models. + +*End of Phase 2. Proceeding to Phase 3: Backend Architecture.* From 45ad7b37b8609944e550de9354a8a0a822e0030d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 19:42:50 +0000 Subject: [PATCH 3/8] =?UTF-8?q?docs(clubscan):=20Phases=203-7=20=E2=80=94?= =?UTF-8?q?=20backend,=20frontend,=20infra,=20security,=20folder=20structu?= =?UTF-8?q?res?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- clubscan/docs/03-backend-architecture.md | 266 ++++++++++++++++++ clubscan/docs/04-frontend-architecture.md | 130 +++++++++ .../docs/05-infrastructure-architecture.md | 101 +++++++ clubscan/docs/06-security-architecture.md | 99 +++++++ clubscan/docs/07-folder-structures.md | 124 ++++++++ 5 files changed, 720 insertions(+) create mode 100644 clubscan/docs/03-backend-architecture.md create mode 100644 clubscan/docs/04-frontend-architecture.md create mode 100644 clubscan/docs/05-infrastructure-architecture.md create mode 100644 clubscan/docs/06-security-architecture.md create mode 100644 clubscan/docs/07-folder-structures.md diff --git a/clubscan/docs/03-backend-architecture.md b/clubscan/docs/03-backend-architecture.md new file mode 100644 index 0000000..7ef1820 --- /dev/null +++ b/clubscan/docs/03-backend-architecture.md @@ -0,0 +1,266 @@ +# ClubScan — Phase 3: Backend Architecture + +> **Status:** Phase 3 of 8 · **Stack:** NestJS · TypeScript · Prisma · PostgreSQL · Redis +> +> Defines module decomposition, layering, the REST API contract, DTO/validation rules, +> authorization policies, rate limiting, audit logging, the event bus, and the canonical +> scoring algorithm. Production code is scaffolded under `clubscan/backend/`. + +--- + +## 1. Layering (Clean Architecture, per module) + +Every NestJS module follows the same four-layer shape. Dependencies point inward only. + +``` +presentation/ controllers, guards, interceptors, request/response DTOs (HTTP edge) + │ depends on ▼ +application/ use cases (commands/queries), ports (interfaces), application DTOs, mappers + │ depends on ▼ +domain/ entities, value objects, domain services, domain events (zero framework deps) + ▲ implemented by +infrastructure/ Prisma repositories, S3/FCM/OAuth/AI adapters (implement application ports) +``` + +- **Domain** never imports `@nestjs/*` or `@prisma/client`. +- **Application** depends on **ports** (`*.repository.port.ts`, `*.gateway.port.ts`); never on + concrete infra. +- **Infrastructure** provides adapters; wired by Nest DI tokens in each module. +- **CQRS**: `Scoring`, `Search`, `Feed` use `@nestjs/cqrs` command/query buses; simple CRUD + modules keep services directly. + +## 2. Module Map (modular monolith) + +| Module | Bounded context | Key providers | +|---|---|---| +| `AuthModule` | IAM | AuthService, TokenService, OAuth strategies, guards | +| `UsersModule` | IAM | UserRepository, account lifecycle | +| `ProfilesModule` | Profile & Reputation | ProfileService, FollowService, ReputationService | +| `VenuesModule` | Venue Catalog | VenueService, VenueRepository (raw geo SQL) | +| `EventsModule` | Venue Catalog | EventService, SavedEventService | +| `ReviewsModule` | Review & Scoring | ReviewService (commands) | +| `ScoringModule` | Review & Scoring | ScoreCalculator (domain), VenueScore read model (CQRS) | +| `SearchModule` | Discovery & Search | SearchService (Postgres FTS/trgm/geo), DiscoveryService | +| `ModerationModule` | Moderation & Trust | ReportService, ModerationCaseService, SanctionService, AI port | +| `SafetyModule` | Safety & Incidents | IncidentService, EscalationService (state machine) | +| `NotificationsModule` | Notifications & Feed | NotificationService, FeedService, FcmGateway | +| `AnalyticsModule` | Analytics | AnalyticsIngestService, metrics queries | +| `MediaModule` | Shared Kernel | S3 presign service, MediaAsset lifecycle | +| `AdminModule` | cross | admin/moderator dashboards composition | +| **Platform modules** | — | `PrismaModule`, `RedisModule`, `EventBusModule`, `AuditModule`, `ConfigModule`, `I18nModule`, `HealthModule`, `ObservabilityModule` | + +## 3. Cross-Cutting Concerns (global) + +- **ValidationPipe** (whitelist + forbidNonWhitelisted + transform) with Zod-backed DTOs + (`nestjs-zod`) — schemas shared in spirit with the mobile client (Phase 4). +- **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). +- **Filters:** `AllExceptionsFilter` → RFC7807 problem+json, Sentry capture, no stack leaks. +- **i18n:** `nestjs-i18n`, `Accept-Language` + user.locale; error messages translated (EN/TR). + +## 4. Authentication & Token Strategy + +- **Access token (JWT):** short-lived (15 min), `sub`, `role`, `status`, `sessionId`; signed + RS256 (key rotation via JWKS). Stateless verification. +- **Refresh token:** opaque random 256-bit, **stored hashed** in `sessions`, long-lived + (30 days), **rotated on every use**; `familyId` enables reuse detection → revoke family. +- **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. +- **Device & session mgmt:** `/me/sessions` (list/revoke), `/me/devices` (register FCM token, + revoke). Refresh binds to `deviceId`. + +## 5. Authorization Model + +- **RBAC** via `@Roles(UserRole.MODERATOR, ...)` + `RolesGuard`. +- **ABAC/ownership** via `PoliciesGuard` + policy handlers, e.g. `CanEditReviewPolicy` + (author or moderator), `CanViewIncidentPolicy` (safety/moderation roles only). +- **Role capability matrix** is the Phase 1 §10 table, enforced here. +- **Shadow ban:** writes succeed for the banned user (they see normal UX) but content carries + `status=SHADOW_*`/author shadow-banned → excluded from other users' query scopes. + +## 6. Rate Limiting (Redis-backed `@nestjs/throttler`) + +| Bucket | Limit | +|---|---| +| Global default | 100 req / 60s / IP+user | +| `/auth/login`, `/auth/forgot-password` | 5 / 15 min / IP (brute-force guard) | +| `/auth/register` | 3 / hour / IP | +| Review create | 5 / hour / user (velocity/abuse guard) | +| Report/Incident create | 10 / hour / user | +| Media presign | 30 / hour / user | +| Search | 60 / min / user | + +Exceeding → `429` with `Retry-After`. Distributed counters in Redis so it works across +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. + +## 8. Event Bus & CQRS Flow + +- `EventBusPort` abstraction; v1 adapter = `@nestjs/cqrs` event bus / `EventEmitter2` + (in-process). Swappable to Redis Streams/SQS/Kafka behind the same port. +- Domain events: `UserRegistered`, `ReviewPublished`, `ReviewRemoved`, `ReviewEdited`, + `UserFollowed`, `EventSaved`, `ReportFiled`, `IncidentSubmitted`, `SanctionIssued`. +- **Reactors** (async handlers): score recompute, search reindex, feed fan-out, notification + dispatch (+FCM), analytics ingest, reputation update. +- Write path stays fast & transactional; projections update eventually (seconds). + +## 9. REST API Contract (v1, prefix `/api/v1`) + +> JSON, cursor pagination (`?cursor=&limit=`), `Authorization: Bearer `. +> All list endpoints return `{ data: [...], nextCursor, total? }`. Errors are RFC7807. + +### Auth +``` +POST /auth/register {email,password,username} +POST /auth/verify-email {token} +POST /auth/login {email,password} -> {accessToken,refreshToken,user} +POST /auth/refresh {refreshToken} -> rotated pair +POST /auth/logout {refreshToken} +POST /auth/logout-all +POST /auth/forgot-password {email} +POST /auth/reset-password {token,password} +POST /auth/oauth/google {idToken[,username]} +POST /auth/oauth/apple {identityToken[,username]} +``` +### Me / Profiles / Social +``` +GET /me current user + profile +PATCH /me/profile {displayName?,bio?,avatarUrl?,isPrivate?,socialLinks?} +GET /me/sessions / DELETE /me/sessions/:id +GET /me/devices / POST /me/devices {platform,pushToken} / DELETE /me/devices/:id +GET /users/:username public profile + reputation +POST /users/:id/follow / DELETE /users/:id/follow +GET /users/:id/followers / GET /users/:id/following +GET /me/feed activity feed (cursor) +``` +### Venues / Events / Discovery +``` +GET /venues ?city&type&genre&q&near=lat,lng&radius&sort&cursor +GET /venues/:slug detail + score + photos + hours + genres +GET /venues/:id/reviews ?sort=recent|helpful&cursor +GET /venues/:id/events +GET /events ?city&genre&from&to&near&cursor (discovery + filters) +GET /events/:id +POST /me/saved-events/:eventId / DELETE /me/saved-events/:eventId +GET /me/saved-events +GET /events/:id/share -> deep link payload +``` +### Reviews +``` +POST /venues/:id/reviews {ratings{...9},body,photoAssetIds?} +PATCH /reviews/:id (author; creates ReviewEdit snapshot) +DELETE /reviews/:id (author soft-delete or moderator) +POST /reviews/:id/helpful / DELETE /reviews/:id/helpful +GET /reviews/:id +``` +### Search +``` +GET /search?q=&type=venue|event|user|city|genre&near&cursor +``` +### Moderation (MODERATOR+) +``` +POST /reports {targetType,targetId,reason,details?} (any user) +GET /moderation/queue ?state&cursor +GET /moderation/cases/:id +PATCH /moderation/cases/:id {state,resolution?,assignTo?} +POST /moderation/cases/:id/sanction {type,reason,expiresAt?} +POST /admin/users/:id/ban|shadow-ban|unban (ADMIN+) +POST /admin/users/:id/role {role} (SUPER_ADMIN) +``` +### Safety (restricted) +``` +POST /safety/incidents {category,severity,venueId?,description,occurredAt?,isAnonymous?} +GET /safety/incidents (SAFETY/MOD roles) ?state&severity&cursor +PATCH /safety/incidents/:id state transitions (escalation workflow) +``` +### Media / Notifications / Analytics +``` +POST /media/presign {mime,size} -> {uploadUrl,assetId,key} +POST /media/:assetId/complete finalize (sets READY) +GET /me/notifications / POST /me/notifications/:id/read / POST /me/notifications/read-all +POST /analytics/events {events:[{type,properties,occurredAt}]} (batched, beacon) +GET /admin/analytics/* dashboards (ADMIN+) +``` +### Platform +``` +GET /health / GET /health/ready (liveness/readiness) +GET /config/public public feature flags + i18n bootstrap +``` + +## 10. DTO & Validation Rules (highlights, Zod-backed) + +- **email**: RFC + lowercase normalize; **password**: ≥10 chars, upper+lower+digit, zxcvbn + strength gate; **username**: `^[a-z0-9_]{3,20}$`, profanity + reserved-word check. +- **ratings**: each int 1–5, all 9 required; **body**: 10–4000 chars, trimmed, control-char + stripped; **photoAssetIds**: ≤8, must belong to caller and be `READY`. +- **geo**: lat ∈ [-90,90], lng ∈ [-180,180], radius ≤ 50km. +- **pagination**: limit ≤ 50; cursor is opaque base64 of `(createdAt,id)`. +- **enums** validated against Prisma enums; unknown fields rejected (whitelist). +- All free text passes sanitization (strip HTML; store as plain text) — XSS defense in depth. + +## 11. Canonical Scoring Algorithm (domain service `ScoreCalculator`) + +Pure, deterministic, framework-free. Inputs: published, non-shadow-banned reviews for a venue. + +``` +For each review i with rating vector r_i (9 categories, 1..5): + reviewWeight_i = recencyWeight_i * reputationWeight_i + recencyWeight_i = 0.5 ^ (ageDays_i / HALF_LIFE_DAYS) // default HALF_LIFE=180 + reputationWeight_i = clamp(1 + log10(1 + reputation_i)/REP_K, 1, REP_CAP) // default cap 2.0 + +For each category c: + weightedAvg_c = Σ_i (reviewWeight_i * r_i,c) / Σ_i reviewWeight_i // in [1..5] + +Composite (raw, 1..5): + effectiveN = Σ_i reviewWeight_i + rawComposite = Σ_c (CATEGORY_WEIGHT_c * weightedAvg_c) // weights sum to 1 + +Bayesian shrinkage toward global mean μ (over all venues), prior strength m (default 8): + shrunk = (effectiveN * rawComposite + m * μ) / (effectiveN + m) + +Scale to 0..100: + score = round( (shrunk - 1) / 4 * 100, 2 ) + +Safety advisory: + safetyAdvisory = (avgSafetyForWomen < 2.5 && reviewCount >= MIN_N) + || (openIncidentCount(venue) >= INCIDENT_THRESHOLD) +``` + +- `CATEGORY_WEIGHT_c`, `HALF_LIFE_DAYS`, `m`, `REP_K`, `REP_CAP`, thresholds come from + `AppConfig` (Phase 1 §4.2 defaults) — **config, not code**. +- Output persisted to `venue_scores` read model; recompute triggered by review events + (debounced per venue). Idempotent; guarded by `lastComputedAt`. +- Excludes: PENDING/HELD/REMOVED reviews, shadow-banned authors, soft-deleted. + +## 12. Error Model (RFC 7807) + +``` +{ "type":"https://clubscan.app/errors/validation", + "title":"Validation failed","status":422, + "detail":"...", "instance":"/api/v1/...","errors":[{path,message}] } +``` +Codes: 400 malformed, 401 unauthenticated, 403 forbidden, 404, 409 conflict (dup review/ +username), 422 validation, 429 rate-limited, 500 internal (no leakage; Sentry id returned). + +## 13. Health, Config, Observability + +- `/health` liveness; `/health/ready` checks DB + Redis. +- OpenTelemetry SDK auto-instruments HTTP/Prisma/Redis → OTLP collector; trace id on every log + and error response. Sentry for exceptions with release + user (id only) context. + +--- + +*End of Phase 3. Production NestJS code is scaffolded under `clubscan/backend/src` (Phase 8), +starting with platform modules + Auth + Users + Profiles + Venues + Reviews/Scoring.* diff --git a/clubscan/docs/04-frontend-architecture.md b/clubscan/docs/04-frontend-architecture.md new file mode 100644 index 0000000..b9e5da2 --- /dev/null +++ b/clubscan/docs/04-frontend-architecture.md @@ -0,0 +1,130 @@ +# ClubScan — Phase 4: Frontend Architecture (Mobile) + +> **Status:** Phase 4 of 8 · **Stack:** Expo (RN) · TS · Expo Router · TanStack Query · +> Zustand · React Hook Form + Zod · NativeWind · Sentry +> +> Defines the design system, theme, navigation, state architecture, and API client. Code is +> scaffolded under `clubscan/mobile/`. + +--- + +## 1. App Architecture Principles + +- **Feature-first foldering** mirroring backend contexts (`features/auth`, `features/venues`, + `features/reviews`, `features/discovery`, `features/profile`, `features/safety`, + `features/notifications`). +- **Server state ≠ client state.** TanStack Query owns all server data (caching, retries, + pagination, optimistic updates). Zustand owns ephemeral/global UI + session state only. +- **Schema-driven.** Zod schemas validate forms (RHF resolver) and parse API responses — + mirroring backend DTO rules so the contract is enforced on both ends. +- **Typed API client** generated around the Phase 3 REST contract; one `apiClient` with auth + refresh interceptor. +- **Dark-first** premium nightlife aesthetic; full light mode; system-driven with override. +- **Offline-tolerant reads** via Query persistence; optimistic writes for follow/save/helpful. + +## 2. Design System & Theme + +### 2.1 Tokens (NativeWind / Tailwind config) +- **Color (dark default):** `bg.base #0A0A0F`, `bg.elevated #14141C`, `surface #1C1C28`, + `border #2A2A38`, `text.primary #F5F5F7`, `text.muted #A1A1B5`. + **Brand:** `primary #7C5CFC` (electric violet), `accent #FF4D8D` (neon pink), + `success #2ED573`, `warn #FFB020`, `danger #FF4757`, `safety #36C5F0`. + Light mode is a token remap, not a separate component set. +- **Score color scale:** red→amber→green ramp for 0–100 (the ClubScan Score ring). +- **Typography:** display/headline/title/body/label scale; system font + optional `Satoshi`/ + `Inter` via expo-font; dynamic-type aware. +- **Spacing:** 4-pt base scale (`1=4px … 8=32px`); radii `sm 8 / md 12 / lg 20 / pill 999`. +- **Elevation:** layered surfaces + subtle glow on primary CTAs (nightlife feel). +- **Motion:** `react-native-reanimated`; 150–250ms ease; press scale, shared-element venue + transitions, skeleton shimmer. + +### 2.2 Theme system +- `ThemeProvider` exposes tokens via context + NativeWind `dark:` variants. +- `useColorScheme()` + persisted user override (Zustand `uiStore`, AsyncStorage). +- All components consume semantic tokens (never raw hex). + +### 2.3 Component library (`src/components/ui`) +Primitives: `Button`, `IconButton`, `Input`, `TextArea`, `Select`, `Chip`, `Badge`, `Avatar`, +`Card`, `Sheet` (bottom sheet), `Modal`, `Toast`, `Skeleton`, `Tabs`, `SegmentedControl`, +`RatingStars`, `ScoreRing` (animated composite score), `CategoryBar` (per-category score), +`EmptyState`, `Spinner`, `Divider`, `ListItem`, `SearchBar`, `FilterSheet`, `MapPreview` +(Google Maps), `ImageGallery`, `PhotoPicker`. All themed, accessible (labels, hit slop, +contrast), RTL-safe. + +Composite domain components: `VenueCard`, `VenueHeader`, `EventCard`, `ReviewCard`, +`ReviewComposer`, `UserCard`, `FeedItem`, `NotificationItem`, `ReportSheet`, +`SafetyReportForm`. + +## 3. Navigation (Expo Router, file-based) + +``` +app/ + _layout.tsx root: providers, theme, splash, auth gate + (auth)/ unauthenticated stack + welcome.tsx login.tsx register.tsx verify-email.tsx + forgot-password.tsx reset-password.tsx choose-username.tsx + (tabs)/ authenticated tab bar + _layout.tsx Discover | Search | Create | Activity | Profile + index.tsx Discover (home: near me, events, top venues) + search.tsx + create.tsx review composer entry + activity.tsx feed + notifications + profile.tsx current user + venue/[slug].tsx venue detail (score, reviews, events, map) + event/[id].tsx event detail + user/[username].tsx public profile + review/[id].tsx review detail / edit + settings/ account, sessions, devices, language, theme + safety/report.tsx safety incident form + +not-found.tsx +``` +- Auth gate in root layout: redirect to `(auth)` if no valid session; deep links + (`clubscan://venue/...`, universal links) resolve to detail screens (share feature). + +## 4. State Architecture + +### 4.1 Server state — TanStack Query +- Query keys namespaced: `['venues',params]`, `['venue',slug]`, `['venue',id,'reviews']`, + `['feed']`, `['me']`, `['notifications']`, etc. +- Infinite queries for lists (cursor pagination from API). +- Mutations with optimistic updates + rollback for: follow/unfollow, save event, mark helpful, + read notification. Invalidate venue + score after review submit. +- Persisted cache (AsyncStorage) for offline reads; staleTime tuned per resource. + +### 4.2 Client state — Zustand stores +- `authStore`: tokens (SecureStore), current user snapshot, hydration state, login/logout. +- `uiStore`: theme override, locale, onboarding flags, active filters. +- `composerStore`: in-progress review draft (ratings, body, photos) — survives navigation. +- No server data duplicated into Zustand. + +### 4.3 Forms — RHF + Zod +- `zodResolver` per form; schemas in `features/*/schema.ts` mirror backend validation. +- Inline field errors, submit disabled until valid, server error mapping to fields. + +## 5. API Client Architecture + +- `src/lib/api/client.ts`: typed fetch wrapper (or axios) with: + - base URL from env, JSON, `Authorization` header from `authStore`, + - **401 interceptor**: single-flight refresh via `/auth/refresh`, queue + retry, logout on + refresh failure, + - request id propagation, Zod response parsing, RFC7807 error normalization to `ApiError`. +- `src/lib/api/endpoints/*`: one module per context returning typed functions used by Query + hooks (`features/*/queries.ts`, `features/*/mutations.ts`). +- Analytics beacon batches events to `/analytics/events`. + +## 6. Internationalization +- `i18next` + `expo-localization`; namespaces per feature; EN + TR catalogs in `src/i18n`. +- Locale persisted (uiStore) and synced to backend (`user.locale`); date/number via `Intl`. + +## 7. Maps & Media +- `react-native-maps` (Google provider) for venue map + "near me" discovery; clustering. +- Photo upload flow: pick → request `/media/presign` → PUT to S3 → `/media/:id/complete` → + attach `assetId` to review. Image compression client-side before upload. + +## 8. Quality & DX +- TS strict; ESLint + Prettier; absolute imports (`@/`); Jest + React Native Testing Library + for components/hooks; Detox (later) for E2E; Sentry RN SDK; EAS Build/Update for OTA. + +--- + +*End of Phase 4.* diff --git a/clubscan/docs/05-infrastructure-architecture.md b/clubscan/docs/05-infrastructure-architecture.md new file mode 100644 index 0000000..ae9d765 --- /dev/null +++ b/clubscan/docs/05-infrastructure-architecture.md @@ -0,0 +1,101 @@ +# ClubScan — Phase 5: Infrastructure Architecture + +> **Status:** Phase 5 of 8 · Docker · GitHub Actions CI/CD · Sentry · OpenTelemetry +> +> Defines environments, containerization, CI/CD pipelines, deployment topology, observability, +> backups, and scaling. Concrete files: `clubscan/backend/Dockerfile`, +> `clubscan/docker-compose.yml`, `.github/workflows/clubscan-*.yml`. + +--- + +## 1. Environments + +| Env | Purpose | Data | +|---|---|---| +| `local` | Dev via docker-compose (Postgres+PostGIS, Redis, MinIO=S3, Mailhog) | seeded demo | +| `ci` | Ephemeral test DB/Redis in Actions | throwaway | +| `staging` | Pre-prod mirror, OTA preview channel | anonymized | +| `production` | Live | real, backed up | + +Config strictly via env vars (12-factor); secrets via the platform secret manager (never in +repo). `.env.example` documents every key. + +## 2. Containerization + +- **Backend:** multi-stage Dockerfile — `deps` → `build` (tsc + prisma generate) → + `runtime` (distroless/node-slim, non-root user, only prod deps, `prisma migrate deploy` on + start via entrypoint). Healthcheck hits `/health/ready`. +- **docker-compose (local):** `api`, `postgres` (postgis/postgis:16), `redis`, + `minio` + `createbuckets`, `mailhog`, `otel-collector`. Named volumes; hot reload via + bind mount in dev compose. +- **Mobile:** not containerized; built via **EAS Build**; OTA via **EAS Update** channels + (staging/production). + +## 3. CI/CD (GitHub Actions) + +### `clubscan-backend-ci.yml` (PRs + main) +1. Setup Node, cache pnpm. 2. `pnpm install --frozen-lockfile`. 3. `prisma generate`. +4. Lint + typecheck. 5. Spin Postgres+Redis services → `prisma migrate deploy` → unit + + integration tests (`vitest`/`jest`) with coverage gate. 6. `docker build` (no push on PR). +7. Build & push image to registry on `main`/tags. + +### `clubscan-backend-cd.yml` (main / tag) +- On green CI: deploy image to **staging** → run `prisma migrate deploy` (job) → smoke test + `/health` → manual approval → **production** rollout (rolling, health-gated) → Sentry + release + sourcemaps + OTel deploy marker. + +### `clubscan-mobile-ci.yml` +- Typecheck, lint, unit tests, `expo-doctor`, Zod/EAS config validation. Tagged release → + `eas build` + `eas update` to the right channel; upload RN sourcemaps to Sentry. + +### `clubscan-db-diagram.yml` +- Render the Mermaid ER diagram + Prisma ERD to SVG artifact on schema change. + +## 4. Deployment Topology (production, default = AWS-style; portable) + +``` +Route53/DNS → CloudFront(CDN, static+media) + WAF + → ALB (TLS term, HTTP/2) → ECS Fargate service (NestJS, N replicas, autoscale) + │ + ┌──────────────┬───────────┼───────────────┬──────────────┐ + ▼ ▼ ▼ ▼ ▼ + RDS Postgres ElastiCache S3 (media, Secrets Mgr OTel Collector + (Multi-AZ + Redis private + / SSM → Tempo/Prom/Loki + read replicas) (cache, presigned; (or Grafana Cloud) + throttler, CDN-fronted) + queues) +``` +- Stateless API → horizontal autoscale on CPU/RPS. Migrations run as a pre-deploy job, not in + every container, to avoid races (entrypoint guards with advisory lock). +- Portable: the same containers run on Fly.io/Render/GKE; managed Postgres/Redis/S3-compatible + swap in. MinIO locally = S3 in prod (same SDK). + +## 5. Observability + +- **OpenTelemetry:** traces (HTTP, Prisma, Redis, outbound), metrics (RED + business: reviews + created, score recomputes, report queue depth), logs correlated by trace id → OTLP collector. +- **Sentry:** backend (errors + performance) and mobile (crashes + JS errors), release health, + sourcemaps in CI. Alert rules → on-call. +- **Dashboards/SLOs:** API availability 99.9%, p95 latency, error rate, queue lag, DB + connections, cache hit ratio. Synthetic `/health` checks + uptime alerts. + +## 6. Data, Backups, DR + +- RDS automated daily snapshots + PITR (≥7d); periodic restore drills. S3 versioning + + lifecycle (cold-tier old media). Redis is cache/ephemeral (rebuildable; not source of truth). +- **RPO ≤ 15 min, RTO ≤ 1 h** target via Multi-AZ + PITR. Migrations are forward-only + + reversible-where-safe; expand/contract pattern for zero-downtime schema changes. + +## 7. Secrets & Config + +- No secrets in git; injected at runtime (Secrets Manager/SSM/Actions secrets). Rotation for + DB creds and JWT signing keys (JWKS rotation). Separate keys per environment. + +## 8. Cost & Scale Levers +- CDN caching of venue pages + media; Redis read-through for venue detail/scores; read + replicas for discovery; partition append-only tables monthly; extract Search/Analytics to + dedicated stores when load dictates (ports already abstracted in Phase 3). + +--- + +*End of Phase 5.* diff --git a/clubscan/docs/06-security-architecture.md b/clubscan/docs/06-security-architecture.md new file mode 100644 index 0000000..d578002 --- /dev/null +++ b/clubscan/docs/06-security-architecture.md @@ -0,0 +1,99 @@ +# ClubScan — Phase 6: Security Architecture + +> **Status:** Phase 6 of 8 · Target: OWASP ASVS L2, OWASP Top 10 coverage, OWASP MASVS (mobile) +> +> Threat model, controls, authz policies, secure uploads, privacy/compliance, and the audit +> model. Security is a cross-cutting concern realized in Phase 3 guards/interceptors and Phase +> 5 infra. + +--- + +## 1. Threat Model (STRIDE, key risks) + +| Threat | Vector | Mitigation | +|---|---|---| +| **Spoofing** | Stolen tokens, OAuth replay | Short JWT TTL, hashed rotating refresh + reuse detection, server-side OAuth token verification, 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 | +| **Denial of service** | Brute force, scraping, review spam | Redis rate limiting (tiered), velocity checks, WAF, pagination caps, query timeouts | +| **Elevation of privilege** | Role escalation, JWT forgery | RS256 + JWKS, role in token validated against DB status, SUPER_ADMIN-only role changes, audited | + +Abuse-specific: review brigading → Bayesian shrinkage + reputation weighting + rate limits + +one-review-per-venue + AI/human moderation. Fake venues → curated/admin publish workflow. + +## 2. OWASP Top 10 Controls + +1. **Broken Access Control** — global `JwtAuthGuard` + `RolesGuard` + `PoliciesGuard`; + default-deny; ownership checks; shadow-ban scoping; no client-trusted authz. +2. **Cryptographic Failures** — TLS everywhere; Argon2id password hashing; tokens hashed + (SHA-256) at rest; secrets in secret manager; no sensitive data in logs. +3. **Injection** — Prisma parameterized queries; raw geo SQL uses bound params; input + validation; output is JSON (no template injection); HTML stripped from user text. +4. **Insecure Design** — threat-modeled flows, safety pipeline isolation, least privilege, + defense in depth. +5. **Security Misconfiguration** — Helmet headers, CORS allowlist, disabled `x-powered-by`, + non-root container, minimal base image, env-separated config, no default creds. +6. **Vulnerable Components** — `pnpm audit`/Dependabot in CI, pinned lockfile, base image + scanning. +7. **Identification & Auth Failures** — strong password policy + zxcvbn, email verification, + lockout/throttle on login & reset, secure session/device management, refresh rotation. +8. **Software & Data Integrity** — signed artifacts, CI provenance, OTA updates signed (EAS), + migration integrity (advisory-lock guarded). +9. **Logging & Monitoring Failures** — audit log + OTel + Sentry; alerting on auth anomalies + and report-queue spikes. +10. **SSRF** — no user-supplied URLs fetched server-side except validated OAuth endpoints; + media via presigned PUT (client→S3), backend never proxies arbitrary URLs. + +## 3. HTTP Hardening +- **Helmet**: HSTS, `X-Content-Type-Options`, `Referrer-Policy`, frame-deny, CSP for any web + surface. **CORS**: explicit origin allowlist, credentials off (bearer tokens). +- **CSRF**: API is token-based (Authorization header, not cookies) → CSRF not applicable to + the mobile bearer flow; any cookie-based web admin uses SameSite=strict + CSRF tokens. +- **XSS**: server stores plain text (HTML stripped); mobile renders text (no HTML injection); + any web admin escapes + CSP. + +## 4. Authorization Policies (enumerated) +- `CanEditReview`: actor is review author (and not banned) OR role ≥ MODERATOR. +- `CanDeleteReview`: author OR role ≥ MODERATOR (moderator delete = audited removal). +- `CanViewIncident` / `CanTransitionIncident`: role ∈ {MODERATOR, ADMIN, SUPER_ADMIN}. +- `CanIssueSanction`: WARNING/CONTENT_REMOVAL ≥ MODERATOR; bans ≥ ADMIN; role change = + SUPER_ADMIN only. All audited. +- `CanManageVenue`: ADMIN+ (v1; owner-claim deferred). +- `CanViewSession/Device`: owner only. + +## 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). +- 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` + assets owned by caller can attach to content. Private bucket; access via CDN signed URLs. + +## 6. Privacy & Compliance (GDPR / KVKK) +- **Lawful basis + consent** for analytics; PII-minimized events. +- **Data subject rights:** export (`/me/export`, async) and erasure (hard-delete + anonymize + authored content, audited). Soft-delete first, purge on request. +- **Data minimization:** safety reports support anonymity; reporter identity never exposed to + targets; access logged. +- **Retention:** audit/analytics retention windows; token tables auto-expire; EXIF/GPS + 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. + +## 8. Mobile (MASVS) Notes +- Tokens in **SecureStore/Keychain** (not AsyncStorage); certificate pinning option; + jailbreak/root awareness for sensitive flows; no secrets bundled (only public config); + OTA bundles signed by EAS. + +## 9. Secrets & Key Management +- JWT signing via rotating RS256 keys (JWKS); per-env keys; refresh tokens single-use rotated; + DB/Redis/S3 creds least-privilege and rotated; nothing secret in the repo (enforced by + secret scanning in CI). + +--- + +*End of Phase 6.* diff --git a/clubscan/docs/07-folder-structures.md b/clubscan/docs/07-folder-structures.md new file mode 100644 index 0000000..7529c4a --- /dev/null +++ b/clubscan/docs/07-folder-structures.md @@ -0,0 +1,124 @@ +# ClubScan — Phase 7: Folder Structures + +> **Status:** Phase 7 of 8 · The definitive directory layout for backend and mobile. +> Phase 8 code is generated into these trees. + +--- + +## 1. Monorepo Root (`clubscan/`) + +``` +clubscan/ +├── docs/ # phases 1–7 (this set) +├── backend/ # NestJS API (modular monolith) +├── mobile/ # Expo React Native app +├── docker-compose.yml # local dev stack +├── .env.example +└── README.md +``` + +## 2. Backend (`clubscan/backend/`) — Clean Architecture per module + +``` +backend/ +├── prisma/ +│ ├── schema.prisma # canonical schema (Phase 2) +│ ├── migrations/ # incl. raw SQL for PostGIS + extensions +│ └── seed.ts # genres, app_config, super-admin, dev demo data +├── src/ +│ ├── main.ts # bootstrap: helmet, CORS, validation pipe, OTel, Sentry +│ ├── app.module.ts # composition root (imports all feature + platform modules) +│ │ +│ ├── platform/ # cross-cutting infrastructure (Shared Kernel) +│ │ ├── prisma/ # PrismaModule + PrismaService +│ │ ├── redis/ # RedisModule (cache, throttler store) +│ │ ├── config/ # typed ConfigModule, AppConfig loader, feature flags +│ │ ├── event-bus/ # EventBusPort + in-process adapter (swappable) +│ │ ├── audit/ # AuditModule, @Audit() interceptor +│ │ ├── i18n/ # nestjs-i18n setup + en/tr catalogs +│ │ ├── observability/ # OTel + Sentry init, LoggingInterceptor +│ │ ├── http/ # AllExceptionsFilter, response/serializer interceptor +│ │ ├── security/ # guards: JwtAuthGuard, RolesGuard, PoliciesGuard, throttler +│ │ └── pagination/ # cursor helpers, base DTOs +│ │ +│ ├── shared/ # framework-agnostic shared kernel +│ │ ├── domain/ # base Entity, AggregateRoot, ValueObject, DomainEvent, Result +│ │ ├── ids/ # uuidv7 generator, branded id types +│ │ └── errors/ # domain/app error types → mapped to RFC7807 +│ │ +│ └── modules/ # one folder per bounded context +│ ├── auth/ +│ │ ├── domain/ # Token VO, Credentials, policies +│ │ ├── application/ # use-cases: Register, Login, Refresh, OAuthLogin, ResetPwd +│ │ │ ├── ports/ # TokenServicePort, OAuthVerifierPort, MailerPort +│ │ │ └── dto/ # request/response DTOs (zod schemas) +│ │ ├── infrastructure/ # JwtTokenService, GoogleVerifier, AppleVerifier, Mailer +│ │ └── presentation/ # AuthController, strategies, guards wiring +│ ├── users/ # (same 4-layer shape) +│ ├── profiles/ # profile + follows + reputation +│ ├── venues/ # venue catalog (+ raw geo SQL repo) +│ ├── events/ +│ ├── reviews/ # review commands +│ ├── scoring/ # ScoreCalculator (domain) + VenueScore read model (CQRS) +│ ├── search/ # discovery + search (Postgres FTS/trgm/geo) behind a port +│ ├── moderation/ # reports, cases, sanctions, AI moderation port +│ ├── safety/ # incidents + escalation state machine +│ ├── notifications/ # notifications + feed fan-out + FcmGateway +│ ├── analytics/ # ingest + metrics queries +│ ├── media/ # S3 presign + MediaAsset lifecycle +│ └── admin/ # moderator/admin dashboard composition +│ +├── test/ # e2e + integration (vitest/jest + supertest) +├── Dockerfile +├── package.json tsconfig.json nest-cli.json .eslintrc vitest.config.ts +└── .env.example +``` + +**Module internal convention (every `modules/`):** +`domain/` (entities, VOs, domain services, events — no framework) → +`application/` (use cases, `ports/`, `dto/`, mappers) → +`infrastructure/` (Prisma repos implementing ports, external adapters) → +`presentation/` (controllers, guards/policies, swagger) → `.module.ts` (DI wiring). + +## 3. Mobile (`clubscan/mobile/`) — feature-first + +``` +mobile/ +├── app/ # Expo Router routes (Phase 4 §3 tree) +│ ├── _layout.tsx (auth)/ (tabs)/ venue/ event/ user/ review/ settings/ +│ ├── safety/ +not-found.tsx +├── src/ +│ ├── components/ +│ │ ├── ui/ # design-system primitives (Button, ScoreRing, ...) +│ │ └── domain/ # VenueCard, ReviewCard, FeedItem, ... +│ ├── features/ # mirrors backend contexts +│ │ ├── auth/ ├── profile/ ├── venues/ ├── events/ +│ │ ├── reviews/ ├── discovery/ ├── search/ ├── notifications/ +│ │ ├── feed/ └── safety/ +│ │ # each: queries.ts, mutations.ts, schema.ts (zod), components/, hooks/ +│ ├── lib/ +│ │ ├── api/ # client.ts (auth-refresh interceptor) + endpoints/ +│ │ ├── query/ # QueryClient, persistence, keys +│ │ └── analytics/ # event beacon +│ ├── stores/ # zustand: authStore, uiStore, composerStore +│ ├── theme/ # tokens, ThemeProvider, color scales +│ ├── i18n/ # i18next setup + en/ tr/ catalogs +│ ├── hooks/ # shared hooks (useColorScheme override, useDebounce, ...) +│ └── utils/ # formatters (Intl), geo, image compression +├── assets/ # fonts, icons, splash +├── app.json / app.config.ts # Expo + EAS config, deep links, plugins +├── tailwind.config.js # NativeWind tokens +├── babel.config.js metro.config.js tsconfig.json package.json +└── .env.example +``` + +## 4. Naming & Conventions +- Files: `kebab-case.ts`; classes `PascalCase`; DI tokens `UPPER_SNAKE` symbols. +- Ports suffixed `.port.ts`; adapters `.adapter.ts`/`.repository.ts`; use cases `*.use-case.ts` + or `*.service.ts`; DTOs `*.dto.ts` (+ zod `*.schema.ts`). +- Absolute imports: backend `@/modules/...`, `@/platform/...`; mobile `@/features/...`. +- Tests colocated `*.spec.ts` (unit) + `test/*.e2e-spec.ts`. + +--- + +*End of Phase 7. Phase 8 generates production code into these trees.* From 654ad010cb23fab0f684f5a482a58008292f7b55 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 19:46:20 +0000 Subject: [PATCH 4/8] feat(clubscan): backend platform layer, shared kernel, and scoring algorithm with tests --- clubscan/backend/nest-cli.json | 8 + clubscan/backend/package.json | 65 ++++++++ .../scoring/domain/score-calculator.spec.ts | 82 ++++++++++ .../scoring/domain/score-calculator.ts | 154 ++++++++++++++++++ .../src/platform/audit/audit.module.ts | 9 + .../src/platform/audit/audit.service.ts | 35 ++++ .../src/platform/config/configuration.ts | 43 +++++ .../src/platform/config/scoring-config.ts | 54 ++++++ .../platform/event-bus/event-bus.module.ts | 12 ++ .../src/platform/event-bus/event-bus.port.ts | 13 ++ .../event-bus/in-process-event-bus.ts | 24 +++ .../platform/http/all-exceptions.filter.ts | 102 ++++++++++++ .../backend/src/platform/pagination/cursor.ts | 55 +++++++ .../src/platform/prisma/prisma.module.ts | 9 + .../src/platform/prisma/prisma.service.ts | 25 +++ .../src/platform/security/auth.types.ts | 16 ++ .../src/platform/security/decorators.ts | 23 +++ .../src/platform/security/jwt-auth.guard.ts | 63 +++++++ .../src/platform/security/roles.guard.ts | 36 ++++ .../src/platform/security/security.module.ts | 23 +++ .../backend/src/shared/domain/domain-event.ts | 13 ++ .../backend/src/shared/errors/domain-error.ts | 57 +++++++ clubscan/backend/src/shared/ids/uuid.ts | 10 ++ clubscan/backend/tsconfig.json | 29 ++++ clubscan/backend/vitest.config.ts | 21 +++ 25 files changed, 981 insertions(+) create mode 100644 clubscan/backend/nest-cli.json create mode 100644 clubscan/backend/package.json create mode 100644 clubscan/backend/src/modules/scoring/domain/score-calculator.spec.ts create mode 100644 clubscan/backend/src/modules/scoring/domain/score-calculator.ts create mode 100644 clubscan/backend/src/platform/audit/audit.module.ts create mode 100644 clubscan/backend/src/platform/audit/audit.service.ts create mode 100644 clubscan/backend/src/platform/config/configuration.ts create mode 100644 clubscan/backend/src/platform/config/scoring-config.ts create mode 100644 clubscan/backend/src/platform/event-bus/event-bus.module.ts create mode 100644 clubscan/backend/src/platform/event-bus/event-bus.port.ts create mode 100644 clubscan/backend/src/platform/event-bus/in-process-event-bus.ts create mode 100644 clubscan/backend/src/platform/http/all-exceptions.filter.ts create mode 100644 clubscan/backend/src/platform/pagination/cursor.ts create mode 100644 clubscan/backend/src/platform/prisma/prisma.module.ts create mode 100644 clubscan/backend/src/platform/prisma/prisma.service.ts create mode 100644 clubscan/backend/src/platform/security/auth.types.ts create mode 100644 clubscan/backend/src/platform/security/decorators.ts create mode 100644 clubscan/backend/src/platform/security/jwt-auth.guard.ts create mode 100644 clubscan/backend/src/platform/security/roles.guard.ts create mode 100644 clubscan/backend/src/platform/security/security.module.ts create mode 100644 clubscan/backend/src/shared/domain/domain-event.ts create mode 100644 clubscan/backend/src/shared/errors/domain-error.ts create mode 100644 clubscan/backend/src/shared/ids/uuid.ts create mode 100644 clubscan/backend/tsconfig.json create mode 100644 clubscan/backend/vitest.config.ts diff --git a/clubscan/backend/nest-cli.json b/clubscan/backend/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/clubscan/backend/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/clubscan/backend/package.json b/clubscan/backend/package.json new file mode 100644 index 0000000..9812cfe --- /dev/null +++ b/clubscan/backend/package.json @@ -0,0 +1,65 @@ +{ + "name": "@clubscan/backend", + "version": "0.1.0", + "description": "ClubScan API — NestJS modular monolith", + "license": "UNLICENSED", + "private": true, + "scripts": { + "build": "nest build", + "start": "node dist/main.js", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "lint": "eslint \"src/**/*.ts\" --max-warnings 0", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "vitest run --config vitest.e2e.config.ts", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev", + "prisma:deploy": "prisma migrate deploy", + "prisma:seed": "tsx prisma/seed.ts" + }, + "prisma": { + "seed": "tsx prisma/seed.ts" + }, + "dependencies": { + "@nestjs/common": "^10.4.4", + "@nestjs/config": "^3.2.3", + "@nestjs/core": "^10.4.4", + "@nestjs/cqrs": "^10.2.7", + "@nestjs/event-emitter": "^2.1.1", + "@nestjs/jwt": "^10.2.0", + "@nestjs/platform-express": "^10.4.4", + "@nestjs/swagger": "^7.4.2", + "@nestjs/throttler": "^6.2.1", + "@prisma/client": "^5.20.0", + "argon2": "^0.41.1", + "axios": "^1.7.7", + "google-auth-library": "^9.14.1", + "helmet": "^8.0.0", + "ioredis": "^5.4.1", + "nestjs-i18n": "^10.4.9", + "nestjs-zod": "^3.0.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "uuid": "^10.0.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@nestjs/cli": "^10.4.5", + "@nestjs/testing": "^10.4.4", + "@types/express": "^4.17.21", + "@types/node": "^22.7.4", + "@types/uuid": "^10.0.0", + "@typescript-eslint/eslint-plugin": "^8.8.0", + "@typescript-eslint/parser": "^8.8.0", + "eslint": "^8.57.1", + "eslint-config-prettier": "^9.1.0", + "prettier": "^3.3.3", + "prisma": "^5.20.0", + "supertest": "^7.0.0", + "tsx": "^4.19.1", + "typescript": "^5.6.2", + "vitest": "^2.1.1" + } +} diff --git a/clubscan/backend/src/modules/scoring/domain/score-calculator.spec.ts b/clubscan/backend/src/modules/scoring/domain/score-calculator.spec.ts new file mode 100644 index 0000000..5e7b2e3 --- /dev/null +++ b/clubscan/backend/src/modules/scoring/domain/score-calculator.spec.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_SCORING_CONFIG } from '@/platform/config/scoring-config'; +import { CategoryRatings, ScoreCalculator, ScoreInputReview } from './score-calculator'; + +const calc = new ScoreCalculator(DEFAULT_SCORING_CONFIG); + +function ratings(value: number): CategoryRatings { + return { + security: value, + staffBehavior: value, + fairPricing: value, + crowdQuality: value, + musicQuality: value, + soundSystem: value, + cleanliness: value, + safetyForWomen: value, + atmosphere: value, + }; +} + +function review(value: number, overrides: Partial = {}): ScoreInputReview { + return { + ratings: ratings(value), + reputation: 0, + createdAt: new Date(), + ...overrides, + }; +} + +describe('ScoreCalculator', () => { + const now = new Date('2026-06-04T00:00:00Z'); + + it('returns 0 with no reviews', () => { + const result = calc.calculate([], 0, now); + expect(result.score).toBe(0); + expect(result.reviewCount).toBe(0); + }); + + it('shrinks a single perfect review toward the global mean', () => { + const result = calc.calculate([review(5, { createdAt: now })], 0, now); + // A lone 5/5 must not yield 100 due to Bayesian shrinkage. + expect(result.score).toBeLessThan(100); + expect(result.score).toBeGreaterThan(50); + }); + + it('approaches the true mean as review volume grows', () => { + const many = Array.from({ length: 200 }, () => review(5, { createdAt: now })); + const result = calc.calculate(many, 0, now); + expect(result.score).toBeGreaterThan(95); + }); + + it('weights recent reviews more than old ones', () => { + const old = review(1, { createdAt: new Date('2024-01-01T00:00:00Z') }); + const fresh = review(5, { createdAt: now }); + const result = calc.calculate([old, fresh], 0, now); + // Fresh 5 should dominate the decayed old 1. + expect(result.categoryAverages.security).toBeGreaterThan(3.5); + }); + + it('weights higher-reputation reviewers more', () => { + const lowRep = review(1, { reputation: 0, createdAt: now }); + const highRep = review(5, { reputation: 100000, createdAt: now }); + const result = calc.calculate([lowRep, highRep], 0, now); + expect(result.categoryAverages.security).toBeGreaterThan(3); + }); + + it('raises a safety advisory when safety-for-women is low with enough reviews', () => { + const reviews = Array.from({ length: 6 }, () => + review(4, { + createdAt: now, + ratings: { ...ratings(4), safetyForWomen: 2 }, + }), + ); + const result = calc.calculate(reviews, 0, now); + expect(result.safetyAdvisory).toBe(true); + }); + + it('raises a safety advisory when open incidents exceed the threshold', () => { + const result = calc.calculate([review(5, { createdAt: now })], 3, now); + expect(result.safetyAdvisory).toBe(true); + }); +}); diff --git a/clubscan/backend/src/modules/scoring/domain/score-calculator.ts b/clubscan/backend/src/modules/scoring/domain/score-calculator.ts new file mode 100644 index 0000000..d755d6c --- /dev/null +++ b/clubscan/backend/src/modules/scoring/domain/score-calculator.ts @@ -0,0 +1,154 @@ +import { ScoringConfig } from '@/platform/config/scoring-config'; + +/** The nine structured rating categories (1..5 each). */ +export interface CategoryRatings { + security: number; + staffBehavior: number; + fairPricing: number; + crowdQuality: number; + musicQuality: number; + soundSystem: number; + cleanliness: number; + safetyForWomen: number; + atmosphere: number; +} + +export interface ScoreInputReview { + ratings: CategoryRatings; + /** Author reputation score (>= 0). */ + reputation: number; + /** Review creation time, used for recency decay. */ + createdAt: Date; +} + +export interface VenueScoreResult { + /** Composite score on a 0..100 scale. */ + score: number; + reviewCount: number; + categoryAverages: CategoryRatings; + safetyAdvisory: boolean; +} + +const CATEGORY_KEYS: (keyof CategoryRatings)[] = [ + 'security', + 'staffBehavior', + 'fairPricing', + 'crowdQuality', + 'musicQuality', + 'soundSystem', + 'cleanliness', + 'safetyForWomen', + 'atmosphere', +]; + +const DAY_MS = 24 * 60 * 60 * 1000; + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function round2(value: number): number { + return Math.round(value * 100) / 100; +} + +/** + * Pure domain service implementing the canonical ClubScan scoring algorithm + * (Phase 3 §11). Deterministic, framework-free, fully unit-testable. + * + * - Per-review weight = recency decay × reputation weight + * - Per-category weighted average across reviews + * - Composite = Σ categoryWeight × categoryAvg + * - Bayesian shrinkage toward the global mean for low-volume venues + * - Scaled to 0..100; safety advisory derived from safety signal + incidents + */ +export class ScoreCalculator { + constructor(private readonly config: ScoringConfig) {} + + calculate( + reviews: ScoreInputReview[], + openIncidentCount = 0, + now: Date = new Date(), + ): VenueScoreResult { + const emptyAverages = this.zeroAverages(); + + if (reviews.length === 0) { + return { + score: 0, + reviewCount: 0, + categoryAverages: emptyAverages, + safetyAdvisory: openIncidentCount >= this.config.openIncidentThreshold, + }; + } + + const weights = reviews.map((r) => this.reviewWeight(r, now)); + const totalWeight = weights.reduce((a, b) => a + b, 0) || 1; + + // Weighted per-category averages (1..5). + const categoryAverages = this.zeroAverages(); + for (const key of CATEGORY_KEYS) { + let acc = 0; + reviews.forEach((review, i) => { + acc += weights[i] * review.ratings[key]; + }); + categoryAverages[key] = round2(acc / totalWeight); + } + + // Composite (1..5) using configured category weights. + const cw = this.config.categoryWeights; + const rawComposite = + cw.security * categoryAverages.security + + cw.staffBehavior * categoryAverages.staffBehavior + + cw.fairPricing * categoryAverages.fairPricing + + cw.crowdQuality * categoryAverages.crowdQuality + + cw.musicQuality * categoryAverages.musicQuality + + cw.soundSystem * categoryAverages.soundSystem + + cw.cleanliness * categoryAverages.cleanliness + + cw.safetyForWomen * categoryAverages.safetyForWomen + + cw.atmosphere * categoryAverages.atmosphere; + + // Bayesian shrinkage toward the global mean using effective sample size. + const effectiveN = totalWeight; + const m = this.config.bayesianPriorM; + const shrunk = (effectiveN * rawComposite + m * this.config.globalMean) / (effectiveN + m); + + // Scale 1..5 -> 0..100. + const score = round2(((shrunk - 1) / 4) * 100); + + const safetyAdvisory = + (categoryAverages.safetyForWomen < this.config.safetyAdvisoryMaxAvg && + reviews.length >= this.config.safetyAdvisoryMinReviews) || + openIncidentCount >= this.config.openIncidentThreshold; + + return { + score: clamp(score, 0, 100), + reviewCount: reviews.length, + categoryAverages, + safetyAdvisory, + }; + } + + private reviewWeight(review: ScoreInputReview, now: Date): number { + const ageDays = Math.max(0, (now.getTime() - review.createdAt.getTime()) / DAY_MS); + const recency = Math.pow(0.5, ageDays / this.config.halfLifeDays); + const reputation = clamp( + 1 + Math.log10(1 + Math.max(0, review.reputation)) / this.config.reputationK, + 1, + this.config.reputationCap, + ); + return recency * reputation; + } + + private zeroAverages(): CategoryRatings { + return { + security: 0, + staffBehavior: 0, + fairPricing: 0, + crowdQuality: 0, + musicQuality: 0, + soundSystem: 0, + cleanliness: 0, + safetyForWomen: 0, + atmosphere: 0, + }; + } +} diff --git a/clubscan/backend/src/platform/audit/audit.module.ts b/clubscan/backend/src/platform/audit/audit.module.ts new file mode 100644 index 0000000..8f9397d --- /dev/null +++ b/clubscan/backend/src/platform/audit/audit.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { AuditService } from './audit.service'; + +@Global() +@Module({ + providers: [AuditService], + exports: [AuditService], +}) +export class AuditModule {} diff --git a/clubscan/backend/src/platform/audit/audit.service.ts b/clubscan/backend/src/platform/audit/audit.service.ts new file mode 100644 index 0000000..0584c1c --- /dev/null +++ b/clubscan/backend/src/platform/audit/audit.service.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@/platform/prisma/prisma.service'; +import { newId } from '@/shared/ids/uuid'; + +export interface AuditInput { + actorId?: string | null; + action: string; + targetType?: string; + targetId?: string; + metadata?: Record; + ip?: string; +} + +/** + * Writes immutable audit-log entries for privileged actions (Phase 6 §7). + * Append-only: entries are never updated or deleted. + */ +@Injectable() +export class AuditService { + constructor(private readonly prisma: PrismaService) {} + + async record(input: AuditInput): Promise { + await this.prisma.auditLogEntry.create({ + data: { + id: newId(), + actorId: input.actorId ?? null, + action: input.action, + targetType: input.targetType, + targetId: input.targetId, + metadata: input.metadata as object | undefined, + ip: input.ip, + }, + }); + } +} diff --git a/clubscan/backend/src/platform/config/configuration.ts b/clubscan/backend/src/platform/config/configuration.ts new file mode 100644 index 0000000..8e11d86 --- /dev/null +++ b/clubscan/backend/src/platform/config/configuration.ts @@ -0,0 +1,43 @@ +import { z } from 'zod'; + +/** + * Strongly-typed, validated environment configuration (12-factor). + * Fails fast on boot if required vars are missing/invalid. + */ +const envSchema = z.object({ + NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), + PORT: z.coerce.number().default(3000), + API_PREFIX: z.string().default('api/v1'), + CORS_ORIGINS: z.string().default('*'), + + DATABASE_URL: z.string().url(), + REDIS_URL: z.string().default('redis://localhost:6379'), + + JWT_ACCESS_SECRET: z.string().min(32), + JWT_ACCESS_TTL: z.string().default('15m'), + JWT_REFRESH_TTL_DAYS: z.coerce.number().default(30), + + GOOGLE_OAUTH_CLIENT_ID: z.string().optional(), + APPLE_OAUTH_CLIENT_ID: z.string().optional(), + + S3_ENDPOINT: z.string().optional(), + S3_REGION: z.string().default('us-east-1'), + S3_BUCKET: z.string().default('clubscan-media'), + S3_ACCESS_KEY: z.string().optional(), + S3_SECRET_KEY: z.string().optional(), + + FCM_PROJECT_ID: z.string().optional(), + SENTRY_DSN: z.string().optional(), + OTEL_EXPORTER_OTLP_ENDPOINT: z.string().optional(), +}); + +export type AppEnv = z.infer; + +export function validateEnv(raw: Record): AppEnv { + const parsed = envSchema.safeParse(raw); + if (!parsed.success) { + const issues = parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; '); + throw new Error(`Invalid environment configuration: ${issues}`); + } + return parsed.data; +} diff --git a/clubscan/backend/src/platform/config/scoring-config.ts b/clubscan/backend/src/platform/config/scoring-config.ts new file mode 100644 index 0000000..77301e8 --- /dev/null +++ b/clubscan/backend/src/platform/config/scoring-config.ts @@ -0,0 +1,54 @@ +/** + * Default ClubScan scoring configuration (Phase 1 §4.2 / Phase 3 §11). + * These values are seeded into the `app_config` table and are tunable at + * runtime without a deploy. The ScoreCalculator domain service reads them. + */ +export interface ScoringConfig { + /** Category weights — must sum to 1.0. Safety-for-women & security are elevated. */ + categoryWeights: { + security: number; + staffBehavior: number; + fairPricing: number; + crowdQuality: number; + musicQuality: number; + soundSystem: number; + cleanliness: number; + safetyForWomen: number; + atmosphere: number; + }; + /** Recency decay half-life in days. */ + halfLifeDays: number; + /** Bayesian prior strength (pseudo-reviews pulling toward the global mean). */ + bayesianPriorM: number; + /** Assumed global mean rating (1..5) used as the Bayesian prior. */ + globalMean: number; + /** Reputation weighting log divisor and cap. */ + reputationK: number; + reputationCap: number; + /** Safety advisory thresholds. */ + safetyAdvisoryMaxAvg: number; + safetyAdvisoryMinReviews: number; + openIncidentThreshold: number; +} + +export const DEFAULT_SCORING_CONFIG: ScoringConfig = { + categoryWeights: { + safetyForWomen: 0.18, + security: 0.16, + staffBehavior: 0.12, + crowdQuality: 0.1, + fairPricing: 0.1, + cleanliness: 0.1, + musicQuality: 0.1, + soundSystem: 0.08, + atmosphere: 0.06, + }, + halfLifeDays: 180, + bayesianPriorM: 8, + globalMean: 3.5, + reputationK: 3, + reputationCap: 2.0, + safetyAdvisoryMaxAvg: 2.5, + safetyAdvisoryMinReviews: 5, + openIncidentThreshold: 3, +}; diff --git a/clubscan/backend/src/platform/event-bus/event-bus.module.ts b/clubscan/backend/src/platform/event-bus/event-bus.module.ts new file mode 100644 index 0000000..597e43a --- /dev/null +++ b/clubscan/backend/src/platform/event-bus/event-bus.module.ts @@ -0,0 +1,12 @@ +import { Global, Module } from '@nestjs/common'; +import { EventEmitterModule } from '@nestjs/event-emitter'; +import { EVENT_BUS } from './event-bus.port'; +import { InProcessEventBus } from './in-process-event-bus'; + +@Global() +@Module({ + imports: [EventEmitterModule.forRoot({ wildcard: false })], + providers: [{ provide: EVENT_BUS, useClass: InProcessEventBus }], + exports: [EVENT_BUS], +}) +export class EventBusModule {} diff --git a/clubscan/backend/src/platform/event-bus/event-bus.port.ts b/clubscan/backend/src/platform/event-bus/event-bus.port.ts new file mode 100644 index 0000000..e881366 --- /dev/null +++ b/clubscan/backend/src/platform/event-bus/event-bus.port.ts @@ -0,0 +1,13 @@ +import { DomainEvent } from '@/shared/domain/domain-event'; + +export const EVENT_BUS = Symbol('EVENT_BUS'); + +/** + * Abstraction over the messaging mechanism. v1 uses an in-process adapter; + * this can be swapped for Redis Streams / SQS / Kafka without touching callers + * (Dependency Inversion — Phase 1 §5.2). + */ +export interface EventBusPort { + publish(event: DomainEvent): Promise; + publishAll(events: DomainEvent[]): Promise; +} diff --git a/clubscan/backend/src/platform/event-bus/in-process-event-bus.ts b/clubscan/backend/src/platform/event-bus/in-process-event-bus.ts new file mode 100644 index 0000000..6216fdc --- /dev/null +++ b/clubscan/backend/src/platform/event-bus/in-process-event-bus.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { DomainEvent } from '@/shared/domain/domain-event'; +import { EventBusPort } from './event-bus.port'; + +/** + * In-process adapter backed by EventEmitter2. Reactors subscribe with + * @OnEvent(). Emission is fire-and-forget relative to the request + * (handlers run async) so the write path stays fast (Phase 1 §5.2). + */ +@Injectable() +export class InProcessEventBus implements EventBusPort { + constructor(private readonly emitter: EventEmitter2) {} + + async publish(event: DomainEvent): Promise { + this.emitter.emit(event.name, event); + } + + async publishAll(events: DomainEvent[]): Promise { + for (const event of events) { + this.emitter.emit(event.name, event); + } + } +} diff --git a/clubscan/backend/src/platform/http/all-exceptions.filter.ts b/clubscan/backend/src/platform/http/all-exceptions.filter.ts new file mode 100644 index 0000000..510e83c --- /dev/null +++ b/clubscan/backend/src/platform/http/all-exceptions.filter.ts @@ -0,0 +1,102 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + Logger, +} from '@nestjs/common'; +import { Request, Response } from 'express'; +import { ZodError } from 'zod'; +import { DomainError } from '@/shared/errors/domain-error'; + +interface ProblemDetails { + type: string; + title: string; + status: number; + detail?: string; + instance: string; + errors?: Array<{ path: string; message: string }>; + traceId?: string; +} + +/** + * Maps every thrown error to an RFC 7807 problem+json response. + * Domain errors and Zod errors are translated without leaking internals; + * unexpected errors return 500 with a trace id (and are reported to Sentry). + */ +@Catch() +export class AllExceptionsFilter implements ExceptionFilter { + private readonly logger = new Logger(AllExceptionsFilter.name); + + catch(exception: unknown, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const res = ctx.getResponse(); + const req = ctx.getRequest(); + const traceId = (req.headers['x-request-id'] as string) ?? undefined; + + const problem = this.toProblem(exception, req.url, traceId); + + if (problem.status >= 500) { + this.logger.error( + `${req.method} ${req.url} -> ${problem.status}`, + exception instanceof Error ? exception.stack : String(exception), + ); + } + + res.status(problem.status).type('application/problem+json').json(problem); + } + + private toProblem(exception: unknown, instance: string, traceId?: string): ProblemDetails { + if (exception instanceof DomainError) { + return { + type: `https://clubscan.app/errors/${exception.code.toLowerCase()}`, + title: exception.code, + status: exception.httpStatus, + detail: exception.message, + instance, + errors: Array.isArray(exception.details) + ? (exception.details as Array<{ path: string; message: string }>) + : undefined, + traceId, + }; + } + + if (exception instanceof ZodError) { + return { + type: 'https://clubscan.app/errors/validation', + title: 'Validation failed', + status: 422, + detail: 'Request validation failed', + instance, + errors: exception.issues.map((i) => ({ path: i.path.join('.'), message: i.message })), + traceId, + }; + } + + if (exception instanceof HttpException) { + const status = exception.getStatus(); + const response = exception.getResponse(); + const detail = + typeof response === 'string' + ? response + : ((response as Record)?.message as string) ?? exception.message; + return { + type: `https://clubscan.app/errors/http-${status}`, + title: exception.name, + status, + detail: Array.isArray(detail) ? detail.join('; ') : detail, + instance, + traceId, + }; + } + + return { + type: 'https://clubscan.app/errors/internal', + title: 'Internal Server Error', + status: 500, + detail: 'An unexpected error occurred', + instance, + traceId, + }; + } +} diff --git a/clubscan/backend/src/platform/pagination/cursor.ts b/clubscan/backend/src/platform/pagination/cursor.ts new file mode 100644 index 0000000..b673ff2 --- /dev/null +++ b/clubscan/backend/src/platform/pagination/cursor.ts @@ -0,0 +1,55 @@ +/** + * Opaque cursor pagination. A cursor encodes (createdAt, id) so list reads + * are stable and keyset-based (no OFFSET scans). limit is capped per Phase 3. + */ +export const MAX_PAGE_SIZE = 50; +export const DEFAULT_PAGE_SIZE = 20; + +export interface CursorPayload { + createdAt: string; + id: string; +} + +export function encodeCursor(payload: CursorPayload): string { + return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url'); +} + +export function decodeCursor(cursor?: string): CursorPayload | undefined { + if (!cursor) return undefined; + try { + const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')); + if (typeof parsed?.createdAt === 'string' && typeof parsed?.id === 'string') { + return parsed as CursorPayload; + } + return undefined; + } catch { + return undefined; + } +} + +export function clampLimit(limit?: number): number { + if (!limit || limit < 1) return DEFAULT_PAGE_SIZE; + return Math.min(limit, MAX_PAGE_SIZE); +} + +export interface Paginated { + data: T[]; + nextCursor: string | null; +} + +/** + * Helper: given limit+1 rows fetched, split into page + nextCursor. + */ +export function buildPage( + rows: T[], + limit: number, +): Paginated { + const hasMore = rows.length > limit; + const data = hasMore ? rows.slice(0, limit) : rows; + const last = data[data.length - 1]; + return { + data, + nextCursor: + hasMore && last ? encodeCursor({ createdAt: last.createdAt.toISOString(), id: last.id }) : null, + }; +} diff --git a/clubscan/backend/src/platform/prisma/prisma.module.ts b/clubscan/backend/src/platform/prisma/prisma.module.ts new file mode 100644 index 0000000..7207426 --- /dev/null +++ b/clubscan/backend/src/platform/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/clubscan/backend/src/platform/prisma/prisma.service.ts b/clubscan/backend/src/platform/prisma/prisma.service.ts new file mode 100644 index 0000000..98c5f2b --- /dev/null +++ b/clubscan/backend/src/platform/prisma/prisma.service.ts @@ -0,0 +1,25 @@ +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(PrismaService.name); + + constructor() { + super({ + log: [ + { level: 'warn', emit: 'event' }, + { level: 'error', emit: 'event' }, + ], + }); + } + + async onModuleInit(): Promise { + await this.$connect(); + this.logger.log('Prisma connected'); + } + + async onModuleDestroy(): Promise { + await this.$disconnect(); + } +} diff --git a/clubscan/backend/src/platform/security/auth.types.ts b/clubscan/backend/src/platform/security/auth.types.ts new file mode 100644 index 0000000..878ca19 --- /dev/null +++ b/clubscan/backend/src/platform/security/auth.types.ts @@ -0,0 +1,16 @@ +import { UserRole, UserStatus } from '@prisma/client'; + +/** Decoded access-token claims attached to the request as `req.user`. */ +export interface AuthenticatedUser { + id: string; + role: UserRole; + status: UserStatus; + sessionId: string; +} + +export interface AccessTokenClaims { + sub: string; + role: UserRole; + status: UserStatus; + sid: string; +} diff --git a/clubscan/backend/src/platform/security/decorators.ts b/clubscan/backend/src/platform/security/decorators.ts new file mode 100644 index 0000000..04bd166 --- /dev/null +++ b/clubscan/backend/src/platform/security/decorators.ts @@ -0,0 +1,23 @@ +import { createParamDecorator, ExecutionContext, SetMetadata } from '@nestjs/common'; +import { UserRole } from '@prisma/client'; +import { AuthenticatedUser } from './auth.types'; + +export const IS_PUBLIC_KEY = 'isPublic'; +/** Marks a route as not requiring authentication. */ +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); + +export const ROLES_KEY = 'roles'; +/** Restricts a route to the given roles (RBAC). */ +export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles); + +export const AUDIT_KEY = 'audit'; +/** Records an immutable audit log entry for a privileged handler. */ +export const Audit = (action: string) => SetMetadata(AUDIT_KEY, action); + +/** Injects the authenticated user (or a single property of it). */ +export const CurrentUser = createParamDecorator( + (data: keyof AuthenticatedUser | undefined, ctx: ExecutionContext) => { + const user = ctx.switchToHttp().getRequest().user as AuthenticatedUser | undefined; + return data && user ? user[data] : user; + }, +); diff --git a/clubscan/backend/src/platform/security/jwt-auth.guard.ts b/clubscan/backend/src/platform/security/jwt-auth.guard.ts new file mode 100644 index 0000000..9a25e28 --- /dev/null +++ b/clubscan/backend/src/platform/security/jwt-auth.guard.ts @@ -0,0 +1,63 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import { Reflector } from '@nestjs/core'; +import { Request } from 'express'; +import { UserStatus } from '@prisma/client'; +import { DomainError } from '@/shared/errors/domain-error'; +import { AccessTokenClaims, AuthenticatedUser } from './auth.types'; +import { IS_PUBLIC_KEY } from './decorators'; + +/** + * Verifies the Bearer access token (stateless), rejects banned/deleted users, + * and attaches `req.user`. Routes marked @Public() bypass authentication. + */ +@Injectable() +export class JwtAuthGuard implements CanActivate { + constructor( + private readonly jwt: JwtService, + private readonly config: ConfigService, + private readonly reflector: Reflector, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) return true; + + const req = context.switchToHttp().getRequest(); + const token = this.extractToken(req); + if (!token) throw DomainError.unauthorized('Missing access token'); + + let claims: AccessTokenClaims; + try { + claims = await this.jwt.verifyAsync(token, { + secret: this.config.getOrThrow('JWT_ACCESS_SECRET'), + }); + } catch { + throw DomainError.unauthorized('Invalid or expired access token'); + } + + if (claims.status === UserStatus.BANNED || claims.status === UserStatus.DELETED) { + throw DomainError.forbidden('Account is not active'); + } + + const user: AuthenticatedUser = { + id: claims.sub, + role: claims.role, + status: claims.status, + sessionId: claims.sid, + }; + (req as Request & { user: AuthenticatedUser }).user = user; + return true; + } + + private extractToken(req: Request): string | null { + const header = req.headers.authorization; + if (!header) return null; + const [type, token] = header.split(' '); + return type === 'Bearer' && token ? token : null; + } +} diff --git a/clubscan/backend/src/platform/security/roles.guard.ts b/clubscan/backend/src/platform/security/roles.guard.ts new file mode 100644 index 0000000..5069352 --- /dev/null +++ b/clubscan/backend/src/platform/security/roles.guard.ts @@ -0,0 +1,36 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { UserRole } from '@prisma/client'; +import { DomainError } from '@/shared/errors/domain-error'; +import { AuthenticatedUser } from './auth.types'; +import { ROLES_KEY } from './decorators'; + +/** RBAC. Role hierarchy: USER < MODERATOR < ADMIN < SUPER_ADMIN. */ +const RANK: Record = { + USER: 0, + MODERATOR: 1, + ADMIN: 2, + SUPER_ADMIN: 3, +}; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const required = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (!required || required.length === 0) return true; + + const user = context.switchToHttp().getRequest().user as AuthenticatedUser | undefined; + if (!user) throw DomainError.unauthorized(); + + const minRequired = Math.min(...required.map((r) => RANK[r])); + if (RANK[user.role] < minRequired) { + throw DomainError.forbidden('Insufficient role'); + } + return true; + } +} diff --git a/clubscan/backend/src/platform/security/security.module.ts b/clubscan/backend/src/platform/security/security.module.ts new file mode 100644 index 0000000..8e73695 --- /dev/null +++ b/clubscan/backend/src/platform/security/security.module.ts @@ -0,0 +1,23 @@ +import { Global, Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { JwtModule } from '@nestjs/jwt'; + +/** + * Provides JwtModule app-wide so guards and the auth token service can sign + * and verify access tokens with the configured secret. + */ +@Global() +@Module({ + imports: [ + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + secret: config.getOrThrow('JWT_ACCESS_SECRET'), + signOptions: { expiresIn: config.get('JWT_ACCESS_TTL', '15m') }, + }), + }), + ], + exports: [JwtModule], +}) +export class SecurityModule {} diff --git a/clubscan/backend/src/shared/domain/domain-event.ts b/clubscan/backend/src/shared/domain/domain-event.ts new file mode 100644 index 0000000..eeea0de --- /dev/null +++ b/clubscan/backend/src/shared/domain/domain-event.ts @@ -0,0 +1,13 @@ +import { newId } from '../ids/uuid'; + +/** + * Base shape for all domain events published on the internal event bus. + * Reactors (scoring recompute, search reindex, feed fan-out, notifications, + * analytics) subscribe to these. Keep payloads small and id-referential. + */ +export abstract class DomainEvent { + readonly eventId: string = newId(); + readonly occurredAt: Date = new Date(); + abstract readonly name: string; + abstract readonly payload: T; +} diff --git a/clubscan/backend/src/shared/errors/domain-error.ts b/clubscan/backend/src/shared/errors/domain-error.ts new file mode 100644 index 0000000..3835eea --- /dev/null +++ b/clubscan/backend/src/shared/errors/domain-error.ts @@ -0,0 +1,57 @@ +/** + * Framework-agnostic domain/application errors. The HTTP layer + * (AllExceptionsFilter) maps these to RFC 7807 problem+json responses, + * so the domain never depends on HTTP. + */ +export type DomainErrorCode = + | 'VALIDATION' + | 'NOT_FOUND' + | 'CONFLICT' + | 'UNAUTHORIZED' + | 'FORBIDDEN' + | 'RATE_LIMITED' + | 'UNPROCESSABLE'; + +const STATUS: Record = { + VALIDATION: 422, + NOT_FOUND: 404, + CONFLICT: 409, + UNAUTHORIZED: 401, + FORBIDDEN: 403, + RATE_LIMITED: 429, + UNPROCESSABLE: 422, +}; + +export class DomainError extends Error { + readonly code: DomainErrorCode; + readonly httpStatus: number; + readonly details?: unknown; + + constructor(code: DomainErrorCode, message: string, details?: unknown) { + super(message); + this.name = 'DomainError'; + this.code = code; + this.httpStatus = STATUS[code]; + this.details = details; + } + + static notFound(entity: string, id?: string): DomainError { + return new DomainError('NOT_FOUND', `${entity}${id ? ` (${id})` : ''} not found`); + } + + static conflict(message: string, details?: unknown): DomainError { + return new DomainError('CONFLICT', message, details); + } + + static forbidden(message = 'Forbidden'): DomainError { + return new DomainError('FORBIDDEN', message); + } + + static unauthorized(message = 'Unauthorized'): DomainError { + return new DomainError('UNAUTHORIZED', message); + } + + static validation(message: string, details?: unknown): DomainError { + return new DomainError('VALIDATION', message, details); + } +} diff --git a/clubscan/backend/src/shared/ids/uuid.ts b/clubscan/backend/src/shared/ids/uuid.ts new file mode 100644 index 0000000..a8c714a --- /dev/null +++ b/clubscan/backend/src/shared/ids/uuid.ts @@ -0,0 +1,10 @@ +import { v7 as uuidv7 } from 'uuid'; + +/** + * Time-ordered UUID v7 generation, used for all primary keys. + * Generated in the application layer so IDs are known before persistence + * (enables emitting domain events that reference the new aggregate id). + */ +export function newId(): string { + return uuidv7(); +} diff --git a/clubscan/backend/tsconfig.json b/clubscan/backend/tsconfig.json new file mode 100644 index 0000000..4651d94 --- /dev/null +++ b/clubscan/backend/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2022", + "lib": ["ES2022"], + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strict": true, + "strictNullChecks": true, + "noImplicitAny": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "resolveJsonModule": true, + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*", "prisma/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/clubscan/backend/vitest.config.ts b/clubscan/backend/vitest.config.ts new file mode 100644 index 0000000..3773d55 --- /dev/null +++ b/clubscan/backend/vitest.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vitest/config'; +import path from 'node:path'; + +export default defineConfig({ + resolve: { + alias: { + '@': path.resolve(__dirname, 'src'), + }, + }, + test: { + globals: true, + environment: 'node', + include: ['src/**/*.spec.ts'], + coverage: { + provider: 'v8', + reportsDirectory: './coverage', + include: ['src/**/*.ts'], + exclude: ['src/**/*.spec.ts', 'src/main.ts'], + }, + }, +}); From f72950c68613af5b7ab1c0aa04fd85b1b80f6c81 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 19:49:04 +0000 Subject: [PATCH 5/8] feat(clubscan): auth module (JWT + rotating refresh + OAuth) and scoring read-model service --- .../modules/auth/application/auth.service.ts | 253 ++++++++++++++++++ .../modules/auth/application/dto/auth.dto.ts | 60 +++++ .../auth/application/ports/mailer.port.ts | 7 + .../application/ports/oauth-verifier.port.ts | 16 ++ .../modules/auth/application/token.service.ts | 127 +++++++++ .../backend/src/modules/auth/auth.module.ts | 20 ++ .../auth/infrastructure/log-mailer.adapter.ts | 19 ++ .../infrastructure/oauth-verifier.adapter.ts | 67 +++++ .../auth/presentation/auth.controller.ts | 108 ++++++++ .../modules/reviews/domain/review.events.ts | 28 ++ .../scoring/application/scoring.reactor.ts | 34 +++ .../scoring/application/scoring.service.ts | 87 ++++++ .../src/modules/scoring/scoring.module.ts | 9 + .../src/platform/config/app-config.module.ts | 15 ++ .../src/platform/config/app-config.service.ts | 38 +++ 15 files changed, 888 insertions(+) create mode 100644 clubscan/backend/src/modules/auth/application/auth.service.ts create mode 100644 clubscan/backend/src/modules/auth/application/dto/auth.dto.ts create mode 100644 clubscan/backend/src/modules/auth/application/ports/mailer.port.ts create mode 100644 clubscan/backend/src/modules/auth/application/ports/oauth-verifier.port.ts create mode 100644 clubscan/backend/src/modules/auth/application/token.service.ts create mode 100644 clubscan/backend/src/modules/auth/auth.module.ts create mode 100644 clubscan/backend/src/modules/auth/infrastructure/log-mailer.adapter.ts create mode 100644 clubscan/backend/src/modules/auth/infrastructure/oauth-verifier.adapter.ts create mode 100644 clubscan/backend/src/modules/auth/presentation/auth.controller.ts create mode 100644 clubscan/backend/src/modules/reviews/domain/review.events.ts create mode 100644 clubscan/backend/src/modules/scoring/application/scoring.reactor.ts create mode 100644 clubscan/backend/src/modules/scoring/application/scoring.service.ts create mode 100644 clubscan/backend/src/modules/scoring/scoring.module.ts create mode 100644 clubscan/backend/src/platform/config/app-config.module.ts create mode 100644 clubscan/backend/src/platform/config/app-config.service.ts diff --git a/clubscan/backend/src/modules/auth/application/auth.service.ts b/clubscan/backend/src/modules/auth/application/auth.service.ts new file mode 100644 index 0000000..e60661b --- /dev/null +++ b/clubscan/backend/src/modules/auth/application/auth.service.ts @@ -0,0 +1,253 @@ +import { createHash, randomBytes } from 'node:crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import * as argon2 from 'argon2'; +import { OAuthProvider, User, UserStatus } from '@prisma/client'; +import { PrismaService } from '@/platform/prisma/prisma.service'; +import { EVENT_BUS, EventBusPort } from '@/platform/event-bus/event-bus.port'; +import { DomainEvent } from '@/shared/domain/domain-event'; +import { DomainError } from '@/shared/errors/domain-error'; +import { newId } from '@/shared/ids/uuid'; +import { MAILER, MailerPort } from './ports/mailer.port'; +import { OAUTH_VERIFIER, OAuthVerifierPort } from './ports/oauth-verifier.port'; +import { TokenPair, TokenService } from './token.service'; + +class UserRegisteredEvent extends DomainEvent<{ userId: string }> { + readonly name = 'user.registered'; + constructor(readonly payload: { userId: string }) { + super(); + } +} + +interface RequestContext { + ip?: string; + userAgent?: string; +} + +const EMAIL_TOKEN_TTL_MS = 24 * 60 * 60 * 1000; +const RESET_TOKEN_TTL_MS = 60 * 60 * 1000; + +@Injectable() +export class AuthService { + constructor( + private readonly prisma: PrismaService, + private readonly tokens: TokenService, + @Inject(MAILER) private readonly mailer: MailerPort, + @Inject(OAUTH_VERIFIER) private readonly oauth: OAuthVerifierPort, + @Inject(EVENT_BUS) private readonly bus: EventBusPort, + ) {} + + private hash(token: string): string { + return createHash('sha256').update(token).digest('hex'); + } + + private sanitizeUser(user: User) { + return { + id: user.id, + email: user.email, + role: user.role, + status: user.status, + emailVerified: !!user.emailVerifiedAt, + reputationScore: user.reputationScore, + locale: user.locale, + }; + } + + async register(input: { email: string; password: string; username: string }) { + const existing = await this.prisma.user.findFirst({ + where: { OR: [{ email: input.email }, { profile: { username: input.username } }] }, + include: { profile: true }, + }); + if (existing) { + throw DomainError.conflict('Email or username already in use'); + } + + const passwordHash = await argon2.hash(input.password, { type: argon2.argon2id }); + const userId = newId(); + + const user = await this.prisma.user.create({ + data: { + id: userId, + email: input.email, + passwordHash, + profile: { + create: { id: newId(), username: input.username, displayName: input.username }, + }, + }, + }); + + await this.issueEmailVerification(user.id, user.email); + await this.bus.publish(new UserRegisteredEvent({ userId: user.id })); + + return this.sanitizeUser(user); + } + + private async issueEmailVerification(userId: string, email: string): Promise { + const raw = randomBytes(32).toString('base64url'); + await this.prisma.emailVerificationToken.create({ + data: { + id: newId(), + userId, + tokenHash: this.hash(raw), + expiresAt: new Date(Date.now() + EMAIL_TOKEN_TTL_MS), + }, + }); + await this.mailer.sendEmailVerification(email, raw); + } + + async verifyEmail(token: string): Promise { + const record = await this.prisma.emailVerificationToken.findUnique({ + where: { tokenHash: this.hash(token) }, + }); + if (!record || record.usedAt || record.expiresAt < new Date()) { + throw DomainError.validation('Invalid or expired verification token'); + } + await this.prisma.$transaction([ + this.prisma.user.update({ + where: { id: record.userId }, + data: { emailVerifiedAt: new Date() }, + }), + this.prisma.emailVerificationToken.update({ + where: { id: record.id }, + data: { usedAt: new Date() }, + }), + ]); + } + + async login( + input: { email: string; password: string }, + ctx: RequestContext, + ): Promise }> { + const user = await this.prisma.user.findUnique({ where: { email: input.email } }); + // Constant-ish work even when user is missing, to reduce enumeration signal. + const hash = user?.passwordHash ?? '$argon2id$v=19$m=65536,t=3,p=4$invalidsaltinvalid$invalidhash'; + const ok = await argon2.verify(hash, input.password).catch(() => false); + + if (!user || !user.passwordHash || !ok) { + throw DomainError.unauthorized('Invalid credentials'); + } + if (user.status === UserStatus.BANNED || user.status === UserStatus.DELETED) { + throw DomainError.forbidden('Account is not active'); + } + + const pair = await this.tokens.issuePair(user, ctx); + return { ...pair, user: this.sanitizeUser(user) }; + } + + async refresh(refreshToken: string, ctx: RequestContext): Promise { + return this.tokens.rotate(refreshToken, ctx); + } + + async logout(refreshToken: string): Promise { + await this.tokens.revoke(refreshToken); + } + + async logoutAll(userId: string): Promise { + await this.tokens.revokeAllForUser(userId); + } + + async forgotPassword(email: string): Promise { + const user = await this.prisma.user.findUnique({ where: { email } }); + // Always return success to avoid account enumeration. + if (!user) return; + const raw = randomBytes(32).toString('base64url'); + await this.prisma.passwordResetToken.create({ + data: { + id: newId(), + userId: user.id, + tokenHash: this.hash(raw), + expiresAt: new Date(Date.now() + RESET_TOKEN_TTL_MS), + }, + }); + await this.mailer.sendPasswordReset(email, raw); + } + + async resetPassword(token: string, password: string): Promise { + const record = await this.prisma.passwordResetToken.findUnique({ + where: { tokenHash: this.hash(token) }, + }); + if (!record || record.usedAt || record.expiresAt < new Date()) { + throw DomainError.validation('Invalid or expired reset token'); + } + const passwordHash = await argon2.hash(password, { type: argon2.argon2id }); + await this.prisma.$transaction([ + this.prisma.user.update({ where: { id: record.userId }, data: { passwordHash } }), + this.prisma.passwordResetToken.update({ + where: { id: record.id }, + data: { usedAt: new Date() }, + }), + ]); + // Invalidate all existing sessions after a password reset. + await this.tokens.revokeAllForUser(record.userId); + } + + async oauthLogin( + provider: OAuthProvider, + token: string, + username: string | undefined, + ctx: RequestContext, + ): Promise; needsUsername: boolean }> { + const identity = + provider === OAuthProvider.GOOGLE + ? await this.oauth.verifyGoogle(token) + : await this.oauth.verifyApple(token); + + let account = await this.prisma.oAuthAccount.findUnique({ + where: { + provider_providerAccountId: { + provider: identity.provider, + providerAccountId: identity.providerAccountId, + }, + }, + include: { user: true }, + }); + + let user = account?.user ?? null; + let needsUsername = false; + + if (!user && identity.email) { + user = await this.prisma.user.findUnique({ where: { email: identity.email } }); + } + + if (!user) { + // First-time social signup requires a username. + if (!username) { + return { + accessToken: '', + refreshToken: '', + user: null as never, + needsUsername: true, + }; + } + const taken = await this.prisma.profile.findUnique({ where: { username } }); + if (taken) throw DomainError.conflict('Username already in use'); + + user = await this.prisma.user.create({ + data: { + id: newId(), + email: identity.email ?? `${identity.providerAccountId}@${provider.toLowerCase()}.oauth`, + emailVerifiedAt: identity.emailVerified ? new Date() : null, + profile: { create: { id: newId(), username, displayName: username } }, + }, + }); + needsUsername = false; + } + + if (!account) { + await this.prisma.oAuthAccount.create({ + data: { + id: newId(), + userId: user.id, + provider: identity.provider, + providerAccountId: identity.providerAccountId, + }, + }); + } + + if (user.status === UserStatus.BANNED || user.status === UserStatus.DELETED) { + throw DomainError.forbidden('Account is not active'); + } + + const pair = await this.tokens.issuePair(user, ctx); + return { ...pair, user: this.sanitizeUser(user), needsUsername }; + } +} diff --git a/clubscan/backend/src/modules/auth/application/dto/auth.dto.ts b/clubscan/backend/src/modules/auth/application/dto/auth.dto.ts new file mode 100644 index 0000000..676d02d --- /dev/null +++ b/clubscan/backend/src/modules/auth/application/dto/auth.dto.ts @@ -0,0 +1,60 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +// Shared field schemas mirror backend validation rules (Phase 3 §10) and are +// intended to be kept in sync with the mobile client's Zod schemas. +export const emailSchema = z.string().trim().toLowerCase().email().max(254); + +export const passwordSchema = z + .string() + .min(10, 'Password must be at least 10 characters') + .max(128) + .regex(/[a-z]/, 'Must contain a lowercase letter') + .regex(/[A-Z]/, 'Must contain an uppercase letter') + .regex(/[0-9]/, 'Must contain a digit'); + +export const usernameSchema = z + .string() + .trim() + .toLowerCase() + .regex(/^[a-z0-9_]{3,20}$/, 'Username must be 3-20 chars: a-z, 0-9, underscore'); + +export const RegisterSchema = z.object({ + email: emailSchema, + password: passwordSchema, + username: usernameSchema, +}); +export class RegisterDto extends createZodDto(RegisterSchema) {} + +export const LoginSchema = z.object({ + email: emailSchema, + password: z.string().min(1), +}); +export class LoginDto extends createZodDto(LoginSchema) {} + +export const VerifyEmailSchema = z.object({ token: z.string().min(10) }); +export class VerifyEmailDto extends createZodDto(VerifyEmailSchema) {} + +export const RefreshSchema = z.object({ refreshToken: z.string().min(10) }); +export class RefreshDto extends createZodDto(RefreshSchema) {} + +export const ForgotPasswordSchema = z.object({ email: emailSchema }); +export class ForgotPasswordDto extends createZodDto(ForgotPasswordSchema) {} + +export const ResetPasswordSchema = z.object({ + token: z.string().min(10), + password: passwordSchema, +}); +export class ResetPasswordDto extends createZodDto(ResetPasswordSchema) {} + +export const OAuthGoogleSchema = z.object({ + idToken: z.string().min(10), + username: usernameSchema.optional(), +}); +export class OAuthGoogleDto extends createZodDto(OAuthGoogleSchema) {} + +export const OAuthAppleSchema = z.object({ + identityToken: z.string().min(10), + username: usernameSchema.optional(), +}); +export class OAuthAppleDto extends createZodDto(OAuthAppleSchema) {} diff --git a/clubscan/backend/src/modules/auth/application/ports/mailer.port.ts b/clubscan/backend/src/modules/auth/application/ports/mailer.port.ts new file mode 100644 index 0000000..fc9a162 --- /dev/null +++ b/clubscan/backend/src/modules/auth/application/ports/mailer.port.ts @@ -0,0 +1,7 @@ +export const MAILER = Symbol('MAILER'); + +/** Outbound transactional email. v1 ships a log-based dev adapter. */ +export interface MailerPort { + sendEmailVerification(to: string, token: string): Promise; + sendPasswordReset(to: string, token: string): Promise; +} diff --git a/clubscan/backend/src/modules/auth/application/ports/oauth-verifier.port.ts b/clubscan/backend/src/modules/auth/application/ports/oauth-verifier.port.ts new file mode 100644 index 0000000..35f3080 --- /dev/null +++ b/clubscan/backend/src/modules/auth/application/ports/oauth-verifier.port.ts @@ -0,0 +1,16 @@ +import { OAuthProvider } from '@prisma/client'; + +export const OAUTH_VERIFIER = Symbol('OAUTH_VERIFIER'); + +export interface VerifiedOAuthIdentity { + provider: OAuthProvider; + providerAccountId: string; + email?: string; + emailVerified: boolean; +} + +/** Verifies third-party identity tokens server-side (never trust the client). */ +export interface OAuthVerifierPort { + verifyGoogle(idToken: string): Promise; + verifyApple(identityToken: string): Promise; +} diff --git a/clubscan/backend/src/modules/auth/application/token.service.ts b/clubscan/backend/src/modules/auth/application/token.service.ts new file mode 100644 index 0000000..0f41a6d --- /dev/null +++ b/clubscan/backend/src/modules/auth/application/token.service.ts @@ -0,0 +1,127 @@ +import { createHash, randomBytes } from 'node:crypto'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import { User } from '@prisma/client'; +import { PrismaService } from '@/platform/prisma/prisma.service'; +import { DomainError } from '@/shared/errors/domain-error'; +import { newId } from '@/shared/ids/uuid'; +import { AccessTokenClaims } from '@/platform/security/auth.types'; + +export interface TokenPair { + accessToken: string; + refreshToken: string; +} + +interface IssueContext { + deviceId?: string; + ip?: string; + userAgent?: string; +} + +/** + * Issues short-lived access JWTs and opaque, rotating refresh tokens. + * Refresh tokens are stored hashed; rotation replaces the stored hash and + * reuse of a consumed token revokes the whole token family (Phase 3 §4). + */ +@Injectable() +export class TokenService { + constructor( + private readonly jwt: JwtService, + private readonly config: ConfigService, + private readonly prisma: PrismaService, + ) {} + + private hash(token: string): string { + return createHash('sha256').update(token).digest('hex'); + } + + private signAccess(user: Pick, sessionId: string): Promise { + const claims: AccessTokenClaims = { + sub: user.id, + role: user.role, + status: user.status, + sid: sessionId, + }; + return this.jwt.signAsync(claims); + } + + /** Issues a brand-new session (new token family). */ + async issuePair( + user: Pick, + ctx: IssueContext = {}, + ): Promise { + const sessionId = newId(); + const familyId = newId(); + const refreshToken = randomBytes(32).toString('base64url'); + const ttlDays = this.config.get('JWT_REFRESH_TTL_DAYS', 30); + + await this.prisma.session.create({ + data: { + id: sessionId, + familyId, + userId: user.id, + deviceId: ctx.deviceId, + refreshTokenHash: this.hash(refreshToken), + ip: ctx.ip, + userAgent: ctx.userAgent, + expiresAt: new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000), + }, + }); + + const accessToken = await this.signAccess(user, sessionId); + return { accessToken, refreshToken }; + } + + /** Rotates a refresh token; detects reuse and revokes the family on abuse. */ + async rotate(refreshToken: string, ctx: IssueContext = {}): Promise { + const tokenHash = this.hash(refreshToken); + const session = await this.prisma.session.findUnique({ + where: { refreshTokenHash: tokenHash }, + include: { user: true }, + }); + + if (!session) { + throw DomainError.unauthorized('Invalid refresh token'); + } + + // Reuse / expiry / revocation detection -> revoke the whole family. + if (session.revokedAt || session.expiresAt < new Date()) { + await this.prisma.session.updateMany({ + where: { familyId: session.familyId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + throw DomainError.unauthorized('Refresh token expired or reused'); + } + + const newRefresh = randomBytes(32).toString('base64url'); + const ttlDays = this.config.get('JWT_REFRESH_TTL_DAYS', 30); + + await this.prisma.session.update({ + where: { id: session.id }, + data: { + refreshTokenHash: this.hash(newRefresh), + ip: ctx.ip ?? session.ip, + userAgent: ctx.userAgent ?? session.userAgent, + expiresAt: new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000), + }, + }); + + const accessToken = await this.signAccess(session.user, session.id); + return { accessToken, refreshToken: newRefresh }; + } + + async revoke(refreshToken: string): Promise { + await this.prisma.session.updateMany({ + where: { refreshTokenHash: this.hash(refreshToken), revokedAt: null }, + data: { revokedAt: new Date() }, + }); + } + + async revokeAllForUser(userId: string): Promise { + await this.prisma.session.updateMany({ + where: { userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + } +} diff --git a/clubscan/backend/src/modules/auth/auth.module.ts b/clubscan/backend/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..2782234 --- /dev/null +++ b/clubscan/backend/src/modules/auth/auth.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { AuthService } from './application/auth.service'; +import { TokenService } from './application/token.service'; +import { MAILER } from './application/ports/mailer.port'; +import { OAUTH_VERIFIER } from './application/ports/oauth-verifier.port'; +import { LogMailerAdapter } from './infrastructure/log-mailer.adapter'; +import { OAuthVerifierAdapter } from './infrastructure/oauth-verifier.adapter'; +import { AuthController } from './presentation/auth.controller'; + +@Module({ + controllers: [AuthController], + providers: [ + AuthService, + TokenService, + { provide: MAILER, useClass: LogMailerAdapter }, + { provide: OAUTH_VERIFIER, useClass: OAuthVerifierAdapter }, + ], + exports: [TokenService], +}) +export class AuthModule {} diff --git a/clubscan/backend/src/modules/auth/infrastructure/log-mailer.adapter.ts b/clubscan/backend/src/modules/auth/infrastructure/log-mailer.adapter.ts new file mode 100644 index 0000000..429056c --- /dev/null +++ b/clubscan/backend/src/modules/auth/infrastructure/log-mailer.adapter.ts @@ -0,0 +1,19 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { MailerPort } from '../application/ports/mailer.port'; + +/** + * Development mailer that logs verification/reset links instead of sending. + * Swap for an SES/SMTP/Resend adapter in production behind the same port. + */ +@Injectable() +export class LogMailerAdapter implements MailerPort { + private readonly logger = new Logger('Mailer'); + + async sendEmailVerification(to: string, token: string): Promise { + this.logger.log(`[email-verification] to=${to} token=${token}`); + } + + async sendPasswordReset(to: string, token: string): Promise { + this.logger.log(`[password-reset] to=${to} token=${token}`); + } +} diff --git a/clubscan/backend/src/modules/auth/infrastructure/oauth-verifier.adapter.ts b/clubscan/backend/src/modules/auth/infrastructure/oauth-verifier.adapter.ts new file mode 100644 index 0000000..d4c6f19 --- /dev/null +++ b/clubscan/backend/src/modules/auth/infrastructure/oauth-verifier.adapter.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { OAuth2Client } from 'google-auth-library'; +import { OAuthProvider } from '@prisma/client'; +import { DomainError } from '@/shared/errors/domain-error'; +import { + OAuthVerifierPort, + VerifiedOAuthIdentity, +} from '../application/ports/oauth-verifier.port'; + +/** + * Verifies Google and Apple identity tokens server-side. Google uses the + * official library; Apple verification is performed against Apple's JWKS + * (issuer/audience checks) — implemented in a dedicated Apple verifier in + * production. Tokens are never trusted from the client without verification. + */ +@Injectable() +export class OAuthVerifierAdapter implements OAuthVerifierPort { + private readonly google: OAuth2Client; + + constructor(private readonly config: ConfigService) { + this.google = new OAuth2Client(config.get('GOOGLE_OAUTH_CLIENT_ID')); + } + + async verifyGoogle(idToken: string): Promise { + try { + const ticket = await this.google.verifyIdToken({ + idToken, + audience: this.config.get('GOOGLE_OAUTH_CLIENT_ID'), + }); + const payload = ticket.getPayload(); + if (!payload?.sub) throw new Error('No subject'); + return { + provider: OAuthProvider.GOOGLE, + providerAccountId: payload.sub, + email: payload.email, + emailVerified: !!payload.email_verified, + }; + } catch { + throw DomainError.unauthorized('Invalid Google token'); + } + } + + async verifyApple(identityToken: string): Promise { + // Production: validate signature against https://appleid.apple.com/auth/keys + // (JWKS), check iss=https://appleid.apple.com and aud=APPLE_OAUTH_CLIENT_ID, + // and expiry. Implemented via a JWKS-backed verifier; abstracted here. + const aud = this.config.get('APPLE_OAUTH_CLIENT_ID'); + if (!aud) throw DomainError.unauthorized('Apple login not configured'); + const claims = await this.verifyAppleJwt(identityToken, aud); + return { + provider: OAuthProvider.APPLE, + providerAccountId: claims.sub, + email: claims.email, + emailVerified: claims.email_verified === 'true' || claims.email_verified === true, + }; + } + + // Placeholder seam for the JWKS-backed Apple verification (kept isolated so + // the rest of the auth flow is provider-agnostic and testable). + private async verifyAppleJwt( + _token: string, + _audience: string, + ): Promise<{ sub: string; email?: string; email_verified?: string | boolean }> { + throw DomainError.unauthorized('Apple verification not yet wired in this environment'); + } +} diff --git a/clubscan/backend/src/modules/auth/presentation/auth.controller.ts b/clubscan/backend/src/modules/auth/presentation/auth.controller.ts new file mode 100644 index 0000000..4ecc188 --- /dev/null +++ b/clubscan/backend/src/modules/auth/presentation/auth.controller.ts @@ -0,0 +1,108 @@ +import { Body, Controller, HttpCode, Post, Req } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { OAuthProvider } from '@prisma/client'; +import { Request } from 'express'; +import { Public, CurrentUser } from '@/platform/security/decorators'; +import { Throttle } from '@nestjs/throttler'; +import { AuthService } from '../application/auth.service'; +import { + ForgotPasswordDto, + LoginDto, + OAuthAppleDto, + OAuthGoogleDto, + RefreshDto, + RegisterDto, + ResetPasswordDto, + VerifyEmailDto, +} from '../application/dto/auth.dto'; + +@ApiTags('auth') +@Controller('auth') +export class AuthController { + constructor(private readonly auth: AuthService) {} + + private ctx(req: Request) { + return { ip: req.ip, userAgent: req.headers['user-agent'] }; + } + + @Public() + @Throttle({ default: { limit: 3, ttl: 60 * 60 * 1000 } }) + @Post('register') + register(@Body() dto: RegisterDto) { + return this.auth.register(dto); + } + + @Public() + @HttpCode(200) + @Post('verify-email') + async verifyEmail(@Body() dto: VerifyEmailDto) { + await this.auth.verifyEmail(dto.token); + return { ok: true }; + } + + @Public() + @Throttle({ default: { limit: 5, ttl: 15 * 60 * 1000 } }) + @HttpCode(200) + @Post('login') + login(@Body() dto: LoginDto, @Req() req: Request) { + return this.auth.login(dto, this.ctx(req)); + } + + @Public() + @HttpCode(200) + @Post('refresh') + refresh(@Body() dto: RefreshDto, @Req() req: Request) { + return this.auth.refresh(dto.refreshToken, this.ctx(req)); + } + + @Public() + @HttpCode(200) + @Post('logout') + async logout(@Body() dto: RefreshDto) { + await this.auth.logout(dto.refreshToken); + return { ok: true }; + } + + @HttpCode(200) + @Post('logout-all') + async logoutAll(@CurrentUser('id') userId: string) { + await this.auth.logoutAll(userId); + return { ok: true }; + } + + @Public() + @Throttle({ default: { limit: 5, ttl: 15 * 60 * 1000 } }) + @HttpCode(200) + @Post('forgot-password') + async forgotPassword(@Body() dto: ForgotPasswordDto) { + await this.auth.forgotPassword(dto.email); + return { ok: true }; + } + + @Public() + @HttpCode(200) + @Post('reset-password') + async resetPassword(@Body() dto: ResetPasswordDto) { + await this.auth.resetPassword(dto.token, dto.password); + return { ok: true }; + } + + @Public() + @HttpCode(200) + @Post('oauth/google') + oauthGoogle(@Body() dto: OAuthGoogleDto, @Req() req: Request) { + return this.auth.oauthLogin(OAuthProvider.GOOGLE, dto.idToken, dto.username, this.ctx(req)); + } + + @Public() + @HttpCode(200) + @Post('oauth/apple') + oauthApple(@Body() dto: OAuthAppleDto, @Req() req: Request) { + return this.auth.oauthLogin( + OAuthProvider.APPLE, + dto.identityToken, + dto.username, + this.ctx(req), + ); + } +} diff --git a/clubscan/backend/src/modules/reviews/domain/review.events.ts b/clubscan/backend/src/modules/reviews/domain/review.events.ts new file mode 100644 index 0000000..3da0d8c --- /dev/null +++ b/clubscan/backend/src/modules/reviews/domain/review.events.ts @@ -0,0 +1,28 @@ +import { DomainEvent } from '@/shared/domain/domain-event'; + +interface ReviewEventPayload { + reviewId: string; + venueId: string; + authorId: string; +} + +export class ReviewPublishedEvent extends DomainEvent { + readonly name = 'review.published'; + constructor(readonly payload: ReviewEventPayload) { + super(); + } +} + +export class ReviewEditedEvent extends DomainEvent { + readonly name = 'review.edited'; + constructor(readonly payload: ReviewEventPayload) { + super(); + } +} + +export class ReviewRemovedEvent extends DomainEvent { + readonly name = 'review.removed'; + constructor(readonly payload: ReviewEventPayload) { + super(); + } +} diff --git a/clubscan/backend/src/modules/scoring/application/scoring.reactor.ts b/clubscan/backend/src/modules/scoring/application/scoring.reactor.ts new file mode 100644 index 0000000..c467b07 --- /dev/null +++ b/clubscan/backend/src/modules/scoring/application/scoring.reactor.ts @@ -0,0 +1,34 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { + ReviewEditedEvent, + ReviewPublishedEvent, + ReviewRemovedEvent, +} from '@/modules/reviews/domain/review.events'; +import { ScoringService } from './scoring.service'; + +/** + * Reacts to review lifecycle events to recompute the affected venue's score + * read model asynchronously (eventual consistency — Phase 1 §5.2). + */ +@Injectable() +export class ScoringReactor { + private readonly logger = new Logger(ScoringReactor.name); + + constructor(private readonly scoring: ScoringService) {} + + @OnEvent('review.published') + @OnEvent('review.edited') + @OnEvent('review.removed') + async onReviewChanged( + event: ReviewPublishedEvent | ReviewEditedEvent | ReviewRemovedEvent, + ): Promise { + try { + await this.scoring.recomputeVenue(event.payload.venueId); + } catch (err) { + this.logger.error( + `Failed to recompute score for venue ${event.payload.venueId}: ${(err as Error).message}`, + ); + } + } +} diff --git a/clubscan/backend/src/modules/scoring/application/scoring.service.ts b/clubscan/backend/src/modules/scoring/application/scoring.service.ts new file mode 100644 index 0000000..8296ddb --- /dev/null +++ b/clubscan/backend/src/modules/scoring/application/scoring.service.ts @@ -0,0 +1,87 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Prisma, ReviewStatus, UserStatus } from '@prisma/client'; +import { PrismaService } from '@/platform/prisma/prisma.service'; +import { AppConfigService } from '@/platform/config/app-config.service'; +import { ScoreCalculator, ScoreInputReview } from '../domain/score-calculator'; + +/** + * Recomputes and persists the VenueScore CQRS read model from the write-side + * reviews. Only PUBLISHED reviews by non-shadow-banned, active authors are + * included (Phase 3 §11). Idempotent and safe to call repeatedly. + */ +@Injectable() +export class ScoringService { + private readonly logger = new Logger(ScoringService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly appConfig: AppConfigService, + ) {} + + async recomputeVenue(venueId: string): Promise { + const reviews = await this.prisma.review.findMany({ + where: { + venueId, + status: ReviewStatus.PUBLISHED, + deletedAt: null, + user: { status: { notIn: [UserStatus.SHADOW_BANNED, UserStatus.BANNED, UserStatus.DELETED] } }, + }, + select: { + createdAt: true, + rating: true, + user: { select: { reputationScore: true } }, + }, + }); + + const openIncidentCount = await this.prisma.incidentReport.count({ + where: { venueId, state: { notIn: ['DISMISSED', 'CLOSED'] } }, + }); + + const inputs: ScoreInputReview[] = reviews + .filter((r) => r.rating) + .map((r) => ({ + reputation: r.user.reputationScore, + createdAt: r.createdAt, + ratings: { + security: r.rating!.security, + staffBehavior: r.rating!.staffBehavior, + fairPricing: r.rating!.fairPricing, + crowdQuality: r.rating!.crowdQuality, + musicQuality: r.rating!.musicQuality, + soundSystem: r.rating!.soundSystem, + cleanliness: r.rating!.cleanliness, + safetyForWomen: r.rating!.safetyForWomen, + atmosphere: r.rating!.atmosphere, + }, + })); + + const calculator = new ScoreCalculator(this.appConfig.getScoringConfig()); + const result = calculator.calculate(inputs, openIncidentCount); + const avg = result.categoryAverages; + + const data: Prisma.VenueScoreUncheckedCreateInput = { + venueId, + score: new Prisma.Decimal(result.score), + reviewCount: result.reviewCount, + avgSecurity: new Prisma.Decimal(avg.security), + avgStaffBehavior: new Prisma.Decimal(avg.staffBehavior), + avgFairPricing: new Prisma.Decimal(avg.fairPricing), + avgCrowdQuality: new Prisma.Decimal(avg.crowdQuality), + avgMusicQuality: new Prisma.Decimal(avg.musicQuality), + avgSoundSystem: new Prisma.Decimal(avg.soundSystem), + avgCleanliness: new Prisma.Decimal(avg.cleanliness), + avgSafetyForWomen: new Prisma.Decimal(avg.safetyForWomen), + avgAtmosphere: new Prisma.Decimal(avg.atmosphere), + safetyAdvisory: result.safetyAdvisory, + lastComputedAt: new Date(), + }; + + await this.prisma.venueScore.upsert({ + where: { venueId }, + create: data, + update: data, + }); + + this.logger.debug(`Recomputed score for venue ${venueId}: ${result.score}`); + } +} diff --git a/clubscan/backend/src/modules/scoring/scoring.module.ts b/clubscan/backend/src/modules/scoring/scoring.module.ts new file mode 100644 index 0000000..d21c07e --- /dev/null +++ b/clubscan/backend/src/modules/scoring/scoring.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { ScoringService } from './application/scoring.service'; +import { ScoringReactor } from './application/scoring.reactor'; + +@Module({ + providers: [ScoringService, ScoringReactor], + exports: [ScoringService], +}) +export class ScoringModule {} diff --git a/clubscan/backend/src/platform/config/app-config.module.ts b/clubscan/backend/src/platform/config/app-config.module.ts new file mode 100644 index 0000000..d800335 --- /dev/null +++ b/clubscan/backend/src/platform/config/app-config.module.ts @@ -0,0 +1,15 @@ +import { Global, Module, OnModuleInit } from '@nestjs/common'; +import { AppConfigService } from './app-config.service'; + +@Global() +@Module({ + providers: [AppConfigService], + exports: [AppConfigService], +}) +export class AppConfigModule implements OnModuleInit { + constructor(private readonly config: AppConfigService) {} + + async onModuleInit(): Promise { + await this.config.refresh(); + } +} diff --git a/clubscan/backend/src/platform/config/app-config.service.ts b/clubscan/backend/src/platform/config/app-config.service.ts new file mode 100644 index 0000000..10aca95 --- /dev/null +++ b/clubscan/backend/src/platform/config/app-config.service.ts @@ -0,0 +1,38 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '@/platform/prisma/prisma.service'; +import { DEFAULT_SCORING_CONFIG, ScoringConfig } from './scoring-config'; + +interface RuntimeConfig { + scoring: ScoringConfig; +} + +/** + * Loads runtime-tunable configuration (scoring weights, thresholds) from the + * `app_config` singleton row, cached in memory. Falls back to defaults so the + * service boots even before the row is seeded (Phase 3 §11 — config over code). + */ +@Injectable() +export class AppConfigService { + private readonly logger = new Logger(AppConfigService.name); + private cache: RuntimeConfig = { scoring: DEFAULT_SCORING_CONFIG }; + + constructor(private readonly prisma: PrismaService) {} + + async refresh(): Promise { + try { + const row = await this.prisma.appConfig.findUnique({ where: { id: 1 } }); + if (row?.config) { + const parsed = row.config as Partial; + this.cache = { + scoring: { ...DEFAULT_SCORING_CONFIG, ...(parsed.scoring ?? {}) }, + }; + } + } catch (err) { + this.logger.warn(`Falling back to default config: ${(err as Error).message}`); + } + } + + getScoringConfig(): ScoringConfig { + return this.cache.scoring; + } +} From b3ce7eed1e308099f10983bc3983f014470ec576 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 19:51:39 +0000 Subject: [PATCH 6/8] feat(clubscan): venues, reviews, health modules + bootstrap and composition root --- clubscan/backend/src/app.module.ts | 50 ++++ clubscan/backend/src/main.ts | 44 +++ .../src/modules/health/health.controller.ts | 30 +++ .../src/modules/health/health.module.ts | 5 + .../reviews/application/dto/review.dto.ts | 29 ++ .../ports/content-moderation.port.ts | 18 ++ .../reviews/application/reviews.service.ts | 253 ++++++++++++++++++ .../rule-based-moderation.adapter.ts | 39 +++ .../presentation/reviews.controller.ts | 64 +++++ .../src/modules/reviews/reviews.module.ts | 14 + .../venues/application/dto/venue-query.dto.ts | 19 ++ .../ports/venue.repository.port.ts | 37 +++ .../venues/application/venues.service.ts | 39 +++ .../infrastructure/prisma-venue.repository.ts | 114 ++++++++ .../venues/presentation/venues.controller.ts | 23 ++ .../src/modules/venues/venues.module.ts | 15 ++ 16 files changed, 793 insertions(+) create mode 100644 clubscan/backend/src/app.module.ts create mode 100644 clubscan/backend/src/main.ts create mode 100644 clubscan/backend/src/modules/health/health.controller.ts create mode 100644 clubscan/backend/src/modules/health/health.module.ts create mode 100644 clubscan/backend/src/modules/reviews/application/dto/review.dto.ts create mode 100644 clubscan/backend/src/modules/reviews/application/ports/content-moderation.port.ts create mode 100644 clubscan/backend/src/modules/reviews/application/reviews.service.ts create mode 100644 clubscan/backend/src/modules/reviews/infrastructure/rule-based-moderation.adapter.ts create mode 100644 clubscan/backend/src/modules/reviews/presentation/reviews.controller.ts create mode 100644 clubscan/backend/src/modules/reviews/reviews.module.ts create mode 100644 clubscan/backend/src/modules/venues/application/dto/venue-query.dto.ts create mode 100644 clubscan/backend/src/modules/venues/application/ports/venue.repository.port.ts create mode 100644 clubscan/backend/src/modules/venues/application/venues.service.ts create mode 100644 clubscan/backend/src/modules/venues/infrastructure/prisma-venue.repository.ts create mode 100644 clubscan/backend/src/modules/venues/presentation/venues.controller.ts create mode 100644 clubscan/backend/src/modules/venues/venues.module.ts diff --git a/clubscan/backend/src/app.module.ts b/clubscan/backend/src/app.module.ts new file mode 100644 index 0000000..ebf4f6c --- /dev/null +++ b/clubscan/backend/src/app.module.ts @@ -0,0 +1,50 @@ +import { Module } from '@nestjs/common'; +import { APP_FILTER, APP_GUARD, APP_PIPE } from '@nestjs/core'; +import { ConfigModule } from '@nestjs/config'; +import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; +import { ZodValidationPipe } from 'nestjs-zod'; + +import { validateEnv } from '@/platform/config/configuration'; +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 { 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 { VenuesModule } from '@/modules/venues/venues.module'; +import { ReviewsModule } from '@/modules/reviews/reviews.module'; +import { ScoringModule } from '@/modules/scoring/scoring.module'; +import { HealthModule } from '@/modules/health/health.module'; + +@Module({ + imports: [ + // Platform / cross-cutting + ConfigModule.forRoot({ isGlobal: true, validate: validateEnv }), + PrismaModule, + AppConfigModule, + EventBusModule, + AuditModule, + SecurityModule, + ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]), + + // Feature modules (bounded contexts) + AuthModule, + VenuesModule, + ReviewsModule, + ScoringModule, + HealthModule, + ], + providers: [ + { provide: APP_PIPE, useClass: ZodValidationPipe }, + { provide: APP_FILTER, useClass: AllExceptionsFilter }, + // Order matters: throttle -> authenticate -> authorize. + { provide: APP_GUARD, useClass: ThrottlerGuard }, + { provide: APP_GUARD, useClass: JwtAuthGuard }, + { provide: APP_GUARD, useClass: RolesGuard }, + ], +}) +export class AppModule {} diff --git a/clubscan/backend/src/main.ts b/clubscan/backend/src/main.ts new file mode 100644 index 0000000..883c6aa --- /dev/null +++ b/clubscan/backend/src/main.ts @@ -0,0 +1,44 @@ +import 'reflect-metadata'; +import { Logger, VersioningType } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { NestFactory } from '@nestjs/core'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { patchNestJsSwagger } from 'nestjs-zod'; +import helmet from 'helmet'; +import { AppModule } from './app.module'; + +async function bootstrap(): Promise { + const app = await NestFactory.create(AppModule, { bufferLogs: true }); + const config = app.get(ConfigService); + const logger = new Logger('Bootstrap'); + + // Security headers (Phase 6 §3). + app.use(helmet()); + + // CORS allowlist (bearer tokens, no cookies). + const origins = config.get('CORS_ORIGINS', '*'); + app.enableCors({ + origin: origins === '*' ? true : origins.split(',').map((o) => o.trim()), + credentials: false, + }); + + const prefix = config.get('API_PREFIX', 'api/v1'); + app.setGlobalPrefix(prefix); + app.enableShutdownHooks(); + + // OpenAPI (Zod-aware). + patchNestJsSwagger(); + const swagger = new DocumentBuilder() + .setTitle('ClubScan API') + .setDescription('Safer, more transparent nightlife — ClubScan REST API') + .setVersion('1.0') + .addBearerAuth() + .build(); + SwaggerModule.setup(`${prefix}/docs`, app, SwaggerModule.createDocument(app, swagger)); + + const port = config.get('PORT', 3000); + await app.listen(port); + logger.log(`ClubScan API listening on :${port}/${prefix}`); +} + +void bootstrap(); diff --git a/clubscan/backend/src/modules/health/health.controller.ts b/clubscan/backend/src/modules/health/health.controller.ts new file mode 100644 index 0000000..4f0183f --- /dev/null +++ b/clubscan/backend/src/modules/health/health.controller.ts @@ -0,0 +1,30 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { PrismaService } from '@/platform/prisma/prisma.service'; +import { Public } from '@/platform/security/decorators'; + +@ApiTags('health') +@Controller('health') +export class HealthController { + constructor(private readonly prisma: PrismaService) {} + + @Public() + @Get() + liveness() { + return { status: 'ok', uptime: process.uptime() }; + } + + @Public() + @Get('ready') + async readiness() { + const checks: Record = { database: 'down' }; + try { + await this.prisma.$queryRaw`SELECT 1`; + checks.database = 'ok'; + } catch { + checks.database = 'down'; + } + const ready = Object.values(checks).every((c) => c === 'ok'); + return { status: ready ? 'ready' : 'degraded', checks }; + } +} diff --git a/clubscan/backend/src/modules/health/health.module.ts b/clubscan/backend/src/modules/health/health.module.ts new file mode 100644 index 0000000..fa9d30b --- /dev/null +++ b/clubscan/backend/src/modules/health/health.module.ts @@ -0,0 +1,5 @@ +import { Module } from '@nestjs/common'; +import { HealthController } from './health.controller'; + +@Module({ controllers: [HealthController] }) +export class HealthModule {} diff --git a/clubscan/backend/src/modules/reviews/application/dto/review.dto.ts b/clubscan/backend/src/modules/reviews/application/dto/review.dto.ts new file mode 100644 index 0000000..ec94c4c --- /dev/null +++ b/clubscan/backend/src/modules/reviews/application/dto/review.dto.ts @@ -0,0 +1,29 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +const rating = z.number().int().min(1).max(5); + +export const ReviewRatingsSchema = z.object({ + security: rating, + staffBehavior: rating, + fairPricing: rating, + crowdQuality: rating, + musicQuality: rating, + soundSystem: rating, + cleanliness: rating, + safetyForWomen: rating, + atmosphere: rating, +}); + +export const CreateReviewSchema = z.object({ + ratings: ReviewRatingsSchema, + body: z.string().trim().min(10).max(4000), + photoAssetIds: z.array(z.string().uuid()).max(8).optional(), +}); +export class CreateReviewDto extends createZodDto(CreateReviewSchema) {} + +export const UpdateReviewSchema = z.object({ + ratings: ReviewRatingsSchema.optional(), + body: z.string().trim().min(10).max(4000).optional(), +}); +export class UpdateReviewDto extends createZodDto(UpdateReviewSchema) {} diff --git a/clubscan/backend/src/modules/reviews/application/ports/content-moderation.port.ts b/clubscan/backend/src/modules/reviews/application/ports/content-moderation.port.ts new file mode 100644 index 0000000..b482a34 --- /dev/null +++ b/clubscan/backend/src/modules/reviews/application/ports/content-moderation.port.ts @@ -0,0 +1,18 @@ +export const CONTENT_MODERATION = Symbol('CONTENT_MODERATION'); + +export interface ModerationVerdict { + /** APPROVED -> publish; FLAGGED -> hold for human review. */ + decision: 'APPROVED' | 'FLAGGED'; + scores?: Record; + labels?: string[]; + provider: string; +} + +/** + * Pluggable content moderation (Phase 1 §15, Phase 3 §2). v1 ships a + * deterministic rule-based adapter; an AI provider adapter can be swapped in + * behind this port without changing the review use case. + */ +export interface ContentModerationPort { + moderateText(text: string): Promise; +} diff --git a/clubscan/backend/src/modules/reviews/application/reviews.service.ts b/clubscan/backend/src/modules/reviews/application/reviews.service.ts new file mode 100644 index 0000000..7b222ff --- /dev/null +++ b/clubscan/backend/src/modules/reviews/application/reviews.service.ts @@ -0,0 +1,253 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { + MediaStatus, + ModerationStatus, + Prisma, + ReviewStatus, + UserRole, +} 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, + Paginated, +} from '@/platform/pagination/cursor'; +import { DomainError } from '@/shared/errors/domain-error'; +import { newId } from '@/shared/ids/uuid'; +import { AuthenticatedUser } from '@/platform/security/auth.types'; +import { + CONTENT_MODERATION, + ContentModerationPort, +} from './ports/content-moderation.port'; +import { CreateReviewDto, UpdateReviewDto } from './dto/review.dto'; +import { + ReviewEditedEvent, + ReviewPublishedEvent, + ReviewRemovedEvent, +} from '../domain/review.events'; + +@Injectable() +export class ReviewsService { + constructor( + private readonly prisma: PrismaService, + @Inject(CONTENT_MODERATION) private readonly moderation: ContentModerationPort, + @Inject(EVENT_BUS) private readonly bus: EventBusPort, + ) {} + + async create(userId: string, venueId: string, dto: CreateReviewDto) { + const venue = await this.prisma.venue.findFirst({ + where: { id: venueId, deletedAt: null }, + select: { id: true }, + }); + if (!venue) throw DomainError.notFound('Venue', venueId); + + const existing = await this.prisma.review.findUnique({ + where: { userId_venueId: { userId, venueId } }, + select: { id: true }, + }); + if (existing) { + throw DomainError.conflict('You have already reviewed this venue'); + } + + const photoUrls = await this.resolveOwnedPhotos(userId, dto.photoAssetIds); + const verdict = await this.moderation.moderateText(dto.body); + const published = verdict.decision === 'APPROVED'; + + const reviewId = newId(); + await this.prisma.review.create({ + data: { + id: reviewId, + userId, + venueId, + body: dto.body, + status: published ? ReviewStatus.PUBLISHED : ReviewStatus.HELD, + moderationStatus: published ? ModerationStatus.APPROVED : ModerationStatus.FLAGGED, + rating: { create: { ...dto.ratings } }, + photos: { + create: photoUrls.map((url, i) => ({ id: newId(), url, position: i })), + }, + }, + }); + + if (published) { + await this.bus.publish(new ReviewPublishedEvent({ reviewId, venueId, authorId: userId })); + } + + return this.getById(reviewId); + } + + async update(user: AuthenticatedUser, reviewId: string, dto: UpdateReviewDto) { + const review = await this.prisma.review.findFirst({ + where: { id: reviewId, deletedAt: null }, + include: { rating: true }, + }); + if (!review) throw DomainError.notFound('Review', reviewId); + if (review.userId !== user.id && this.rank(user.role) < this.rank(UserRole.MODERATOR)) { + throw DomainError.forbidden('You cannot edit this review'); + } + + // Snapshot prior state into immutable edit history. + await this.prisma.reviewEdit.create({ + data: { + id: newId(), + reviewId, + bodySnapshot: review.body, + ratingSnapshot: (review.rating ?? {}) as unknown as Prisma.InputJsonValue, + }, + }); + + const verdict = dto.body ? await this.moderation.moderateText(dto.body) : null; + const published = verdict ? verdict.decision === 'APPROVED' : review.status === ReviewStatus.PUBLISHED; + + await this.prisma.review.update({ + where: { id: reviewId }, + data: { + ...(dto.body ? { body: dto.body } : {}), + ...(verdict + ? { + status: published ? ReviewStatus.PUBLISHED : ReviewStatus.HELD, + moderationStatus: published ? ModerationStatus.APPROVED : ModerationStatus.FLAGGED, + } + : {}), + ...(dto.ratings ? { rating: { update: { ...dto.ratings } } } : {}), + }, + }); + + await this.bus.publish( + new ReviewEditedEvent({ reviewId, venueId: review.venueId, authorId: review.userId }), + ); + return this.getById(reviewId); + } + + async remove(user: AuthenticatedUser, reviewId: string) { + const review = await this.prisma.review.findFirst({ + where: { id: reviewId, deletedAt: null }, + select: { id: true, userId: true, venueId: true }, + }); + if (!review) throw DomainError.notFound('Review', reviewId); + const isModerator = this.rank(user.role) >= this.rank(UserRole.MODERATOR); + if (review.userId !== user.id && !isModerator) { + throw DomainError.forbidden('You cannot delete this review'); + } + + await this.prisma.review.update({ + where: { id: reviewId }, + data: { + deletedAt: new Date(), + status: ReviewStatus.REMOVED, + }, + }); + await this.bus.publish( + new ReviewRemovedEvent({ reviewId, venueId: review.venueId, authorId: review.userId }), + ); + return { ok: true }; + } + + async markHelpful(userId: string, reviewId: string, helpful: boolean) { + const review = await this.prisma.review.findFirst({ + where: { id: reviewId, deletedAt: null }, + select: { id: true }, + }); + if (!review) throw DomainError.notFound('Review', reviewId); + + if (helpful) { + await this.prisma.$transaction(async (tx) => { + const created = await tx.reviewHelpful + .create({ data: { id: newId(), reviewId, userId } }) + .catch(() => null); + if (created) { + await tx.review.update({ + where: { id: reviewId }, + data: { helpfulCount: { increment: 1 } }, + }); + } + }); + } else { + await this.prisma.$transaction(async (tx) => { + const deleted = await tx.reviewHelpful + .delete({ where: { reviewId_userId: { reviewId, userId } } }) + .catch(() => null); + if (deleted) { + await tx.review.update({ + where: { id: reviewId }, + data: { helpfulCount: { decrement: 1 } }, + }); + } + }); + } + return { ok: true }; + } + + async listForVenue( + venueId: string, + sort: 'recent' | 'helpful', + cursor?: string, + limit?: number, + ): Promise> { + const take = clampLimit(limit); + const decoded = decodeCursor(cursor); + + const rows = await this.prisma.review.findMany({ + where: { + venueId, + status: ReviewStatus.PUBLISHED, + deletedAt: null, + ...(decoded + ? { + OR: [ + { createdAt: { lt: new Date(decoded.createdAt) } }, + { createdAt: new Date(decoded.createdAt), id: { lt: decoded.id } }, + ], + } + : {}), + }, + include: { + rating: true, + photos: { orderBy: { position: 'asc' } }, + user: { select: { id: true, profile: { select: { username: true, avatarUrl: true } } } }, + }, + orderBy: + sort === 'helpful' + ? [{ helpfulCount: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }] + : [{ createdAt: 'desc' }, { id: 'desc' }], + take: take + 1, + }); + + return buildPage(rows, take); + } + + async getById(reviewId: string) { + const review = await this.prisma.review.findFirst({ + where: { id: reviewId, deletedAt: null }, + include: { + rating: true, + photos: { orderBy: { position: 'asc' } }, + user: { select: { id: true, profile: { select: { username: true, avatarUrl: true } } } }, + }, + }); + if (!review) throw DomainError.notFound('Review', reviewId); + return review; + } + + private async resolveOwnedPhotos(userId: string, assetIds?: string[]): Promise { + if (!assetIds || assetIds.length === 0) return []; + const assets = await this.prisma.mediaAsset.findMany({ + where: { id: { in: assetIds }, ownerId: userId, status: MediaStatus.READY }, + }); + if (assets.length !== assetIds.length) { + throw DomainError.validation('One or more photo assets are invalid or not ready'); + } + // Preserve caller-provided order. + const byId = new Map(assets.map((a) => [a.id, a])); + return assetIds.map((id) => { + const a = byId.get(id)!; + return `${a.bucket}/${a.key}`; + }); + } + + private rank(role: UserRole): number { + return { USER: 0, MODERATOR: 1, ADMIN: 2, SUPER_ADMIN: 3 }[role]; + } +} diff --git a/clubscan/backend/src/modules/reviews/infrastructure/rule-based-moderation.adapter.ts b/clubscan/backend/src/modules/reviews/infrastructure/rule-based-moderation.adapter.ts new file mode 100644 index 0000000..b0f99c0 --- /dev/null +++ b/clubscan/backend/src/modules/reviews/infrastructure/rule-based-moderation.adapter.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@nestjs/common'; +import { + ContentModerationPort, + ModerationVerdict, +} from '../application/ports/content-moderation.port'; + +/** + * Deterministic, dependency-free moderation baseline: flags content with + * banned terms, excessive links, or shouting. Provides defense-in-depth and a + * working default until an AI provider adapter is configured. + */ +@Injectable() +export class RuleBasedModerationAdapter implements ContentModerationPort { + private readonly bannedTerms = [ + // Minimal illustrative list; real list is config-driven and localized. + 'kill yourself', + 'kys', + ]; + + async moderateText(text: string): Promise { + const lower = text.toLowerCase(); + const labels: string[] = []; + + if (this.bannedTerms.some((t) => lower.includes(t))) labels.push('harassment'); + + const linkCount = (text.match(/https?:\/\//g) ?? []).length; + if (linkCount > 2) labels.push('spam'); + + const letters = text.replace(/[^a-zA-Z]/g, ''); + const upper = text.replace(/[^A-Z]/g, ''); + if (letters.length > 20 && upper.length / letters.length > 0.7) labels.push('shouting'); + + return { + decision: labels.includes('harassment') || labels.includes('spam') ? 'FLAGGED' : 'APPROVED', + labels, + provider: 'rule-based-v1', + }; + } +} diff --git a/clubscan/backend/src/modules/reviews/presentation/reviews.controller.ts b/clubscan/backend/src/modules/reviews/presentation/reviews.controller.ts new file mode 100644 index 0000000..6592461 --- /dev/null +++ b/clubscan/backend/src/modules/reviews/presentation/reviews.controller.ts @@ -0,0 +1,64 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { CurrentUser, Public } from '@/platform/security/decorators'; +import { AuthenticatedUser } from '@/platform/security/auth.types'; +import { ReviewsService } from '../application/reviews.service'; +import { CreateReviewDto, UpdateReviewDto } from '../application/dto/review.dto'; + +@ApiTags('reviews') +@Controller() +export class ReviewsController { + constructor(private readonly reviews: ReviewsService) {} + + @Public() + @Get('venues/:venueId/reviews') + listForVenue( + @Param('venueId') venueId: string, + @Query('sort') sort: 'recent' | 'helpful' = 'recent', + @Query('cursor') cursor?: string, + @Query('limit') limit?: number, + ) { + return this.reviews.listForVenue(venueId, sort === 'helpful' ? 'helpful' : 'recent', cursor, limit); + } + + @Throttle({ default: { limit: 5, ttl: 60 * 60 * 1000 } }) + @Post('venues/:venueId/reviews') + create( + @CurrentUser('id') userId: string, + @Param('venueId') venueId: string, + @Body() dto: CreateReviewDto, + ) { + return this.reviews.create(userId, venueId, dto); + } + + @Public() + @Get('reviews/:id') + getOne(@Param('id') id: string) { + return this.reviews.getById(id); + } + + @Patch('reviews/:id') + update( + @CurrentUser() user: AuthenticatedUser, + @Param('id') id: string, + @Body() dto: UpdateReviewDto, + ) { + return this.reviews.update(user, id, dto); + } + + @Delete('reviews/:id') + remove(@CurrentUser() user: AuthenticatedUser, @Param('id') id: string) { + return this.reviews.remove(user, id); + } + + @Post('reviews/:id/helpful') + markHelpful(@CurrentUser('id') userId: string, @Param('id') id: string) { + return this.reviews.markHelpful(userId, id, true); + } + + @Delete('reviews/:id/helpful') + unmarkHelpful(@CurrentUser('id') userId: string, @Param('id') id: string) { + return this.reviews.markHelpful(userId, id, false); + } +} diff --git a/clubscan/backend/src/modules/reviews/reviews.module.ts b/clubscan/backend/src/modules/reviews/reviews.module.ts new file mode 100644 index 0000000..c0e68ca --- /dev/null +++ b/clubscan/backend/src/modules/reviews/reviews.module.ts @@ -0,0 +1,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 { ReviewsController } from './presentation/reviews.controller'; + +@Module({ + controllers: [ReviewsController], + providers: [ + ReviewsService, + { provide: CONTENT_MODERATION, useClass: RuleBasedModerationAdapter }, + ], +}) +export class ReviewsModule {} diff --git a/clubscan/backend/src/modules/venues/application/dto/venue-query.dto.ts b/clubscan/backend/src/modules/venues/application/dto/venue-query.dto.ts new file mode 100644 index 0000000..0b65c03 --- /dev/null +++ b/clubscan/backend/src/modules/venues/application/dto/venue-query.dto.ts @@ -0,0 +1,19 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; +import { VenueType } from '@prisma/client'; + +export const VenueQuerySchema = z.object({ + city: z.string().trim().min(1).max(80).optional(), + type: z.nativeEnum(VenueType).optional(), + genre: z.string().trim().min(1).max(40).optional(), + q: z.string().trim().min(1).max(80).optional(), + near: z + .string() + .regex(/^-?\d{1,3}(\.\d+)?,-?\d{1,3}(\.\d+)?$/, 'near must be "lat,lng"') + .optional(), + radiusKm: z.coerce.number().min(0.1).max(50).default(10), + sort: z.enum(['score', 'recent', 'distance']).default('score'), + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(50).optional(), +}); +export class VenueQueryDto extends createZodDto(VenueQuerySchema) {} diff --git a/clubscan/backend/src/modules/venues/application/ports/venue.repository.port.ts b/clubscan/backend/src/modules/venues/application/ports/venue.repository.port.ts new file mode 100644 index 0000000..40f9854 --- /dev/null +++ b/clubscan/backend/src/modules/venues/application/ports/venue.repository.port.ts @@ -0,0 +1,37 @@ +import { Prisma, Venue, VenueScore } from '@prisma/client'; +import { Paginated } from '@/platform/pagination/cursor'; + +export const VENUE_REPOSITORY = Symbol('VENUE_REPOSITORY'); + +export interface VenueListFilter { + city?: string; + type?: Venue['type']; + genre?: string; + q?: string; + near?: { lat: number; lng: number }; + radiusKm: number; + sort: 'score' | 'recent' | 'distance'; + cursor?: string; + limit?: number; +} + +export type VenueWithScore = Venue & { score: VenueScore | null }; + +export type VenueDetail = Prisma.VenueGetPayload<{ + include: { + score: true; + photos: true; + hours: true; + genres: { include: { genre: true } }; + }; +}>; + +/** + * Repository port — hides Prisma from the application layer (Phase 3 §1). + * Geo queries are implemented with raw SQL (PostGIS ST_DWithin) in the adapter. + */ +export interface VenueRepositoryPort { + list(filter: VenueListFilter): Promise>; + findBySlug(slug: string): Promise; + findById(id: string): Promise; +} diff --git a/clubscan/backend/src/modules/venues/application/venues.service.ts b/clubscan/backend/src/modules/venues/application/venues.service.ts new file mode 100644 index 0000000..2c9f592 --- /dev/null +++ b/clubscan/backend/src/modules/venues/application/venues.service.ts @@ -0,0 +1,39 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DomainError } from '@/shared/errors/domain-error'; +import { VenueQueryDto } from './dto/venue-query.dto'; +import { + VENUE_REPOSITORY, + VenueListFilter, + VenueRepositoryPort, +} from './ports/venue.repository.port'; + +@Injectable() +export class VenuesService { + constructor( + @Inject(VENUE_REPOSITORY) private readonly venues: VenueRepositoryPort, + ) {} + + async list(query: VenueQueryDto) { + const filter: VenueListFilter = { + city: query.city, + type: query.type, + genre: query.genre, + q: query.q, + radiusKm: query.radiusKm, + sort: query.sort, + cursor: query.cursor, + limit: query.limit, + }; + if (query.near) { + const [lat, lng] = query.near.split(',').map(Number); + filter.near = { lat, lng }; + } + return this.venues.list(filter); + } + + async getBySlug(slug: string) { + const venue = await this.venues.findBySlug(slug); + if (!venue) throw DomainError.notFound('Venue'); + return venue; + } +} diff --git a/clubscan/backend/src/modules/venues/infrastructure/prisma-venue.repository.ts b/clubscan/backend/src/modules/venues/infrastructure/prisma-venue.repository.ts new file mode 100644 index 0000000..8be7b79 --- /dev/null +++ b/clubscan/backend/src/modules/venues/infrastructure/prisma-venue.repository.ts @@ -0,0 +1,114 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma, VenueStatus } from '@prisma/client'; +import { PrismaService } from '@/platform/prisma/prisma.service'; +import { buildPage, clampLimit, decodeCursor, Paginated } from '@/platform/pagination/cursor'; +import { + VenueDetail, + VenueListFilter, + VenueRepositoryPort, + VenueWithScore, +} from '../application/ports/venue.repository.port'; + +@Injectable() +export class PrismaVenueRepository implements VenueRepositoryPort { + constructor(private readonly prisma: PrismaService) {} + + async list(filter: VenueListFilter): Promise> { + const limit = clampLimit(filter.limit); + + // Geo/distance sort uses raw PostGIS; other sorts use keyset pagination. + if (filter.near && filter.sort === 'distance') { + return this.listByDistance(filter, limit); + } + + const cursor = decodeCursor(filter.cursor); + const where: Prisma.VenueWhereInput = { + status: VenueStatus.PUBLISHED, + deletedAt: null, + ...(filter.city ? { city: { equals: filter.city, mode: 'insensitive' } } : {}), + ...(filter.type ? { type: filter.type } : {}), + ...(filter.genre + ? { genres: { some: { genre: { slug: filter.genre } } } } + : {}), + ...(filter.q + ? { + OR: [ + { name: { contains: filter.q, mode: 'insensitive' } }, + { description: { contains: filter.q, mode: 'insensitive' } }, + ], + } + : {}), + ...(cursor + ? { + OR: [ + { createdAt: { lt: new Date(cursor.createdAt) } }, + { createdAt: new Date(cursor.createdAt), id: { lt: cursor.id } }, + ], + } + : {}), + }; + + const orderBy: Prisma.VenueOrderByWithRelationInput[] = + filter.sort === 'score' + ? [{ score: { score: 'desc' } }, { createdAt: 'desc' }, { id: 'desc' }] + : [{ createdAt: 'desc' }, { id: 'desc' }]; + + const rows = await this.prisma.venue.findMany({ + where, + include: { score: true }, + orderBy, + take: limit + 1, + }); + + return buildPage(rows, limit); + } + + private async listByDistance( + filter: VenueListFilter, + limit: number, + ): Promise> { + const { lat, lng } = filter.near!; + const radiusMeters = filter.radiusKm * 1000; + + // Raw PostGIS query; parameters are bound (no SQL injection). + const ids = await this.prisma.$queryRaw<{ id: string }[]>` + SELECT id + FROM venues + WHERE status = 'PUBLISHED' + AND "deletedAt" IS NULL + AND ST_DWithin( + geog, + ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography, + ${radiusMeters} + ) + ORDER BY geog <-> ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography + LIMIT ${limit} + `; + + const venues = await this.prisma.venue.findMany({ + where: { id: { in: ids.map((r) => r.id) } }, + include: { score: true }, + }); + // Preserve distance ordering from the raw query. + const order = new Map(ids.map((r, i) => [r.id, i])); + venues.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)); + + return { data: venues, nextCursor: null }; + } + + async findBySlug(slug: string): Promise { + return this.prisma.venue.findFirst({ + where: { slug, deletedAt: null }, + include: { + score: true, + photos: { orderBy: { position: 'asc' } }, + hours: true, + genres: { include: { genre: true } }, + }, + }); + } + + async findById(id: string) { + return this.prisma.venue.findFirst({ where: { id, deletedAt: null } }); + } +} diff --git a/clubscan/backend/src/modules/venues/presentation/venues.controller.ts b/clubscan/backend/src/modules/venues/presentation/venues.controller.ts new file mode 100644 index 0000000..9bf7d86 --- /dev/null +++ b/clubscan/backend/src/modules/venues/presentation/venues.controller.ts @@ -0,0 +1,23 @@ +import { Controller, Get, Param, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Public } from '@/platform/security/decorators'; +import { VenuesService } from '../application/venues.service'; +import { VenueQueryDto } from '../application/dto/venue-query.dto'; + +@ApiTags('venues') +@Controller('venues') +export class VenuesController { + constructor(private readonly venues: VenuesService) {} + + @Public() + @Get() + list(@Query() query: VenueQueryDto) { + return this.venues.list(query); + } + + @Public() + @Get(':slug') + detail(@Param('slug') slug: string) { + return this.venues.getBySlug(slug); + } +} diff --git a/clubscan/backend/src/modules/venues/venues.module.ts b/clubscan/backend/src/modules/venues/venues.module.ts new file mode 100644 index 0000000..4ad4af4 --- /dev/null +++ b/clubscan/backend/src/modules/venues/venues.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { VenuesService } from './application/venues.service'; +import { VENUE_REPOSITORY } from './application/ports/venue.repository.port'; +import { PrismaVenueRepository } from './infrastructure/prisma-venue.repository'; +import { VenuesController } from './presentation/venues.controller'; + +@Module({ + controllers: [VenuesController], + providers: [ + VenuesService, + { provide: VENUE_REPOSITORY, useClass: PrismaVenueRepository }, + ], + exports: [VENUE_REPOSITORY], +}) +export class VenuesModule {} From 87d77c9d57eb1cac6cd643fdd2fc6816ecc8b1d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 19:53:19 +0000 Subject: [PATCH 7/8] feat(clubscan): seed, PostGIS migration, Docker, compose, CI workflow, README --- .github/workflows/clubscan-backend-ci.yml | 76 ++++++++++++++++++++ clubscan/README.md | 67 ++++++++++++++++++ clubscan/backend/.env.example | 35 ++++++++++ clubscan/backend/.eslintrc.cjs | 17 +++++ clubscan/backend/.gitignore | 6 ++ clubscan/backend/Dockerfile | 32 +++++++++ clubscan/backend/docker-entrypoint.sh | 10 +++ clubscan/backend/prisma/seed.ts | 85 +++++++++++++++++++++++ clubscan/backend/prisma/sql/postgis.sql | 21 ++++++ clubscan/docker-compose.yml | 85 +++++++++++++++++++++++ 10 files changed, 434 insertions(+) create mode 100644 .github/workflows/clubscan-backend-ci.yml create mode 100644 clubscan/README.md create mode 100644 clubscan/backend/.env.example create mode 100644 clubscan/backend/.eslintrc.cjs create mode 100644 clubscan/backend/.gitignore create mode 100644 clubscan/backend/Dockerfile create mode 100644 clubscan/backend/docker-entrypoint.sh create mode 100644 clubscan/backend/prisma/seed.ts create mode 100644 clubscan/backend/prisma/sql/postgis.sql create mode 100644 clubscan/docker-compose.yml diff --git a/.github/workflows/clubscan-backend-ci.yml b/.github/workflows/clubscan-backend-ci.yml new file mode 100644 index 0000000..b20ea25 --- /dev/null +++ b/.github/workflows/clubscan-backend-ci.yml @@ -0,0 +1,76 @@ +name: clubscan-backend-ci + +on: + push: + branches: [main, "claude/**"] + paths: + - "clubscan/backend/**" + - ".github/workflows/clubscan-backend-ci.yml" + pull_request: + paths: + - "clubscan/backend/**" + - ".github/workflows/clubscan-backend-ci.yml" + +defaults: + run: + working-directory: clubscan/backend + +jobs: + build-test: + runs-on: ubuntu-latest + services: + postgres: + image: postgis/postgis:16-3.4 + env: + POSTGRES_USER: clubscan + POSTGRES_PASSWORD: clubscan + POSTGRES_DB: clubscan_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U clubscan" + --health-interval 5s --health-timeout 5s --health-retries 10 + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s --health-timeout 3s --health-retries 10 + env: + DATABASE_URL: postgresql://clubscan:clubscan@localhost:5432/clubscan_test?schema=public + REDIS_URL: redis://localhost:6379 + JWT_ACCESS_SECRET: ci-only-secret-please-change-me-32chars + NODE_ENV: test + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: clubscan/backend/pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --no-frozen-lockfile + + - name: Prisma generate + run: pnpm prisma:generate + + - name: Lint + run: pnpm lint + + - name: Typecheck + run: pnpm typecheck + + - name: Migrate + PostGIS + run: | + pnpm prisma migrate deploy || pnpm prisma db push + psql "$DATABASE_URL" -f prisma/sql/postgis.sql || true + + - name: Unit tests + run: pnpm test diff --git a/clubscan/README.md b/clubscan/README.md new file mode 100644 index 0000000..156a9d6 --- /dev/null +++ b/clubscan/README.md @@ -0,0 +1,67 @@ +# ClubScan + +> A safer, more transparent nightlife ecosystem — discover clubs, evaluate venues with +> structured trust signals, share experiences, and make informed decisions. + +ClubScan is a production-grade mobile platform built to scale. This repository contains the +full architecture (Phases 1–7) and the production code foundation (Phase 8). + +## Repository layout + +``` +clubscan/ +├── docs/ Architecture (8 phases): product, DB, backend, frontend, infra, security, folders +├── backend/ NestJS modular monolith (Clean Architecture + DDD + CQRS where it pays off) +├── mobile/ Expo (React Native) app — design system, navigation, state, API client +├── docker-compose.yml +└── README.md +``` + +## Architecture docs + +| Phase | Document | +|---|---| +| 1 | [Product Architecture](docs/01-product-architecture.md) | +| 2 | [Database Design](docs/02-database-design.md) | +| 3 | [Backend Architecture](docs/03-backend-architecture.md) | +| 4 | [Frontend Architecture](docs/04-frontend-architecture.md) | +| 5 | [Infrastructure Architecture](docs/05-infrastructure-architecture.md) | +| 6 | [Security Architecture](docs/06-security-architecture.md) | +| 7 | [Folder Structures](docs/07-folder-structures.md) | + +## Quick start (local) + +```bash +# 1) Bring up Postgres (PostGIS), Redis, MinIO, Mailhog +cd clubscan +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 +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) +``` + +## The ClubScan Score + +Venues are rated across nine structured categories (Security, Staff behavior, Fair pricing, +Crowd quality, Music quality, Sound system, Cleanliness, **Safety for women**, Atmosphere). +The composite 0–100 score is a weighted, recency-decayed, reputation-weighted, Bayesian-shrunk +aggregate that prioritizes safety and resists manipulation. See +[Backend §11](docs/03-backend-architecture.md) and `backend/src/modules/scoring`. + +## Tech stack + +**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 + +## License + +UNLICENSED — proprietary. diff --git a/clubscan/backend/.env.example b/clubscan/backend/.env.example new file mode 100644 index 0000000..f05c263 --- /dev/null +++ b/clubscan/backend/.env.example @@ -0,0 +1,35 @@ +# ---- Runtime ---- +NODE_ENV=development +PORT=3000 +API_PREFIX=api/v1 +CORS_ORIGINS=* + +# ---- Data ---- +DATABASE_URL=postgresql://clubscan:clubscan@localhost:5432/clubscan?schema=public +REDIS_URL=redis://localhost:6379 + +# ---- Auth ---- +# Generate strong secrets: `openssl rand -base64 48` +JWT_ACCESS_SECRET=replace-with-a-strong-32+char-secret-value +JWT_ACCESS_TTL=15m +JWT_REFRESH_TTL_DAYS=30 + +# ---- OAuth ---- +GOOGLE_OAUTH_CLIENT_ID= +APPLE_OAUTH_CLIENT_ID= + +# ---- Storage (S3-compatible; MinIO in local dev) ---- +S3_ENDPOINT=http://localhost:9000 +S3_REGION=us-east-1 +S3_BUCKET=clubscan-media +S3_ACCESS_KEY=clubscan +S3_SECRET_KEY=clubscan-secret + +# ---- Push / Observability ---- +FCM_PROJECT_ID= +SENTRY_DSN= +OTEL_EXPORTER_OTLP_ENDPOINT= + +# ---- Seed (dev) ---- +SEED_ADMIN_EMAIL=admin@clubscan.app +SEED_ADMIN_PASSWORD=ChangeMe123! diff --git a/clubscan/backend/.eslintrc.cjs b/clubscan/backend/.eslintrc.cjs new file mode 100644 index 0000000..998de38 --- /dev/null +++ b/clubscan/backend/.eslintrc.cjs @@ -0,0 +1,17 @@ +module.exports = { + parser: '@typescript-eslint/parser', + parserOptions: { project: 'tsconfig.json', sourceType: 'module' }, + plugins: ['@typescript-eslint'], + extends: [ + 'plugin:@typescript-eslint/recommended', + 'prettier', + ], + root: true, + env: { node: true }, + ignorePatterns: ['dist', 'node_modules', '.eslintrc.cjs'], + rules: { + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + }, +}; diff --git a/clubscan/backend/.gitignore b/clubscan/backend/.gitignore new file mode 100644 index 0000000..dd6ef19 --- /dev/null +++ b/clubscan/backend/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +coverage/ +.env +*.log +.DS_Store diff --git a/clubscan/backend/Dockerfile b/clubscan/backend/Dockerfile new file mode 100644 index 0000000..007f5f9 --- /dev/null +++ b/clubscan/backend/Dockerfile @@ -0,0 +1,32 @@ +# ClubScan API — multi-stage build (Phase 5 §2) +# 1) deps 2) build (tsc + prisma generate) 3) slim non-root runtime + +FROM node:22-bookworm-slim AS deps +WORKDIR /app +RUN corepack enable +COPY package.json pnpm-lock.yaml* ./ +RUN npm install -g pnpm@9 && pnpm install --frozen-lockfile || pnpm install + +FROM node:22-bookworm-slim AS build +WORKDIR /app +RUN corepack enable && npm install -g pnpm@9 +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN pnpm prisma generate && pnpm build + +FROM node:22-bookworm-slim AS runtime +ENV NODE_ENV=production +WORKDIR /app +# Non-root user +RUN groupadd -r app && useradd -r -g app app +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/dist ./dist +COPY --from=build /app/prisma ./prisma +COPY --from=build /app/package.json ./ +COPY docker-entrypoint.sh ./ +RUN chmod +x docker-entrypoint.sh && chown -R app:app /app +USER app +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \ + CMD node -e "fetch('http://localhost:3000/api/v1/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" +ENTRYPOINT ["./docker-entrypoint.sh"] diff --git a/clubscan/backend/docker-entrypoint.sh b/clubscan/backend/docker-entrypoint.sh new file mode 100644 index 0000000..a164023 --- /dev/null +++ b/clubscan/backend/docker-entrypoint.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -e + +# Apply migrations forward-only (advisory-locked by Prisma to avoid races across +# replicas — Phase 5 §4). Then start the API. +echo "Running database migrations..." +npx prisma migrate deploy + +echo "Starting ClubScan API..." +exec node dist/main.js diff --git a/clubscan/backend/prisma/seed.ts b/clubscan/backend/prisma/seed.ts new file mode 100644 index 0000000..41f8832 --- /dev/null +++ b/clubscan/backend/prisma/seed.ts @@ -0,0 +1,85 @@ +import { 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'; + +const prisma = new PrismaClient(); + +const GENRES = [ + 'House', + 'Techno', + 'Hip-Hop', + 'Disco', + 'Drum & Bass', + 'Afrobeats', + 'Pop', + 'R&B', +]; + +async function main(): Promise { + // Genres + for (const name of GENRES) { + const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-'); + await prisma.genre.upsert({ + where: { slug }, + update: {}, + create: { id: uuidv7(), name, slug }, + }); + } + + // Singleton app config (scoring weights/thresholds). + await prisma.appConfig.upsert({ + where: { id: 1 }, + update: { config: { scoring: DEFAULT_SCORING_CONFIG } }, + create: { id: 1, config: { scoring: DEFAULT_SCORING_CONFIG } }, + }); + + // Super admin (env-driven). + const adminEmail = process.env.SEED_ADMIN_EMAIL ?? 'admin@clubscan.app'; + const adminPassword = process.env.SEED_ADMIN_PASSWORD ?? 'ChangeMe123!'; + const adminId = uuidv7(); + await prisma.user.upsert({ + where: { email: adminEmail }, + update: { role: UserRole.SUPER_ADMIN }, + create: { + id: adminId, + email: adminEmail, + passwordHash: await argon2.hash(adminPassword, { type: argon2.argon2id }), + emailVerifiedAt: new Date(), + role: UserRole.SUPER_ADMIN, + profile: { create: { id: uuidv7(), username: 'clubscan', displayName: 'ClubScan' } }, + }, + }); + + // Dev demo venue (only outside production). + if (process.env.NODE_ENV !== 'production') { + const venueId = uuidv7(); + await prisma.venue.upsert({ + where: { slug: 'neon-cathedral' }, + update: {}, + create: { + id: venueId, + slug: 'neon-cathedral', + name: 'Neon Cathedral', + description: 'Flagship techno club with a renowned sound system.', + type: VenueType.CLUB, + city: 'Istanbul', + country: 'TR', + latitude: 41.0082, + longitude: 28.9784, + capacity: 1200, + }, + }); + } + + // eslint-disable-next-line no-console + console.log('Seed complete'); +} + +main() + .catch((e) => { + // eslint-disable-next-line no-console + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/clubscan/backend/prisma/sql/postgis.sql b/clubscan/backend/prisma/sql/postgis.sql new file mode 100644 index 0000000..91e37cd --- /dev/null +++ b/clubscan/backend/prisma/sql/postgis.sql @@ -0,0 +1,21 @@ +-- 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/docker-compose.yml b/clubscan/docker-compose.yml new file mode 100644 index 0000000..adad4be --- /dev/null +++ b/clubscan/docker-compose.yml @@ -0,0 +1,85 @@ +# ClubScan — local development stack (Phase 5 §2) +# Postgres (PostGIS), Redis, MinIO (S3), Mailhog, and the API. +version: "3.9" + +services: + postgres: + image: postgis/postgis:16-3.4 + environment: + POSTGRES_USER: clubscan + POSTGRES_PASSWORD: clubscan + POSTGRES_DB: clubscan + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U clubscan"] + interval: 5s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + minio: + image: minio/minio:latest + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: clubscan + MINIO_ROOT_PASSWORD: clubscan-secret + ports: + - "9000:9000" + - "9001:9001" + volumes: + - miniodata:/data + + createbuckets: + image: minio/mc:latest + depends_on: + - minio + entrypoint: > + /bin/sh -c " + until (mc alias set local http://minio:9000 clubscan clubscan-secret) do sleep 2; done; + mc mb -p local/clubscan-media || true; + mc anonymous set none local/clubscan-media; + exit 0; + " + + mailhog: + image: mailhog/mailhog:latest + ports: + - "1025:1025" + - "8025:8025" + + api: + build: + context: ./backend + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + environment: + NODE_ENV: development + PORT: 3000 + DATABASE_URL: postgresql://clubscan:clubscan@postgres:5432/clubscan?schema=public + REDIS_URL: redis://redis:6379 + JWT_ACCESS_SECRET: dev-only-secret-please-change-me-32chars + S3_ENDPOINT: http://minio:9000 + S3_ACCESS_KEY: clubscan + S3_SECRET_KEY: clubscan-secret + S3_BUCKET: clubscan-media + ports: + - "3000:3000" + +volumes: + pgdata: + miniodata: From 199bb441288e4c546cb015eeb9b7fbd6ad03103a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 19:58:35 +0000 Subject: [PATCH 8/8] =?UTF-8?q?feat(clubscan):=20mobile=20foundation=20?= =?UTF-8?q?=E2=80=94=20Expo=20Router,=20theme,=20API=20client,=20stores,?= =?UTF-8?q?=20auth=20+=20discover=20+=20venue=20screens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- clubscan/mobile/.env.example | 4 + clubscan/mobile/.gitignore | 8 ++ clubscan/mobile/app.config.ts | 41 +++++++ clubscan/mobile/app/(auth)/login.tsx | 92 +++++++++++++++ clubscan/mobile/app/(auth)/register.tsx | 78 +++++++++++++ clubscan/mobile/app/(auth)/welcome.tsx | 30 +++++ clubscan/mobile/app/(tabs)/_layout.tsx | 20 ++++ clubscan/mobile/app/(tabs)/activity.tsx | 13 +++ clubscan/mobile/app/(tabs)/index.tsx | 47 ++++++++ clubscan/mobile/app/(tabs)/profile.tsx | 27 +++++ clubscan/mobile/app/(tabs)/search.tsx | 15 +++ clubscan/mobile/app/_layout.tsx | 50 ++++++++ clubscan/mobile/app/venue/[slug].tsx | 101 +++++++++++++++++ clubscan/mobile/babel.config.js | 10 ++ clubscan/mobile/global.css | 3 + clubscan/mobile/metro.config.js | 6 + clubscan/mobile/nativewind-env.d.ts | 1 + clubscan/mobile/package.json | 51 +++++++++ clubscan/mobile/src/components/Providers.tsx | 14 +++ .../src/components/domain/VenueCard.tsx | 57 ++++++++++ clubscan/mobile/src/components/ui/Button.tsx | 54 +++++++++ .../mobile/src/components/ui/ScoreRing.tsx | 61 ++++++++++ .../mobile/src/features/auth/mutations.ts | 32 ++++++ clubscan/mobile/src/features/auth/schema.ts | 28 +++++ .../mobile/src/features/venues/queries.ts | 50 ++++++++ clubscan/mobile/src/i18n/en.json | 31 +++++ clubscan/mobile/src/i18n/index.ts | 16 +++ clubscan/mobile/src/i18n/tr.json | 31 +++++ clubscan/mobile/src/lib/api/client.ts | 107 ++++++++++++++++++ clubscan/mobile/src/lib/api/errors.ts | 23 ++++ clubscan/mobile/src/lib/api/types.ts | 58 ++++++++++ clubscan/mobile/src/lib/config.ts | 15 +++ clubscan/mobile/src/lib/query/queryClient.ts | 40 +++++++ clubscan/mobile/src/stores/authStore.ts | 84 ++++++++++++++ clubscan/mobile/src/stores/uiStore.ts | 42 +++++++ clubscan/mobile/tailwind.config.js | 32 ++++++ clubscan/mobile/tsconfig.json | 11 ++ 37 files changed, 1383 insertions(+) create mode 100644 clubscan/mobile/.env.example create mode 100644 clubscan/mobile/.gitignore create mode 100644 clubscan/mobile/app.config.ts create mode 100644 clubscan/mobile/app/(auth)/login.tsx create mode 100644 clubscan/mobile/app/(auth)/register.tsx create mode 100644 clubscan/mobile/app/(auth)/welcome.tsx create mode 100644 clubscan/mobile/app/(tabs)/_layout.tsx create mode 100644 clubscan/mobile/app/(tabs)/activity.tsx create mode 100644 clubscan/mobile/app/(tabs)/index.tsx create mode 100644 clubscan/mobile/app/(tabs)/profile.tsx create mode 100644 clubscan/mobile/app/(tabs)/search.tsx create mode 100644 clubscan/mobile/app/_layout.tsx create mode 100644 clubscan/mobile/app/venue/[slug].tsx create mode 100644 clubscan/mobile/babel.config.js create mode 100644 clubscan/mobile/global.css create mode 100644 clubscan/mobile/metro.config.js create mode 100644 clubscan/mobile/nativewind-env.d.ts create mode 100644 clubscan/mobile/package.json create mode 100644 clubscan/mobile/src/components/Providers.tsx create mode 100644 clubscan/mobile/src/components/domain/VenueCard.tsx create mode 100644 clubscan/mobile/src/components/ui/Button.tsx create mode 100644 clubscan/mobile/src/components/ui/ScoreRing.tsx create mode 100644 clubscan/mobile/src/features/auth/mutations.ts create mode 100644 clubscan/mobile/src/features/auth/schema.ts create mode 100644 clubscan/mobile/src/features/venues/queries.ts create mode 100644 clubscan/mobile/src/i18n/en.json create mode 100644 clubscan/mobile/src/i18n/index.ts create mode 100644 clubscan/mobile/src/i18n/tr.json create mode 100644 clubscan/mobile/src/lib/api/client.ts create mode 100644 clubscan/mobile/src/lib/api/errors.ts create mode 100644 clubscan/mobile/src/lib/api/types.ts create mode 100644 clubscan/mobile/src/lib/config.ts create mode 100644 clubscan/mobile/src/lib/query/queryClient.ts create mode 100644 clubscan/mobile/src/stores/authStore.ts create mode 100644 clubscan/mobile/src/stores/uiStore.ts create mode 100644 clubscan/mobile/tailwind.config.js create mode 100644 clubscan/mobile/tsconfig.json diff --git a/clubscan/mobile/.env.example b/clubscan/mobile/.env.example new file mode 100644 index 0000000..4af8566 --- /dev/null +++ b/clubscan/mobile/.env.example @@ -0,0 +1,4 @@ +# Public client config (exposed in the bundle — never put secrets here). +EXPO_PUBLIC_API_BASE_URL=http://localhost:3000/api/v1 +EXPO_PUBLIC_GOOGLE_MAPS_KEY= +EXPO_PUBLIC_SENTRY_DSN= diff --git a/clubscan/mobile/.gitignore b/clubscan/mobile/.gitignore new file mode 100644 index 0000000..ba768d3 --- /dev/null +++ b/clubscan/mobile/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +.expo/ +dist/ +web-build/ +*.log +.env +.DS_Store +expo-env.d.ts diff --git a/clubscan/mobile/app.config.ts b/clubscan/mobile/app.config.ts new file mode 100644 index 0000000..a504e32 --- /dev/null +++ b/clubscan/mobile/app.config.ts @@ -0,0 +1,41 @@ +import { ExpoConfig, ConfigContext } from 'expo/config'; + +/** + * Expo app configuration (Phase 4 §3). Deep links power event/venue sharing; + * the API base URL and Maps key are injected via EAS env per channel. + */ +export default ({ config }: ConfigContext): ExpoConfig => ({ + ...config, + name: 'ClubScan', + slug: 'clubscan', + scheme: 'clubscan', + version: '0.1.0', + orientation: 'portrait', + userInterfaceStyle: 'automatic', + newArchEnabled: true, + splash: { + backgroundColor: '#0A0A0F', + resizeMode: 'contain', + }, + ios: { + bundleIdentifier: 'app.clubscan.mobile', + supportsTablet: false, + config: { usesNonExemptEncryption: false }, + infoPlist: { + NSLocationWhenInUseUsageDescription: + 'ClubScan uses your location to show nearby venues and events.', + }, + }, + android: { + package: 'app.clubscan.mobile', + adaptiveIcon: { backgroundColor: '#0A0A0F' }, + permissions: ['ACCESS_FINE_LOCATION', 'ACCESS_COARSE_LOCATION'], + }, + plugins: ['expo-router', 'expo-secure-store', 'expo-localization', 'expo-font'], + experiments: { typedRoutes: true }, + extra: { + apiBaseUrl: process.env.EXPO_PUBLIC_API_BASE_URL ?? 'http://localhost:3000/api/v1', + googleMapsApiKey: process.env.EXPO_PUBLIC_GOOGLE_MAPS_KEY ?? '', + sentryDsn: process.env.EXPO_PUBLIC_SENTRY_DSN ?? '', + }, +}); diff --git a/clubscan/mobile/app/(auth)/login.tsx b/clubscan/mobile/app/(auth)/login.tsx new file mode 100644 index 0000000..ebeb394 --- /dev/null +++ b/clubscan/mobile/app/(auth)/login.tsx @@ -0,0 +1,92 @@ +import { View, Text, TextInput } from 'react-native'; +import { Controller, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useTranslation } from 'react-i18next'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Button } from '@/components/ui/Button'; +import { LoginForm, loginSchema } from '@/features/auth/schema'; +import { useLogin } from '@/features/auth/mutations'; +import { ApiError } from '@/lib/api/errors'; + +export default function LoginScreen() { + const { t } = useTranslation(); + const login = useLogin(); + const { + control, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(loginSchema), + defaultValues: { email: '', password: '' }, + }); + + const onSubmit = (values: LoginForm) => login.mutate(values); + const serverError = login.error instanceof ApiError ? login.error.message : null; + + return ( + + + {t('auth.login')} + + + + ( + + )} + /> + + + + ( + + )} + /> + + + {serverError ? {serverError} : null} + +