From 40da58385e3c9822b9b913f735af77f66fa96b49 Mon Sep 17 00:00:00 2001 From: Harish-Naruto Date: Tue, 23 Jun 2026 16:41:22 +0530 Subject: [PATCH 01/11] Refactor Docker workflow to build and push image --- .github/workflows/docker-image.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/docker-image.yml diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 0000000..735ac43 --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,30 @@ +name: Docker Image CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build-and-push: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: ${{ github.event_name == 'push' }} + tags: callofcode07/coc-api:latest + From a2ea3c0db7ec7b781f1bfbddb92556bb2bbc155a Mon Sep 17 00:00:00 2001 From: Harish-Naruto Date: Tue, 23 Jun 2026 17:12:23 +0530 Subject: [PATCH 02/11] Update Docker image workflow with persist-credentials Add persist-credentials option to checkout step --- .github/workflows/docker-image.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 735ac43..00db28a 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -13,8 +13,11 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + with: + persist-credentials: false - name: Log in to Docker Hub + if: github.event_name == 'push' uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} From 5be76b9f021f04f9fc5268a5d7717af2a274565b Mon Sep 17 00:00:00 2001 From: Harish-Naruto Date: Tue, 23 Jun 2026 17:26:30 +0530 Subject: [PATCH 03/11] refactor: remove legacy database dump, local setup scripts, and associated configuration files --- .dockerignore | 5 +- Dockerfile | 77 ++-- LOCAL_DEVELOPMENT.md | 54 --- docker-compose.yml | 78 ++-- scripts/setup-local.sh | 166 -------- seed/dump.sql | 863 ----------------------------------------- 6 files changed, 77 insertions(+), 1166 deletions(-) delete mode 100644 LOCAL_DEVELOPMENT.md delete mode 100755 scripts/setup-local.sh delete mode 100644 seed/dump.sql diff --git a/.dockerignore b/.dockerignore index 03dade1..91070b5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,6 @@ # Dependencies node_modules -bun.lockb +bun.lock # Build outputs dist @@ -9,8 +9,7 @@ build # Environment files .env -.env.local -.env.*.local +.env.* # Logs logs diff --git a/Dockerfile b/Dockerfile index 7e4436f..07a4255 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,55 +1,58 @@ -FROM oven/bun:1.3 AS base -WORKDIR /var/www/api +# ─── Stage 1: Dependencies ──────────────────────────────────────────────────── +FROM oven/bun:1-alpine AS deps -# ----------------------------- -# deps stage - cache dependencies -# ----------------------------- -FROM base AS deps +WORKDIR /app +# Copy package manifests COPY package.json bun.lock* ./ -COPY prisma ./prisma + +# Install production dependencies only RUN bun install --frozen-lockfile -RUN bunx prisma generate +# ─── Stage 2: Builder ───────────────────────────────────────────────────────── +FROM oven/bun:1-alpine AS builder -# ----------------------------- -# build stage - compile TypeScript to JavaScript -# ----------------------------- -FROM deps AS build +WORKDIR /app +# Copy all source files COPY . . -RUN bun build src/server.ts --target=bun --production --outdir dist -# ----------------------------- -# development stage -# ----------------------------- -FROM deps AS development -COPY . . -EXPOSE 3000 -CMD ["bun", "src/server.ts"] - -# ----------------------------- -# production stage -# ----------------------------- +# Install all deps (including dev) for Prisma client generation +RUN bun install --frozen-lockfile - -FROM oven/bun:1-slim AS production +# prisma generate doesn't connect to the DB, but prisma.config.ts uses env("DATABASE_URL") +# which throws at config-load time if the var is absent. Satisfy it with a dummy placeholder. +# The real DATABASE_URL is injected at runtime via env_file / --env-file / -e flags. +ARG DATABASE_URL=postgresql://placeholder:placeholder@localhost:5432/placeholder +ENV DATABASE_URL=${DATABASE_URL} -WORKDIR /var/www/api -RUN groupadd -g 1001 nodejs && useradd -u 1001 -g nodejs -m bunjs +# Generate Prisma client +RUN bunx prisma generate -COPY --from=build --chown=bunjs:nodejs /var/www/api/dist ./dist -COPY --from=deps --chown=bunjs:nodejs /var/www/api/src/generated ./dist/generated +# Unset the dummy URL so it doesn't leak into downstream stages via ENV +ENV DATABASE_URL= +# ─── Stage 3: Production runner +FROM oven/bun:1-alpine AS runner -RUN chown -R bunjs:nodejs /var/www/api -USER bunjs +WORKDIR /app ENV NODE_ENV=production -ENV PORT=3000 -EXPOSE 3000 +# Create a non-root user for security +RUN addgroup --system --gid 1001 cocgroup && \ + adduser --system --uid 1001 cocuser + +# Copy production node_modules from deps stage +COPY --from=deps --chown=cocuser:cocgroup /app/node_modules ./node_modules + +# Copy application source +COPY --chown=cocuser:cocgroup . . -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD wget -qO- http://localhost:3000/health || exit 1 +COPY --from=builder --chown=cocuser:cocgroup /app/src/generated/prisma ./src/generated/prisma + +USER cocuser + +EXPOSE 3000 -CMD ["bun", "./dist/server.js"] \ No newline at end of file +# Run Prisma migrations then start the server +CMD ["sh", "-c", "bunx prisma migrate deploy && bun src/server.ts"] diff --git a/LOCAL_DEVELOPMENT.md b/LOCAL_DEVELOPMENT.md deleted file mode 100644 index 1a7af3a..0000000 --- a/LOCAL_DEVELOPMENT.md +++ /dev/null @@ -1,54 +0,0 @@ -# Local Development Environment Setup - -This document explains how to set up and manage the local development environment for the COC-API project. - -## Prerequisites - -- **Docker & Docker Compose**: Ensure you have Docker installed and running. -- **PostgreSQL Client (`pg_dump`)**: Optional. The current local setup prefers loading a local `seed/dump.sql` file. `pg_dump` is only required if you want to create a fresh dump from a remote database manually. - -## Setup Instructions - -### 1. Configure Environment Variables - -Copy the `.env.example` file to `.env` and fill in the required values: - -```bash -cp .env.example .env -``` - -Key variables for the setup script: -- `SESSION_POOLER`: The direct connection string to your Supabase project (Session Pooler / IPv4). - - **Format**: `postgresql://postgres.PROJECT_REF:PASSWORD@aws-0-us-east-1.pooler.supabase.com:5432/postgres` - -### 2. Run the Setup Script - -The `scripts/setup-local.sh` script automates the local environment bring-up. By default it will load the local `seed/dump.sql` into the local Postgres instance. - -```bash -bun run local -``` - -#### What the script does: -1. **Starts Postgres**: Launches the `db` container. -2. **Installs Extensions**: Pre-installs `pgcrypto`, `uuid-ossp`, and `pg_stat_statements` into the `public` schema. -3. **Loads Local Seed**: The script will attempt to load `seed/dump.sql` into the DB only when there are no existing user tables present in the database. If the database already contains tables (non-system tables), the seed step will be skipped to avoid accidentally overwriting existing data. This is the default and recommended local flow. -4. **Starts the API**: Launches the `api` container and waits for it to be healthy. - -Flags: - - `--skip-dump`: reuse existing `seed/dump.sql` (no remote dump step) - - `--skip-seed`: skip loading the seed and just start containers - -### 3. Useful Commands - -- **Start environment**: `bash scripts/setup-local.sh` -- **Skip dump (reuse existing `seed/dump.sql`)**: `bash scripts/setup-local.sh --skip-dump` -- **Skip seeding (just start containers)**: `bash scripts/setup-local.sh --skip-seed` -- **View logs**: `docker compose logs -f` -- **Stop containers**: `docker compose down` -- **Wipe local data (force re-seed)**: `docker compose down -v` - -## Troubleshooting - -### Tables not in `public` schema -The script automatically patches the dump to ensure tables are placed in the `public` schema. If you manually imported a dump, ensure you've installed the required extensions first. diff --git a/docker-compose.yml b/docker-compose.yml index 46a1305..bf2d25b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,45 +1,37 @@ -services: - api: - image: coc-api - build: - context: . - dockerfile: Dockerfile - target: development - ports: - - "127.0.0.1:3000:3000" - env_file: - - .env - environment: - NODE_ENV: development - PORT: 3000 - DATABASE_URL: "postgresql://postgres:example@db:5432/coc?sslmode=disable" - volumes: - - ./:/app:cached - - /app/node_modules - depends_on: - - db - healthcheck: - test: ["CMD", "curl", "-sf", "http://localhost:3000/health"] - interval: 30s - timeout: 3s - retries: 3 +name: coc-api - db: - image: postgres:17 - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: example - POSTGRES_DB: coc - volumes: - - pgdata:/var/lib/postgresql/data - ports: - - "127.0.0.1:5432:5432" - healthcheck: - test: ["CMD", "pg_isready", "-U", "postgres", "-d", "coc", "-h", "127.0.0.1", "-p", "5432"] - interval: 5s - timeout: 5s - retries: 5 +services: + api: + build: + context: . + dockerfile: Dockerfile + target: builder # Use builder stage in dev (has devDeps + bun watch) + container_name: coc-api-dev + restart: unless-stopped + ports: + - "${PORT:-3000}:3000" + env_file: + - .env.local # Local dev env vars (Supabase connection strings, etc.) + environment: + NODE_ENV: development + volumes: + # Mount source for live-reload (everything except node_modules) + - .:/app + - /app/node_modules + - /app/node_modules/.prisma + command: > + sh -c "bunx prisma generate && + bunx prisma migrate deploy && + bun --watch src/server.ts" + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + networks: + - coc-network -volumes: - pgdata: - seed-data: +networks: + coc-network: + driver: bridge diff --git a/scripts/setup-local.sh b/scripts/setup-local.sh deleted file mode 100755 index 3bf9e5d..0000000 --- a/scripts/setup-local.sh +++ /dev/null @@ -1,166 +0,0 @@ -!/usr/bin/env bash -# ============================================================================= -# setup-local.sh -# -# Sets up the local development environment: -# 1. Starts the local postgres container -# 2. Waits for postgres to be healthy -# 3. Installs required extensions (pgcrypto, uuid-ossp, pg_stat_statements) -# 4. Checks whether the DB already has data — skips seeding if so -# 5. Dumps the remote database into ./seed/dump.sql using pg_dump -# 6. Strips Supabase-only extensions from the dump -# 7. Loads the dump into the running postgres container -# 8. Starts the API container -# -# --skip-dump Skip the pg_dump step (reuse an existing ./seed/dump.sql) -# --skip-seed Skip seeding entirely (just start containers) -# ============================================================================= - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -ENV_FILE="$PROJECT_ROOT/.env" -SEED_DIR="$PROJECT_ROOT/seed" -DUMP_FILE="$SEED_DIR/dump.sql" -SKIP_DUMP=false -SKIP_SEED=false - -if [[ "$OSTYPE" == "darwin"* ]]; then - SED_INPLACE=(-i '') -else - SED_INPLACE=(-i) -fi - -# ---- Parse args ---- -for arg in "$@"; do - case $arg in - --skip-dump) SKIP_DUMP=true ;; - --skip-seed) SKIP_SEED=true ;; - *) echo "Unknown argument: $arg" && exit 1 ;; - esac -done - -# ---- Colour helpers ---- -GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; RESET='\033[0m' -info() { echo -e "${GREEN}[setup]${RESET} $*"; } -warn() { echo -e "${YELLOW}[setup]${RESET} $*"; } -error() { echo -e "${RED}[setup]${RESET} $*" >&2; exit 1; } - -# ---- Sanity checks ---- -command -v docker >/dev/null 2>&1 || error "docker is not installed or not in PATH" - -# ---- Load .env ---- -if [[ ! -f "$ENV_FILE" ]]; then - error ".env file not found at $ENV_FILE. Copy .env.example and fill in your values." -fi - -info "Loading environment from $ENV_FILE" -set -o allexport -# shellcheck disable: SC1090 -source <(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' "$ENV_FILE" | sed 's/\r//') -set +o allexport - -# ---- Local postgres URL ---- -LOCAL_DATABASE_URL="postgresql://postgres:example@db:5432/coc?sslmode=disable" - - -# ======================================================== -# 1. Start the DB container -# ======================================================== -info "Starting postgres container..." -cd "$PROJECT_ROOT" -docker compose up db --build -d - - -# ======================================================== -# 2. Wait for postgres to be healthy -# ======================================================== -info "Waiting for postgres to be healthy..." -RETRIES=10 -until docker compose exec -T db pg_isready -U postgres -d coc -h 127.0.0.1 -p 5432 -q 2>/dev/null; do - RETRIES=$((RETRIES - 1)) - if [[ $RETRIES -le 0 ]]; then - error "Postgres did not become healthy in time. Check logs: docker compose logs db" - fi - sleep 2 -done -info "Postgres is healthy." - - -# ======================================================== -# 3. Install required extensions -# ======================================================== -info "Installing required extensions into postgres..." -docker compose exec -T db psql -v ON_ERROR_STOP=1 -U postgres -d coc <<'EXTSQL' -CREATE SCHEMA IF NOT EXISTS extensions; -CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public; -CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA public; -CREATE EXTENSION IF NOT EXISTS pg_stat_statements WITH SCHEMA public; -EXTSQL -info "Extensions installed." - - -# ======================================================== -# 4. Check if seeding is required -# - If user passed --skip-seed, we skip -# - Otherwise, if the DB already contains user tables (non-system), skip seeding -# ======================================================== -if [[ "$SKIP_SEED" == true ]]; then - warn "--skip-seed passed. Skipping dump and load." -else - mkdir -p "$SEED_DIR" - if [[ ! -f "$DUMP_FILE" ]]; then - error "$DUMP_FILE not found. Create the seed SQL at $DUMP_FILE and re-run this script." - fi - - info "Checking whether database already has user tables..." - TABLE_COUNT=$(docker compose exec -T db psql -U postgres -d coc -t -A -c "SELECT count(*) FROM pg_catalog.pg_tables WHERE schemaname NOT IN ('pg_catalog','information_schema');" | tr -d '[:space:]' || true) - - if [[ -z "$TABLE_COUNT" ]]; then - warn "Could not determine table count; proceeding with seed." - info "Loading local seed SQL from $DUMP_FILE ..." - docker compose exec -T db psql -v ON_ERROR_STOP=1 -U postgres -d coc < "$DUMP_FILE" - info "Seed complete." - elif [[ "$TABLE_COUNT" -gt 0 ]]; then - warn "Database already has ${TABLE_COUNT} user tables; skipping seed." - else - info "No existing user tables found. Loading local seed SQL from $DUMP_FILE ..." - docker compose exec -T db psql -v ON_ERROR_STOP=1 -U postgres -d coc < "$DUMP_FILE" - info "Seed complete." - fi -fi - - -# ======================================================== -# 7. Start the API container -# ======================================================== -info "Starting API container..." -DATABASE_URL="$LOCAL_DATABASE_URL" \ - docker compose up api --build -d - -info "Waiting for api to be healthy..." -RETRIES=10 -until curl -sf http://localhost:3000/health >/dev/null 2>&1; do - RETRIES=$((RETRIES - 1)) - if [[ $RETRIES -le 0 ]]; then - warn "API health check timed out — it may still be starting. Check: docker compose logs api" - break - fi - sleep 3 -done - - -echo "" -echo -e "${GREEN}======================================================${RESET}" -echo -e "${GREEN} Local development environment is up!${RESET}" -echo -e "${GREEN}======================================================${RESET}" -echo -e " API: http://localhost:3000" -echo -e " Postgres: localhost:5432 (user=postgres, db=coc)" -echo -e "" -echo -e " Useful commands:" -echo -e " docker compose logs -f # stream all logs" -echo -e " docker compose logs -f api # api logs only" -echo -e " docker compose down # stop containers (keep data)" -echo -e " docker compose down -v # stop and wipe local DB volume" -echo -e "${GREEN}======================================================${RESET}" diff --git a/seed/dump.sql b/seed/dump.sql deleted file mode 100644 index 1e21c39..0000000 --- a/seed/dump.sql +++ /dev/null @@ -1,863 +0,0 @@ --- --- PostgreSQL database dump --- - - --- Dumped from database version 17.4 - -SET statement_timeout = 0; -SET lock_timeout = 0; -SET idle_in_transaction_session_timeout = 0; -SET transaction_timeout = 0; -SET client_encoding = 'UTF8'; -SET standard_conforming_strings = on; -SELECT pg_catalog.set_config('search_path', '', false); -SET check_function_bodies = false; -SET xmloption = content; -SET client_min_messages = warning; -SET row_security = off; - --- Supabase internal commands removed to ensure compatibility with local seeding - --- Simplified seed SQL for local development --- Creates core tables and inserts up to 30 members and some relations. - -CREATE SCHEMA IF NOT EXISTS realtime; -CREATE SCHEMA IF NOT EXISTS storage; -CREATE SCHEMA IF NOT EXISTS extensions; - -BEGIN; - --- Drop existing tables and types (simple ordering) -DROP TABLE IF EXISTS public."_prisma_migrations" CASCADE; -DROP TABLE IF EXISTS public."MemberProject" CASCADE; -DROP TABLE IF EXISTS public."MemberAchievement" CASCADE; -DROP TABLE IF EXISTS public."CompletedQuestion" CASCADE; -DROP TABLE IF EXISTS public."InterviewExperience" CASCADE; -DROP TABLE IF EXISTS public."Question" CASCADE; -DROP TABLE IF EXISTS public."Topic" CASCADE; -DROP TABLE IF EXISTS public."Project" CASCADE; -DROP TABLE IF EXISTS public."Achievement" CASCADE; -DROP TABLE IF EXISTS public."Account" CASCADE; -DROP TABLE IF EXISTS public."Member" CASCADE; -DROP TABLE IF EXISTS realtime.schema_migrations CASCADE; -DROP TABLE IF EXISTS auth.users CASCADE; - -DROP TYPE IF EXISTS public."Difficulty" CASCADE; -DROP TYPE IF EXISTS public."Verdict" CASCADE; - --- Enum types -CREATE TYPE public."Difficulty" AS ENUM ( - 'Easy', - 'Medium', - 'Hard' -); - -CREATE TYPE public."Verdict" AS ENUM ( - 'Selected', - 'Rejected', - 'Pending' -); - -CREATE TABLE IF NOT EXISTS realtime.schema_migrations ( - version TEXT, - inserted_at TIMESTAMP WITH TIME ZONE -); - - --- Member table -CREATE TABLE public."Member" ( - id UUID, - name TEXT NOT NULL, - email TEXT UNIQUE NOT NULL, - birth_date DATE, - phone TEXT, - bio TEXT, - "profilePhoto" TEXT, - github TEXT, - linkedin TEXT, - twitter TEXT, - geeksforgeeks TEXT, - leetcode TEXT, - codechef TEXT, - codeforces TEXT, - "passoutYear" DATE, - "isApproved" BOOLEAN NOT NULL DEFAULT false, - "isManager" BOOLEAN NOT NULL DEFAULT false, - "createdAt" TIMESTAMP WITH TIME ZONE DEFAULT now(), - "updatedAt" TIMESTAMP WITH TIME ZONE DEFAULT now(), - "approvedById" UUID -); - --- Account -CREATE TABLE public."Account" ( - id UUID, - provider TEXT NOT NULL, - "providerAccountId" TEXT NOT NULL, - password TEXT, - "accessToken" TEXT, - "refreshToken" TEXT, - "expiresAt" TIMESTAMP WITH TIME ZONE, - "createdAt" TIMESTAMP WITH TIME ZONE DEFAULT now(), - "updatedAt" TIMESTAMP WITH TIME ZONE DEFAULT now(), - "memberId" UUID -); - --- Achievement -CREATE TABLE public."Achievement" ( - id SERIAL, - title TEXT NOT NULL, - description TEXT NOT NULL, - "achievedAt" DATE NOT NULL, - "imageUrl" TEXT NOT NULL, - "createdById" UUID, - "createdAt" TIMESTAMP WITH TIME ZONE DEFAULT now(), - "updatedById" UUID, - "updatedAt" TIMESTAMP WITH TIME ZONE DEFAULT now() -); - --- MemberAchievement (join) -CREATE TABLE public."MemberAchievement" ( - "memberId" UUID, - "achievementId" INTEGER -); - --- Project -CREATE TABLE public."Project" ( - id SERIAL, - name TEXT NOT NULL, - "imageUrl" TEXT NOT NULL, - "githubUrl" TEXT NOT NULL, - "deployUrl" TEXT, - "createdById" UUID, - "createdAt" TIMESTAMP WITH TIME ZONE DEFAULT now(), - "updatedById" UUID, - "updatedAt" TIMESTAMP WITH TIME ZONE DEFAULT now() -); - --- MemberProject (join) -CREATE TABLE public."MemberProject" ( - "memberId" UUID, - "projectId" INTEGER -); - --- Topic -CREATE TABLE public."Topic" ( - id SERIAL, - title TEXT NOT NULL, - description TEXT NOT NULL, - "createdById" UUID, - "createdAt" TIMESTAMP WITH TIME ZONE DEFAULT now(), - "updatedById" UUID, - "updatedAt" TIMESTAMP WITH TIME ZONE DEFAULT now() -); - --- Question -CREATE TABLE public."Question" ( - id SERIAL, - "questionName" TEXT NOT NULL, - difficulty public."Difficulty" NOT NULL, - link TEXT NOT NULL, - "topicId" INTEGER, - "createdById" UUID, - "createdAt" TIMESTAMP WITH TIME ZONE DEFAULT now(), - "updatedById" UUID, - "updatedAt" TIMESTAMP WITH TIME ZONE DEFAULT now() -); - --- InterviewExperience -CREATE TABLE public."InterviewExperience" ( - id SERIAL, - company TEXT NOT NULL, - role TEXT NOT NULL, - verdict public."Verdict" NOT NULL, - content TEXT NOT NULL, - "isAnonymous" BOOLEAN NOT NULL DEFAULT false, - "memberId" UUID -); - --- CompletedQuestion (join) -CREATE TABLE public."CompletedQuestion" ( - "memberId" UUID, - "questionId" INTEGER -); - --- _prisma_migrations -CREATE TABLE public."_prisma_migrations" ( - id TEXT, - checksum TEXT NOT NULL, - finished_at TIMESTAMP WITH TIME ZONE, - migration_name TEXT NOT NULL, - logs TEXT, - rolled_back_at TIMESTAMP WITH TIME ZONE, - started_at TIMESTAMP WITH TIME ZONE DEFAULT now(), - applied_steps_count INTEGER DEFAULT 0 -); - --- (Enum types moved to top of file, before CREATE TABLE statements) - -INSERT INTO public."Account" (id, provider, "providerAccountId", password, "accessToken", "refreshToken", "expiresAt", "createdAt", "updatedAt", "memberId") VALUES -('e882157d-fc39-49e0-9df9-99ca0adb8a7c', 'vedant@gmail.com', '$2b$10$eH5Xpk0e88tWULMgIMJdqeAPE3ND9qych/bfGeh7MM3tFbXX90sF.', NULL, NULL, NULL, NULL, '2025-07-27 12:36:07.398', '2025-07-27 12:36:07.398', '738b73b4-0725-4f7c-95bf-cbdb72eb4e84'), -('0b121686-c462-4fae-8324-b88316013243', 'bhaven@gmail.com', '$2b$10$irh3xCai75iDsLKfp8TJPeEMA2pfFHpIqIqkCAezNjvs25HV13vSy', NULL, NULL, NULL, NULL, '2025-07-27 12:42:51.984', '2025-07-27 12:42:51.984', '86237b63-1339-49d6-ad57-1d4b93bd5092'), -('8b8d3535-1592-4673-ab98-5c41e7899932', 'shaheen@gmail.com', '$2b$10$psuckT2vhmGq3ngd9L5PMOg/ACk/qg7wH.060RqBhs9YcP0j23w4S', NULL, NULL, NULL, NULL, '2025-07-27 12:49:37.408', '2025-07-27 12:49:37.408', '3e6666f5-8ad9-4686-8656-a6904360d4ba'), -('0d95b051-0a15-4811-84ed-e0b67a57dcbb', 'sanskar@gmail.com', '$2b$10$ajN/.3Jdvb78aGo/HKY9suTqrwozl4VFbrpz9BTFRarVnKBkb3QBO', NULL, NULL, NULL, NULL, '2025-07-27 12:53:26.146', '2025-07-27 12:53:26.146', '855fb554-02f7-4b6a-a17e-d55b7976babf'), -('36fc123d-0f51-4373-aa9c-a2db94cbaf6c', 'eshwar@gmail.com', '$2b$10$boqMJ//X86GNTKG4uG4yV.ibMMi1U43iz2MwYiwUgPwYcBCDPNjf6', NULL, NULL, NULL, NULL, '2025-07-27 13:00:38.612', '2025-07-27 13:00:38.612', '03a1a0f8-c1b9-4ac1-99d9-f10f26a82f7c'), -('a895aacf-aabe-4d0d-9d42-5a463061d45c', 'yash@gmail.com', '$2b$10$zonROajObqXYsiDX1UuYou6Pms.qvS4hxiR4Ehm5bTHT6MT.n8xmW', NULL, NULL, NULL, NULL, '2025-07-27 13:03:03.733', '2025-07-27 13:03:03.733', '0cc29a18-180c-408d-9cbe-0fc06109a5c1'), -('0ece4d7c-6a17-4d5f-8e57-2dddb8f1cc12', 'prathamesh@gmail.com', '$2b$10$KaF.GZydyvWUTb.Pk85APuVCh66sZ/uY3/3heTYUAhnQdoHThKVpG', NULL, NULL, NULL, NULL, '2025-07-27 13:04:35.928', '2025-07-27 13:04:35.928', 'bf152df5-3c23-4c1c-85aa-212e0487b420'), -('89c4d268-131f-439d-9ebd-02082af93c2f', 'pratik@gmail.com', '$2b$10$0PDGCKCKSuC5d0aJ4SyoReJjrMuRjUdNPRjSUVxJcYRACcKWu0TT6', NULL, NULL, NULL, NULL, '2025-07-27 13:05:30.243', '2025-07-27 13:05:30.243', 'ac713749-77c1-46b6-ae82-f16d616b1c7c'), -('94023539-b795-4504-85de-f4dd89362884', 'swaraj@gmail.com', '$2b$10$pH/8h6.Ic.jFy0vusM/au.1VCe.Sl37GBtrAR99eNxl7GxPS7Asxa', NULL, NULL, NULL, NULL, '2025-07-27 13:11:28.79', '2025-07-27 13:11:28.79', '1404e81c-d567-4103-941b-0abeea7fc049'), -('b95dc5ec-e7a2-4689-b0c3-2247db3b3c23', 'vansh@gmail.com', '$2b$10$eq6f05tX/ZMwWm2gkxNJnOhfr3m.zYW23eY4TTiIVDvAOE03RZS.6', NULL, NULL, NULL, NULL, '2025-07-27 13:12:10.092', '2025-07-27 13:12:10.092', '22ba7f7a-14e7-45fa-bf5f-51d5f015496f'), -('0a667c9f-e611-4d58-bd2d-1a65860fcc97', 'shivaji@gmail.com', '$2b$10$LHj6GmVwPH0.YHU3wSVLf.PK2cQ23swfr6MoI3hFxp4tInDDLnwu2', NULL, NULL, NULL, NULL, '2025-07-27 13:13:07.284', '2025-07-27 13:13:07.284', '8eeacf82-18e5-48f8-a11e-fdbbe2eb81ce'), -('edd6063c-a60f-4653-8512-f21973ab5879', 'sanica@gmail.com', '$2b$10$QlXDLQeKvVeRyA9r2.MC8umgGR.GF343BUrFtX2KSg8gUo92bFGZW', NULL, NULL, NULL, NULL, '2025-07-27 13:20:15.287', '2025-07-27 13:20:15.287', '644b5e8f-910d-450e-a855-a88f31d02b7b'), -('7e78693b-d788-4e17-aa87-94e631cee02e', 'aditya@gmail.com', '$2b$10$W4aJemPA5H/Ws1rUuC.Kge8/fhLXJE2eTTKh6x.wksQ4Z35waZmkW', NULL, NULL, NULL, NULL, '2025-07-27 13:21:05.854', '2025-07-27 13:21:05.854', '1ff4b36d-5671-4855-8476-d0a8993f9873'), -('a524c15b-3a24-484d-947d-b440aa5fa4f3', 'sarvesh@gmail.com', '$2b$10$6F4VAW1PAOwRYvXbM14Cd.XMvI61neTdp.54qXVze/r.GwF/bWseS', NULL, NULL, NULL, NULL, '2025-07-27 13:29:39.074', '2025-07-27 13:29:39.074', 'ad525dce-67dd-4878-ab95-068943923b81'), -('d754025d-f04a-489f-9cd3-a863e3a2083b', 'Mukul@gmail.com', '$2b$10$gsbswEwuP3LEY6mUg3WHUu3qfeDe7S2KLHvv.ibMTEIlwNqLJPl9u', NULL, NULL, NULL, NULL, '2025-07-27 13:36:08.586', '2025-07-27 13:36:08.586', '1b482f80-f649-45f9-a90b-7538a7a6e66e'), -('1fc11447-d4b2-44ac-9bc8-76f841f11d15', 'anushka@gmail.com', '$2b$10$lAn7dozXDFFKzmD2SovmF.MXRKChjy7EqzV/REdPokjNvbfBoJdHa', NULL, NULL, NULL, NULL, '2025-07-27 13:37:18.144', '2025-07-27 13:37:18.144', 'b6f44922-fff3-48eb-a0c9-15d41e786e38'), -('c577b0c8-ad80-46b9-bb57-03a39f740157', 'samarth@gmail.com', '$2b$10$NbmkATGYbJckgOs.rIkVIetZXCAv0jdyNbo075Kp.ybgDR2MySHIq', NULL, NULL, NULL, NULL, '2025-07-27 13:41:04.035', '2025-07-27 13:41:04.035', '1b5933a9-5d50-4246-861a-ca0d30bd581f'), -('102d22ea-192b-400f-bdce-dac29abeb49b', 'vaishnavi@gmail.com', '$2b$10$wTra4lS7IrfMWPJiMc/V9u8YDZJg5.2mIWmD8ZNyvbt07JPmako/S', NULL, NULL, NULL, NULL, '2025-07-27 13:42:05.112', '2025-07-27 13:42:05.112', 'd46a667d-5b68-4b82-9de7-fcfdf0ab0181'), -('1f24974e-5ffe-4655-94e8-282f3266bb7d', 'vaishnaviadhav@gmail.com', '$2b$10$CvBVhnqFPq3s5f6q2VYCuOVZtuZloaBduLuloZwERvS3CGJOo3nnG', NULL, NULL, NULL, NULL, '2025-07-27 13:43:26.945', '2025-07-27 13:43:26.945', '7dd07cc1-08da-48cc-a162-f546356fe291'), -('78e6f26b-dd53-440e-96c1-f4d2205fab87', 'sakshi@gmail.com', '$2b$10$TdCn5HveTLvSoIdFMR1n/eJGKLofoFXx5lQCsEEQU0GnyZrdp9qkC', NULL, NULL, NULL, NULL, '2025-07-27 13:44:46.419', '2025-07-27 13:44:46.419', '259a1e70-c093-43d4-8aa1-bba058a896b8'), -('c0c097c3-c2dc-4ded-8a82-2e718eb46eff', 'piyushaa@gmail.com', '$2b$10$GkiaYm.5cG73HgDAANN6xedU/zqzvmz1JVsoC1C6/jmCXM67shOSG', NULL, NULL, NULL, NULL, '2025-07-27 13:46:17.354', '2025-07-27 13:46:17.354', '46cfa3aa-1efe-4cc6-a624-340808ef7cb8'), -('bea77f0d-37f6-4303-b549-93846e36d774', 'siddhesh@gmail.com', '$2b$10$TcfZ9HVPsTNARAk31CwbxeAIn7gADgfQ1E2cF/8AgeCh7dRSY/xfi', NULL, NULL, NULL, NULL, '2025-07-27 13:47:45.957', '2025-07-27 13:47:45.957', 'a8443783-dd59-446a-93f5-19f5e590e88b'), -('e43283f2-13d9-4a1b-a505-deb2d4f8b967', 'aarya@gmail.com', '$2b$10$C3yPZHUkWOpgXDNFNYuRf.OSSz.RhpQI03T1IwdgiH/bqyl6rvCw2', NULL, NULL, NULL, NULL, '2025-07-27 13:56:12.936', '2025-07-27 13:56:12.936', 'db2bd9ec-25e5-4134-ae53-fea1734ca161'), -('07c9e6e0-7f65-4494-9764-c7d1c258fd75', 'shashwati@gmail.com', '$2b$10$Xb45AKKbCU8ma95Me6Yc7u5nmmX.OGnkShA4CKgInaw9On6XhFLEy', NULL, NULL, NULL, NULL, '2025-07-27 13:57:15.237', '2025-07-27 13:57:15.237', 'c5b2470d-7fb4-4c93-bfe2-fedc00415dc2'), -('2cf94aa7-3790-4758-b83d-66e762b2505d', 'suhani@gmail.com', '$2b$10$a8QFdX9ws02jnsUz4O1DdehSDJjhRvX96fuUxJIjZHoFlhfzMRkYq', NULL, NULL, NULL, NULL, '2025-07-27 13:58:46.809', '2025-07-27 13:58:46.809', '329d6d7a-9787-452e-9c0c-506481c5462a'), -('f48da39b-98fd-481a-bbd6-68c10be660d0', 'sarveshshiralkar@gmail.com', '$2b$10$7V9FuR7rpABeRvbMuU4bUeoMQKQXrehykMmvzrXarIsLuJKNa1tl.', NULL, NULL, NULL, NULL, '2025-07-27 14:02:16.734', '2025-07-27 14:02:16.734', 'd7c96d3c-d45d-4bde-8c2b-39f0451a389f'), -('2074b920-4329-4e6f-9798-07b372a6679c', 'sahillakare@gmail.com', '$2b$10$XJ.r8SaroRak1GUyVIoXj.oBCYMW1regaZVHBlP1lWLc50WyYSovW', NULL, NULL, NULL, NULL, '2025-07-27 14:07:23.722', '2025-07-27 14:07:23.722', '516af252-e8dc-48a4-80c4-5af1e0758e58'), -('7e4be315-6cd3-410f-8083-fe49f9c2305c', 'sachin@gmail.com', '$2b$10$4EiXQeSIWLAsaY.4ThRyR.Dmpt3Yo5ezPrV9re7wcES8KG7QHTmB2', NULL, NULL, NULL, NULL, '2025-07-27 14:28:15.082', '2025-07-27 14:28:15.082', '6c968bfa-ebf8-4b2b-a349-36bbc9cc2870'), -('3891d5cf-1c51-4245-9a78-81b28ce13266', 'sherin@gmail.com', '$2b$10$NvZJXjKUK2IOMzC3raJvi.9bIYUgEWH69ST9pHbbcrJyMmAJm4HIS', NULL, NULL, NULL, NULL, '2025-07-27 16:57:39.729', '2025-07-27 16:57:39.729', 'd7d54e46-8db2-449c-87f9-8e89e8537c42'), -('101d1b9b-743e-4930-830e-9a33c0429199', 'shruti@gmail.com', '$2b$10$ZuuiwH/L3Aal9jBCDO9qsuZLd6lRZz6rPCMQWIr2bAnM33oMUfsVa', NULL, NULL, NULL, NULL, '2025-07-27 17:18:51.245', '2025-07-27 17:18:51.245', 'c494d747-5123-457f-b9cf-f3359f5a0fe8'), -('f121b746-6942-4013-a11b-178571ed988e', 'shivam@gmail.com', '$2b$10$L5c9yXrvfgLjEQ4y7yLSseLhafRXbYPiOkoWt//rwj1h6f80PdnnC', NULL, NULL, NULL, NULL, '2025-07-27 17:32:16.949', '2025-07-27 17:32:16.949', '75ef229a-3770-46aa-adc8-f4d250c6ac81'), -('ab673cb2-cbb2-4129-a2af-1d911cd981d1', 'veda@gmail.com', '$2b$10$oHbUZdsrcq6tk9lUyKsFeuCLDEzpbdrPzN9/nL8BcUSFB3FbTJvPe', NULL, NULL, NULL, NULL, '2025-07-27 18:06:53.921', '2025-07-27 18:06:53.921', '64464cc4-4dfc-4522-a256-6aca2371df7f'), -('3195836d-c300-4406-bb40-7e6e665ac9a9', 'sheryash@gmail.com', '$2b$10$wdVUVGhmuykY.tdcEYNHU.8jjskz/U0JXw/XHh8OOgir4c2qux/3q', NULL, NULL, NULL, NULL, '2025-07-27 18:28:45.633', '2025-07-27 18:28:45.633', '046d352f-10d9-49b5-bbed-31d67bf4b583'), -('16423861-0e69-4a67-84f0-383dbfca9bd8', 'prajakta@gmail.com', '$2b$10$JXImJtLR3SblUWGxAMF.6.VHetvNMKcoNOMF4BAkAKDsXHK/TU0rC', NULL, NULL, NULL, NULL, '2025-07-27 18:32:27.558', '2025-07-27 18:32:27.558', '0b83d3e3-8685-4cfe-9f63-6cc22c1ceae4'), -('5bf224bd-cd63-41d7-950d-7c66725dd7a6', 'harsh@gmail.com', '$2b$10$ND15qcpzkY3t7lg5gZx4CuPs5XWP3OsT7.9x2/ODf7tGo6AvZuMDm', NULL, NULL, NULL, NULL, '2025-07-27 18:37:17.457', '2025-07-27 18:37:17.457', 'cffc47fb-1147-4dd5-9818-21d209dbe3f3'), -('e2e31f9d-1321-4c7e-98f6-fdb004cd0f27', 'Abhiram@gmail.com', '$2b$10$gOz6M.O48PwsBmvv4AsdLuHkArwIKNSspySKaAmEdkBy5by.s1Clm', NULL, NULL, NULL, NULL, '2025-07-27 18:41:32.941', '2025-07-27 18:41:32.941', 'e68ca856-978b-4bb5-a2f1-6497278624bb'), -('55badea7-0c98-4d18-a2a6-f54d15a12afc', 'aryan@gmail.com', '$2b$10$atDdFN1RzGh0ixzo8AAqjuLoIScI1DFhc1/y620fDg9.iKBL/K0AS', NULL, NULL, NULL, NULL, '2025-07-27 18:47:08.087', '2025-07-27 18:47:08.087', '48724979-f9c8-46de-b9ab-9ca0186596d0'), -('4e428487-f19d-476f-ab27-10e9577e98fd', 'shubham@gmail.com', '$2b$10$4pmoJTrSIDrfYS4t0sqql..MZXu0K5f1FA1hSJ7cf4VGsBkYDWqxi', NULL, NULL, NULL, NULL, '2025-07-27 18:49:15.105', '2025-07-27 18:49:15.105', '69394246-3e41-4eb5-812a-48801b0b5f3e'), -('5aca50b8-f251-4632-834d-3f4e92ef6c9c', 'komal@gmail.com', '$2b$10$hf21ih62PzTbX6ba4VaZAeBLrBLwwdbkXsvDfT5swq0CPd/EsIP.a', NULL, NULL, NULL, NULL, '2025-07-27 19:29:30.114', '2025-07-27 19:29:30.114', 'ef59db8b-2ad5-4e0a-b741-f58521bf61ec'), -('ab59bbd5-d03f-41fe-8267-5697d2d7774a', 'sahil@gmail.com', '$2b$10$lMayDbHFuV3p.xSULly1zOoLhWOlgPaQoVDykm3TG12RcXTSoZfua', NULL, NULL, NULL, NULL, '2025-07-27 19:42:25.794', '2025-07-27 19:42:25.794', 'f032f524-c153-496f-9eea-e2ff8622f3d1'), -('ceb3abbe-ec03-4531-9434-3265e5d1f141', 'dillip@gmail.com', '$2b$10$3o.97fG5vAcS3WJAmy9MbOMyU9yUDXkZMOHtMhQ4vbV5P757F5Z2G', NULL, NULL, NULL, NULL, '2025-07-27 19:47:56.176', '2025-07-27 19:47:56.176', '20ee0910-36b3-48d6-ad96-2112d02fd9b6'), -('229a9b54-94ec-4164-87a8-abe852079016', 'harish@gmail.com', '$2b$10$C2V0fELssTODLmt6AjO53eLTqT51C8ga.JyT4bTgFrajpB.37OvT.', '$2b$10$C2V0fELssTODLmt6AjO53eLTqT51C8ga.JyT4bTgFrajpB.37OvT.', NULL, NULL, NULL, '2025-07-27 20:43:23.073', '2025-07-27 20:43:23.073', '77165f92-1a09-407c-987f-0fc9be16fad8'), -('50317f35-cd52-4a05-8641-52abf9736c2a', 'credentials', 'yourmom@gmail.com', '$2b$08$M63.zOUte/5o2DLUAMxgJOK/VOyVy2CQF61XucPcuTZWd80hZBLG.', NULL, NULL, NULL, '2025-10-27 20:12:59.287', '2025-10-27 20:12:59.287', 'a6bc0b3a-71bf-4e0d-8879-ddedbbc0a766'), -('bc3b8b6c-b292-46b6-96bf-48168d6a7c21', 'credentials', 'hello123@gmail.com', '$2b$08$tY.I//asON4Xxci0ANDuLeEVzysjnPDoBynffnYrsVIfUbzksWXNS', NULL, NULL, NULL, '2025-11-20 09:01:14.333', '2025-11-20 09:01:14.333', '92f0e65d-f306-4cdd-baad-059f645cf148'), -('d696a095-96fc-44af-8daa-f9afb01049ba', 'credentials', 'syswraith@gmail.com', '$2b$08$tFFZNuza5BopfhggwSR7zuedbc9O9egCZ/NGwEXLAEr.iEi/nIMAK', NULL, NULL, NULL, '2025-10-27 14:57:14.684', '2025-10-27 14:57:14.684', '207bb8bd-3e48-40c8-83ce-a825cb9fe474'); - - --- --- Data for Name: Achievement; Type: TABLE DATA; Schema: public; Owner: - --- - -INSERT INTO public."Achievement" (id, title, "achievedAt", "imageUrl", "createdAt", "createdById", description, "updatedAt", "updatedById") VALUES -('14', 'Winner At BMCC''s Troika 2025 Coding Event', '2025-02-04 00:00:00', 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/achievements/2dd9eb5c-66c6-4c8d-b978-0e671a243c76.jpeg', '2025-07-27 19:39:03.77', 'd7d54e46-8db2-449c-87f9-8e89e8537c42', 'On 4th February 2025, the team secured the winner position at BMCC''s Troika 2025 Coding Event, demonstrating outstanding innovation and technical excellence.', '2025-07-27 19:39:03.77', NULL), -('3', 'Runner-Up At Jigyasa Coding Competition', '2024-02-10 00:00:00', 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/achievements/67235c1c-6573-40af-994c-f38138d0e175.jpeg', '2025-07-27 17:27:07.721', 'd7d54e46-8db2-449c-87f9-8e89e8537c42', 'On 10th February 2024, Vansh Waldeo secured the runner-up position in a prestigious coding competition held at IMCC, Pune.', '2025-07-27 18:10:15.82', 'd7d54e46-8db2-449c-87f9-8e89e8537c42'), -('4', 'Runner Up BITS PILANI POSTMAN API Hackathon 3.0', '2023-01-08 00:00:00', 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/achievements/97d19654-7002-4f76-ba6c-e38bfa958d8e.png', '2025-07-27 18:36:06.816', '1b5933a9-5d50-4246-861a-ca0d30bd581f', 'Secured Runner-Up position in the BITS Pilani Postman API Hackathon 3.0, an online event centered around solving real-world problems through effective API design and collaboration. Showcased strong skills in building, testing, and integrating APIs using Postman.', '2025-07-27 18:36:06.816', NULL), -('5', 'Runner Up IXPLORER WEB DESIGN & DEVELOPMENT HACKATHON', '2023-11-27 00:00:00', 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/achievements/1bcc40eb-3916-466a-baf4-7e4ed69ca6e8.jpeg', '2025-07-27 18:49:57.866', '1b5933a9-5d50-4246-861a-ca0d30bd581f', 'Secured Runner-Up position in the IXplorer Web Design & Development Hackathon, an online hackathon organized by IIT Patna. Built a creative and functional web application, showcasing strong skills in full-stack development and user-centered design.', '2025-07-27 18:49:57.866', NULL), -('6', 'Runner Up At Zeal Institute''s Web Development Project Competition', '2025-04-12 00:00:00', 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/achievements/57de0ddc-322e-4c1f-a91b-b0c4a1acc8db.jpeg', '2025-07-27 18:51:38.653', 'd7d54e46-8db2-449c-87f9-8e89e8537c42', 'On 12th April 2025, the team secured the runner-up position at Zeal Institute''s Web Development Project Competition, showcasing innovation and strong technical skills.', '2025-07-27 18:51:38.653', NULL), -('9', 'Winner At MKSSS Cummins College of Engineering CodeBid Event', '2025-04-05 00:00:00', 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/achievements/f39c95ca-ca7e-4138-9ac4-0d9cdfdb0cb5.jpeg', '2025-07-27 19:12:45.321', 'd7d54e46-8db2-449c-87f9-8e89e8537c42', 'On 5th April 2025, the team secured the winner position at MKSSS Cummins College of Engineering''s CodeBid event, demonstrating outstanding innovation and technical excellence.', '2025-07-27 20:02:49.514', 'd7d54e46-8db2-449c-87f9-8e89e8537c42'), -('11', 'Runner Up Smart India Hackathon 2023', '2023-12-26 00:00:00', 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/achievements/26bce2c2-9603-4b99-8868-abfeb15ed07f.jpeg', '2025-07-27 19:24:19.865', '1b5933a9-5d50-4246-861a-ca0d30bd581f', 'Crowned Winner at Smart India Hackathon 2023 for developing an AI-powered women safety solution. Our model could detect distress situations in real time and send instant alerts, showcasing innovation, social impact, and strong technical execution.', '2025-07-27 19:24:19.865', NULL), -('12', 'Winner At Hunar Intern Web Development Hackathon', '2024-08-26 00:00:00', 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/achievements/d982a872-e4c9-4b1d-8af9-91cbc0e2f2d1.jpeg', '2025-07-27 19:26:23.925', 'd7d54e46-8db2-449c-87f9-8e89e8537c42', 'On 26th August 2024, the team secured the winner position at Hunar Intern''s Web Development Hackathon, demonstrating outstanding innovation and technical excellence.', '2025-07-27 19:42:06.922', 'd7d54e46-8db2-449c-87f9-8e89e8537c42'), -('15', 'Consecutive COEP MindSpark Finalists 2023 & 2024 ', '2024-09-23 00:00:00', 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/achievements/03379229-271c-4e7a-b09e-9dbbaedc894c.jpeg', '2025-07-27 19:50:25.906', '1b5933a9-5d50-4246-861a-ca0d30bd581f', 'Achieved finalist positions two years in a row at COEP MindSpark, Pune’s premier techfest, competing among hundreds of participants. Selected in the top 10 for WebScape (2023) and Retracer (2024), showcasing standout skills in web development, problem-solving, and technical creativity.', '2025-07-27 19:50:25.906', NULL), -('7', 'Winner At Zeal Institute''s Web Development Project Competition', '2025-04-12 00:00:00', 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/achievements/04c4131b-56a7-46d1-a6e0-797e095a33b6.jpeg', '2025-07-27 18:58:46.02', 'd7d54e46-8db2-449c-87f9-8e89e8537c42', 'On 12th April 2025, the team secured the winner position at Zeal Institute''s Web Development Project Competition, demonstrating outstanding innovation and technical excellence.', '2025-07-27 18:58:46.02', NULL), -('10', 'Finalist Meher Baba Drone Competition', '2022-02-13 00:00:00', 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/achievements/ac141a2e-07f5-42f2-a5a2-6195d22f15a7.jpeg', '2025-07-27 19:16:06.848', '1b5933a9-5d50-4246-861a-ca0d30bd581f', 'Selected as a Finalist in the Meher Baba Drone Competition, showcasing innovative solutions in drone technology and aerial system design. Recognized for technical creativity, problem-solving, and practical implementation in a competitive national setting.', '2025-07-29 11:57:51.485', 'd7d54e46-8db2-449c-87f9-8e89e8537c42'), -('13', 'Runner Up Smart India Hackathon 2024', '2024-12-12 00:00:00', 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/achievements/d99c3eae-831d-4043-90a8-4f15d9970d65.jpeg', '2025-07-27 19:27:03.366', '1b5933a9-5d50-4246-861a-ca0d30bd581f', 'Secured Runner-Up position at Smart India Hackathon 2024, a prestigious national-level innovation challenge. Built an AI-powered virtual therapy platform for individuals with speech impairments, promoting accessible and inclusive mental health support.', '2025-07-27 19:27:03.366', NULL), -('8', 'Runner Up At Clash of CSS NIT Kurukshetra', '2023-11-30 00:00:00', 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/achievements/a6a92603-a4a9-4ea8-9afe-f1473f0b21ab.jpeg', '2025-07-27 19:09:12.806', '1b5933a9-5d50-4246-861a-ca0d30bd581f', 'Runner Up at the Clash of CSS Hackathon organized online by NIT Kurukshetra.\nWorked in a team of three to design a visually appealing, responsive frontend interface under time constraints, demonstrating strong UI/UX skills and effective collaboration.', '2025-07-27 19:09:12.806', NULL); - - --- --- Data for Name: CompletedQuestion; Type: TABLE DATA; Schema: public; Owner: - --- - --- Empty COPY for public."CompletedQuestion" ("memberId", "questionId") removed - - --- --- Data for Name: InterviewExperience; Type: TABLE DATA; Schema: public; Owner: - --- - -INSERT INTO public."InterviewExperience" (id, company, role, verdict, content, "isAnonymous", "memberId") VALUES -('33', 'Grinder', 'Sword Department', 'Pending', 'I got selected because of my skills with my sword ( i am revealing my identity)', 'false', '77165f92-1a09-407c-987f-0fc9be16fad8'), -('6', 'Google', 'Software Engineering', 'Rejected', '"
  1. I SUCK

"', 'false', '77165f92-1a09-407c-987f-0fc9be16fad8'), -('7', 'Amazon', 'Engineer', 'Selected', '

I ROCK
i SHINE

I AM THE BEST

I AM HUGE

FABULOUS IS MY MIDDLE NAME

', 'true', '77165f92-1a09-407c-987f-0fc9be16fad8'), -('8', 'TCS', 'Prime', 'Pending', '

IF YOU BECAME PREGNANT ON THE DAY OF YOUR TCS INTERIVEW THEN BY THE TIME YOU GET YOUR INTERVIEW RESULT YOUR FETUS WOULD HAVE DEVLEOPED THE ABILITY TO OPEN ITS EYES, LITERAL EYES DEVELOP IN LESS TIME THAN THESE PEOPLE NEED TO TELL YOU WHETHER YOU HAVE A JOB OR NOT

', 'true', '77165f92-1a09-407c-987f-0fc9be16fad8'), -('10', 'Meta', 'Floor sweeper', 'Rejected', '# how tf did I mess this up man\n\n*It was literally a floor sweeping job*\n\n### I even showed my floor coding skills by creating a program that does `print("I am floor sweeper!!!")`\n\n> Honestly I am quite impressed that someone can fail an interveiw for this job\n\n -my interviewer\n\nThe job requirements:\n\n\n1. Know how to walk\n2. Know how to hold a mop\n3. Know how to use it on the floor \n\nWhen I asked them why I failed they gave the following evaluation:\n\n\n- The candidate came with a hatsune miku themed mop that had "my waifu helps me clean" on it \n- He was constantly staring at the breasts of female as well as male interviewers (while licking lips)\n- When asked for an explaination for the "I love pedophilia" tshirt that the candidate was sporting, he went on a 30 minute tirade on the topic of **"Old enough to swear, then my children she can bear"**\n\n**idk man just sounds very unreasonable if you ask me :(**\n\n', 'true', '77165f92-1a09-407c-987f-0fc9be16fad8'), -('12', 'call of code', 'President', 'Rejected', 'How tf did I get rejected for a job I already had 😭', 'false', '77165f92-1a09-407c-987f-0fc9be16fad8'), -('13', 'xyz', 'asdf', 'Pending', '> asdfasdfasdf', 'true', '77165f92-1a09-407c-987f-0fc9be16fad8'), -('14', 'asdf', 'asdf', 'Selected', '` -dsfasdf`', 'true', '77165f92-1a09-407c-987f-0fc9be16fad8'), -('15', 'asdf', 'qwer', 'Pending', ' sarvesh', 'false', '77165f92-1a09-407c-987f-0fc9be16fad8'), -('16', 'xvbasdf', 'l;sjdfl;jasdf', 'Pending', 'asdfasdfasdfasdfasdfasdf', 'false', '77165f92-1a09-407c-987f-0fc9be16fad8'), -('17', 'Deolitte', 'SDE1', 'Selected', 'Guys i got selected the techical rounds were easy** **', 'false', '77165f92-1a09-407c-987f-0fc9be16fad8'), -('18', 'Human Inc', 'Ex-Kitten', 'Selected', 'Moo. I mean, Mew.', 'true', '77165f92-1a09-407c-987f-0fc9be16fad8'), -('19', 'Company', 'SDE', 'Selected', 'Yeahhhhhhhhh i got selected', 'false', '77165f92-1a09-407c-987f-0fc9be16fad8'), -('11', 'Netflix', 'floor sweeper', 'Rejected', '# how tf did I mess this up man\n\n*It was literally a floor sweeping job*\n\n### I even showed my floor coding skills by creating a program that does `print("I am floor sweeper!!!")`\n\n> Honestly I am quite impressed that someone can fail an interveiw for this job\n\n```\n -my interviewer\n\n```\n\nThe job requirements:\n\n1. Know how to walk\n2. Know how to hold a mop\n3. Know how to use it on the floor\n\nWhen I asked them why I failed they gave the following evaluation:\n\n- The candidate came with a hatsune miku themed mop that had "my waifu helps me clean" on it\n', 'true', '77165f92-1a09-407c-987f-0fc9be16fad8'), -('20', 'Google', 'Software Engineer', 'Selected', 'Yeah i got selected at Google', 'false', '77165f92-1a09-407c-987f-0fc9be16fad8'), -('23', 'aaaaaaaaaaaa', 'aaaaaaaaaaaaaaaa', 'Selected', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'false', '77165f92-1a09-407c-987f-0fc9be16fad8'), -('24', 'aabaasaxax', 'aaaa', 'Selected', '1234567cdcs', 'false', '77165f92-1a09-407c-987f-0fc9be16fad8'), -('25', 'qwert', 'qwert', 'Selected', 'qwertyuiokjhgfdszxcv', 'true', '77165f92-1a09-407c-987f-0fc9be16fad8'), -('26', 'asdfghjk', 'asdfghj', 'Selected', 'qwerdfghbnss', 'true', '77165f92-1a09-407c-987f-0fc9be16fad8'), -('27', 'qqqqqqqqqq', 'qqqqqqqqqq', 'Selected', 'qqqqqqqqqqqqqqqqqqqqqq', 'false', '77165f92-1a09-407c-987f-0fc9be16fad8'), -('28', 'qqqqqqqqqqqqqqq', 'qqqqqqqqqqqqqqqqqqq', 'Selected', 'qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq', 'false', '77165f92-1a09-407c-987f-0fc9be16fad8'), -('29', 'qqqqqqqqqqqqqqq', 'qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq', 'Pending', 'qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq', 'false', '77165f92-1a09-407c-987f-0fc9be16fad8'), -('34', 'qwed', 'ssss', 'Selected', 'qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq', 'false', '77165f92-1a09-407c-987f-0fc9be16fad8'), -('31', 'OnlyFans', 'Content Head', 'Selected', 'Yeahh i got selected because of my content ideas.', 'false', '77165f92-1a09-407c-987f-0fc9be16fad8'), -('32', 'Tinder', 'Match Maker', 'Rejected', 'I got rejected because of my dogshit match making skillssss brooooooo', 'false', '77165f92-1a09-407c-987f-0fc9be16fad8'); - - --- --- Data for Name: Member; Type: TABLE DATA; Schema: public; Owner: - --- - -INSERT INTO public."Member" (id, name, email, phone, bio, "profilePhoto", github, linkedin, twitter, geeksforgeeks, leetcode, codechef, codeforces, "passoutYear", "isApproved", "isManager", "createdAt", "updatedAt", "approvedById", birth_date) VALUES -('a6bc0b3a-71bf-4e0d-8879-ddedbbc0a766', 'your mom', 'yourmom@gmail.com', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, '2026-01-01 00:00:00', 'false', 'false', '2025-10-27 20:12:58.862', '2025-10-27 20:12:58.862', NULL, NULL), -('259a1e70-c093-43d4-8aa1-bba058a896b8', 'Sakshi Chaudhari', 'sakshi@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/27b32a1e-2edd-4663-8635-e0cf9c159967.jpeg', 'https://github.com/sakshiatul30', 'https://www.linkedin.com/in/sakshi-chaudhari-536a81324', NULL, NULL, NULL, NULL, NULL, '2027-01-01 00:00:00', 'false', 'false', '2025-07-27 13:44:45.59', '2025-09-15 18:34:02.698', '77165f92-1a09-407c-987f-0fc9be16fad8', NULL), -('92f0e65d-f306-4cdd-baad-059f645cf148', 'hello', 'hello123@gmail.com', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, '2027-01-01 00:00:00', 'false', 'false', '2025-11-20 09:01:13.485', '2025-11-20 09:01:13.485', NULL, NULL), -('db2bd9ec-25e5-4134-ae53-fea1734ca161', 'Aarya Godbole', 'aaryagodbole550@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/b7d68ce2-87c9-4ea6-9add-4e809b7ae37c.jpeg', 'https://github.com/aaryagodbole', 'https://www.linkedin.com/in/aarya-godbole/', NULL, NULL, NULL, NULL, NULL, '2027-01-01 00:00:00', 'true', 'false', '2025-07-27 13:56:11.408', '2025-07-27 19:37:09.064', NULL, NULL), -('22ba7f7a-14e7-45fa-bf5f-51d5f015496f', 'Vansh Waldeo', 'vansh@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/b856d400-78ea-4eb4-8f3c-1ffb1eb1b0fa.jpeg', 'https://github.com/VanshKing30', 'https://www.linkedin.com/in/vansh-waldeo-81ab31285/', NULL, NULL, NULL, NULL, NULL, '2025-01-01 00:00:00', 'true', 'false', '2025-07-27 13:12:09.267', '2025-07-27 13:12:09.267', NULL, NULL), -('6c968bfa-ebf8-4b2b-a349-36bbc9cc2870', 'Sachin Barvekar', 'sachin@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/d84017f1-8859-4444-a378-f7591f2c2456.jpeg', NULL, 'https://www.linkedin.com/in/sachin-barvekar-2874481a2/', NULL, NULL, NULL, NULL, NULL, '2025-01-01 00:00:00', 'true', 'false', '2025-07-27 14:28:13.538', '2025-07-27 14:28:13.538', NULL, NULL), -('7dd07cc1-08da-48cc-a162-f546356fe291', 'Vaishnavi Adhav', 'vaishnaviadhav@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/2ed0e256-7b52-4c90-abf1-2c759b4aeaa8.jpeg', 'https://github.com/vaishnavi4049', 'https://www.linkedin.com/in/vaishnavi-adhav-b5b346362', NULL, NULL, NULL, NULL, NULL, '2027-01-01 00:00:00', 'true', 'false', '2025-07-27 13:43:26.12', '2025-07-27 19:35:31.505', NULL, NULL), -('86237b63-1339-49d6-ad57-1d4b93bd5092', 'Bhaven Rathod', 'bhaven@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/df2e0e37-50e8-435e-92e7-fa2e507ca424.jpeg', NULL, 'https://www.linkedin.com/in/bhaven-rathod/', NULL, NULL, NULL, NULL, NULL, '2025-01-01 00:00:00', 'true', 'false', '2025-07-27 12:42:50.416', '2025-07-27 12:42:50.416', NULL, NULL), -('46cfa3aa-1efe-4cc6-a624-340808ef7cb8', 'Piyushaa Mahajan', 'piyushaa@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/396f4028-0423-45d0-94b4-5222e239d875.jpeg', 'https://github.com/piyushaa20', 'https://www.linkedin.com/in/piyushaa-mahajan-826594323', NULL, NULL, NULL, NULL, NULL, '2027-01-01 00:00:00', 'true', 'false', '2025-07-27 13:46:16.531', '2025-07-27 19:51:48.667', NULL, NULL), -('a8443783-dd59-446a-93f5-19f5e590e88b', 'Siddhesh Thorat', 'siddhesh@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/1ed6dc74-090b-4cca-9c9a-687b12a5086f.jpeg', 'https://github.com/siddhu9993', 'https://www.linkedin.com/in/d2d-siddhesh/', NULL, NULL, NULL, NULL, NULL, '2027-01-01 00:00:00', 'true', 'false', '2025-07-27 13:47:44.371', '2025-07-27 13:47:44.371', NULL, NULL), -('329d6d7a-9787-452e-9c0c-506481c5462a', 'Suhani Bhati', 'suhani@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/57bb90b4-817f-4aaf-ab83-6f202e50a1e7.jpeg', 'https://github.com/SuhaniBhati', 'https://www.linkedin.com/in/suhani-bhati-aa528828b', NULL, NULL, NULL, NULL, NULL, '2027-01-01 00:00:00', 'true', 'false', '2025-07-27 13:58:45.984', '2025-07-27 19:39:11.221', NULL, NULL), -('855fb554-02f7-4b6a-a17e-d55b7976babf', 'Sanskar Darekar', 'sanskar@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/3d832d1b-3d81-481e-a6a3-e9e206c61c65.jpeg', 'https://github.com/sanskar-sd', 'https://www.linkedin.com/in/sanskar-darekar/', NULL, NULL, NULL, NULL, NULL, '2026-01-01 00:00:00', 'true', 'false', '2025-07-27 12:53:25.333', '2025-07-27 19:40:07.915', NULL, NULL), -('738b73b4-0725-4f7c-95bf-cbdb72eb4e84', 'Vedant Bulbule', 'vedant@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/6b61cb98-edc9-4e17-9ef7-6784850b77c7.jpeg', 'https://github.com/Vedantbulbule1223', 'https://www.linkedin.com/in/vedant-bulbule-aiml/', NULL, NULL, NULL, NULL, NULL, '2026-01-01 00:00:00', 'true', 'false', '2025-07-27 12:36:06.689', '2025-07-27 12:36:06.689', NULL, NULL), -('b6f44922-fff3-48eb-a0c9-15d41e786e38', 'Anushka Bendle', 'anushka@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/f71382bc-a6d5-467e-ac77-81e6667ddbd1.jpeg', 'https://github.com/anushkabendle', 'https://www.linkedin.com/in/anushkabendle445', NULL, NULL, NULL, NULL, NULL, '2027-01-01 00:00:00', 'true', 'false', '2025-07-27 13:37:17.319', '2025-07-27 19:52:42.056', NULL, NULL), -('1b482f80-f649-45f9-a90b-7538a7a6e66e', 'Mukul Dhobale', 'Mukul@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/0c6b2d21-32cd-4cce-96ee-58d1e265dac5.jpeg', 'https://github.com/Mukul306', 'https://www.linkedin.com/in/mukul-dhobale-53b547333', NULL, NULL, NULL, NULL, NULL, '2027-01-01 00:00:00', 'true', 'false', '2025-07-27 13:36:07.064', '2025-07-27 19:55:31.61', NULL, NULL), -('1b5933a9-5d50-4246-861a-ca0d30bd581f', 'Samarth Lad', 'samarth@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/1f07275c-e0e5-4c66-b068-25c593c263cb.jpeg', 'https://github.com/samrth07', 'https://www.linkedin.com/in/samarth-lad-675b99322/', NULL, NULL, NULL, NULL, NULL, '2027-01-01 00:00:00', 'true', 'true', '2025-07-27 13:41:03.21', '2025-07-27 19:58:32.541', NULL, NULL), -('3e6666f5-8ad9-4686-8656-a6904360d4ba', 'Shaheen Khan', 'shaheen@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/71310f7a-2dba-4d68-9aef-eb18dc60b1e6.jpeg', 'https://github.com/Shaheen-25', 'https://www.linkedin.com/in/shaheenkhan25', NULL, NULL, NULL, NULL, NULL, '2026-01-01 00:00:00', 'true', 'false', '2025-07-27 12:49:35.89', '2025-07-27 19:34:16.322', NULL, NULL), -('8eeacf82-18e5-48f8-a11e-fdbbe2eb81ce', 'Shivaji Raut', 'shivaji@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/cb24b95d-7ff8-444e-993e-c4dae169a5b2.jpeg', 'https://github.com/shivaji43', 'https://www.linkedin.com/in/shivajiraut/', NULL, NULL, NULL, NULL, NULL, '2025-01-01 00:00:00', 'true', 'false', '2025-07-27 13:13:06.46', '2025-07-27 13:13:06.46', NULL, NULL), -('1404e81c-d567-4103-941b-0abeea7fc049', 'Swaraj Pawar', 'swaraj@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/1d6c896c-7889-4140-81df-6d1d11021b71.jpeg', 'https://github.com/Swaraj-23', 'https://www.linkedin.com/in/swaraj-pawar-webdev/', NULL, NULL, NULL, NULL, NULL, '2025-01-01 00:00:00', 'true', 'false', '2025-07-27 13:11:27.264', '2025-07-27 13:11:27.264', NULL, NULL), -('644b5e8f-910d-450e-a855-a88f31d02b7b', 'Sanica chorey', 'sanica@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/5cfd39ec-9bd1-4414-91f7-c25b2f4b96b7.jpeg', NULL, 'https://www.linkedin.com/in/sanica-chorey-0876b024b/', NULL, NULL, NULL, NULL, NULL, '2025-01-01 00:00:00', 'true', 'false', '2025-07-27 13:20:13.746', '2025-07-27 13:20:13.746', NULL, NULL), -('03a1a0f8-c1b9-4ac1-99d9-f10f26a82f7c', 'Eshwar Varpe', 'eshwar@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/23b3a8c4-5821-43fe-9f91-9e48e43ed1be.jpeg', NULL, 'https://www.linkedin.com/in/eshwar-varpe-37a309246/', NULL, NULL, NULL, NULL, NULL, '2024-01-01 00:00:00', 'true', 'false', '2025-07-27 13:00:37.038', '2025-07-27 13:00:37.038', NULL, NULL), -('0cc29a18-180c-408d-9cbe-0fc06109a5c1', 'Yash Kathane', 'yash@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/5dd11423-a067-4ccb-b24b-1a4c0eb0aed2.jpeg', NULL, 'https://www.linkedin.com/in/yash-kathane-31b3b1215/', NULL, NULL, NULL, NULL, NULL, '2024-01-01 00:00:00', 'true', 'false', '2025-07-27 13:03:02.901', '2025-07-27 13:03:02.901', NULL, NULL), -('ac713749-77c1-46b6-ae82-f16d616b1c7c', 'Pratik Bhoite', 'pratik@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/3dc40d0d-5101-4550-9999-1dc4a4f649c3.jpeg', NULL, 'https://www.linkedin.com/in/pratik-bhoite-839770232/', NULL, NULL, NULL, NULL, NULL, '2024-01-01 00:00:00', 'true', 'false', '2025-07-27 13:05:29.412', '2025-07-27 19:19:08.055', NULL, NULL), -('48724979-f9c8-46de-b9ab-9ca0186596d0', 'Aryan Jadlie', 'aryan@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/a7e96fa8-21af-481d-ad80-dcc6c55b3be2.png', NULL, NULL, NULL, NULL, NULL, NULL, NULL, '2026-01-01 00:00:00', 'false', 'false', '2025-07-27 18:47:06.554', '2025-10-15 21:42:34.758', '77165f92-1a09-407c-987f-0fc9be16fad8', NULL), -('69394246-3e41-4eb5-812a-48801b0b5f3e', 'Shubham Tohake', 'shubham@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/22dc88c0-57a4-42ca-913f-7ab9ffd92080.png', NULL, NULL, NULL, NULL, NULL, NULL, NULL, '2027-01-01 00:00:00', 'false', 'false', '2025-07-27 18:49:14.273', '2025-09-15 18:35:19.025', '77165f92-1a09-407c-987f-0fc9be16fad8', NULL), -('f032f524-c153-496f-9eea-e2ff8622f3d1', 'Sahil Mulani', 'sahil@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/c286bed1-186d-4c3d-aa3c-3fcfc2e33752.jpeg', 'https://github.com/MulaniSahil', 'https://www.linkedin.com/in/sahil-mulani-5b7bba2a8/', NULL, NULL, 'https://leetcode.com/u/mulanisahil/', NULL, NULL, '2026-01-01 00:00:00', 'true', 'false', '2025-07-27 19:42:24.97', '2025-07-28 10:07:00.727', NULL, NULL), -('d46a667d-5b68-4b82-9de7-fcfdf0ab0181', 'Vaishnavi Ambhore', 'vaishnavi@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/c13561c7-cebf-4bad-994e-2370c841720d.jpeg', 'https://github.com/vaish12345678', 'https://www.linkedin.com/in/vaishnavi-ambhore-157131335/', NULL, NULL, NULL, NULL, NULL, '2027-01-01 00:00:00', 'true', 'false', '2025-07-27 13:42:03.576', '2025-07-27 19:36:06.959', NULL, NULL), -('d7c96d3c-d45d-4bde-8c2b-39f0451a389f', 'Sarvesh Shiralkar', 'sarveshshiralkar@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/84ee8088-de1e-49ab-8bae-e044cf8d7830.jpeg', 'https://github.com/SarveshMS7', 'https://www.linkedin.com/in/sarvesh-shiralkar-69559a326/', NULL, NULL, NULL, NULL, NULL, '2027-01-01 00:00:00', 'true', 'false', '2025-07-27 14:02:15.223', '2025-07-27 19:41:33.591', NULL, NULL), -('d7d54e46-8db2-449c-87f9-8e89e8537c42', 'Sherin Thomas', 'sherin@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/de491aeb-c096-4995-8428-26b94607d45b.jpeg', 'https://github.com/Sherin-2711', 'https://www.linkedin.com/in/sherin-thomas-644242333', NULL, NULL, NULL, NULL, NULL, '2027-01-01 00:00:00', 'true', 'true', '2025-07-27 16:57:39.035', '2025-07-27 19:57:11.54', NULL, NULL), -('77165f92-1a09-407c-987f-0fc9be16fad8', 'Harish Narote', 'harish@gmail.com', '8668673365', 'DSA Head of Call Of Code.....', 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/bc133101-04d9-457f-8c57-a0b3f7fd683a.jpeg', 'https://github.com/Harish-Naruto', 'https://www.linkedin.com/in/harish-narote-600717339/', NULL, NULL, NULL, NULL, NULL, '2028-01-01 18:30:00', 'true', 'true', '2025-07-27 20:43:21.525', '2025-11-21 14:20:00.495', NULL, '2025-11-28'), -('20ee0910-36b3-48d6-ad96-2112d02fd9b6', 'Dilip Choudhary', 'dillip@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/1a814f4f-cdf4-43a8-980e-f1062695f730.png', 'https://github.com/dilip7654', 'https://www.linkedin.com/in/dilip-choudhary-39966421a/', NULL, NULL, NULL, NULL, NULL, '2026-01-01 00:00:00', 'true', 'false', '2025-07-27 19:47:54.658', '2025-07-27 20:00:57.185', NULL, NULL), -('1ff4b36d-5671-4855-8476-d0a8993f9873', ' Aditya Modak', 'aditya@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/01800613-ca50-43ae-b0a4-a076c9da427d.jpeg', 'https://github.com/Aditya2002M', 'https://www.linkedin.com/in/aditya-modak-42a684250/', NULL, NULL, NULL, NULL, NULL, '2025-01-01 00:00:00', 'true', 'false', '2025-07-27 13:21:05.022', '2025-07-27 13:21:05.022', NULL, NULL), -('207bb8bd-3e48-40c8-83ce-a825cb9fe474', 'syswraith', 'syswraith@gmail.com', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, '2027-01-01 00:00:00', 'false', 'false', '2025-10-27 14:57:13.93', '2025-10-27 14:57:13.93', NULL, NULL), -('c5b2470d-7fb4-4c93-bfe2-fedc00415dc2', 'Shashwati Meshram ', 'shashwati@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/WhatsApp%20Image%202025-07-28%20at%205.00.43%20PM.jpeg', 'https://github.com/Shashwati12', 'https://www.linkedin.com/in/shashwati-meshram-785316325/', NULL, NULL, NULL, NULL, NULL, '2027-01-01 00:00:00', 'true', 'false', '2025-07-27 13:57:14.412', '2025-07-27 13:57:14.412', NULL, NULL), -('75ef229a-3770-46aa-adc8-f4d250c6ac81', 'Shivam Korade', 'shivam@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/be5d5c49-da35-4979-8e06-0b8bc56edea6.jpeg', NULL, 'https://www.linkedin.com/in/shivam-korade/', NULL, NULL, NULL, NULL, NULL, '2025-01-01 00:00:00', 'true', 'false', '2025-07-27 17:32:15.43', '2025-07-27 17:32:15.43', NULL, NULL), -('516af252-e8dc-48a4-80c4-5af1e0758e58', 'Sahil Lakare', 'sahillakare@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/ec07d6cd-d1b5-43b1-8749-11baca35abba.jpeg', NULL, 'https://www.linkedin.com/in/sahil-lakare-842304298/', NULL, NULL, NULL, NULL, NULL, '2027-01-01 00:00:00', 'false', 'false', '2025-07-27 14:07:22.15', '2025-10-06 06:38:03.943', '77165f92-1a09-407c-987f-0fc9be16fad8', NULL), -('046d352f-10d9-49b5-bbed-31d67bf4b583', 'Shreyash Kapse', 'sheryash@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/6360ee38-249d-4d40-83c2-de1bd2b03d22.png', NULL, NULL, NULL, NULL, 'https://leetcode.com', 'https://codechef.com', NULL, '2026-01-01 00:00:00', 'false', 'false', '2025-07-27 18:28:44.084', '2025-10-06 06:38:11.206', '77165f92-1a09-407c-987f-0fc9be16fad8', NULL), -('e68ca856-978b-4bb5-a2f1-6497278624bb', 'Abhiram Suradkar', 'Abhiram@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/6cec207d-5cac-466a-96cf-00faaae9141f.jpeg', 'https://github.com/abhi32GBram', 'https://www.linkedin.com/in/abhiram-suradkar-a6728622b/', NULL, NULL, NULL, NULL, NULL, '2025-01-01 00:00:00', 'true', 'false', '2025-07-27 18:41:32.116', '2025-07-27 18:41:32.116', NULL, NULL), -('ef59db8b-2ad5-4e0a-b741-f58521bf61ec', 'Komal Warake', 'komal@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/8aa5e9df-6bf1-458f-b65f-03d1a3389398.png', 'https://github.com/komalwarke1', 'https://www.linkedin.com/in/komal-warake01', NULL, NULL, NULL, NULL, NULL, '2026-01-01 00:00:00', 'true', 'false', '2025-07-27 19:29:28.585', '2025-07-27 19:32:17.955', NULL, NULL), -('bf152df5-3c23-4c1c-85aa-212e0487b420', 'Prathamesh Shinde', 'prathamesh@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/a0915ccc-c8f3-498a-96b1-1731e0f01258.jpeg', 'https://github.com/prathameshshinde555', 'https://www.linkedin.com/in/prathameshshinde555/', NULL, NULL, NULL, NULL, NULL, '2024-01-01 00:00:00', 'true', 'false', '2025-07-27 13:04:35.098', '2025-07-27 13:04:35.098', NULL, NULL), -('0b83d3e3-8685-4cfe-9f63-6cc22c1ceae4', 'Prajkta Patil', 'prajakta@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/ac073ec8-59b2-4f71-8fb0-1be1af7044b5.png', NULL, NULL, NULL, NULL, NULL, NULL, NULL, '2026-01-01 00:00:00', 'false', 'false', '2025-07-27 18:32:26.724', '2025-07-31 18:13:22.227', '77165f92-1a09-407c-987f-0fc9be16fad8', NULL), -('64464cc4-4dfc-4522-a256-6aca2371df7f', 'Veda Bhadane', 'veda@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/0b34ae85-da4a-480f-adc5-f27e83e47c32.jpeg', NULL, NULL, NULL, NULL, NULL, NULL, NULL, '2026-01-01 00:00:00', 'false', 'false', '2025-07-27 18:06:52.395', '2025-10-27 06:54:48.588', '77165f92-1a09-407c-987f-0fc9be16fad8', NULL), -('ad525dce-67dd-4878-ab95-068943923b81', 'Sarvesh Shahane', 'sarvesh@gmail.com', '9421957635', 'I am that guy.', 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/2e341e00-8b4b-4276-b7c6-e0a68c45f73c.jpeg', 'https://github.com/i-am-that-guy', 'https://www.linkedin.com/in/sarveshshahane', 'https://x.com/_That_Guy_Here_', 'https://www.geeksforgeeks.org/profile/sarveshshaqram/', 'https://leetcode.com/u/This-Is-My-GitHub-Account/', 'https://www.codechef.com/users/i_am_that_guy', 'https://codeforces.com/profile/i_am_that_guy', '2025-01-01 00:00:00', 'true', 'false', '2025-07-27 13:29:37.542', '2025-07-27 21:14:03.77', NULL, '2026-01-07'), -('c494d747-5123-457f-b9cf-f3359f5a0fe8', 'Shruti Jadhav ', 'shruti@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/b01eec07-de6c-49eb-b4cd-31dbb17d1d71.jpeg', 'https://github.com/shrutiiiyet', 'https://www.linkedin.com/in/shruti-jadhav-892164277/', NULL, NULL, NULL, NULL, NULL, '2027-01-01 00:00:00', 'true', 'true', '2025-07-27 17:18:50.419', '2025-07-27 19:59:53.048', NULL, NULL), -('cffc47fb-1147-4dd5-9818-21d209dbe3f3', 'Harsh Bhavsar', 'harsh@gmail.com', NULL, NULL, 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/members/66a493c8-5de1-4be8-b64a-d2639ec000e3.png', NULL, NULL, NULL, NULL, NULL, NULL, NULL, '2027-01-01 00:00:00', 'false', 'false', '2025-07-27 18:37:15.882', '2025-09-15 14:45:06.273', 'c494d747-5123-457f-b9cf-f3359f5a0fe8', NULL); - - --- --- Data for Name: MemberAchievement; Type: TABLE DATA; Schema: public; Owner: - --- - -INSERT INTO public."MemberAchievement" ("memberId", "achievementId") VALUES -('22ba7f7a-14e7-45fa-bf5f-51d5f015496f', '3'), -('644b5e8f-910d-450e-a855-a88f31d02b7b', '4'), -('6c968bfa-ebf8-4b2b-a349-36bbc9cc2870', '4'), -('1ff4b36d-5671-4855-8476-d0a8993f9873', '4'), -('046d352f-10d9-49b5-bbed-31d67bf4b583', '4'), -('bf152df5-3c23-4c1c-85aa-212e0487b420', '5'), -('75ef229a-3770-46aa-adc8-f4d250c6ac81', '5'), -('48724979-f9c8-46de-b9ab-9ca0186596d0', '5'), -('86237b63-1339-49d6-ad57-1d4b93bd5092', '5'), -('1b5933a9-5d50-4246-861a-ca0d30bd581f', '6'), -('c494d747-5123-457f-b9cf-f3359f5a0fe8', '6'), -('db2bd9ec-25e5-4134-ae53-fea1734ca161', '6'), -('d46a667d-5b68-4b82-9de7-fcfdf0ab0181', '6'), -('69394246-3e41-4eb5-812a-48801b0b5f3e', '7'), -('7dd07cc1-08da-48cc-a162-f546356fe291', '7'), -('b6f44922-fff3-48eb-a0c9-15d41e786e38', '7'), -('259a1e70-c093-43d4-8aa1-bba058a896b8', '7'), -('644b5e8f-910d-450e-a855-a88f31d02b7b', '8'), -('86237b63-1339-49d6-ad57-1d4b93bd5092', '8'), -('ad525dce-67dd-4878-ab95-068943923b81', '8'), -('69394246-3e41-4eb5-812a-48801b0b5f3e', '9'), -('d7d54e46-8db2-449c-87f9-8e89e8537c42', '9'), -('46cfa3aa-1efe-4cc6-a624-340808ef7cb8', '9'), -('b6f44922-fff3-48eb-a0c9-15d41e786e38', '9'), -('bf152df5-3c23-4c1c-85aa-212e0487b420', '10'), -('0cc29a18-180c-408d-9cbe-0fc06109a5c1', '10'), -('03a1a0f8-c1b9-4ac1-99d9-f10f26a82f7c', '11'), -('64464cc4-4dfc-4522-a256-6aca2371df7f', '11'), -('046d352f-10d9-49b5-bbed-31d67bf4b583', '11'), -('69394246-3e41-4eb5-812a-48801b0b5f3e', '12'), -('c5b2470d-7fb4-4c93-bfe2-fedc00415dc2', '12'), -('0b83d3e3-8685-4cfe-9f63-6cc22c1ceae4', '13'), -('1b5933a9-5d50-4246-861a-ca0d30bd581f', '13'), -('22ba7f7a-14e7-45fa-bf5f-51d5f015496f', '13'), -('329d6d7a-9787-452e-9c0c-506481c5462a', '13'), -('64464cc4-4dfc-4522-a256-6aca2371df7f', '13'), -('cffc47fb-1147-4dd5-9818-21d209dbe3f3', '13'), -('69394246-3e41-4eb5-812a-48801b0b5f3e', '14'), -('ad525dce-67dd-4878-ab95-068943923b81', '15'), -('20ee0910-36b3-48d6-ad96-2112d02fd9b6', '15'), -('738b73b4-0725-4f7c-95bf-cbdb72eb4e84', '15'), -('f032f524-c153-496f-9eea-e2ff8622f3d1', '15'); - - --- --- Data for Name: MemberProject; Type: TABLE DATA; Schema: public; Owner: - --- - -INSERT INTO public."MemberProject" ("memberId", "projectId") VALUES -('22ba7f7a-14e7-45fa-bf5f-51d5f015496f', '3'), -('22ba7f7a-14e7-45fa-bf5f-51d5f015496f', '4'), -('ad525dce-67dd-4878-ab95-068943923b81', '4'), -('1404e81c-d567-4103-941b-0abeea7fc049', '4'), -('1b5933a9-5d50-4246-861a-ca0d30bd581f', '6'), -('c494d747-5123-457f-b9cf-f3359f5a0fe8', '6'), -('db2bd9ec-25e5-4134-ae53-fea1734ca161', '6'), -('d46a667d-5b68-4b82-9de7-fcfdf0ab0181', '6'), -('d7d54e46-8db2-449c-87f9-8e89e8537c42', '8'), -('c5b2470d-7fb4-4c93-bfe2-fedc00415dc2', '8'), -('1b482f80-f649-45f9-a90b-7538a7a6e66e', '8'), -('329d6d7a-9787-452e-9c0c-506481c5462a', '8'), -('69394246-3e41-4eb5-812a-48801b0b5f3e', '10'), -('d7d54e46-8db2-449c-87f9-8e89e8537c42', '10'), -('46cfa3aa-1efe-4cc6-a624-340808ef7cb8', '10'), -('b6f44922-fff3-48eb-a0c9-15d41e786e38', '10'), -('ad525dce-67dd-4878-ab95-068943923b81', '11'), -('8eeacf82-18e5-48f8-a11e-fdbbe2eb81ce', '11'), -('1ff4b36d-5671-4855-8476-d0a8993f9873', '11'), -('22ba7f7a-14e7-45fa-bf5f-51d5f015496f', '11'), -('77165f92-1a09-407c-987f-0fc9be16fad8', '11'), -('d7d54e46-8db2-449c-87f9-8e89e8537c42', '11'), -('c5b2470d-7fb4-4c93-bfe2-fedc00415dc2', '11'), -('1b5933a9-5d50-4246-861a-ca0d30bd581f', '11'), -('c494d747-5123-457f-b9cf-f3359f5a0fe8', '11'), -('69394246-3e41-4eb5-812a-48801b0b5f3e', '13'), -('c5b2470d-7fb4-4c93-bfe2-fedc00415dc2', '13'), -('ad525dce-67dd-4878-ab95-068943923b81', '14'), -('77165f92-1a09-407c-987f-0fc9be16fad8', '14'), -('bf152df5-3c23-4c1c-85aa-212e0487b420', '15'), -('69394246-3e41-4eb5-812a-48801b0b5f3e', '16'), -('c5b2470d-7fb4-4c93-bfe2-fedc00415dc2', '16'), -('22ba7f7a-14e7-45fa-bf5f-51d5f015496f', '16'), -('db2bd9ec-25e5-4134-ae53-fea1734ca161', '16'); - - --- --- Data for Name: Project; Type: TABLE DATA; Schema: public; Owner: - --- - -INSERT INTO public."Project" (id, name, "imageUrl", "githubUrl", "deployUrl", "createdAt", "createdById", "updatedAt", "updatedById") VALUES -('3', 'Codenest', 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/projects/ea048f75-12ca-426f-a0f7-da5bd8d2a6ab.jpeg', 'https://github.com/VanshKing30/codenest', NULL, '2025-07-27 20:24:12.073', 'c494d747-5123-457f-b9cf-f3359f5a0fe8', '2025-07-27 20:24:12.073', NULL), -('4', 'FoodiesWeb', 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/projects/5b2ea1df-4bbb-40f5-896a-add8dd6573c7.png', 'https://github.com/VanshKing30/FoodiesWeb', 'https://foodies-web-app.vercel.app/', '2025-07-27 20:25:05.349', 'c494d747-5123-457f-b9cf-f3359f5a0fe8', '2025-07-27 20:25:05.349', NULL), -('6', 'Sportify', 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/projects/2cfe891d-f838-4118-9a89-bae85a5adab6.png', 'https://github.com/call-0f-code/Sportify', NULL, '2025-07-27 20:40:13.289', 'c494d747-5123-457f-b9cf-f3359f5a0fe8', '2025-07-27 20:40:13.289', NULL), -('8', 'EventHub', 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/projects/259f8cf5-d265-4948-bbc7-987710af6b7e.jpeg', 'https://github.com/Shashwati12/Event-Hub', NULL, '2025-07-27 20:51:06.857', 'c494d747-5123-457f-b9cf-f3359f5a0fe8', '2025-07-27 20:51:06.857', NULL), -('10', 'Wanderlust', 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/projects/0da621d2-a076-4043-ac6a-04d2aabbcebf.jpeg', 'https://github.com/Sherin-2711/Wanderlust', NULL, '2025-07-27 20:58:40.253', 'c494d747-5123-457f-b9cf-f3359f5a0fe8', '2025-07-27 20:58:40.253', NULL), -('11', 'CallOfCode', 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/projects/fb99db7f-4262-4093-af16-c45fdadea1e8.png', 'https://github.com/call-0f-code/call-of-code', 'https://callofcode.in/', '2025-07-27 21:07:20.413', 'c494d747-5123-457f-b9cf-f3359f5a0fe8', '2025-07-27 21:07:20.413', NULL), -('13', 'WellnessWave', 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/projects/54f25849-1484-4746-aa77-aa623b52bf54.png', 'https://github.com/SHUBHAMTOHAKE0203/WellnessWave-Hospital-Hunar-Intern-Hackathon', 'https://shubhamtohake0203.github.io/WellnessWave-Hospital-Hunar-Intern-Hackathon/', '2025-07-27 21:10:42.857', 'c494d747-5123-457f-b9cf-f3359f5a0fe8', '2025-07-27 21:10:42.857', NULL), -('14', 'EventHub', 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/projects/79e2f1c2-4fa6-4267-9847-b8b6ae516a76.png', 'https://github.com/i-am-that-guy/EventHub', 'https://eventhub-1ukr.onrender.com/', '2025-07-27 21:28:03.777', 'c494d747-5123-457f-b9cf-f3359f5a0fe8', '2025-07-27 21:28:03.777', NULL), -('15', 'Technothon', 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/projects/a4fae4ae-3829-47c6-a088-b5e3f2d1d51a.png', 'https://github.com/call-0f-code/technothon', 'https://call-0f-code.github.io/technothon/', '2025-07-27 21:29:52.401', 'c494d747-5123-457f-b9cf-f3359f5a0fe8', '2025-07-27 21:29:52.401', NULL), -('16', 'CureWave', 'https://riqqtbuoaycwwiemnmri.supabase.co/storage/v1/object/public/images/projects/bedabfee-a51e-49e4-86e7-f9d263002f1f.png', 'https://github.com/SHUBHAMTOHAKE0203/CureWave', 'https://cure-wave-one.vercel.app/', '2025-07-27 21:33:33.669', 'c494d747-5123-457f-b9cf-f3359f5a0fe8', '2025-07-27 21:33:33.669', NULL); - - --- --- Data for Name: Question; Type: TABLE DATA; Schema: public; Owner: - --- - -INSERT INTO public."Question" (id, "questionName", difficulty, link, "topicId", "createdById", "createdAt", "updatedAt", "updatedById") VALUES -('19', 'Remove element', 'Medium', 'https://leetcode.com/problems/remove-element/description/?envType=study-plan-v2&envId=top-interview-150', '22', '77165f92-1a09-407c-987f-0fc9be16fad8', '2025-10-14 19:05:11.882', '2025-10-15 03:49:46.988', '77165f92-1a09-407c-987f-0fc9be16fad8'); - - --- --- Data for Name: Topic; Type: TABLE DATA; Schema: public; Owner: - --- - -INSERT INTO public."Topic" (id, title, description, "createdById", "createdAt", "updatedAt", "updatedById") VALUES -('23', 'TOPIC NAME ', 'JSBDNREMC DKVNROEJFPWMC REGFIRHGR VSM CLSMJFORJO HA HEE ITJAOJJA', '77165f92-1a09-407c-987f-0fc9be16fad8', '2025-10-14 18:47:47.527', '2025-10-14 18:47:47.527', '77165f92-1a09-407c-987f-0fc9be16fad8'), -('22', 'demoooooo', 'who the fuck writes demo like this ', '77165f92-1a09-407c-987f-0fc9be16fad8', '2025-10-14 18:31:21.048', '2025-10-15 03:43:53.083', '77165f92-1a09-407c-987f-0fc9be16fad8'); - - --- Supabase compatibility fixes (appended for local setup) --- Ensure enum types used by Prisma exist -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'Difficulty') THEN - CREATE TYPE public."Difficulty" AS ENUM ('Easy','Medium','Hard'); - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'Verdict') THEN - CREATE TYPE public."Verdict" AS ENUM ('Selected','Rejected','Pending'); - END IF; -END$$; - --- Create auth schema and minimal users table if missing -CREATE SCHEMA IF NOT EXISTS auth; - -CREATE TABLE IF NOT EXISTS auth.users ( - id uuid PRIMARY KEY, - aud text, - role text, - email text, - encrypted_password text, - email_confirmed boolean DEFAULT false, - raw_user_meta_data jsonb, - created_at timestamptz DEFAULT now(), - updated_at timestamptz DEFAULT now() -); - --- Insert a sample auth user mapped to an existing Member (no-op if already exists) -INSERT INTO auth.users (id, aud, role, email, email_confirmed) -VALUES ('77165f92-1a09-407c-987f-0fc9be16fad8','authenticated','authenticated','harish@gmail.com', true) -ON CONFLICT (id) DO NOTHING; - --- Create simple DB roles for local testing and grant access -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN - CREATE ROLE anon; - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN - CREATE ROLE service_role; - END IF; -END$$; - --- Grant read access to anon and full access to service_role for local development only -GRANT USAGE ON SCHEMA public TO anon, service_role; -GRANT SELECT ON ALL TABLES IN SCHEMA public TO anon; -GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO service_role; - --- Ensure future tables inherit privileges -ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO anon; -ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO service_role; - --- End of appended Supabase compatibility SQL - - --- --- Data for Name: _prisma_migrations; Type: TABLE DATA; Schema: public; Owner: - --- - -INSERT INTO public._prisma_migrations (id, checksum, finished_at, migration_name, logs, rolled_back_at, started_at, applied_steps_count) VALUES -('2b698eba-1b33-44fd-b82a-c5c6043ef533', '89625be867ca784d446e9010c858e973cdac24ce7375c77db48bf56c294ec822', '2025-07-27 11:16:59.055498+00', '20250713111533_first_prisma_migration', NULL, NULL, '2025-07-27 11:16:58.7832+00', '1'), -('62ee3fac-53d8-4a55-84a1-3b92da855594', 'a13448142741adec639e1f8a1c28c39c767427e580ead7fac23fe3a6a3f394ae', '2025-07-27 11:16:59.4006+00', '20250718201903_init', NULL, NULL, '2025-07-27 11:16:59.145301+00', '1'), -('f8043ab8-18cb-4d4e-a20c-acb3ef6cdf68', '14515e15ca68369491dabe78a7f059e4d5157e51eab6a28d38483afe49afcb39', '2025-07-27 11:16:59.703971+00', '20250719123800_image_url_githuburk_make_complusory', NULL, NULL, '2025-07-27 11:16:59.501527+00', '1'), -('f0a81f30-46ba-4561-a537-60ce243a191e', 'e312f3719ce8eb87764107f8467b419001c25510c4302f444e7743fa20b0c30d', '2025-07-27 11:18:21.610297+00', '20250727111821_passoutyear_optional', NULL, NULL, '2025-07-27 11:18:21.388193+00', '1'), -('6446026f-c163-4250-a5f1-e1e89e8269a0', '247a03b37d029c1af7ade67e7c91eced3e5adeec02cd9b8c7e67e08e148cbfd6', '2025-10-16 19:11:32.783002+00', '20251016191132_birth_date_added_to_member', NULL, NULL, '2025-10-16 19:11:32.620529+00', '1'), -('e87cdcbd-1fbb-4285-8738-e7072925decb', 'f8c188dd8523234c3b95f96c4a8a78a585828817f07fdbf1c294502072ace662', '2025-10-16 19:38:12.06791+00', '20251016193811_birth_date_type_fix', NULL, NULL, '2025-10-16 19:38:11.846426+00', '1'); - - --- --- Data for Name: schema_migrations; Type: TABLE DATA; Schema: realtime; Owner: - --- - -INSERT INTO realtime.schema_migrations (version, inserted_at) VALUES -('20211116024918', '2025-07-13 10:28:51'), -('20211116045059', '2025-07-13 10:28:52'), -('20211116050929', '2025-07-13 10:28:53'), -('20211116051442', '2025-07-13 10:28:53'), -('20211116212300', '2025-07-13 10:28:54'), -('20211116213355', '2025-07-13 10:28:55'), -('20211116213934', '2025-07-13 10:28:55'), -('20211116214523', '2025-07-13 10:28:56'), -('20211122062447', '2025-07-13 10:28:57'), -('20211124070109', '2025-07-13 10:28:57'), -('20211202204204', '2025-07-13 10:28:58'), -('20211202204605', '2025-07-13 10:28:59'), -('20211210212804', '2025-07-13 10:29:01'), -('20211228014915', '2025-07-13 10:29:01'), -('20220107221237', '2025-07-13 10:29:02'), -('20220228202821', '2025-07-13 10:29:02'), -('20220312004840', '2025-07-13 10:29:03'), -('20220603231003', '2025-07-13 10:29:04'), -('20220603232444', '2025-07-13 10:29:05'), -('20220615214548', '2025-07-13 10:29:05'), -('20220712093339', '2025-07-13 10:29:06'), -('20220908172859', '2025-07-13 10:29:07'), -('20220916233421', '2025-07-13 10:29:07'), -('20230119133233', '2025-07-13 10:29:08'), -('20230128025114', '2025-07-13 10:29:09'), -('20230128025212', '2025-07-13 10:29:09'), -('20230227211149', '2025-07-13 10:29:10'), -('20230228184745', '2025-07-13 10:29:11'), -('20230308225145', '2025-07-13 10:29:11'), -('20230328144023', '2025-07-13 10:29:12'), -('20231018144023', '2025-07-13 10:29:12'), -('20231204144023', '2025-07-13 10:29:13'), -('20231204144024', '2025-07-13 10:29:14'), -('20231204144025', '2025-07-13 10:29:15'), -('20240108234812', '2025-07-13 10:29:15'), -('20240109165339', '2025-07-13 10:29:16'), -('20240227174441', '2025-07-13 10:29:17'), -('20240311171622', '2025-07-13 10:29:18'), -('20240321100241', '2025-07-13 10:29:19'), -('20240401105812', '2025-07-13 10:29:21'), -('20240418121054', '2025-07-13 10:29:22'), -('20240523004032', '2025-07-13 10:29:24'), -('20240618124746', '2025-07-13 10:29:25'), -('20240801235015', '2025-07-13 10:29:25'), -('20240805133720', '2025-07-13 10:29:26'), -('20240827160934', '2025-07-13 10:29:26'), -('20240919163303', '2025-07-13 10:29:27'), -('20240919163305', '2025-07-13 10:29:28'), -('20241019105805', '2025-07-13 10:29:29'), -('20241030150047', '2025-07-13 10:29:31'), -('20241108114728', '2025-07-13 10:29:32'), -('20241121104152', '2025-07-13 10:29:32'), -('20241130184212', '2025-07-13 10:29:33'), -('20241220035512', '2025-07-13 10:29:34'), -('20241220123912', '2025-07-13 10:29:34'), -('20241224161212', '2025-07-13 10:29:35'), -('20250107150512', '2025-07-13 10:29:35'), -('20250110162412', '2025-07-13 10:29:36'), -('20250123174212', '2025-07-13 10:29:37'), -('20250128220012', '2025-07-13 10:29:37'), -('20250506224012', '2025-07-13 10:29:38'), -('20250523164012', '2025-07-13 10:29:38'), -('20250714121412', '2025-07-18 09:59:25'), -('20250905041441', '2025-10-03 22:35:03'), -('20251103001201', '2025-11-13 08:50:05'), -('20251120212548', '2026-03-23 05:56:26'), -('20251120215549', '2026-03-23 05:56:26'), -('20260218120000', '2026-03-23 05:56:27'); - --- --- Name: Account Account_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public."Account" - ADD CONSTRAINT "Account_pkey" PRIMARY KEY (id); - - --- --- Name: Achievement Achievement_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public."Achievement" - ADD CONSTRAINT "Achievement_pkey" PRIMARY KEY (id); - - --- --- Name: CompletedQuestion CompletedQuestion_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public."CompletedQuestion" - ADD CONSTRAINT "CompletedQuestion_pkey" PRIMARY KEY ("memberId", "questionId"); - - --- --- Name: InterviewExperience InterviewExperience_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public."InterviewExperience" - ADD CONSTRAINT "InterviewExperience_pkey" PRIMARY KEY (id); - - --- --- Name: MemberAchievement MemberAchievement_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public."MemberAchievement" - ADD CONSTRAINT "MemberAchievement_pkey" PRIMARY KEY ("memberId", "achievementId"); - - --- --- Name: MemberProject MemberProject_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public."MemberProject" - ADD CONSTRAINT "MemberProject_pkey" PRIMARY KEY ("memberId", "projectId"); - - --- --- Name: Member Member_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public."Member" - ADD CONSTRAINT "Member_pkey" PRIMARY KEY (id); - - --- --- Name: Project Project_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public."Project" - ADD CONSTRAINT "Project_pkey" PRIMARY KEY (id); - - --- --- Name: Question Question_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public."Question" - ADD CONSTRAINT "Question_pkey" PRIMARY KEY (id); - - --- --- Name: Topic Topic_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public."Topic" - ADD CONSTRAINT "Topic_pkey" PRIMARY KEY (id); - - --- --- Name: _prisma_migrations _prisma_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public._prisma_migrations - ADD CONSTRAINT _prisma_migrations_pkey PRIMARY KEY (id); - - --- --- Name: schema_migrations schema_migrations_pkey; Type: CONSTRAINT; Schema: realtime; Owner: - --- - -ALTER TABLE ONLY realtime.schema_migrations - ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version); - --- --- Name: Account Account_memberId_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public."Account" - ADD CONSTRAINT "Account_memberId_fkey" FOREIGN KEY ("memberId") REFERENCES public."Member"(id) ON UPDATE CASCADE ON DELETE CASCADE; - - --- --- Name: Achievement Achievement_createdById_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public."Achievement" - ADD CONSTRAINT "Achievement_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES public."Member"(id) ON UPDATE CASCADE ON DELETE SET NULL; - - --- --- Name: Achievement Achievement_updatedById_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public."Achievement" - ADD CONSTRAINT "Achievement_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES public."Member"(id) ON UPDATE CASCADE ON DELETE SET NULL; - - --- --- Name: CompletedQuestion CompletedQuestion_memberId_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public."CompletedQuestion" - ADD CONSTRAINT "CompletedQuestion_memberId_fkey" FOREIGN KEY ("memberId") REFERENCES public."Member"(id) ON UPDATE CASCADE ON DELETE CASCADE; - - --- --- Name: CompletedQuestion CompletedQuestion_questionId_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public."CompletedQuestion" - ADD CONSTRAINT "CompletedQuestion_questionId_fkey" FOREIGN KEY ("questionId") REFERENCES public."Question"(id) ON UPDATE CASCADE ON DELETE CASCADE; - - --- --- Name: InterviewExperience InterviewExperience_memberId_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public."InterviewExperience" - ADD CONSTRAINT "InterviewExperience_memberId_fkey" FOREIGN KEY ("memberId") REFERENCES public."Member"(id) ON UPDATE CASCADE ON DELETE CASCADE; - - --- --- Name: MemberAchievement MemberAchievement_achievementId_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public."MemberAchievement" - ADD CONSTRAINT "MemberAchievement_achievementId_fkey" FOREIGN KEY ("achievementId") REFERENCES public."Achievement"(id) ON UPDATE CASCADE ON DELETE CASCADE; - - --- --- Name: MemberAchievement MemberAchievement_memberId_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public."MemberAchievement" - ADD CONSTRAINT "MemberAchievement_memberId_fkey" FOREIGN KEY ("memberId") REFERENCES public."Member"(id) ON UPDATE CASCADE ON DELETE CASCADE; - - --- --- Name: MemberProject MemberProject_memberId_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public."MemberProject" - ADD CONSTRAINT "MemberProject_memberId_fkey" FOREIGN KEY ("memberId") REFERENCES public."Member"(id) ON UPDATE CASCADE ON DELETE CASCADE; - - --- --- Name: MemberProject MemberProject_projectId_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public."MemberProject" - ADD CONSTRAINT "MemberProject_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES public."Project"(id) ON UPDATE CASCADE ON DELETE CASCADE; - - --- --- Name: Member Member_approvedById_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public."Member" - ADD CONSTRAINT "Member_approvedById_fkey" FOREIGN KEY ("approvedById") REFERENCES public."Member"(id) ON UPDATE CASCADE ON DELETE SET NULL; - - --- --- Name: Project Project_createdById_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public."Project" - ADD CONSTRAINT "Project_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES public."Member"(id) ON UPDATE CASCADE ON DELETE SET NULL; - - --- --- Name: Project Project_updatedById_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public."Project" - ADD CONSTRAINT "Project_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES public."Member"(id) ON UPDATE CASCADE ON DELETE SET NULL; - - --- --- Name: Question Question_createdById_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public."Question" - ADD CONSTRAINT "Question_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES public."Member"(id) ON UPDATE CASCADE ON DELETE SET NULL; - - --- --- Name: Question Question_topicId_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public."Question" - ADD CONSTRAINT "Question_topicId_fkey" FOREIGN KEY ("topicId") REFERENCES public."Topic"(id) ON UPDATE CASCADE ON DELETE CASCADE; - - --- --- Name: Question Question_updatedById_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public."Question" - ADD CONSTRAINT "Question_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES public."Member"(id) ON UPDATE CASCADE ON DELETE SET NULL; - - --- --- Name: Topic Topic_createdById_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public."Topic" - ADD CONSTRAINT "Topic_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES public."Member"(id) ON UPDATE CASCADE ON DELETE SET NULL; - - --- --- Name: Topic Topic_updatedById_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public."Topic" - ADD CONSTRAINT "Topic_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES public."Member"(id) ON UPDATE CASCADE ON DELETE SET NULL; - - -COMMIT; From 30814e66876e00726423a31c699063c428f6f128 Mon Sep 17 00:00:00 2001 From: Harish-Naruto Date: Wed, 24 Jun 2026 19:07:21 +0530 Subject: [PATCH 04/11] feat: add docker-compose configurations for full stack development environments --- .gitignore | 1 + DOCKER.md | 235 ++++++++++++++++++ README.md | 90 +++++-- docker-compose.yml | 15 +- .../callofcode.in/.env.local.frontend.example | 6 + docker/callofcode.in/docker-compose.yml | 56 +++++ docker/coc-admin/.env.local.backend.example | 38 +++ docker/coc-admin/.env.local.frontend.example | 8 + docker/coc-admin/docker-compose.yml | 75 ++++++ docker/coc-member/.env.local.backend.example | 31 +++ docker/coc-member/.env.local.frontend.example | 5 + docker/coc-member/docker-compose.yml | 75 ++++++ 12 files changed, 607 insertions(+), 28 deletions(-) create mode 100644 DOCKER.md create mode 100644 docker/callofcode.in/.env.local.frontend.example create mode 100644 docker/callofcode.in/docker-compose.yml create mode 100644 docker/coc-admin/.env.local.backend.example create mode 100644 docker/coc-admin/.env.local.frontend.example create mode 100644 docker/coc-admin/docker-compose.yml create mode 100644 docker/coc-member/.env.local.backend.example create mode 100644 docker/coc-member/.env.local.frontend.example create mode 100644 docker/coc-member/docker-compose.yml diff --git a/.gitignore b/.gitignore index 11aecae..4d22288 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,7 @@ web_modules/ # dotenv environment variable files .env .env.* +!.env.local.*.example !.env.example # parcel-bundler cache (https://parceljs.org/) diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..d0623c1 --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,235 @@ +# 🐳 Docker — Local Development Guide + +This guide covers everything you need to run any part of the Call of Code platform locally using Docker Compose. + +--- + +## Overview + +The repository ships with **four Docker Compose configurations**: + +| File | Purpose | Services | +|------|---------|----------| +| `docker-compose.yml` | **API-only** dev (standalone) | `coc-api` | +| `docker/coc-member/docker-compose.yml` | Full **COC Member** stack | `coc-api` + `server` + `web` | +| `docker/coc-admin/docker-compose.yml` | Full **COC Admin** stack | `coc-api` + `server` + `web` | +| `docker/callofcode.in/docker-compose.yml` | Full **callofcode.in** website stack | `coc-api` + `frontend` | + +> All platform stacks build `coc-api` locally from the repo root `Dockerfile` and pull their respective frontend/backend images from Docker Hub. + +--- + +## Prerequisites + +- [Docker Desktop](https://docs.docker.com/get-docker/) or Docker Engine + Docker Compose plugin (v2.22+) +- Git + +No need to install Bun, Node, or any other runtime locally — everything runs inside containers. + +--- + +## Environment Setup + +### 1. API environment (required by all stacks) + +Copy the example and fill in your Supabase credentials: + +```bash +cp .env.example .env.local +``` + +| Variable | Description | +|----------|-------------| +| `DATABASE_URL` | Supabase connection-pooling URL (Prisma runtime) | +| `DIRECT_URL` | Direct DB connection URL (Prisma Migrate) | +| `SUPABASE_URL` | Your Supabase project URL | +| `SUPABASE_SERVICE_ROLE_KEY` | Supabase service-role secret key | + +### 2. Platform-specific environments + +Each platform directory ships **example files** with all variables documented and placeholder values. Copy and fill in only the secrets you need: + +```bash +# COC Member +cp docker/coc-member/.env.local.backend.example docker/coc-member/.env.local.backend +cp docker/coc-member/.env.local.frontend.example docker/coc-member/.env.local.frontend + +# COC Admin +cp docker/coc-admin/.env.local.backend.example docker/coc-admin/.env.local.backend +cp docker/coc-admin/.env.local.frontend.example docker/coc-admin/.env.local.frontend + +# callofcode.in +cp docker/callofcode.in/.env.local.frontend.example docker/callofcode.in/.env.local.frontend +``` + +| Platform | File | Key Variables | +|----------|------|---------------| +| All backends | `.env.local.backend` | `JWT_SECRET`, `REFRESH_SECRET`, `RESEND_API_KEY` | +| `coc-admin` backend | `.env.local.backend` | + `WHATSAPP_LINK`, `DISCORD_LINK` | +| `coc-member` frontend | `.env.local.frontend` | `VITE_API_URL` | +| `coc-admin` frontend | `.env.local.frontend` | `VITE_API_URL`, `VITE_GIF_URL` | +| `callofcode.in` frontend | `.env.local.frontend` | `API_BASE_URL`, `GITHUB_TOKEN` (optional) | + +> **Never commit real secrets.** All `*.env.local*` files are git-ignored by default. + +--- + +## Running the Stacks + +### API-only (standalone development) + +Use this when you're only working on the `coc-api` itself: + +```bash +# Standard mode +docker compose up --build + +# Watch mode — Docker syncs src/ changes and restarts bun automatically +docker compose watch +``` + +The API will be available at **http://localhost:3000** + +Health check: **http://localhost:3000/health** + +--- + +### COC Member stack + +```bash +cd docker/coc-member +docker compose up --build +``` + +| Service | URL | +|---------|-----| +| `coc-api` | http://localhost:3000 | +| `server` (member backend) | http://localhost:8000 | +| `web` (member frontend) | http://localhost:5173 | + +--- + +### COC Admin stack + +```bash +cd docker/coc-admin +docker compose up --build +``` + +| Service | URL | +|---------|-----| +| `coc-api` | http://localhost:3000 | +| `server` (admin backend) | http://localhost:8000 | +| `web` (admin frontend) | http://localhost:5173 | + +--- + +### callofcode.in website + +```bash +cd docker/callofcode.in +docker compose up --build +``` + +| Service | URL | +|---------|-----| +| `coc-api` | http://localhost:3000 | +| `frontend` | http://localhost:3001 | + +--- + +## Startup Order & Health Checks + +All stacks use health-checked `depends_on` to ensure correct startup ordering: + +``` +coc-api (healthy) → server/backend (healthy) → web/frontend +``` + +The `coc-api` health check polls `GET /health` every 30 seconds with a 15-second grace period on startup. Downstream services only start once `coc-api` reports healthy. + +--- + +## Hot Reload (API-only stack) + +The root `docker-compose.yml` supports `docker compose watch` with the following rules: + +| Path changed | Action | +|---|---| +| `src/**` | **Sync** — files copied into container instantly; `bun --watch` picks up the change | +| `package.json` / `bun.lock` | **Rebuild** — full image rebuild to reinstall dependencies | +| `prisma/**` | **Rebuild** — triggers `prisma generate` on next container start | + +```bash +# Start in watch mode (preferred for API development) +docker compose watch +``` + +--- + +## Common Commands + +```bash +# Start in the background (detached) +docker compose up -d --build + +# Follow logs +docker compose logs -f + +# Follow logs for a specific service +docker compose logs -f coc-api + +# Stop all containers +docker compose down + +# Stop and remove volumes (clean slate) +docker compose down -v + +# Rebuild a single service without restarting others +docker compose build coc-api + +# Open a shell inside a running container +docker compose exec coc-api sh + +# Run Prisma Studio (from inside the coc-api container) +docker compose exec coc-api bunx prisma studio +``` + +--- + +## Dockerfile Stages + +The root `Dockerfile` uses a multi-stage build: + +| Stage | Target | Purpose | +|-------|--------|---------| +| `deps` | — | Installs production dependencies only | +| `builder` | `target: builder` | Installs all deps + generates Prisma client; **used by all dev compose files** | +| `runner` | — | Lean production image; runs as non-root `cocuser` | + +The dev compose files use `target: builder` so that devDependencies and the Prisma CLI are available inside the container. + +--- + +## Troubleshooting + +**`open Dockerfile: no such file or directory`** +The `dockerfile:` path in compose is relative to the `context`, not the compose file. Our configs set `context: ../..` (repo root) so `dockerfile: Dockerfile` resolves correctly. + +**Port already in use** +Set a custom port via the `PORT` env variable before running: +```bash +PORT=3001 docker compose up +``` + +**Prisma migration errors on startup** +The `coc-api` container runs `prisma migrate deploy` on every start. If the DB is unreachable, the container will exit. Verify your `DATABASE_URL` and `DIRECT_URL` in `.env.local`. + +**Container exits immediately after `healthy`** +Check logs with `docker compose logs coc-api`. Common causes: missing env vars or a failed migration. + +**Stale node_modules in container** +The anonymous volume `/app/node_modules` is intentionally excluded from the host mount to avoid cross-OS binary conflicts. If you change `package.json`, let Docker rebuild: +```bash +docker compose build --no-cache coc-api +``` diff --git a/README.md b/README.md index defde45..e8f198e 100644 --- a/README.md +++ b/README.md @@ -5,17 +5,11 @@ This repository contains the shared Express.js API for the backends of our Codin ## 📂 Folder Structure ``` -/ +COC-API/ ├── prisma/ # Prisma schema and migration files │ ├── schema.prisma │ └── migrations/ # auto-generated by `bun run migrate` │ -├── seed/ -│ └── dump.sql # local seed file loaded by the setup script -│ -├── scripts/ -│ └── setup-local.sh # automates local environment bring-up -│ ├── src/ │ ├── config/ # environment/configuration loaders │ │ @@ -75,7 +69,21 @@ This repository contains the shared Express.js API for the backends of our Codin │ ├── SiteContent.test.ts │ └── imageUtils.test.ts │ -├── .env.example # template for environment variables +├── docker/ # Platform-specific Docker Compose stacks +│ ├── coc-member/ # Full COC Member platform stack +│ │ ├── docker-compose.yml +│ │ ├── .env.local.backend.example +│ │ └── .env.local.frontend.example +│ ├── coc-admin/ # Full COC Admin platform stack +│ │ ├── docker-compose.yml +│ │ ├── .env.local.backend.example +│ │ └── .env.local.frontend.example +│ └── callofcode.in/ # Full callofcode.in website stack +│ ├── docker-compose.yml +│ └── .env.local.frontend.example +│ +├── .env.example # API environment variable template +├── docker-compose.yml # API-only dev stack (standalone) ├── package.json └── tsconfig.json ``` @@ -84,8 +92,8 @@ This repository contains the shared Express.js API for the backends of our Codin ### Prerequisites -- Install [Bun](https://bun.sh/) on your machine. -- Install [Docker & Docker Compose](https://docs.docker.com/get-docker/) for the local database. +- [Docker Desktop](https://docs.docker.com/get-docker/) or Docker Engine + Compose plugin v2.22+ — **required for all workflows** +- [Bun](https://bun.sh/) — only needed if you want to run the API outside Docker ### 1. Clone the repo @@ -94,40 +102,68 @@ git clone https://github.com/call-0f-code/COC-API.git cd COC-API ``` -### 2. Install dependencies +### 2. Configure environment + +**API credentials** (required by all stacks): ```bash -bun install +cp .env.example .env.local ``` -### 3. Configure environment +| Variable | Description | +|----------|-------------| +| `DATABASE_URL` | Supabase connection-pooling URL (Prisma runtime) | +| `DIRECT_URL` | Direct DB connection URL (Prisma Migrate) | +| `SUPABASE_URL` | Your Supabase project URL | +| `SUPABASE_SERVICE_ROLE_KEY` | Supabase service-role secret key | +| `NODE_ENV` | `development` \| `production` | -Copy `.env.example` to `.env` and fill in the required values: +**Platform-specific credentials** (only needed for the stack you're running): ```bash -cp .env.example .env +# COC Member +cp docker/coc-member/.env.local.backend.example docker/coc-member/.env.local.backend +cp docker/coc-member/.env.local.frontend.example docker/coc-member/.env.local.frontend + +# COC Admin +cp docker/coc-admin/.env.local.backend.example docker/coc-admin/.env.local.backend +cp docker/coc-admin/.env.local.frontend.example docker/coc-admin/.env.local.frontend + +# callofcode.in +cp docker/callofcode.in/.env.local.frontend.example docker/callofcode.in/.env.local.frontend ``` -Key variables: +Edit each file and replace the `change_me_*` and `your_*` placeholders with real values. See [DOCKER.md](DOCKER.md) for a full variable reference. + +### 3. Install dependencies (local dev only) -| Variable | Description | -| ------------------------- | --------------------------------------------------------------------------- | -| `DATABASE_URL` | Supabase connection-pooling URL (used by Prisma at runtime) | -| `DIRECT_URL` | Direct DB connection URL (used by Prisma Migrate) | -| `SUPABASE_URL` | Your Supabase project URL | -| `SUPABASE_SERVICE_ROLE_KEY` | Supabase service-role secret key | -| `SESSION_POOLER` | Session-mode pooler URL — used by `setup-local.sh` for `pg_dump` (IPv4) | -| `NODE_ENV` | `development` \| `production` | +Only needed if you are running the API outside Docker: + +```bash +bun install +``` ### 4. Local development (Docker) -The setup script starts a local Postgres container and seeds it automatically: +The project ships **four Docker Compose stacks**. Choose the one you need: ```bash -bun run local +# API only (for coc-api development) +docker compose up --build + +# Full COC Member platform +cd docker/coc-member && docker compose up --build + +# Full COC Admin platform +cd docker/coc-admin && docker compose up --build + +# Full callofcode.in website +cd docker/callofcode.in && docker compose up --build ``` -> See [LOCAL_DEVELOPMENT.md](LOCAL_DEVELOPMENT.md) for full details, flags, and troubleshooting. +All stacks automatically run Prisma migrations on startup and use health checks to ensure correct service startup order. + +> 📖 See [DOCKER.md](DOCKER.md) for full details — environment files, watch mode, port mappings, common commands, and troubleshooting. ### 5. Initialize / run migrations diff --git a/docker-compose.yml b/docker-compose.yml index bf2d25b..00c06bb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,6 @@ services: context: . dockerfile: Dockerfile target: builder # Use builder stage in dev (has devDeps + bun watch) - container_name: coc-api-dev restart: unless-stopped ports: - "${PORT:-3000}:3000" @@ -23,6 +22,20 @@ services: sh -c "bunx prisma generate && bunx prisma migrate deploy && bun --watch src/server.ts" + develop: + watch: + # Sync source changes into container instantly + - path: ./src + action: sync + target: /app/src + # Rebuild if package.json or lock file changes + - path: ./package.json + action: rebuild + - path: ./bun.lock + action: rebuild + # Rebuild if Prisma schema changes (needs prisma generate) + - path: ./prisma + action: rebuild healthcheck: test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"] interval: 30s diff --git a/docker/callofcode.in/.env.local.frontend.example b/docker/callofcode.in/.env.local.frontend.example new file mode 100644 index 0000000..754c0bf --- /dev/null +++ b/docker/callofcode.in/.env.local.frontend.example @@ -0,0 +1,6 @@ + +# Points to the coc-api service on the shared Docker network +API_BASE_URL=http://coc-api:3000 + +# GitHub token for fetching contribution data (optional) +GITHUB_TOKEN=your_github_token_here diff --git a/docker/callofcode.in/docker-compose.yml b/docker/callofcode.in/docker-compose.yml new file mode 100644 index 0000000..516ba25 --- /dev/null +++ b/docker/callofcode.in/docker-compose.yml @@ -0,0 +1,56 @@ +name: coc-callofcode + +services: + + coc-api: + image: coc-api + build: + context: ../.. + dockerfile: Dockerfile + target: builder # Use builder stage in dev (has devDeps + bun watch) + restart: unless-stopped + ports: + - "${PORT:-3000}:3000" + env_file: + - ../../.env.local # Local dev env vars (Supabase connection strings, etc.) + environment: + NODE_ENV: development + volumes: + # Mount source for live-reload (everything except node_modules) + - ../../:/app + - /app/node_modules + - /app/node_modules/.prisma + command: > + sh -c "bunx prisma generate && + bunx prisma migrate deploy && + bun --watch src/server.ts" + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + networks: + - coc-local + + frontend: + image: callofcode07/callofcode:latest + pull_policy: missing + restart: unless-stopped + ports: + - "3001:3001" + env_file: + - .env.local.frontend + environment: + - NODE_ENV=development + - API_BASE_URL=http://coc-api:3000 + depends_on: + coc-api: + condition: service_healthy + networks: + - coc-local + command: ["npm", "run", "dev"] + +networks: + coc-local: + driver: bridge diff --git a/docker/coc-admin/.env.local.backend.example b/docker/coc-admin/.env.local.backend.example new file mode 100644 index 0000000..2dfc30a --- /dev/null +++ b/docker/coc-admin/.env.local.backend.example @@ -0,0 +1,38 @@ +## local setup ENV — COC Admin Backend +## Copy this file: cp .env.local.backend.example .env.local.backend + +# URL to the coc-api service on the Docker network +API_URL=http://coc-api:3000 + +# BCrypt salt rounds +SALTING=8 + +# JWT signing secrets — change these to strong random values +JWT_SECRET=change_me_admin_jwt_secret +REFRESH_SECRET=change_me_admin_refresh_secret + +# Token TTL (in minutes / days) +ACCESS_TTL=15 +REFRESH_TTL=7 + +# Rate limiting +RATE_LIMIT_WINDOW_MINUTES=15 +RATE_LIMIT_MAX_REQUESTS=100 + +# CORS — set to your frontend's origin +ALLOWED_ORIGINS=http://localhost:5173 + +# Email service (Resend) +EMAIL_ID=your_sender@example.com +CONTACT_EMAIL_ID=callofcode07@gmail.com +RESEND_API_KEY=your_resend_api_key_here + +# Community links (shown in emails / UI) +WHATSAPP_LINK=https://chat.whatsapp.com/your-group +DISCORD_LINK=https://discord.gg/your-server + +# Keep as production so refresh token cookie works correctly +NODE_ENV=production + +# Server port +PORT=8000 diff --git a/docker/coc-admin/.env.local.frontend.example b/docker/coc-admin/.env.local.frontend.example new file mode 100644 index 0000000..3d481d9 --- /dev/null +++ b/docker/coc-admin/.env.local.frontend.example @@ -0,0 +1,8 @@ +## local setup ENV — COC Admin Frontend +## Copy this file: cp .env.local.frontend.example .env.local.frontend + +# Points to the admin backend on the host +VITE_API_URL=http://localhost:8000 + +# Fun GIF shown on certain pages +VITE_GIF_URL=https://media.tenor.com/WqfUsqUQWRsAAAAC/chala-ja.gif diff --git a/docker/coc-admin/docker-compose.yml b/docker/coc-admin/docker-compose.yml new file mode 100644 index 0000000..974949d --- /dev/null +++ b/docker/coc-admin/docker-compose.yml @@ -0,0 +1,75 @@ +name: coc-admin + +services: + + coc-api: + image: coc-api + build: + context: ../.. + dockerfile: Dockerfile + target: builder # Use builder stage in dev (has devDeps + bun watch) + restart: unless-stopped + ports: + - "${PORT:-3000}:3000" + env_file: + - ../../.env.local # Local dev env vars (Supabase connection strings, etc.) + environment: + NODE_ENV: development + volumes: + # Mount source for live-reload (everything except node_modules) + - ../../:/app + - /app/node_modules + - /app/node_modules/.prisma + command: > + sh -c "bunx prisma generate && + bunx prisma migrate deploy && + bun --watch src/server.ts" + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + networks: + - coc-local + + server: + image: callofcode07/coc-admin-backend:latest + pull_policy: missing + restart: unless-stopped + ports: + - "8000:8000" + env_file: + - .env.local.backend + environment: + - PORT=8000 + - API_URL=http://coc-api:3000 + depends_on: + coc-api: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 10s + networks: + - coc-local + + web: + image: callofcode07/coc-admin-frontend:latest + pull_policy: missing + restart: unless-stopped + ports: + - "5173:5173" + env_file: + - .env.local.frontend + depends_on: + server: + condition: service_healthy + networks: + - coc-local + +networks: + coc-local: + driver: bridge diff --git a/docker/coc-member/.env.local.backend.example b/docker/coc-member/.env.local.backend.example new file mode 100644 index 0000000..d6f5ece --- /dev/null +++ b/docker/coc-member/.env.local.backend.example @@ -0,0 +1,31 @@ +## local setup ENV — COC Member Backend +## Copy this file: cp .env.local.backend.example .env.local.backend + +# URL to the coc-api service on the Docker network +API_URL=http://coc-api:3000 + +# BCrypt salt rounds +SALTING=8 + +# JWT signing secrets — change these to strong random values +JWT_SECRET=change_me_member_jwt_secret +REFRESH_SECRET=change_me_member_refresh_secret + +# Token TTL (in minutes / days) +ACCESS_TTL=15 +REFRESH_TTL=7 + +# Rate limiting +RATE_LIMIT_WINDOW_MINUTES=1 +RATE_LIMIT_MAX_REQUESTS=1000 + +# CORS — set to your frontend's origin +ALLOWED_ORIGINS=http://localhost:5173 + +# Email service (Resend) +EMAIL_ID=your_sender@example.com +CONTACT_EMAIL_ID=callofcode07@gmail.com +RESEND_API_KEY=your_resend_api_key_here + +# Keep as production so refresh token cookie works correctly +NODE_ENV=production diff --git a/docker/coc-member/.env.local.frontend.example b/docker/coc-member/.env.local.frontend.example new file mode 100644 index 0000000..e2d96e9 --- /dev/null +++ b/docker/coc-member/.env.local.frontend.example @@ -0,0 +1,5 @@ +## local setup ENV — COC Member Frontend +## Copy this file: cp .env.local.frontend.example .env.local.frontend + +# Points to the member backend on the host +VITE_API_URL=http://localhost:8000 diff --git a/docker/coc-member/docker-compose.yml b/docker/coc-member/docker-compose.yml new file mode 100644 index 0000000..814c0cc --- /dev/null +++ b/docker/coc-member/docker-compose.yml @@ -0,0 +1,75 @@ +name: coc-member + +services: + + coc-api: + image: coc-api + build: + context: ../.. + dockerfile: Dockerfile + target: builder # Use builder stage in dev (has devDeps + bun watch) + restart: unless-stopped + ports: + - "${PORT:-3000}:3000" + env_file: + - ../../.env.local # Local dev env vars (Supabase connection strings, etc.) + environment: + NODE_ENV: development + volumes: + # Mount source for live-reload (everything except node_modules) + - ../../:/app + - /app/node_modules + - /app/node_modules/.prisma + command: > + sh -c "bunx prisma generate && + bunx prisma migrate deploy && + bun --watch src/server.ts" + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + networks: + - coc-local + + server: + image: callofcode07/coc-member-backend:latest + pull_policy: missing + restart: unless-stopped + ports: + - "8000:8000" + env_file: + - .env.local.backend + environment: + - PORT=8000 + - API_URL=http://coc-api:3000 + depends_on: + coc-api: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 10s + networks: + - coc-local + + web: + image: callofcode07/coc-member-frontend:latest + pull_policy: missing + restart: unless-stopped + ports: + - "5173:5173" + env_file: + - .env.local.frontend + depends_on: + server: + condition: service_healthy + networks: + - coc-local + +networks: + coc-local: + driver: bridge From 2722de26aca2d0b1205a91750da378423d8c760e Mon Sep 17 00:00:00 2001 From: Harish-Naruto Date: Wed, 24 Jun 2026 20:02:12 +0530 Subject: [PATCH 05/11] feat: add docker-compose configuration to orchestrate API and platform services --- docker/docker-compose.yml | 167 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 docker/docker-compose.yml diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..0503edc --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,167 @@ +# ============================================================================= +# Master Docker Compose — All Platforms +# Runs coc-api + all three frontends/backends together on a single network. +# +# Usage: +# docker compose -f docker/docker-compose.yml up --watch +# +# Port map: +# 3000 → coc-api (shared API) +# 3001 → callofcode.in (frontend) +# 8001 → coc-admin backend +# 5174 → coc-admin frontend +# 8002 → coc-member backend +# 5175 → coc-member frontend +# ============================================================================= + +name: coc-all + +services: + + coc-api: + image: coc-api + build: + context: .. + dockerfile: Dockerfile + target: builder # builder stage has devDeps + bun --watch + restart: unless-stopped + ports: + - "${COC_API_PORT:-3000}:3000" + env_file: + - ../.env.local # Local dev env vars (Supabase connection strings, etc.) + environment: + NODE_ENV: development + volumes: + - ../:/app + - /app/node_modules + - /app/node_modules/.prisma + command: > + sh -c "bunx prisma generate && + bunx prisma migrate deploy && + bun --watch src/server.ts" + develop: + watch: + - path: ../src + action: sync + target: /app/src + - path: ../package.json + action: rebuild + - path: ../bun.lock + action: rebuild + - path: ../prisma + action: rebuild + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + networks: + - coc-network + + # callofcode.in — main website frontend + + callofcode-frontend: + image: callofcode07/callofcode:latest + pull_policy: missing + restart: unless-stopped + ports: + - "${CALLOFCODE_PORT:-3001}:3001" + env_file: + - callofcode.in/.env.local.frontend + environment: + - NODE_ENV=development + - API_BASE_URL=http://coc-api:3000 + depends_on: + coc-api: + condition: service_healthy + networks: + - coc-network + command: ["npm", "run", "dev"] + + # coc-admin — admin backend + + coc-admin-server: + image: callofcode07/coc-admin-backend:latest + pull_policy: missing + restart: unless-stopped + ports: + - "${COC_ADMIN_BACKEND_PORT:-8001}:8000" # remapped to 8001 to avoid conflict + env_file: + - coc-admin/.env.local.backend + environment: + - PORT=8000 + - API_URL=http://coc-api:3000 + depends_on: + coc-api: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 10s + networks: + - coc-network + + # coc-admin — admin frontend + coc-admin-web: + image: callofcode07/coc-admin-frontend:latest + pull_policy: missing + restart: unless-stopped + ports: + - "${COC_ADMIN_FRONTEND_PORT:-5174}:5173" # remapped to 5174 to avoid conflict + env_file: + - coc-admin/.env.local.frontend + depends_on: + coc-admin-server: + condition: service_healthy + networks: + - coc-network + + # --------------------------------------------------------------------------- + # coc-member — member backend + # --------------------------------------------------------------------------- + coc-member-server: + image: callofcode07/coc-member-backend:latest + pull_policy: missing + restart: unless-stopped + ports: + - "${COC_MEMBER_BACKEND_PORT:-8002}:8000" # remapped to 8002 to avoid conflict + env_file: + - coc-member/.env.local.backend + environment: + - PORT=8000 + - API_URL=http://coc-api:3000 + depends_on: + coc-api: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 10s + networks: + - coc-network + + # --------------------------------------------------------------------------- + # coc-member — member frontend + # --------------------------------------------------------------------------- + coc-member-web: + image: callofcode07/coc-member-frontend:latest + pull_policy: missing + restart: unless-stopped + ports: + - "${COC_MEMBER_FRONTEND_PORT:-5175}:5173" # remapped to 5175 to avoid conflict + env_file: + - coc-member/.env.local.frontend + depends_on: + coc-member-server: + condition: service_healthy + networks: + - coc-network + +networks: + coc-network: + driver: bridge From 085162b52a38756ddad2637d665b8f9ad59ddca6 Mon Sep 17 00:00:00 2001 From: Harish-Naruto Date: Wed, 24 Jun 2026 20:08:23 +0530 Subject: [PATCH 06/11] feat: add master docker-compose stack for running all platforms simultaneously --- DOCKER.md | 38 ++++++++++++++++++++++++++++++++++++-- README.md | 8 ++++++-- docker/docker-compose.yml | 2 ++ 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/DOCKER.md b/DOCKER.md index d0623c1..0863d95 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -6,11 +6,12 @@ This guide covers everything you need to run any part of the Call of Code platfo ## Overview -The repository ships with **four Docker Compose configurations**: +The repository ships with **five Docker Compose configurations**: | File | Purpose | Services | |------|---------|----------| | `docker-compose.yml` | **API-only** dev (standalone) | `coc-api` | +| `docker/docker-compose.yml` | **All platforms** master stack | all 6 services | | `docker/coc-member/docker-compose.yml` | Full **COC Member** stack | `coc-api` + `server` + `web` | | `docker/coc-admin/docker-compose.yml` | Full **COC Admin** stack | `coc-api` + `server` + `web` | | `docker/callofcode.in/docker-compose.yml` | Full **callofcode.in** website stack | `coc-api` + `frontend` | @@ -76,6 +77,35 @@ cp docker/callofcode.in/.env.local.frontend.example docker/callofcode.in/.env.lo ## Running the Stacks +### All platforms together (master stack) + +Use this when you need all three platforms running simultaneously (e.g. testing cross-platform API behaviour or running the full suite locally): + +```bash +# From the repo root — watch mode recommended +docker compose -f docker/docker-compose.yml up --watch + +# Or standard mode +docker compose -f docker/docker-compose.yml up --build +``` + +All six services share a single `coc-network` bridge and a single `coc-api` instance: + +| Service | Container | Host port | Override env var | +|---------|-----------|-----------|------------------| +| `coc-api` | `3000` | **3000** | `COC_API_PORT` | +| `callofcode-frontend` | `3001` | **3001** | `CALLOFCODE_PORT` | +| `coc-admin-server` | `8000` | **8001** | `COC_ADMIN_BACKEND_PORT` | +| `coc-admin-web` | `5173` | **5174** | `COC_ADMIN_FRONTEND_PORT` | +| `coc-member-server` | `8000` | **8002** | `COC_MEMBER_BACKEND_PORT` | +| `coc-member-web` | `5173` | **5175** | `COC_MEMBER_FRONTEND_PORT` | + +> **Port remapping**: because `coc-admin` and `coc-member` both internally bind `8000` and `5173`, the master stack remaps them to unique host ports to avoid conflicts. Internal service-to-service communication (`API_URL=http://coc-api:3000`) still uses the original container ports. + +> **Env files required**: make sure all six env files exist before starting (see [Environment Setup](#environment-setup) above). + +--- + ### API-only (standalone development) Use this when you're only working on the `coc-api` itself: @@ -217,10 +247,14 @@ The dev compose files use `target: builder` so that devDependencies and the Pris The `dockerfile:` path in compose is relative to the `context`, not the compose file. Our configs set `context: ../..` (repo root) so `dockerfile: Dockerfile` resolves correctly. **Port already in use** -Set a custom port via the `PORT` env variable before running: +For the individual stacks, set a custom port via the `PORT` env variable: ```bash PORT=3001 docker compose up ``` +For the master stack, override the specific service port: +```bash +COC_API_PORT=3010 COC_ADMIN_BACKEND_PORT=8010 docker compose -f docker/docker-compose.yml up +``` **Prisma migration errors on startup** The `coc-api` container runs `prisma migrate deploy` on every start. If the DB is unreachable, the container will exit. Verify your `DATABASE_URL` and `DIRECT_URL` in `.env.local`. diff --git a/README.md b/README.md index e8f198e..adf8a04 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,8 @@ COC-API/ │ ├── SiteContent.test.ts │ └── imageUtils.test.ts │ -├── docker/ # Platform-specific Docker Compose stacks +├── docker/ # Docker Compose stacks +│ ├── docker-compose.yml # ★ Master stack — all platforms together │ ├── coc-member/ # Full COC Member platform stack │ │ ├── docker-compose.yml │ │ ├── .env.local.backend.example @@ -145,9 +146,12 @@ bun install ### 4. Local development (Docker) -The project ships **four Docker Compose stacks**. Choose the one you need: +The project ships **five Docker Compose stacks**. Choose the one you need: ```bash +# ★ All platforms at once (recommended for full-stack development) +docker compose -f docker/docker-compose.yml up --watch + # API only (for coc-api development) docker compose up --build diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 0503edc..fb6aa50 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -92,6 +92,7 @@ services: environment: - PORT=8000 - API_URL=http://coc-api:3000 + - ALLOWED_ORIGINS=http://localhost:${COC_ADMIN_FRONTEND_PORT:-5174} depends_on: coc-api: condition: service_healthy @@ -133,6 +134,7 @@ services: environment: - PORT=8000 - API_URL=http://coc-api:3000 + - ALLOWED_ORIGINS=http://localhost:${COC_MEMBER_FRONTEND_PORT:-5175} depends_on: coc-api: condition: service_healthy From d4d3eb4184a5bedd556a8d6e6c3790c70a43005c Mon Sep 17 00:00:00 2001 From: Harish-Naruto Date: Thu, 25 Jun 2026 00:34:20 +0530 Subject: [PATCH 07/11] chore: inject VITE_API_URL environment variable into admin and member frontend services --- docker/docker-compose.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index fb6aa50..449172e 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -114,6 +114,8 @@ services: - "${COC_ADMIN_FRONTEND_PORT:-5174}:5173" # remapped to 5174 to avoid conflict env_file: - coc-admin/.env.local.frontend + environment: + - VITE_API_URL=http://localhost:8001 depends_on: coc-admin-server: condition: service_healthy @@ -158,6 +160,8 @@ services: - "${COC_MEMBER_FRONTEND_PORT:-5175}:5173" # remapped to 5175 to avoid conflict env_file: - coc-member/.env.local.frontend + environment: + - VITE_API_URL=http://localhost:8002 depends_on: coc-member-server: condition: service_healthy From a8296b72263a14219a970f957f4c483bd9c78ea8 Mon Sep 17 00:00:00 2001 From: Sherin Thomas Date: Wed, 21 Oct 2026 19:26:36 +0530 Subject: [PATCH 08/11] Add routes for birthday mail service --- .env.example | 3 + .../migration.sql | 33 ++ prisma/schema.prisma | 31 ++ src/controllers/birthdayEmail.controller.ts | 101 +++++ src/middleware/internalAuth.ts | 54 +++ src/routes/index.ts | 3 +- src/routes/internalBirthday.ts | 27 ++ src/services/birthdayEmail.service.ts | 391 +++++++++++++++++ tests/BirthdayEmail.test.ts | 406 ++++++++++++++++++ 9 files changed, 1048 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20261021120000_add_birthday_email_log/migration.sql create mode 100644 src/controllers/birthdayEmail.controller.ts create mode 100644 src/middleware/internalAuth.ts create mode 100644 src/routes/internalBirthday.ts create mode 100644 src/services/birthdayEmail.service.ts create mode 100644 tests/BirthdayEmail.test.ts diff --git a/.env.example b/.env.example index 6d8cbae..52efaef 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,6 @@ SUPABASE_SERVICE_ROLE_KEY= # Environment variables for the application NODE_ENV=development +# Service-to-service internal authentication secret (used by codenest backend) +INTERNAL_SERVICE_SECRET= + diff --git a/prisma/migrations/20261021120000_add_birthday_email_log/migration.sql b/prisma/migrations/20261021120000_add_birthday_email_log/migration.sql new file mode 100644 index 0000000..640541c --- /dev/null +++ b/prisma/migrations/20261021120000_add_birthday_email_log/migration.sql @@ -0,0 +1,33 @@ +-- CreateEnum +CREATE TYPE "BirthdayEmailStatus" AS ENUM ('PENDING', 'PROCESSING', 'SENT', 'FAILED', 'DEAD'); + +-- CreateTable +CREATE TABLE "BirthdayEmailLog" ( + "id" TEXT NOT NULL, + "memberId" TEXT NOT NULL, + "birthdayYear" INTEGER NOT NULL, + "status" "BirthdayEmailStatus" NOT NULL DEFAULT 'PENDING', + "attemptCount" INTEGER NOT NULL DEFAULT 0, + "nextRetryAt" TIMESTAMP(3), + "lockedUntil" TIMESTAMP(3), + "lockToken" TEXT, + "providerMessageId" TEXT, + "sentAt" TIMESTAMP(3), + "lastError" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "BirthdayEmailLog_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "BirthdayEmailLog_memberId_birthdayYear_key" ON "BirthdayEmailLog"("memberId", "birthdayYear"); + +-- CreateIndex +CREATE INDEX "BirthdayEmailLog_status_nextRetryAt_idx" ON "BirthdayEmailLog"("status", "nextRetryAt"); + +-- CreateIndex +CREATE INDEX "BirthdayEmailLog_status_lockedUntil_idx" ON "BirthdayEmailLog"("status", "lockedUntil"); + +-- AddForeignKey +ALTER TABLE "BirthdayEmailLog" ADD CONSTRAINT "BirthdayEmailLog_memberId_fkey" FOREIGN KEY ("memberId") REFERENCES "Member"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0d277c4..87bc4de 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -62,6 +62,7 @@ model Member { updatedSiteActions SiteAction[] @relation("SiteActionUpdatedBy") createdEmailTemplates EmailTemplate[] @relation("EmailTemplateCreatedBy") updatedEmailTemplates EmailTemplate[] @relation("EmailTemplateUpdatedBy") + birthdayEmailLogs BirthdayEmailLog[] } model Account { @@ -249,3 +250,33 @@ model EmailTemplate { updatedById String? updatedAt DateTime @updatedAt } + +enum BirthdayEmailStatus { + PENDING + PROCESSING + SENT + FAILED + DEAD +} + +model BirthdayEmailLog { + id String @id @default(uuid()) + memberId String + birthdayYear Int + status BirthdayEmailStatus @default(PENDING) + attemptCount Int @default(0) + nextRetryAt DateTime? + lockedUntil DateTime? + lockToken String? + providerMessageId String? + sentAt DateTime? + lastError String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + member Member @relation(fields: [memberId], references: [id], onDelete: Cascade) + + @@unique([memberId, birthdayYear]) + @@index([status, nextRetryAt]) + @@index([status, lockedUntil]) +} diff --git a/src/controllers/birthdayEmail.controller.ts b/src/controllers/birthdayEmail.controller.ts new file mode 100644 index 0000000..d74d330 --- /dev/null +++ b/src/controllers/birthdayEmail.controller.ts @@ -0,0 +1,101 @@ +import { Request, Response } from "express"; +import * as birthdayEmailService from "../services/birthdayEmail.service"; +import { ApiError } from "../utils/apiError"; + +/** + * POST /api/v1/birthday-emails/initialize + */ +export async function initializeJobs(req: Request, res: Response): Promise { + const { birthdayYear, memberIds } = req.body || {}; + + if (typeof birthdayYear !== "number") { + throw new ApiError("birthdayYear is required and must be a number", 400); + } + + if (!Array.isArray(memberIds)) { + throw new ApiError("memberIds is required and must be an array of strings", 400); + } + + const result = await birthdayEmailService.initializeBirthdayJobs( + birthdayYear, + memberIds + ); + + res.status(200).json({ + success: true, + message: "Birthday jobs initialized", + data: result, + }); +} + +/** + * POST /api/v1/birthday-emails/claim + */ +export async function claimJobs(req: Request, res: Response): Promise { + const { batchSize, leaseDurationMs } = req.body || {}; + + const jobs = await birthdayEmailService.claimBirthdayJobs( + typeof batchSize === "number" ? batchSize : 10, + typeof leaseDurationMs === "number" ? leaseDurationMs : 300000 + ); + + res.status(200).json({ + success: true, + count: jobs.length, + jobs, + }); +} + +/** + * POST /api/v1/birthday-emails/:id/sent + */ +export async function markJobSent(req: Request, res: Response): Promise { + const { id } = req.params; + const { lockToken, providerMessageId, sentAt } = req.body || {}; + + if (!lockToken || typeof lockToken !== "string") { + throw new ApiError("lockToken is required in request body", 400); + } + + const result = await birthdayEmailService.recordBirthdayJobSent( + id, + lockToken, + providerMessageId, + sentAt + ); + + res.status(200).json(result); +} + +/** + * POST /api/v1/birthday-emails/:id/failed + */ +export async function markJobFailed(req: Request, res: Response): Promise { + const { id } = req.params; + const { lockToken, errorClassification, errorMessage, isRetryable } = req.body || {}; + + if (!lockToken || typeof lockToken !== "string") { + throw new ApiError("lockToken is required in request body", 400); + } + + const result = await birthdayEmailService.recordBirthdayJobFailed( + id, + lockToken, + errorClassification ?? "TRANSIENT", + errorMessage ?? "Unknown failure", + typeof isRetryable === "boolean" ? isRetryable : true + ); + + res.status(200).json(result); +} + +/** + * GET /api/v1/birthday-emails/stats + */ +export async function getStats(_req: Request, res: Response): Promise { + const stats = await birthdayEmailService.getBirthdayEmailStats(); + res.status(200).json({ + success: true, + stats, + }); +} diff --git a/src/middleware/internalAuth.ts b/src/middleware/internalAuth.ts new file mode 100644 index 0000000..74fee0f --- /dev/null +++ b/src/middleware/internalAuth.ts @@ -0,0 +1,54 @@ +import { Request, Response, NextFunction } from "express"; +import crypto from "crypto"; +import { ApiError } from "../utils/apiError"; + +/** + * Service-to-service authentication middleware for internal APIs. + * Compares incoming secret header against configured environment secret using constant-time comparison. + */ +export function requireInternalAuth( + req: Request, + _res: Response, + next: NextFunction +): void { + const configuredSecret = + process.env.INTERNAL_SERVICE_SECRET || process.env.BIRTHDAY_SERVICE_SECRET; + + if (!configuredSecret) { + throw new ApiError( + "Service authentication is not configured on server", + 500 + ); + } + + const authHeader = req.headers["authorization"]; + let providedSecret = + (req.headers["x-internal-secret"] as string) || + (req.headers["x-service-secret"] as string); + + if (!providedSecret && authHeader && authHeader.startsWith("Bearer ")) { + providedSecret = authHeader.substring(7).trim(); + } + + if (!providedSecret) { + throw new ApiError( + "Unauthorized: Missing internal service secret header", + 401 + ); + } + + const providedBuffer = Buffer.from(providedSecret, "utf-8"); + const expectedBuffer = Buffer.from(configuredSecret, "utf-8"); + + if ( + providedBuffer.length !== expectedBuffer.length || + !crypto.timingSafeEqual(providedBuffer, expectedBuffer) + ) { + throw new ApiError( + "Unauthorized: Invalid internal service secret", + 401 + ); + } + + next(); +} diff --git a/src/routes/index.ts b/src/routes/index.ts index 74ddb5b..791a9fa 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,6 +1,5 @@ import { Router } from 'express' import { Multer } from 'multer' -import { SupabaseClient } from '@supabase/supabase-js' import projectsRouter from './projects' import acheivementsRouter from './achievements' import interviewRouter from './interviews' @@ -10,6 +9,7 @@ import progressRouter from './progress' import membersRouter from './members' import siteContentRouter from './site-content' import emailRouter from './email' +import internalBirthdayRouter from './internalBirthday' export default function routes(upload: Multer) { const router = Router(); @@ -29,6 +29,7 @@ export default function routes(upload: Multer) { router.use("/site-content", siteContentRouter(upload)); router.use("/email", emailRouter()); + router.use("/birthday-emails", internalBirthdayRouter()); return router; } diff --git a/src/routes/internalBirthday.ts b/src/routes/internalBirthday.ts new file mode 100644 index 0000000..dba0387 --- /dev/null +++ b/src/routes/internalBirthday.ts @@ -0,0 +1,27 @@ +import { Router } from "express"; +import * as birthdayCtrl from "../controllers/birthdayEmail.controller"; +import { requireInternalAuth } from "../middleware/internalAuth"; + +export default function internalBirthdayRouter(): Router { + const router = Router(); + + // Protect all internal birthday tracking endpoints with service-to-service auth + router.use(requireInternalAuth); + + // Initialize jobs idempotently + router.post("/initialize", birthdayCtrl.initializeJobs); + + // Atomically claim a batch of eligible jobs + router.post("/claim", birthdayCtrl.claimJobs); + + // Statistics + router.get("/stats", birthdayCtrl.getStats); + + // Report job send result + router.post("/:id/sent", birthdayCtrl.markJobSent); + + // Report job failure result + router.post("/:id/failed", birthdayCtrl.markJobFailed); + + return router; +} diff --git a/src/services/birthdayEmail.service.ts b/src/services/birthdayEmail.service.ts new file mode 100644 index 0000000..f61b135 --- /dev/null +++ b/src/services/birthdayEmail.service.ts @@ -0,0 +1,391 @@ +import prisma from "../db/client"; +import { ApiError } from "../utils/apiError"; +import crypto from "crypto"; + +export interface ClaimedJob { + id: string; + memberId: string; + birthdayYear: number; + attemptCount: number; + lockToken: string; +} + +export interface JobStats { + pending: number; + processing: number; + sent: number; + failed: number; + dead: number; + dueForRetry: number; + total: number; +} + +/** Retry delays in milliseconds based on attempt count */ +const RETRY_DELAYS_MS: Record = { + 1: 5 * 60 * 1000, // 5 min + 2: 15 * 60 * 1000, // 15 min + 3: 30 * 60 * 1000, // 30 min + 4: 120 * 60 * 1000, // 120 min +}; + +const QUOTA_RETRY_DELAY_MS = 4 * 60 * 60 * 1000; // 4 hours for quota exhaustion +const MAX_ATTEMPTS = 5; + +/** + * Sanitize error message to ensure no secrets, keys, or bearer tokens are stored. + */ +function sanitizeErrorMessage(msg: string): string { + if (!msg || typeof msg !== "string") return "Unknown error"; + return msg + .replace(/(bearer\s+)[^\s]+/gi, "$1[REDACTED]") + .replace(/(api[-_]?key\s*[:=]\s*)[^\s]+/gi, "$1[REDACTED]") + .replace(/(secret\s*[:=]\s*)[^\s]+/gi, "$1[REDACTED]") + .replace(/(re_[a-zA-Z0-9_-]{20,})/g, "[REDACTED_RESEND_KEY]") + .substring(0, 500); +} + +/** + * Initialize birthday email jobs for a given year and list of member IDs idempotently. + */ +export async function initializeBirthdayJobs( + birthdayYear: number, + memberIds: string[] +): Promise<{ + created: number; + existing: number; + totalEligible: number; +}> { + if (!Number.isInteger(birthdayYear) || birthdayYear < 1900 || birthdayYear > 2200) { + throw new ApiError("Invalid birthdayYear: must be a 4-digit integer", 400); + } + + if (!Array.isArray(memberIds) || memberIds.length === 0) { + throw new ApiError("memberIds must be a non-empty array of strings", 400); + } + + const cleanIds = Array.from( + new Set(memberIds.filter((id) => typeof id === "string" && id.trim().length > 0)) + ); + + if (cleanIds.length === 0) { + throw new ApiError("No valid member IDs provided", 400); + } + + // Fetch only eligible members: approved, not ghosted, has birth_date, has valid email + const eligibleMembers = await prisma.member.findMany({ + where: { + id: { in: cleanIds }, + isApproved: true, + isGhosted: false, + birth_date: { not: null }, + }, + select: { + id: true, + email: true, + }, + }); + + const validEligible = eligibleMembers.filter( + (m) => typeof m.email === "string" && m.email.includes("@") + ); + + if (validEligible.length === 0) { + return { created: 0, existing: 0, totalEligible: 0 }; + } + + const eligibleIds = validEligible.map((m) => m.id); + + // Check existing logs to preserve state and avoid resetting SENT or attempt counts + const existingLogs = await prisma.birthdayEmailLog.findMany({ + where: { + birthdayYear, + memberId: { in: eligibleIds }, + }, + select: { + memberId: true, + }, + }); + + const existingMemberIdSet = new Set(existingLogs.map((l) => l.memberId)); + const newMemberIds = eligibleIds.filter((id) => !existingMemberIdSet.has(id)); + + let createdCount = 0; + if (newMemberIds.length > 0) { + const recordsToCreate = newMemberIds.map((mId) => ({ + memberId: mId, + birthdayYear, + status: "PENDING" as const, + attemptCount: 0, + })); + + const result = await prisma.birthdayEmailLog.createMany({ + data: recordsToCreate, + skipDuplicates: true, + }); + createdCount = result.count; + } + + return { + created: createdCount, + existing: existingLogs.length, + totalEligible: validEligible.length, + }; +} + +/** + * Atomically claim a batch of eligible jobs for processing. + * A job is claimable when: + * - PENDING + * - FAILED and nextRetryAt <= now + * - PROCESSING and lease expired (lockedUntil < now) + * Prevents race conditions with FOR UPDATE SKIP LOCKED. + */ +export async function claimBirthdayJobs( + batchSize = 10, + leaseDurationMs = 300000 +): Promise { + const safeBatchSize = Math.max(1, Math.min(batchSize, 50)); + const safeLeaseMs = Math.max(10000, Math.min(leaseDurationMs, 3600000)); + const now = new Date(); + + return await prisma.$transaction(async (tx) => { + // Select eligible jobs with row-level locks, skipping already-locked rows + const eligible = await tx.$queryRaw>` + SELECT id FROM "BirthdayEmailLog" + WHERE ( + "status" = 'PENDING'::"BirthdayEmailStatus" OR + ("status" = 'FAILED'::"BirthdayEmailStatus" AND ("nextRetryAt" IS NULL OR "nextRetryAt" <= ${now})) OR + ("status" = 'PROCESSING'::"BirthdayEmailStatus" AND "lockedUntil" < ${now}) + ) + ORDER BY "createdAt" ASC + LIMIT ${safeBatchSize} + FOR UPDATE SKIP LOCKED + `; + + if (!eligible || eligible.length === 0) { + return []; + } + + const lockedUntil = new Date(now.getTime() + safeLeaseMs); + const claimed: ClaimedJob[] = []; + + for (const row of eligible) { + const lockToken = crypto.randomUUID(); + const updated = await tx.birthdayEmailLog.update({ + where: { id: row.id }, + data: { + status: "PROCESSING", + lockToken, + lockedUntil, + }, + select: { + id: true, + memberId: true, + birthdayYear: true, + attemptCount: true, + lockToken: true, + }, + }); + + if (updated.lockToken) { + claimed.push({ + id: updated.id, + memberId: updated.memberId, + birthdayYear: updated.birthdayYear, + attemptCount: updated.attemptCount, + lockToken: updated.lockToken, + }); + } + } + + return claimed; + }); +} + +/** + * Mark a claimed birthday email job as SENT. + * Validates the lockToken to guard against stale workers. + */ +export async function recordBirthdayJobSent( + id: string, + lockToken: string, + providerMessageId?: string, + sentAt?: string +): Promise<{ success: boolean; message: string }> { + if (!id || typeof id !== "string") { + throw new ApiError("Missing tracking record id", 400); + } + if (!lockToken || typeof lockToken !== "string") { + throw new ApiError("Missing lockToken", 400); + } + + const record = await prisma.birthdayEmailLog.findUnique({ + where: { id }, + }); + + if (!record) { + throw new ApiError("Birthday email record not found", 404); + } + + // Idempotent duplicate check: already SENT with provider ID + if (record.status === "SENT") { + return { success: true, message: "Record was already marked as sent" }; + } + + // Validate lease ownership + if (record.status !== "PROCESSING" || record.lockToken !== lockToken) { + throw new ApiError( + "Invalid or expired lock token: job is no longer owned by caller", + 409 + ); + } + + await prisma.birthdayEmailLog.update({ + where: { id }, + data: { + status: "SENT", + providerMessageId: providerMessageId ?? record.providerMessageId, + sentAt: sentAt ? new Date(sentAt) : new Date(), + lockToken: null, + lockedUntil: null, + nextRetryAt: null, + attemptCount: record.attemptCount + 1, + lastError: null, + }, + }); + + return { success: true, message: "Marked as sent" }; +} + +/** + * Record a failed attempt for a claimed job. + * Calculates next retry time or transitions to DEAD if max attempts exceeded or permanent failure. + */ +export async function recordBirthdayJobFailed( + id: string, + lockToken: string, + errorClassification: string, + errorMessage: string, + isRetryable: boolean +): Promise<{ + success: boolean; + status: "FAILED" | "DEAD"; + attemptCount: number; + nextRetryAt: Date | null; +}> { + if (!id || typeof id !== "string") { + throw new ApiError("Missing tracking record id", 400); + } + if (!lockToken || typeof lockToken !== "string") { + throw new ApiError("Missing lockToken", 400); + } + + const record = await prisma.birthdayEmailLog.findUnique({ + where: { id }, + }); + + if (!record) { + throw new ApiError("Birthday email record not found", 404); + } + + if (record.status !== "PROCESSING" || record.lockToken !== lockToken) { + throw new ApiError( + "Invalid or expired lock token: job is no longer owned by caller", + 409 + ); + } + + const newAttemptCount = record.attemptCount + 1; + const sanitized = sanitizeErrorMessage(errorMessage); + + const isPermanent = + !isRetryable || + errorClassification === "PERMANENT" || + errorClassification === "CONFIG"; + + let newStatus: "FAILED" | "DEAD"; + let nextRetryAt: Date | null = null; + + if (isPermanent || newAttemptCount >= MAX_ATTEMPTS) { + newStatus = "DEAD"; + nextRetryAt = null; + } else { + newStatus = "FAILED"; + let delayMs = RETRY_DELAYS_MS[newAttemptCount] || 120 * 60 * 1000; + if (errorClassification === "QUOTA") { + delayMs = QUOTA_RETRY_DELAY_MS; + } + nextRetryAt = new Date(Date.now() + delayMs); + } + + await prisma.birthdayEmailLog.update({ + where: { id }, + data: { + status: newStatus, + attemptCount: newAttemptCount, + nextRetryAt, + lastError: sanitized, + lockToken: null, + lockedUntil: null, + }, + }); + + return { + success: true, + status: newStatus, + attemptCount: newAttemptCount, + nextRetryAt, + }; +} + +/** + * Fetch tracking statistics without exposing member emails. + */ +export async function getBirthdayEmailStats(): Promise { + const countsByStatus = await prisma.birthdayEmailLog.groupBy({ + by: ["status"], + _count: { id: true }, + }); + + const stats: JobStats = { + pending: 0, + processing: 0, + sent: 0, + failed: 0, + dead: 0, + dueForRetry: 0, + total: 0, + }; + + for (const group of countsByStatus) { + const count = group._count.id; + stats.total += count; + switch (group.status) { + case "PENDING": + stats.pending = count; + break; + case "PROCESSING": + stats.processing = count; + break; + case "SENT": + stats.sent = count; + break; + case "FAILED": + stats.failed = count; + break; + case "DEAD": + stats.dead = count; + break; + } + } + + const dueForRetry = await prisma.birthdayEmailLog.count({ + where: { + status: "FAILED", + nextRetryAt: { lte: new Date() }, + }, + }); + stats.dueForRetry = dueForRetry; + + return stats; +} diff --git a/tests/BirthdayEmail.test.ts b/tests/BirthdayEmail.test.ts new file mode 100644 index 0000000..7e5626a --- /dev/null +++ b/tests/BirthdayEmail.test.ts @@ -0,0 +1,406 @@ +import { Request, Response, NextFunction } from "express"; +import * as birthdayService from "../src/services/birthdayEmail.service"; +import * as birthdayCtrl from "../src/controllers/birthdayEmail.controller"; +import { requireInternalAuth } from "../src/middleware/internalAuth"; +import prisma from "../src/db/client"; +import { ApiError } from "../src/utils/apiError"; + +jest.mock("../src/db/client", () => { + const mockPrisma = { + member: { + findMany: jest.fn(), + findUnique: jest.fn(), + }, + birthdayEmailLog: { + findMany: jest.fn(), + findUnique: jest.fn(), + createMany: jest.fn(), + update: jest.fn(), + groupBy: jest.fn(), + count: jest.fn(), + }, + $transaction: jest.fn(), + $queryRaw: jest.fn(), + }; + return { + __esModule: true, + default: mockPrisma, + prisma: mockPrisma, + }; +}); + +describe("Central Backend: Birthday Email Tracking & Internal APIs", () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { ...originalEnv }; + process.env.INTERNAL_SERVICE_SECRET = "super-secret-service-token"; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + // ────────────────────────────────────────────── + // 1. Initializing the same member/year job twice creates one record + // ────────────────────────────────────────────── + it("1. Initializing the same member/year job twice creates one record", async () => { + const memberId = "member-123"; + (prisma.member.findMany as jest.Mock).mockResolvedValue([ + { id: memberId, email: "alice@example.com" }, + ]); + + // First initialization: no existing log + (prisma.birthdayEmailLog.findMany as jest.Mock).mockResolvedValueOnce([]); + (prisma.birthdayEmailLog.createMany as jest.Mock).mockResolvedValueOnce({ count: 1 }); + + const firstRun = await birthdayService.initializeBirthdayJobs(2026, [memberId]); + expect(firstRun.created).toBe(1); + expect(firstRun.existing).toBe(0); + + // Second initialization: existing log found + (prisma.birthdayEmailLog.findMany as jest.Mock).mockResolvedValueOnce([ + { memberId, status: "PENDING" }, + ]); + + const secondRun = await birthdayService.initializeBirthdayJobs(2026, [memberId]); + expect(secondRun.created).toBe(0); + expect(secondRun.existing).toBe(1); + expect(prisma.birthdayEmailLog.createMany).toHaveBeenCalledTimes(1); + }); + + // ────────────────────────────────────────────── + // 2. Existing SENT records are not reset + // ────────────────────────────────────────────── + it("2. Existing SENT records are not reset during initialization", async () => { + const memberId = "member-sent"; + (prisma.member.findMany as jest.Mock).mockResolvedValue([ + { id: memberId, email: "sent@example.com" }, + ]); + + // Existing record is SENT + (prisma.birthdayEmailLog.findMany as jest.Mock).mockResolvedValueOnce([ + { memberId, status: "SENT", attemptCount: 1 }, + ]); + + const res = await birthdayService.initializeBirthdayJobs(2026, [memberId]); + expect(res.created).toBe(0); + expect(res.existing).toBe(1); + expect(prisma.birthdayEmailLog.update).not.toHaveBeenCalled(); + expect(prisma.birthdayEmailLog.createMany).not.toHaveBeenCalled(); + }); + + // ────────────────────────────────────────────── + // 3. Two concurrent workers cannot claim the same active job + // ────────────────────────────────────────────── + it("3. Atomic job claim locks jobs and prevents concurrent workers from claiming the same active job", async () => { + const jobRow = { id: "job-1" }; + + // Transaction executes queryRaw and update + (prisma.$transaction as jest.Mock).mockImplementation(async (callback) => { + const tx = { + $queryRaw: jest.fn().mockResolvedValue([jobRow]), + birthdayEmailLog: { + update: jest.fn().mockResolvedValue({ + id: "job-1", + memberId: "m-1", + birthdayYear: 2026, + attemptCount: 0, + lockToken: "token-worker-1", + }), + }, + }; + return await callback(tx); + }); + + const claimed = await birthdayService.claimBirthdayJobs(10, 300000); + expect(claimed).toHaveLength(1); + expect(claimed[0].id).toBe("job-1"); + expect(claimed[0].lockToken).toBeDefined(); + + // Second worker claims concurrently, queryRaw returns empty (already locked/skipped) + (prisma.$transaction as jest.Mock).mockImplementationOnce(async (callback) => { + const tx = { + $queryRaw: jest.fn().mockResolvedValue([]), + birthdayEmailLog: { update: jest.fn() }, + }; + return await callback(tx); + }); + + const secondClaim = await birthdayService.claimBirthdayJobs(10, 300000); + expect(secondClaim).toHaveLength(0); + }); + + // ────────────────────────────────────────────── + // 4. Expired PROCESSING jobs can be recovered + // ────────────────────────────────────────────── + it("4. Expired PROCESSING jobs can be recovered by claim query", async () => { + (prisma.$transaction as jest.Mock).mockImplementation(async (callback) => { + const tx = { + $queryRaw: jest.fn().mockImplementation((strings, ...values) => { + // Verify query includes lease expiration check: lockedUntil < now + const queryText = strings.join(""); + expect(queryText).toContain('"lockedUntil" <'); + return Promise.resolve([{ id: "expired-job-1" }]); + }), + birthdayEmailLog: { + update: jest.fn().mockResolvedValue({ + id: "expired-job-1", + memberId: "m-expired", + birthdayYear: 2026, + attemptCount: 1, + lockToken: "new-recovery-token", + }), + }, + }; + return await callback(tx); + }); + + const claimed = await birthdayService.claimBirthdayJobs(5, 300000); + expect(claimed).toHaveLength(1); + expect(claimed[0].id).toBe("expired-job-1"); + expect(claimed[0].lockToken).toBe("new-recovery-token"); + }); + + // ────────────────────────────────────────────── + // 5. A stale lock token cannot overwrite a newer worker's result + // ────────────────────────────────────────────── + it("5. A stale lock token cannot overwrite a newer worker's result (409 conflict)", async () => { + // Record in DB currently has 'newer-lock-token' + (prisma.birthdayEmailLog.findUnique as jest.Mock).mockResolvedValue({ + id: "job-conflict", + status: "PROCESSING", + lockToken: "newer-lock-token", + attemptCount: 2, + }); + + // Stale worker tries to report sent with old token + await expect( + birthdayService.recordBirthdayJobSent( + "job-conflict", + "stale-old-token", + "msg-123" + ) + ).rejects.toThrow(ApiError); + + try { + await birthdayService.recordBirthdayJobSent( + "job-conflict", + "stale-old-token", + "msg-123" + ); + } catch (err: any) { + expect(err.statusCode).toBe(409); + } + + // Stale worker tries to report failed with old token + await expect( + birthdayService.recordBirthdayJobFailed( + "job-conflict", + "stale-old-token", + "TRANSIENT", + "network timeout", + true + ) + ).rejects.toThrow(ApiError); + }); + + // ────────────────────────────────────────────── + // 6. Successful sends persist provider IDs and timestamps + // ────────────────────────────────────────────── + it("6. Successful sends persist provider IDs and timestamps", async () => { + (prisma.birthdayEmailLog.findUnique as jest.Mock).mockResolvedValue({ + id: "job-success", + status: "PROCESSING", + lockToken: "valid-token-abc", + attemptCount: 0, + providerMessageId: null, + }); + + (prisma.birthdayEmailLog.update as jest.Mock).mockResolvedValue({ + id: "job-success", + status: "SENT", + providerMessageId: "resend-msg-999", + sentAt: new Date("2026-10-21T09:05:00.000Z"), + }); + + const res = await birthdayService.recordBirthdayJobSent( + "job-success", + "valid-token-abc", + "resend-msg-999", + "2026-10-21T09:05:00.000Z" + ); + + expect(res.success).toBe(true); + expect(prisma.birthdayEmailLog.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "job-success" }, + data: expect.objectContaining({ + status: "SENT", + providerMessageId: "resend-msg-999", + sentAt: new Date("2026-10-21T09:05:00.000Z"), + lockToken: null, + lockedUntil: null, + attemptCount: 1, + }), + }) + ); + }); + + // ────────────────────────────────────────────── + // 7. Retryable failures schedule a future retry + // ────────────────────────────────────────────── + it("7. Retryable failures schedule a future retry with backoff", async () => { + (prisma.birthdayEmailLog.findUnique as jest.Mock).mockResolvedValue({ + id: "job-retryable", + status: "PROCESSING", + lockToken: "tok-retry", + attemptCount: 1, // attempt 1 failed, next will be attempt 2 + }); + + (prisma.birthdayEmailLog.update as jest.Mock).mockResolvedValue({}); + + const res = await birthdayService.recordBirthdayJobFailed( + "job-retryable", + "tok-retry", + "TRANSIENT", + "Temporary 500 error", + true + ); + + expect(res.success).toBe(true); + expect(res.status).toBe("FAILED"); + expect(res.attemptCount).toBe(2); + expect(res.nextRetryAt).toBeInstanceOf(Date); + // Attempt 2 backoff is 15 minutes (~900000ms from now) + const diffMs = res.nextRetryAt!.getTime() - Date.now(); + expect(diffMs).toBeGreaterThan(14 * 60 * 1000); + expect(diffMs).toBeLessThanOrEqual(16 * 60 * 1000); + }); + + // ────────────────────────────────────────────── + // 8. Permanent failures become terminal (DEAD) + // ────────────────────────────────────────────── + it("8. Permanent failures become terminal (DEAD) without scheduling retry", async () => { + (prisma.birthdayEmailLog.findUnique as jest.Mock).mockResolvedValue({ + id: "job-perm", + status: "PROCESSING", + lockToken: "tok-perm", + attemptCount: 0, + }); + + (prisma.birthdayEmailLog.update as jest.Mock).mockResolvedValue({}); + + const res = await birthdayService.recordBirthdayJobFailed( + "job-perm", + "tok-perm", + "PERMANENT", + "Invalid recipient mailbox not found", + false + ); + + expect(res.success).toBe(true); + expect(res.status).toBe("DEAD"); + expect(res.nextRetryAt).toBeNull(); + expect(res.attemptCount).toBe(1); + expect(prisma.birthdayEmailLog.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: "DEAD", + nextRetryAt: null, + }), + }) + ); + }); + + // ────────────────────────────────────────────── + // 9. Maximum attempts are enforced + // ────────────────────────────────────────────── + it("9. Maximum attempts (5) are enforced, moving record to DEAD", async () => { + (prisma.birthdayEmailLog.findUnique as jest.Mock).mockResolvedValue({ + id: "job-max-attempt", + status: "PROCESSING", + lockToken: "tok-max", + attemptCount: 4, // 5th attempt failing + }); + + (prisma.birthdayEmailLog.update as jest.Mock).mockResolvedValue({}); + + const res = await birthdayService.recordBirthdayJobFailed( + "job-max-attempt", + "tok-max", + "TRANSIENT", + "Server error 503", + true + ); + + expect(res.success).toBe(true); + expect(res.status).toBe("DEAD"); + expect(res.attemptCount).toBe(5); + expect(res.nextRetryAt).toBeNull(); + }); + + // ────────────────────────────────────────────── + // 10. Unauthorized internal API requests are rejected + // ────────────────────────────────────────────── + it("10. Unauthorized internal API requests are rejected by middleware", () => { + const next: NextFunction = jest.fn(); + + // 10a: Missing secret + const reqMissing = { + headers: {}, + } as unknown as Request; + const res = {} as Response; + + expect(() => requireInternalAuth(reqMissing, res, next)).toThrow(ApiError); + try { + requireInternalAuth(reqMissing, res, next); + } catch (err: any) { + expect(err.statusCode).toBe(401); + } + + // 10b: Invalid secret + const reqInvalid = { + headers: { + "x-service-secret": "wrong-secret-token", + }, + } as unknown as Request; + + expect(() => requireInternalAuth(reqInvalid, res, next)).toThrow(ApiError); + try { + requireInternalAuth(reqInvalid, res, next); + } catch (err: any) { + expect(err.statusCode).toBe(401); + } + + // 10c: Valid secret + const reqValid = { + headers: { + "x-service-secret": "super-secret-service-token", + }, + } as unknown as Request; + + expect(() => requireInternalAuth(reqValid, res, next)).not.toThrow(); + expect(next).toHaveBeenCalled(); + }); + + // ────────────────────────────────────────────── + // Stats endpoint + // ────────────────────────────────────────────── + it("Fetches internal stats safely", async () => { + (prisma.birthdayEmailLog.groupBy as jest.Mock).mockResolvedValue([ + { status: "SENT", _count: { id: 10 } }, + { status: "PENDING", _count: { id: 2 } }, + { status: "FAILED", _count: { id: 1 } }, + ]); + (prisma.birthdayEmailLog.count as jest.Mock).mockResolvedValue(1); + + const stats = await birthdayService.getBirthdayEmailStats(); + expect(stats.sent).toBe(10); + expect(stats.pending).toBe(2); + expect(stats.failed).toBe(1); + expect(stats.dueForRetry).toBe(1); + expect(stats.total).toBe(13); + }); +}); From 8cca9d01dd495cdcd67430a303f252a918a77a25 Mon Sep 17 00:00:00 2001 From: Sarvesh Shahane Date: Wed, 21 Oct 2026 20:54:08 +0530 Subject: [PATCH 09/11] fix: address review feedback on birthday email API - Write job results with a lease-checked updateMany so a worker whose lease expired can't overwrite a job another worker has claimed - Count the lost attempt when reclaiming an expired PROCESSING job and move it to DEAD at max attempts, so crashing jobs don't retry forever - Validate sentAt and errorClassification in the controller (400 not 500) - Controller builds the { success: true, ... } response; service returns data - Read INTERNAL_SERVICE_SECRET via config, drop BIRTHDAY_SERVICE_SECRET fallback, hash before timingSafeEqual to hide secret length - Rename route file to birthdayEmails.ts and add apidoc blocks - Dedupe lease checks, defaults and retry delays; type errorClassification - Update and extend tests --- src/config/index.ts | 1 + src/controllers/birthdayEmail.controller.ts | 78 ++-- src/middleware/internalAuth.ts | 20 +- src/routes/birthdayEmails.ts | 122 ++++++ src/routes/index.ts | 4 +- src/routes/internalBirthday.ts | 27 -- src/services/birthdayEmail.service.ts | 242 ++++++------ tests/BirthdayEmail.test.ts | 388 +++++++++++++------- 8 files changed, 575 insertions(+), 307 deletions(-) create mode 100644 src/routes/birthdayEmails.ts delete mode 100644 src/routes/internalBirthday.ts diff --git a/src/config/index.ts b/src/config/index.ts index cc31432..03d7399 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -5,4 +5,5 @@ export default { SUPABASE_URL: process.env.SUPABASE_URL!, SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY!, ALLOWED_ORIGINS: process.env.ALLOWED_ORIGINS, + INTERNAL_SERVICE_SECRET: process.env.INTERNAL_SERVICE_SECRET, }; diff --git a/src/controllers/birthdayEmail.controller.ts b/src/controllers/birthdayEmail.controller.ts index d74d330..fdbf7b4 100644 --- a/src/controllers/birthdayEmail.controller.ts +++ b/src/controllers/birthdayEmail.controller.ts @@ -1,7 +1,39 @@ import { Request, Response } from "express"; import * as birthdayEmailService from "../services/birthdayEmail.service"; +import { + ERROR_CLASSIFICATIONS, + ErrorClassification, +} from "../services/birthdayEmail.service"; import { ApiError } from "../utils/apiError"; +function requireLockToken(body: Record): string { + const { lockToken } = body; + if (!lockToken || typeof lockToken !== "string") { + throw new ApiError("lockToken is required in request body", 400); + } + return lockToken; +} + +function parseSentAt(value: unknown): Date | undefined { + if (value === undefined || value === null) return undefined; + const date = new Date(value as string); + if (typeof value !== "string" || Number.isNaN(date.getTime())) { + throw new ApiError("sentAt must be a valid ISO date string", 400); + } + return date; +} + +function parseErrorClassification(value: unknown): ErrorClassification { + if (value === undefined || value === null) return "TRANSIENT"; + if (!ERROR_CLASSIFICATIONS.includes(value as ErrorClassification)) { + throw new ApiError( + `errorClassification must be one of: ${ERROR_CLASSIFICATIONS.join(", ")}`, + 400 + ); + } + return value as ErrorClassification; +} + /** * POST /api/v1/birthday-emails/initialize */ @@ -35,8 +67,8 @@ export async function claimJobs(req: Request, res: Response): Promise { const { batchSize, leaseDurationMs } = req.body || {}; const jobs = await birthdayEmailService.claimBirthdayJobs( - typeof batchSize === "number" ? batchSize : 10, - typeof leaseDurationMs === "number" ? leaseDurationMs : 300000 + typeof batchSize === "number" ? batchSize : undefined, + typeof leaseDurationMs === "number" ? leaseDurationMs : undefined ); res.status(200).json({ @@ -50,43 +82,43 @@ export async function claimJobs(req: Request, res: Response): Promise { * POST /api/v1/birthday-emails/:id/sent */ export async function markJobSent(req: Request, res: Response): Promise { - const { id } = req.params; - const { lockToken, providerMessageId, sentAt } = req.body || {}; + const body = req.body || {}; + const lockToken = requireLockToken(body); + const sentAt = parseSentAt(body.sentAt); - if (!lockToken || typeof lockToken !== "string") { - throw new ApiError("lockToken is required in request body", 400); - } - - const result = await birthdayEmailService.recordBirthdayJobSent( - id, + const { alreadySent } = await birthdayEmailService.recordBirthdayJobSent( + req.params.id, lockToken, - providerMessageId, + body.providerMessageId, sentAt ); - res.status(200).json(result); + res.status(200).json({ + success: true, + message: alreadySent ? "Record was already marked as sent" : "Marked as sent", + }); } /** * POST /api/v1/birthday-emails/:id/failed */ export async function markJobFailed(req: Request, res: Response): Promise { - const { id } = req.params; - const { lockToken, errorClassification, errorMessage, isRetryable } = req.body || {}; - - if (!lockToken || typeof lockToken !== "string") { - throw new ApiError("lockToken is required in request body", 400); - } + const body = req.body || {}; + const lockToken = requireLockToken(body); + const errorClassification = parseErrorClassification(body.errorClassification); const result = await birthdayEmailService.recordBirthdayJobFailed( - id, + req.params.id, lockToken, - errorClassification ?? "TRANSIENT", - errorMessage ?? "Unknown failure", - typeof isRetryable === "boolean" ? isRetryable : true + errorClassification, + typeof body.errorMessage === "string" ? body.errorMessage : "Unknown failure", + typeof body.isRetryable === "boolean" ? body.isRetryable : true ); - res.status(200).json(result); + res.status(200).json({ + success: true, + ...result, + }); } /** diff --git a/src/middleware/internalAuth.ts b/src/middleware/internalAuth.ts index 74fee0f..ce3e282 100644 --- a/src/middleware/internalAuth.ts +++ b/src/middleware/internalAuth.ts @@ -1,18 +1,22 @@ import { Request, Response, NextFunction } from "express"; import crypto from "crypto"; +import config from "../config"; import { ApiError } from "../utils/apiError"; +function sha256(value: string): Buffer { + return crypto.createHash("sha256").update(value, "utf-8").digest(); +} + /** * Service-to-service authentication middleware for internal APIs. - * Compares incoming secret header against configured environment secret using constant-time comparison. + * Compares the incoming secret header against INTERNAL_SERVICE_SECRET in constant time. */ export function requireInternalAuth( req: Request, _res: Response, next: NextFunction ): void { - const configuredSecret = - process.env.INTERNAL_SERVICE_SECRET || process.env.BIRTHDAY_SERVICE_SECRET; + const configuredSecret = config.INTERNAL_SERVICE_SECRET; if (!configuredSecret) { throw new ApiError( @@ -37,13 +41,9 @@ export function requireInternalAuth( ); } - const providedBuffer = Buffer.from(providedSecret, "utf-8"); - const expectedBuffer = Buffer.from(configuredSecret, "utf-8"); - - if ( - providedBuffer.length !== expectedBuffer.length || - !crypto.timingSafeEqual(providedBuffer, expectedBuffer) - ) { + // Hash both sides so the buffers are always equal length and the + // comparison doesn't reveal the secret's length. + if (!crypto.timingSafeEqual(sha256(providedSecret), sha256(configuredSecret))) { throw new ApiError( "Unauthorized: Invalid internal service secret", 401 diff --git a/src/routes/birthdayEmails.ts b/src/routes/birthdayEmails.ts new file mode 100644 index 0000000..cf8d13a --- /dev/null +++ b/src/routes/birthdayEmails.ts @@ -0,0 +1,122 @@ +import { Router } from "express"; +import * as birthdayCtrl from "../controllers/birthdayEmail.controller"; +import { requireInternalAuth } from "../middleware/internalAuth"; + +/** + * Internal, service-to-service API used by the birthday email worker. + * Every route requires the INTERNAL_SERVICE_SECRET, sent as an + * `x-internal-secret` / `x-service-secret` header or as a Bearer token. + */ +export default function birthdayEmailsRouter(): Router { + const router = Router(); + + router.use(requireInternalAuth); + + /** + * @api {post} /birthday-emails/initialize Create birthday email jobs + * @apiName InitializeBirthdayJobs + * @apiGroup BirthdayEmail + * @apiDescription Idempotently creates one PENDING job per eligible member + * (approved, not ghosted, has a birth date and email) for the given year. + * Existing jobs are left untouched. + * + * @apiHeader {String} x-internal-secret Internal service secret. + * + * @apiBody {Number} birthdayYear Year the birthday email is for. (Required) + * @apiBody {String[]} memberIds Member IDs to create jobs for. (Required) + * + * @apiSuccess {Boolean} success Always true. + * @apiSuccess {Object} data + * @apiSuccess {Number} data.created Jobs newly created. + * @apiSuccess {Number} data.existing Jobs that already existed. + * @apiSuccess {Number} data.totalEligible Eligible members found. + * @apiError (400) BadRequest Invalid birthdayYear or memberIds. + * @apiError (401) Unauthorized Missing or invalid service secret. + */ + router.post("/initialize", birthdayCtrl.initializeJobs); + + /** + * @api {post} /birthday-emails/claim Claim a batch of jobs + * @apiName ClaimBirthdayJobs + * @apiGroup BirthdayEmail + * @apiDescription Atomically leases PENDING jobs, FAILED jobs due for retry, + * and PROCESSING jobs whose lease expired. Each claimed job gets a fresh + * lockToken that must be sent back when reporting the result. + * + * @apiHeader {String} x-internal-secret Internal service secret. + * + * @apiBody {Number} [batchSize=10] Jobs to claim (1-50). + * @apiBody {Number} [leaseDurationMs=300000] Lease length in ms (10s-1h). + * + * @apiSuccess {Boolean} success Always true. + * @apiSuccess {Number} count Number of jobs claimed. + * @apiSuccess {Object[]} jobs Claimed jobs (id, memberId, birthdayYear, attemptCount, lockToken). + * @apiError (401) Unauthorized Missing or invalid service secret. + */ + router.post("/claim", birthdayCtrl.claimJobs); + + /** + * @api {get} /birthday-emails/stats Get job statistics + * @apiName GetBirthdayEmailStats + * @apiGroup BirthdayEmail + * + * @apiHeader {String} x-internal-secret Internal service secret. + * + * @apiSuccess {Boolean} success Always true. + * @apiSuccess {Object} stats Counts: pending, processing, sent, failed, dead, dueForRetry, total. + * @apiError (401) Unauthorized Missing or invalid service secret. + */ + router.get("/stats", birthdayCtrl.getStats); + + /** + * @api {post} /birthday-emails/:id/sent Report a sent email + * @apiName MarkBirthdayJobSent + * @apiGroup BirthdayEmail + * @apiDescription Marks a claimed job as SENT. Calling it again for a job + * that is already SENT succeeds without changing anything. + * + * @apiHeader {String} x-internal-secret Internal service secret. + * + * @apiParam (URL Params) {String} id Job ID. + * + * @apiBody {String} lockToken Token returned by /claim. (Required) + * @apiBody {String} [providerMessageId] Email provider's message ID. + * @apiBody {String} [sentAt] ISO timestamp of the send. Defaults to now. + * + * @apiSuccess {Boolean} success Always true. + * @apiSuccess {String} message Result description. + * @apiError (400) BadRequest Missing lockToken or invalid sentAt. + * @apiError (404) NotFound Job not found. + * @apiError (409) Conflict Lease expired or held by another worker. + */ + router.post("/:id/sent", birthdayCtrl.markJobSent); + + /** + * @api {post} /birthday-emails/:id/failed Report a failed send + * @apiName MarkBirthdayJobFailed + * @apiGroup BirthdayEmail + * @apiDescription Records a failed attempt. Retryable failures are + * rescheduled with backoff; permanent failures, or a job's 5th attempt, + * move it to DEAD. + * + * @apiHeader {String} x-internal-secret Internal service secret. + * + * @apiParam (URL Params) {String} id Job ID. + * + * @apiBody {String} lockToken Token returned by /claim. (Required) + * @apiBody {String="TRANSIENT","PERMANENT","CONFIG","QUOTA"} [errorClassification=TRANSIENT] Failure type. + * @apiBody {String} [errorMessage] Error details. Secrets are redacted before storing. + * @apiBody {Boolean} [isRetryable=true] Whether the failure can be retried. + * + * @apiSuccess {Boolean} success Always true. + * @apiSuccess {String} status FAILED or DEAD. + * @apiSuccess {Number} attemptCount Attempts made so far. + * @apiSuccess {String} nextRetryAt Next retry time, or null when DEAD. + * @apiError (400) BadRequest Missing lockToken or invalid errorClassification. + * @apiError (404) NotFound Job not found. + * @apiError (409) Conflict Lease expired or held by another worker. + */ + router.post("/:id/failed", birthdayCtrl.markJobFailed); + + return router; +} diff --git a/src/routes/index.ts b/src/routes/index.ts index 791a9fa..673ff79 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -9,7 +9,7 @@ import progressRouter from './progress' import membersRouter from './members' import siteContentRouter from './site-content' import emailRouter from './email' -import internalBirthdayRouter from './internalBirthday' +import birthdayEmailsRouter from './birthdayEmails' export default function routes(upload: Multer) { const router = Router(); @@ -29,7 +29,7 @@ export default function routes(upload: Multer) { router.use("/site-content", siteContentRouter(upload)); router.use("/email", emailRouter()); - router.use("/birthday-emails", internalBirthdayRouter()); + router.use("/birthday-emails", birthdayEmailsRouter()); return router; } diff --git a/src/routes/internalBirthday.ts b/src/routes/internalBirthday.ts deleted file mode 100644 index dba0387..0000000 --- a/src/routes/internalBirthday.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Router } from "express"; -import * as birthdayCtrl from "../controllers/birthdayEmail.controller"; -import { requireInternalAuth } from "../middleware/internalAuth"; - -export default function internalBirthdayRouter(): Router { - const router = Router(); - - // Protect all internal birthday tracking endpoints with service-to-service auth - router.use(requireInternalAuth); - - // Initialize jobs idempotently - router.post("/initialize", birthdayCtrl.initializeJobs); - - // Atomically claim a batch of eligible jobs - router.post("/claim", birthdayCtrl.claimJobs); - - // Statistics - router.get("/stats", birthdayCtrl.getStats); - - // Report job send result - router.post("/:id/sent", birthdayCtrl.markJobSent); - - // Report job failure result - router.post("/:id/failed", birthdayCtrl.markJobFailed); - - return router; -} diff --git a/src/services/birthdayEmail.service.ts b/src/services/birthdayEmail.service.ts index f61b135..f3d5bb9 100644 --- a/src/services/birthdayEmail.service.ts +++ b/src/services/birthdayEmail.service.ts @@ -1,4 +1,5 @@ import prisma from "../db/client"; +import type { Prisma } from "../generated/prisma/client"; import { ApiError } from "../utils/apiError"; import crypto from "crypto"; @@ -20,17 +21,30 @@ export interface JobStats { total: number; } -/** Retry delays in milliseconds based on attempt count */ -const RETRY_DELAYS_MS: Record = { - 1: 5 * 60 * 1000, // 5 min - 2: 15 * 60 * 1000, // 15 min - 3: 30 * 60 * 1000, // 30 min - 4: 120 * 60 * 1000, // 120 min -}; +export const ERROR_CLASSIFICATIONS = [ + "TRANSIENT", + "PERMANENT", + "CONFIG", + "QUOTA", +] as const; +export type ErrorClassification = (typeof ERROR_CLASSIFICATIONS)[number]; -const QUOTA_RETRY_DELAY_MS = 4 * 60 * 60 * 1000; // 4 hours for quota exhaustion +export interface FailedJobResult { + status: "FAILED" | "DEAD"; + attemptCount: number; + nextRetryAt: Date | null; +} + +const MINUTE_MS = 60 * 1000; + +/** Retry delay after the Nth failed attempt (index 0 = after attempt 1). */ +const RETRY_DELAYS_MS = [5, 15, 30, 120].map((m) => m * MINUTE_MS); +const QUOTA_RETRY_DELAY_MS = 4 * 60 * MINUTE_MS; const MAX_ATTEMPTS = 5; +export const DEFAULT_BATCH_SIZE = 10; +export const DEFAULT_LEASE_MS = 5 * MINUTE_MS; + /** * Sanitize error message to ensure no secrets, keys, or bearer tokens are stored. */ @@ -44,6 +58,11 @@ function sanitizeErrorMessage(msg: string): string { .substring(0, 500); } +function retryDelayFor(attemptCount: number): number { + const index = Math.min(Math.max(attemptCount, 1), RETRY_DELAYS_MS.length) - 1; + return RETRY_DELAYS_MS[index]; +} + /** * Initialize birthday email jobs for a given year and list of member IDs idempotently. */ @@ -139,10 +158,14 @@ export async function initializeBirthdayJobs( * - FAILED and nextRetryAt <= now * - PROCESSING and lease expired (lockedUntil < now) * Prevents race conditions with FOR UPDATE SKIP LOCKED. + * + * An expired PROCESSING lease means the previous worker never reported a + * result (for example, it crashed), so that lost attempt is counted. A job + * that runs out of attempts this way is moved to DEAD instead of reclaimed. */ export async function claimBirthdayJobs( - batchSize = 10, - leaseDurationMs = 300000 + batchSize = DEFAULT_BATCH_SIZE, + leaseDurationMs = DEFAULT_LEASE_MS ): Promise { const safeBatchSize = Math.max(1, Math.min(batchSize, 50)); const safeLeaseMs = Math.max(10000, Math.min(leaseDurationMs, 3600000)); @@ -150,8 +173,10 @@ export async function claimBirthdayJobs( return await prisma.$transaction(async (tx) => { // Select eligible jobs with row-level locks, skipping already-locked rows - const eligible = await tx.$queryRaw>` - SELECT id FROM "BirthdayEmailLog" + const eligible = await tx.$queryRaw< + Array<{ id: string; status: string; attemptCount: number }> + >` + SELECT id, "status", "attemptCount" FROM "BirthdayEmailLog" WHERE ( "status" = 'PENDING'::"BirthdayEmailStatus" OR ("status" = 'FAILED'::"BirthdayEmailStatus" AND ("nextRetryAt" IS NULL OR "nextRetryAt" <= ${now})) OR @@ -170,11 +195,32 @@ export async function claimBirthdayJobs( const claimed: ClaimedJob[] = []; for (const row of eligible) { + let attemptCount = row.attemptCount; + + if (row.status === "PROCESSING") { + attemptCount += 1; + if (attemptCount >= MAX_ATTEMPTS) { + await tx.birthdayEmailLog.update({ + where: { id: row.id }, + data: { + status: "DEAD", + attemptCount, + lastError: "Lease expired: worker never reported a result", + lockToken: null, + lockedUntil: null, + nextRetryAt: null, + }, + }); + continue; + } + } + const lockToken = crypto.randomUUID(); const updated = await tx.birthdayEmailLog.update({ where: { id: row.id }, data: { status: "PROCESSING", + attemptCount, lockToken, lockedUntil, }, @@ -183,78 +229,82 @@ export async function claimBirthdayJobs( memberId: true, birthdayYear: true, attemptCount: true, - lockToken: true, }, }); - if (updated.lockToken) { - claimed.push({ - id: updated.id, - memberId: updated.memberId, - birthdayYear: updated.birthdayYear, - attemptCount: updated.attemptCount, - lockToken: updated.lockToken, - }); - } + claimed.push({ ...updated, lockToken }); } return claimed; }); } +async function findJobOrThrow(id: string) { + const record = await prisma.birthdayEmailLog.findUnique({ where: { id } }); + if (!record) { + throw new ApiError("Birthday email record not found", 404); + } + return record; +} + +function leaseConflict(): ApiError { + return new ApiError( + "Invalid or expired lock token: job is no longer owned by caller", + 409 + ); +} + +/** + * Write a job result only if the caller still holds the lease. + * The lease check and the write are one statement, so a worker whose lease + * expired cannot overwrite a job that another worker has since claimed. + */ +async function updateIfLeaseOwned( + id: string, + lockToken: string, + data: Prisma.BirthdayEmailLogUpdateManyMutationInput +): Promise { + const { count } = await prisma.birthdayEmailLog.updateMany({ + where: { id, lockToken, status: "PROCESSING" }, + data, + }); + if (count === 0) { + throw leaseConflict(); + } +} + /** * Mark a claimed birthday email job as SENT. - * Validates the lockToken to guard against stale workers. + * Returns `alreadySent: true` when the job had already been marked as sent. */ export async function recordBirthdayJobSent( id: string, lockToken: string, providerMessageId?: string, - sentAt?: string -): Promise<{ success: boolean; message: string }> { - if (!id || typeof id !== "string") { - throw new ApiError("Missing tracking record id", 400); - } - if (!lockToken || typeof lockToken !== "string") { - throw new ApiError("Missing lockToken", 400); - } - - const record = await prisma.birthdayEmailLog.findUnique({ - where: { id }, - }); - - if (!record) { - throw new ApiError("Birthday email record not found", 404); - } + sentAt: Date = new Date() +): Promise<{ alreadySent: boolean }> { + const record = await findJobOrThrow(id); - // Idempotent duplicate check: already SENT with provider ID if (record.status === "SENT") { - return { success: true, message: "Record was already marked as sent" }; + return { alreadySent: true }; } - // Validate lease ownership if (record.status !== "PROCESSING" || record.lockToken !== lockToken) { - throw new ApiError( - "Invalid or expired lock token: job is no longer owned by caller", - 409 - ); + throw leaseConflict(); } - await prisma.birthdayEmailLog.update({ - where: { id }, - data: { - status: "SENT", - providerMessageId: providerMessageId ?? record.providerMessageId, - sentAt: sentAt ? new Date(sentAt) : new Date(), - lockToken: null, - lockedUntil: null, - nextRetryAt: null, - attemptCount: record.attemptCount + 1, - lastError: null, - }, + await updateIfLeaseOwned(id, lockToken, { + status: "SENT", + providerMessageId: providerMessageId ?? record.providerMessageId, + sentAt, + lockToken: null, + lockedUntil: null, + nextRetryAt: null, + attemptCount: record.attemptCount + 1, + lastError: null, }); - return { success: true, message: "Marked as sent" }; + return { alreadySent: false }; } /** @@ -264,78 +314,46 @@ export async function recordBirthdayJobSent( export async function recordBirthdayJobFailed( id: string, lockToken: string, - errorClassification: string, + errorClassification: ErrorClassification, errorMessage: string, isRetryable: boolean -): Promise<{ - success: boolean; - status: "FAILED" | "DEAD"; - attemptCount: number; - nextRetryAt: Date | null; -}> { - if (!id || typeof id !== "string") { - throw new ApiError("Missing tracking record id", 400); - } - if (!lockToken || typeof lockToken !== "string") { - throw new ApiError("Missing lockToken", 400); - } - - const record = await prisma.birthdayEmailLog.findUnique({ - where: { id }, - }); - - if (!record) { - throw new ApiError("Birthday email record not found", 404); - } +): Promise { + const record = await findJobOrThrow(id); if (record.status !== "PROCESSING" || record.lockToken !== lockToken) { - throw new ApiError( - "Invalid or expired lock token: job is no longer owned by caller", - 409 - ); + throw leaseConflict(); } - const newAttemptCount = record.attemptCount + 1; - const sanitized = sanitizeErrorMessage(errorMessage); - + const attemptCount = record.attemptCount + 1; const isPermanent = !isRetryable || errorClassification === "PERMANENT" || errorClassification === "CONFIG"; - let newStatus: "FAILED" | "DEAD"; + let status: "FAILED" | "DEAD"; let nextRetryAt: Date | null = null; - if (isPermanent || newAttemptCount >= MAX_ATTEMPTS) { - newStatus = "DEAD"; - nextRetryAt = null; + if (isPermanent || attemptCount >= MAX_ATTEMPTS) { + status = "DEAD"; } else { - newStatus = "FAILED"; - let delayMs = RETRY_DELAYS_MS[newAttemptCount] || 120 * 60 * 1000; - if (errorClassification === "QUOTA") { - delayMs = QUOTA_RETRY_DELAY_MS; - } + status = "FAILED"; + const delayMs = + errorClassification === "QUOTA" + ? QUOTA_RETRY_DELAY_MS + : retryDelayFor(attemptCount); nextRetryAt = new Date(Date.now() + delayMs); } - await prisma.birthdayEmailLog.update({ - where: { id }, - data: { - status: newStatus, - attemptCount: newAttemptCount, - nextRetryAt, - lastError: sanitized, - lockToken: null, - lockedUntil: null, - }, + await updateIfLeaseOwned(id, lockToken, { + status, + attemptCount, + nextRetryAt, + lastError: sanitizeErrorMessage(errorMessage), + lockToken: null, + lockedUntil: null, }); - return { - success: true, - status: newStatus, - attemptCount: newAttemptCount, - nextRetryAt, - }; + return { status, attemptCount, nextRetryAt }; } /** diff --git a/tests/BirthdayEmail.test.ts b/tests/BirthdayEmail.test.ts index 7e5626a..7106e2c 100644 --- a/tests/BirthdayEmail.test.ts +++ b/tests/BirthdayEmail.test.ts @@ -3,6 +3,7 @@ import * as birthdayService from "../src/services/birthdayEmail.service"; import * as birthdayCtrl from "../src/controllers/birthdayEmail.controller"; import { requireInternalAuth } from "../src/middleware/internalAuth"; import prisma from "../src/db/client"; +import config from "../src/config"; import { ApiError } from "../src/utils/apiError"; jest.mock("../src/db/client", () => { @@ -16,6 +17,7 @@ jest.mock("../src/db/client", () => { findUnique: jest.fn(), createMany: jest.fn(), update: jest.fn(), + updateMany: jest.fn(), groupBy: jest.fn(), count: jest.fn(), }, @@ -29,17 +31,38 @@ jest.mock("../src/db/client", () => { }; }); -describe("Central Backend: Birthday Email Tracking & Internal APIs", () => { - const originalEnv = process.env; +const mockResponse = (): Response => { + const res = {} as Response; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; +}; + +/** Run claimBirthdayJobs against a fake transaction returning the given rows. */ +function mockClaimTransaction( + rows: Array<{ id: string; status: string; attemptCount: number }> +) { + const update = jest.fn().mockImplementation(({ where, data }) => + Promise.resolve({ + id: where.id, + memberId: `member-of-${where.id}`, + birthdayYear: 2026, + attemptCount: data.attemptCount, + }) + ); + (prisma.$transaction as jest.Mock).mockImplementationOnce(async (callback) => + callback({ + $queryRaw: jest.fn().mockResolvedValue(rows), + birthdayEmailLog: { update }, + }) + ); + return update; +} +describe("Central Backend: Birthday Email Tracking & Internal APIs", () => { beforeEach(() => { jest.clearAllMocks(); - process.env = { ...originalEnv }; - process.env.INTERNAL_SERVICE_SECRET = "super-secret-service-token"; - }); - - afterAll(() => { - process.env = originalEnv; + config.INTERNAL_SERVICE_SECRET = "super-secret-service-token"; }); // ────────────────────────────────────────────── @@ -95,72 +118,76 @@ describe("Central Backend: Birthday Email Tracking & Internal APIs", () => { // 3. Two concurrent workers cannot claim the same active job // ────────────────────────────────────────────── it("3. Atomic job claim locks jobs and prevents concurrent workers from claiming the same active job", async () => { - const jobRow = { id: "job-1" }; - - // Transaction executes queryRaw and update - (prisma.$transaction as jest.Mock).mockImplementation(async (callback) => { - const tx = { - $queryRaw: jest.fn().mockResolvedValue([jobRow]), - birthdayEmailLog: { - update: jest.fn().mockResolvedValue({ - id: "job-1", - memberId: "m-1", - birthdayYear: 2026, - attemptCount: 0, - lockToken: "token-worker-1", - }), - }, - }; - return await callback(tx); - }); + mockClaimTransaction([{ id: "job-1", status: "PENDING", attemptCount: 0 }]); const claimed = await birthdayService.claimBirthdayJobs(10, 300000); expect(claimed).toHaveLength(1); expect(claimed[0].id).toBe("job-1"); - expect(claimed[0].lockToken).toBeDefined(); + expect(claimed[0].attemptCount).toBe(0); + expect(claimed[0].lockToken).toEqual(expect.any(String)); // Second worker claims concurrently, queryRaw returns empty (already locked/skipped) - (prisma.$transaction as jest.Mock).mockImplementationOnce(async (callback) => { - const tx = { - $queryRaw: jest.fn().mockResolvedValue([]), - birthdayEmailLog: { update: jest.fn() }, - }; - return await callback(tx); - }); + mockClaimTransaction([]); const secondClaim = await birthdayService.claimBirthdayJobs(10, 300000); expect(secondClaim).toHaveLength(0); }); // ────────────────────────────────────────────── - // 4. Expired PROCESSING jobs can be recovered + // 4. Expired PROCESSING jobs can be recovered, and the lost attempt counts // ────────────────────────────────────────────── - it("4. Expired PROCESSING jobs can be recovered by claim query", async () => { - (prisma.$transaction as jest.Mock).mockImplementation(async (callback) => { - const tx = { - $queryRaw: jest.fn().mockImplementation((strings, ...values) => { - // Verify query includes lease expiration check: lockedUntil < now - const queryText = strings.join(""); - expect(queryText).toContain('"lockedUntil" <'); - return Promise.resolve([{ id: "expired-job-1" }]); + it("4. Expired PROCESSING jobs are reclaimed and the lost attempt is counted", async () => { + let queryText = ""; + const update = jest.fn().mockImplementation(({ where, data }) => + Promise.resolve({ + id: where.id, + memberId: "m-expired", + birthdayYear: 2026, + attemptCount: data.attemptCount, + }) + ); + (prisma.$transaction as jest.Mock).mockImplementationOnce(async (callback) => + callback({ + $queryRaw: jest.fn().mockImplementation((strings: string[]) => { + queryText = strings.join(""); + return Promise.resolve([ + { id: "expired-job-1", status: "PROCESSING", attemptCount: 1 }, + ]); }), - birthdayEmailLog: { - update: jest.fn().mockResolvedValue({ - id: "expired-job-1", - memberId: "m-expired", - birthdayYear: 2026, - attemptCount: 1, - lockToken: "new-recovery-token", - }), - }, - }; - return await callback(tx); - }); + birthdayEmailLog: { update }, + }) + ); const claimed = await birthdayService.claimBirthdayJobs(5, 300000); + expect(queryText).toContain('"lockedUntil" <'); expect(claimed).toHaveLength(1); expect(claimed[0].id).toBe("expired-job-1"); - expect(claimed[0].lockToken).toBe("new-recovery-token"); + expect(claimed[0].attemptCount).toBe(2); + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: "PROCESSING", attemptCount: 2 }), + }) + ); + }); + + it("4b. An expired PROCESSING job on its last attempt goes DEAD instead of being reclaimed", async () => { + const update = mockClaimTransaction([ + { id: "crashy-job", status: "PROCESSING", attemptCount: 4 }, + { id: "fresh-job", status: "PENDING", attemptCount: 0 }, + ]); + + const claimed = await birthdayService.claimBirthdayJobs(10, 300000); + expect(claimed.map((j) => j.id)).toEqual(["fresh-job"]); + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "crashy-job" }, + data: expect.objectContaining({ + status: "DEAD", + attemptCount: 5, + lockToken: null, + }), + }) + ); }); // ────────────────────────────────────────────── @@ -175,26 +202,10 @@ describe("Central Backend: Birthday Email Tracking & Internal APIs", () => { attemptCount: 2, }); - // Stale worker tries to report sent with old token await expect( - birthdayService.recordBirthdayJobSent( - "job-conflict", - "stale-old-token", - "msg-123" - ) - ).rejects.toThrow(ApiError); - - try { - await birthdayService.recordBirthdayJobSent( - "job-conflict", - "stale-old-token", - "msg-123" - ); - } catch (err: any) { - expect(err.statusCode).toBe(409); - } + birthdayService.recordBirthdayJobSent("job-conflict", "stale-old-token", "msg-123") + ).rejects.toMatchObject({ statusCode: 409 }); - // Stale worker tries to report failed with old token await expect( birthdayService.recordBirthdayJobFailed( "job-conflict", @@ -203,7 +214,35 @@ describe("Central Backend: Birthday Email Tracking & Internal APIs", () => { "network timeout", true ) - ).rejects.toThrow(ApiError); + ).rejects.toMatchObject({ statusCode: 409 }); + + expect(prisma.birthdayEmailLog.updateMany).not.toHaveBeenCalled(); + }); + + it("5b. Losing the lease between the read and the write is rejected (409 conflict)", async () => { + // The read still shows our lease... + (prisma.birthdayEmailLog.findUnique as jest.Mock).mockResolvedValue({ + id: "job-race", + status: "PROCESSING", + lockToken: "my-token", + attemptCount: 1, + }); + // ...but another worker reclaimed it before our write landed. + (prisma.birthdayEmailLog.updateMany as jest.Mock).mockResolvedValue({ count: 0 }); + + await expect( + birthdayService.recordBirthdayJobSent("job-race", "my-token", "msg-1") + ).rejects.toMatchObject({ statusCode: 409 }); + + await expect( + birthdayService.recordBirthdayJobFailed("job-race", "my-token", "TRANSIENT", "boom", true) + ).rejects.toMatchObject({ statusCode: 409 }); + + expect(prisma.birthdayEmailLog.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "job-race", lockToken: "my-token", status: "PROCESSING" }, + }) + ); }); // ────────────────────────────────────────────── @@ -217,35 +256,41 @@ describe("Central Backend: Birthday Email Tracking & Internal APIs", () => { attemptCount: 0, providerMessageId: null, }); + (prisma.birthdayEmailLog.updateMany as jest.Mock).mockResolvedValue({ count: 1 }); - (prisma.birthdayEmailLog.update as jest.Mock).mockResolvedValue({ - id: "job-success", - status: "SENT", - providerMessageId: "resend-msg-999", - sentAt: new Date("2026-10-21T09:05:00.000Z"), - }); - + const sentAt = new Date("2026-10-21T09:05:00.000Z"); const res = await birthdayService.recordBirthdayJobSent( "job-success", "valid-token-abc", "resend-msg-999", - "2026-10-21T09:05:00.000Z" + sentAt ); - expect(res.success).toBe(true); - expect(prisma.birthdayEmailLog.update).toHaveBeenCalledWith( - expect.objectContaining({ - where: { id: "job-success" }, - data: expect.objectContaining({ - status: "SENT", - providerMessageId: "resend-msg-999", - sentAt: new Date("2026-10-21T09:05:00.000Z"), - lockToken: null, - lockedUntil: null, - attemptCount: 1, - }), - }) - ); + expect(res.alreadySent).toBe(false); + expect(prisma.birthdayEmailLog.updateMany).toHaveBeenCalledWith({ + where: { id: "job-success", lockToken: "valid-token-abc", status: "PROCESSING" }, + data: expect.objectContaining({ + status: "SENT", + providerMessageId: "resend-msg-999", + sentAt, + lockToken: null, + lockedUntil: null, + attemptCount: 1, + }), + }); + }); + + it("6b. Reporting an already SENT job is a no-op", async () => { + (prisma.birthdayEmailLog.findUnique as jest.Mock).mockResolvedValue({ + id: "job-done", + status: "SENT", + lockToken: null, + attemptCount: 1, + }); + + const res = await birthdayService.recordBirthdayJobSent("job-done", "any-token"); + expect(res.alreadySent).toBe(true); + expect(prisma.birthdayEmailLog.updateMany).not.toHaveBeenCalled(); }); // ────────────────────────────────────────────── @@ -258,8 +303,7 @@ describe("Central Backend: Birthday Email Tracking & Internal APIs", () => { lockToken: "tok-retry", attemptCount: 1, // attempt 1 failed, next will be attempt 2 }); - - (prisma.birthdayEmailLog.update as jest.Mock).mockResolvedValue({}); + (prisma.birthdayEmailLog.updateMany as jest.Mock).mockResolvedValue({ count: 1 }); const res = await birthdayService.recordBirthdayJobFailed( "job-retryable", @@ -269,7 +313,6 @@ describe("Central Backend: Birthday Email Tracking & Internal APIs", () => { true ); - expect(res.success).toBe(true); expect(res.status).toBe("FAILED"); expect(res.attemptCount).toBe(2); expect(res.nextRetryAt).toBeInstanceOf(Date); @@ -289,8 +332,7 @@ describe("Central Backend: Birthday Email Tracking & Internal APIs", () => { lockToken: "tok-perm", attemptCount: 0, }); - - (prisma.birthdayEmailLog.update as jest.Mock).mockResolvedValue({}); + (prisma.birthdayEmailLog.updateMany as jest.Mock).mockResolvedValue({ count: 1 }); const res = await birthdayService.recordBirthdayJobFailed( "job-perm", @@ -300,11 +342,10 @@ describe("Central Backend: Birthday Email Tracking & Internal APIs", () => { false ); - expect(res.success).toBe(true); expect(res.status).toBe("DEAD"); expect(res.nextRetryAt).toBeNull(); expect(res.attemptCount).toBe(1); - expect(prisma.birthdayEmailLog.update).toHaveBeenCalledWith( + expect(prisma.birthdayEmailLog.updateMany).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ status: "DEAD", @@ -324,8 +365,7 @@ describe("Central Backend: Birthday Email Tracking & Internal APIs", () => { lockToken: "tok-max", attemptCount: 4, // 5th attempt failing }); - - (prisma.birthdayEmailLog.update as jest.Mock).mockResolvedValue({}); + (prisma.birthdayEmailLog.updateMany as jest.Mock).mockResolvedValue({ count: 1 }); const res = await birthdayService.recordBirthdayJobFailed( "job-max-attempt", @@ -335,7 +375,6 @@ describe("Central Backend: Birthday Email Tracking & Internal APIs", () => { true ); - expect(res.success).toBe(true); expect(res.status).toBe("DEAD"); expect(res.attemptCount).toBe(5); expect(res.nextRetryAt).toBeNull(); @@ -346,45 +385,128 @@ describe("Central Backend: Birthday Email Tracking & Internal APIs", () => { // ────────────────────────────────────────────── it("10. Unauthorized internal API requests are rejected by middleware", () => { const next: NextFunction = jest.fn(); - - // 10a: Missing secret - const reqMissing = { - headers: {}, - } as unknown as Request; const res = {} as Response; - expect(() => requireInternalAuth(reqMissing, res, next)).toThrow(ApiError); - try { - requireInternalAuth(reqMissing, res, next); - } catch (err: any) { - expect(err.statusCode).toBe(401); - } + // 10a: Missing secret + const reqMissing = { headers: {} } as unknown as Request; + expect(() => requireInternalAuth(reqMissing, res, next)).toThrow( + expect.objectContaining({ statusCode: 401 }) + ); - // 10b: Invalid secret + // 10b: Invalid secret (different length from the real one) const reqInvalid = { - headers: { - "x-service-secret": "wrong-secret-token", - }, + headers: { "x-service-secret": "wrong" }, } as unknown as Request; - - expect(() => requireInternalAuth(reqInvalid, res, next)).toThrow(ApiError); - try { - requireInternalAuth(reqInvalid, res, next); - } catch (err: any) { - expect(err.statusCode).toBe(401); - } + expect(() => requireInternalAuth(reqInvalid, res, next)).toThrow( + expect.objectContaining({ statusCode: 401 }) + ); + expect(next).not.toHaveBeenCalled(); // 10c: Valid secret const reqValid = { - headers: { - "x-service-secret": "super-secret-service-token", - }, + headers: { "x-service-secret": "super-secret-service-token" }, } as unknown as Request; - expect(() => requireInternalAuth(reqValid, res, next)).not.toThrow(); expect(next).toHaveBeenCalled(); }); + it("10d. Missing server secret is a 500", () => { + config.INTERNAL_SERVICE_SECRET = undefined; + const req = { headers: { "x-internal-secret": "anything" } } as unknown as Request; + expect(() => requireInternalAuth(req, {} as Response, jest.fn())).toThrow( + expect.objectContaining({ statusCode: 500 }) + ); + }); + + // ────────────────────────────────────────────── + // 11. Controller input validation and response shape + // ────────────────────────────────────────────── + describe("controller", () => { + it("rejects an invalid sentAt with 400", async () => { + const req = { + params: { id: "job-1" }, + body: { lockToken: "tok", sentAt: "not-a-date" }, + } as unknown as Request; + + await expect(birthdayCtrl.markJobSent(req, mockResponse())).rejects.toMatchObject({ + statusCode: 400, + }); + expect(prisma.birthdayEmailLog.findUnique).not.toHaveBeenCalled(); + }); + + it("rejects an unknown errorClassification with 400", async () => { + const req = { + params: { id: "job-1" }, + body: { lockToken: "tok", errorClassification: "permanent" }, + } as unknown as Request; + + await expect(birthdayCtrl.markJobFailed(req, mockResponse())).rejects.toBeInstanceOf( + ApiError + ); + expect(prisma.birthdayEmailLog.findUnique).not.toHaveBeenCalled(); + }); + + it("rejects a missing lockToken with 400", async () => { + const req = { params: { id: "job-1" }, body: {} } as unknown as Request; + + await expect(birthdayCtrl.markJobSent(req, mockResponse())).rejects.toMatchObject({ + statusCode: 400, + }); + }); + + it("wraps a failure result as { success: true, ... }", async () => { + (prisma.birthdayEmailLog.findUnique as jest.Mock).mockResolvedValue({ + id: "job-1", + status: "PROCESSING", + lockToken: "tok", + attemptCount: 0, + }); + (prisma.birthdayEmailLog.updateMany as jest.Mock).mockResolvedValue({ count: 1 }); + + const req = { + params: { id: "job-1" }, + body: { lockToken: "tok", errorClassification: "QUOTA", errorMessage: "429" }, + } as unknown as Request; + const res = mockResponse(); + + await birthdayCtrl.markJobFailed(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + success: true, + status: "FAILED", + attemptCount: 1, + nextRetryAt: expect.any(Date), + }); + }); + + it("passes a valid sentAt through as a Date", async () => { + (prisma.birthdayEmailLog.findUnique as jest.Mock).mockResolvedValue({ + id: "job-1", + status: "PROCESSING", + lockToken: "tok", + attemptCount: 0, + providerMessageId: null, + }); + (prisma.birthdayEmailLog.updateMany as jest.Mock).mockResolvedValue({ count: 1 }); + + const req = { + params: { id: "job-1" }, + body: { lockToken: "tok", sentAt: "2026-10-21T09:05:00.000Z" }, + } as unknown as Request; + const res = mockResponse(); + + await birthdayCtrl.markJobSent(req, res); + + expect(prisma.birthdayEmailLog.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ sentAt: new Date("2026-10-21T09:05:00.000Z") }), + }) + ); + expect(res.json).toHaveBeenCalledWith({ success: true, message: "Marked as sent" }); + }); + }); + // ────────────────────────────────────────────── // Stats endpoint // ────────────────────────────────────────────── From 0f5bfdf9d045351003aa228a601cfff98e920339 Mon Sep 17 00:00:00 2001 From: Sarvesh Shahane Date: Wed, 21 Oct 2026 21:04:33 +0530 Subject: [PATCH 10/11] fix: accept UNCERTAIN failures and tighten birthday API input - Accept the UNCERTAIN error classification sent by the COC-Members birthday worker (provider timeout after dispatch); retry it like TRANSIENT. The previous commit rejected it with a 400. - Validate providerMessageId as string, null or missing (400 otherwise) - Accept only the x-internal-secret header; the worker already sends it - Match sibling controllers: export const handlers, single service import --- src/controllers/birthdayEmail.controller.ts | 39 ++++++---- src/middleware/internalAuth.ts | 13 +--- src/routes/birthdayEmails.ts | 8 +- src/services/birthdayEmail.service.ts | 6 ++ tests/BirthdayEmail.test.ts | 85 ++++++++++++++++++++- 5 files changed, 120 insertions(+), 31 deletions(-) diff --git a/src/controllers/birthdayEmail.controller.ts b/src/controllers/birthdayEmail.controller.ts index fdbf7b4..7232bda 100644 --- a/src/controllers/birthdayEmail.controller.ts +++ b/src/controllers/birthdayEmail.controller.ts @@ -1,11 +1,10 @@ import { Request, Response } from "express"; import * as birthdayEmailService from "../services/birthdayEmail.service"; -import { - ERROR_CLASSIFICATIONS, - ErrorClassification, -} from "../services/birthdayEmail.service"; import { ApiError } from "../utils/apiError"; +const { ERROR_CLASSIFICATIONS } = birthdayEmailService; +type ErrorClassification = birthdayEmailService.ErrorClassification; + function requireLockToken(body: Record): string { const { lockToken } = body; if (!lockToken || typeof lockToken !== "string") { @@ -23,6 +22,14 @@ function parseSentAt(value: unknown): Date | undefined { return date; } +function parseProviderMessageId(value: unknown): string | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value !== "string") { + throw new ApiError("providerMessageId must be a string", 400); + } + return value; +} + function parseErrorClassification(value: unknown): ErrorClassification { if (value === undefined || value === null) return "TRANSIENT"; if (!ERROR_CLASSIFICATIONS.includes(value as ErrorClassification)) { @@ -37,7 +44,7 @@ function parseErrorClassification(value: unknown): ErrorClassification { /** * POST /api/v1/birthday-emails/initialize */ -export async function initializeJobs(req: Request, res: Response): Promise { +export const initializeJobs = async (req: Request, res: Response) => { const { birthdayYear, memberIds } = req.body || {}; if (typeof birthdayYear !== "number") { @@ -58,12 +65,12 @@ export async function initializeJobs(req: Request, res: Response): Promise message: "Birthday jobs initialized", data: result, }); -} +}; /** * POST /api/v1/birthday-emails/claim */ -export async function claimJobs(req: Request, res: Response): Promise { +export const claimJobs = async (req: Request, res: Response) => { const { batchSize, leaseDurationMs } = req.body || {}; const jobs = await birthdayEmailService.claimBirthdayJobs( @@ -76,20 +83,21 @@ export async function claimJobs(req: Request, res: Response): Promise { count: jobs.length, jobs, }); -} +}; /** * POST /api/v1/birthday-emails/:id/sent */ -export async function markJobSent(req: Request, res: Response): Promise { +export const markJobSent = async (req: Request, res: Response) => { const body = req.body || {}; const lockToken = requireLockToken(body); + const providerMessageId = parseProviderMessageId(body.providerMessageId); const sentAt = parseSentAt(body.sentAt); const { alreadySent } = await birthdayEmailService.recordBirthdayJobSent( req.params.id, lockToken, - body.providerMessageId, + providerMessageId, sentAt ); @@ -97,12 +105,12 @@ export async function markJobSent(req: Request, res: Response): Promise { success: true, message: alreadySent ? "Record was already marked as sent" : "Marked as sent", }); -} +}; /** * POST /api/v1/birthday-emails/:id/failed */ -export async function markJobFailed(req: Request, res: Response): Promise { +export const markJobFailed = async (req: Request, res: Response) => { const body = req.body || {}; const lockToken = requireLockToken(body); const errorClassification = parseErrorClassification(body.errorClassification); @@ -115,19 +123,20 @@ export async function markJobFailed(req: Request, res: Response): Promise typeof body.isRetryable === "boolean" ? body.isRetryable : true ); + // Flat shape is the contract the COC-Members birthday worker is typed against. res.status(200).json({ success: true, ...result, }); -} +}; /** * GET /api/v1/birthday-emails/stats */ -export async function getStats(_req: Request, res: Response): Promise { +export const getStats = async (_req: Request, res: Response) => { const stats = await birthdayEmailService.getBirthdayEmailStats(); res.status(200).json({ success: true, stats, }); -} +}; diff --git a/src/middleware/internalAuth.ts b/src/middleware/internalAuth.ts index ce3e282..c9a3b67 100644 --- a/src/middleware/internalAuth.ts +++ b/src/middleware/internalAuth.ts @@ -9,7 +9,7 @@ function sha256(value: string): Buffer { /** * Service-to-service authentication middleware for internal APIs. - * Compares the incoming secret header against INTERNAL_SERVICE_SECRET in constant time. + * Compares the `x-internal-secret` header against INTERNAL_SERVICE_SECRET in constant time. */ export function requireInternalAuth( req: Request, @@ -25,16 +25,9 @@ export function requireInternalAuth( ); } - const authHeader = req.headers["authorization"]; - let providedSecret = - (req.headers["x-internal-secret"] as string) || - (req.headers["x-service-secret"] as string); + const providedSecret = req.headers["x-internal-secret"]; - if (!providedSecret && authHeader && authHeader.startsWith("Bearer ")) { - providedSecret = authHeader.substring(7).trim(); - } - - if (!providedSecret) { + if (!providedSecret || typeof providedSecret !== "string") { throw new ApiError( "Unauthorized: Missing internal service secret header", 401 diff --git a/src/routes/birthdayEmails.ts b/src/routes/birthdayEmails.ts index cf8d13a..d5887c1 100644 --- a/src/routes/birthdayEmails.ts +++ b/src/routes/birthdayEmails.ts @@ -4,8 +4,7 @@ import { requireInternalAuth } from "../middleware/internalAuth"; /** * Internal, service-to-service API used by the birthday email worker. - * Every route requires the INTERNAL_SERVICE_SECRET, sent as an - * `x-internal-secret` / `x-service-secret` header or as a Bearer token. + * Every route requires the INTERNAL_SERVICE_SECRET in the `x-internal-secret` header. */ export default function birthdayEmailsRouter(): Router { const router = Router(); @@ -80,7 +79,7 @@ export default function birthdayEmailsRouter(): Router { * @apiParam (URL Params) {String} id Job ID. * * @apiBody {String} lockToken Token returned by /claim. (Required) - * @apiBody {String} [providerMessageId] Email provider's message ID. + * @apiBody {String} [providerMessageId] Email provider's message ID (null allowed). * @apiBody {String} [sentAt] ISO timestamp of the send. Defaults to now. * * @apiSuccess {Boolean} success Always true. @@ -104,7 +103,8 @@ export default function birthdayEmailsRouter(): Router { * @apiParam (URL Params) {String} id Job ID. * * @apiBody {String} lockToken Token returned by /claim. (Required) - * @apiBody {String="TRANSIENT","PERMANENT","CONFIG","QUOTA"} [errorClassification=TRANSIENT] Failure type. + * @apiBody {String="TRANSIENT","UNCERTAIN","PERMANENT","CONFIG","QUOTA"} [errorClassification=TRANSIENT] Failure type. + * UNCERTAIN (provider timed out after the request was sent) is retried like TRANSIENT. * @apiBody {String} [errorMessage] Error details. Secrets are redacted before storing. * @apiBody {Boolean} [isRetryable=true] Whether the failure can be retried. * diff --git a/src/services/birthdayEmail.service.ts b/src/services/birthdayEmail.service.ts index f3d5bb9..e4f2c63 100644 --- a/src/services/birthdayEmail.service.ts +++ b/src/services/birthdayEmail.service.ts @@ -21,8 +21,14 @@ export interface JobStats { total: number; } +/** + * UNCERTAIN means the request to the email provider timed out after it was + * sent, so the email may or may not have gone out. It is retried like + * TRANSIENT (the provider's idempotency key stops a duplicate send). + */ export const ERROR_CLASSIFICATIONS = [ "TRANSIENT", + "UNCERTAIN", "PERMANENT", "CONFIG", "QUOTA", diff --git a/tests/BirthdayEmail.test.ts b/tests/BirthdayEmail.test.ts index 7106e2c..6360518 100644 --- a/tests/BirthdayEmail.test.ts +++ b/tests/BirthdayEmail.test.ts @@ -395,7 +395,7 @@ describe("Central Backend: Birthday Email Tracking & Internal APIs", () => { // 10b: Invalid secret (different length from the real one) const reqInvalid = { - headers: { "x-service-secret": "wrong" }, + headers: { "x-internal-secret": "wrong" }, } as unknown as Request; expect(() => requireInternalAuth(reqInvalid, res, next)).toThrow( expect.objectContaining({ statusCode: 401 }) @@ -404,12 +404,27 @@ describe("Central Backend: Birthday Email Tracking & Internal APIs", () => { // 10c: Valid secret const reqValid = { - headers: { "x-service-secret": "super-secret-service-token" }, + headers: { "x-internal-secret": "super-secret-service-token" }, } as unknown as Request; expect(() => requireInternalAuth(reqValid, res, next)).not.toThrow(); expect(next).toHaveBeenCalled(); }); + it("10e. Only the x-internal-secret header is accepted", () => { + const next: NextFunction = jest.fn(); + const secret = "super-secret-service-token"; + for (const headers of [ + { "x-service-secret": secret }, + { authorization: `Bearer ${secret}` }, + ]) { + const req = { headers } as unknown as Request; + expect(() => requireInternalAuth(req, {} as Response, next)).toThrow( + expect.objectContaining({ statusCode: 401 }) + ); + } + expect(next).not.toHaveBeenCalled(); + }); + it("10d. Missing server secret is a 500", () => { config.INTERNAL_SERVICE_SECRET = undefined; const req = { headers: { "x-internal-secret": "anything" } } as unknown as Request; @@ -446,6 +461,72 @@ describe("Central Backend: Birthday Email Tracking & Internal APIs", () => { expect(prisma.birthdayEmailLog.findUnique).not.toHaveBeenCalled(); }); + it("accepts UNCERTAIN and retries it like TRANSIENT", async () => { + (prisma.birthdayEmailLog.findUnique as jest.Mock).mockResolvedValue({ + id: "job-1", + status: "PROCESSING", + lockToken: "tok", + attemptCount: 0, + }); + (prisma.birthdayEmailLog.updateMany as jest.Mock).mockResolvedValue({ count: 1 }); + + const req = { + params: { id: "job-1" }, + body: { + lockToken: "tok", + errorClassification: "UNCERTAIN", + errorMessage: "Network timeout: ETIMEDOUT", + isRetryable: true, + }, + } as unknown as Request; + const res = mockResponse(); + + await birthdayCtrl.markJobFailed(req, res); + + const body = (res.json as jest.Mock).mock.calls[0][0]; + expect(body.status).toBe("FAILED"); + // Attempt 1 backoff is 5 minutes + const diffMs = body.nextRetryAt.getTime() - Date.now(); + expect(diffMs).toBeGreaterThan(4 * 60 * 1000); + expect(diffMs).toBeLessThanOrEqual(5 * 60 * 1000); + }); + + it("accepts a null providerMessageId and keeps the stored one", async () => { + (prisma.birthdayEmailLog.findUnique as jest.Mock).mockResolvedValue({ + id: "job-1", + status: "PROCESSING", + lockToken: "tok", + attemptCount: 0, + providerMessageId: null, + }); + (prisma.birthdayEmailLog.updateMany as jest.Mock).mockResolvedValue({ count: 1 }); + + const req = { + params: { id: "job-1" }, + body: { lockToken: "tok", providerMessageId: null }, + } as unknown as Request; + + await birthdayCtrl.markJobSent(req, mockResponse()); + + expect(prisma.birthdayEmailLog.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: "SENT", providerMessageId: null }), + }) + ); + }); + + it("rejects a non-string providerMessageId with 400", async () => { + const req = { + params: { id: "job-1" }, + body: { lockToken: "tok", providerMessageId: 12345 }, + } as unknown as Request; + + await expect(birthdayCtrl.markJobSent(req, mockResponse())).rejects.toMatchObject({ + statusCode: 400, + }); + expect(prisma.birthdayEmailLog.findUnique).not.toHaveBeenCalled(); + }); + it("rejects a missing lockToken with 400", async () => { const req = { params: { id: "job-1" }, body: {} } as unknown as Request; From f1055fd21374bdd2c6c67aeb39a91b4ea8b33ed0 Mon Sep 17 00:00:00 2001 From: Sarvesh Shahane Date: Wed, 21 Oct 2026 21:11:51 +0530 Subject: [PATCH 11/11] refactor: simplify birthday email service - initialize: rely on the unique (memberId, birthdayYear) index with createMany skipDuplicates instead of pre-loading existing jobs; move the email check into the member query - /sent: write first with the lease-guarded updateMany and only read on a miss (404 / already sent / 409). Saves a query per send and makes overlapping /sent calls return alreadySent instead of 409 - stats: replace the status switch with a lookup map - drop controller checks the service already does - derive ClaimedJob from the Prisma model --- src/controllers/birthdayEmail.controller.ts | 9 +- src/services/birthdayEmail.service.ts | 136 +++++++------------ tests/BirthdayEmail.test.ts | 138 +++++++++++--------- 3 files changed, 124 insertions(+), 159 deletions(-) diff --git a/src/controllers/birthdayEmail.controller.ts b/src/controllers/birthdayEmail.controller.ts index 7232bda..bd5df91 100644 --- a/src/controllers/birthdayEmail.controller.ts +++ b/src/controllers/birthdayEmail.controller.ts @@ -45,16 +45,9 @@ function parseErrorClassification(value: unknown): ErrorClassification { * POST /api/v1/birthday-emails/initialize */ export const initializeJobs = async (req: Request, res: Response) => { + // birthdayYear and memberIds are validated by the service const { birthdayYear, memberIds } = req.body || {}; - if (typeof birthdayYear !== "number") { - throw new ApiError("birthdayYear is required and must be a number", 400); - } - - if (!Array.isArray(memberIds)) { - throw new ApiError("memberIds is required and must be an array of strings", 400); - } - const result = await birthdayEmailService.initializeBirthdayJobs( birthdayYear, memberIds diff --git a/src/services/birthdayEmail.service.ts b/src/services/birthdayEmail.service.ts index e4f2c63..e46bbb9 100644 --- a/src/services/birthdayEmail.service.ts +++ b/src/services/birthdayEmail.service.ts @@ -1,15 +1,12 @@ import prisma from "../db/client"; -import type { Prisma } from "../generated/prisma/client"; +import type { BirthdayEmailLog, Prisma } from "../generated/prisma/client"; import { ApiError } from "../utils/apiError"; import crypto from "crypto"; -export interface ClaimedJob { - id: string; - memberId: string; - birthdayYear: number; - attemptCount: number; - lockToken: string; -} +export type ClaimedJob = Pick< + BirthdayEmailLog, + "id" | "memberId" | "birthdayYear" | "attemptCount" +> & { lockToken: string }; export interface JobStats { pending: number; @@ -96,64 +93,33 @@ export async function initializeBirthdayJobs( throw new ApiError("No valid member IDs provided", 400); } - // Fetch only eligible members: approved, not ghosted, has birth_date, has valid email + // Eligible: approved, not ghosted, has a birth date and an email address const eligibleMembers = await prisma.member.findMany({ where: { id: { in: cleanIds }, isApproved: true, isGhosted: false, birth_date: { not: null }, + email: { contains: "@" }, }, - select: { - id: true, - email: true, - }, + select: { id: true }, }); - const validEligible = eligibleMembers.filter( - (m) => typeof m.email === "string" && m.email.includes("@") - ); - - if (validEligible.length === 0) { + if (eligibleMembers.length === 0) { return { created: 0, existing: 0, totalEligible: 0 }; } - const eligibleIds = validEligible.map((m) => m.id); - - // Check existing logs to preserve state and avoid resetting SENT or attempt counts - const existingLogs = await prisma.birthdayEmailLog.findMany({ - where: { - birthdayYear, - memberId: { in: eligibleIds }, - }, - select: { - memberId: true, - }, + // The unique (memberId, birthdayYear) index makes skipDuplicates leave + // existing jobs untouched, so SENT jobs and attempt counts are never reset. + const { count: created } = await prisma.birthdayEmailLog.createMany({ + data: eligibleMembers.map((m) => ({ memberId: m.id, birthdayYear })), + skipDuplicates: true, }); - const existingMemberIdSet = new Set(existingLogs.map((l) => l.memberId)); - const newMemberIds = eligibleIds.filter((id) => !existingMemberIdSet.has(id)); - - let createdCount = 0; - if (newMemberIds.length > 0) { - const recordsToCreate = newMemberIds.map((mId) => ({ - memberId: mId, - birthdayYear, - status: "PENDING" as const, - attemptCount: 0, - })); - - const result = await prisma.birthdayEmailLog.createMany({ - data: recordsToCreate, - skipDuplicates: true, - }); - createdCount = result.count; - } - return { - created: createdCount, - existing: existingLogs.length, - totalEligible: validEligible.length, + created, + existing: eligibleMembers.length - created, + totalEligible: eligibleMembers.length, }; } @@ -264,19 +230,18 @@ function leaseConflict(): ApiError { * Write a job result only if the caller still holds the lease. * The lease check and the write are one statement, so a worker whose lease * expired cannot overwrite a job that another worker has since claimed. + * Returns false when the caller no longer holds the lease. */ async function updateIfLeaseOwned( id: string, lockToken: string, data: Prisma.BirthdayEmailLogUpdateManyMutationInput -): Promise { +): Promise { const { count } = await prisma.birthdayEmailLog.updateMany({ where: { id, lockToken, status: "PROCESSING" }, data, }); - if (count === 0) { - throw leaseConflict(); - } + return count > 0; } /** @@ -289,28 +254,27 @@ export async function recordBirthdayJobSent( providerMessageId?: string, sentAt: Date = new Date() ): Promise<{ alreadySent: boolean }> { - const record = await findJobOrThrow(id); - - if (record.status === "SENT") { - return { alreadySent: true }; - } - - if (record.status !== "PROCESSING" || record.lockToken !== lockToken) { - throw leaseConflict(); - } - - await updateIfLeaseOwned(id, lockToken, { + const updated = await updateIfLeaseOwned(id, lockToken, { status: "SENT", - providerMessageId: providerMessageId ?? record.providerMessageId, + providerMessageId, // undefined leaves any stored ID unchanged sentAt, lockToken: null, lockedUntil: null, nextRetryAt: null, - attemptCount: record.attemptCount + 1, + attemptCount: { increment: 1 }, lastError: null, }); - return { alreadySent: false }; + if (updated) { + return { alreadySent: false }; + } + + // Nothing written: work out why. + const record = await findJobOrThrow(id); + if (record.status === "SENT") { + return { alreadySent: true }; + } + throw leaseConflict(); } /** @@ -350,7 +314,7 @@ export async function recordBirthdayJobFailed( nextRetryAt = new Date(Date.now() + delayMs); } - await updateIfLeaseOwned(id, lockToken, { + const updated = await updateIfLeaseOwned(id, lockToken, { status, attemptCount, nextRetryAt, @@ -358,10 +322,24 @@ export async function recordBirthdayJobFailed( lockToken: null, lockedUntil: null, }); + if (!updated) { + throw leaseConflict(); + } return { status, attemptCount, nextRetryAt }; } +const STATS_KEY_BY_STATUS: Record< + BirthdayEmailLog["status"], + "pending" | "processing" | "sent" | "failed" | "dead" +> = { + PENDING: "pending", + PROCESSING: "processing", + SENT: "sent", + FAILED: "failed", + DEAD: "dead", +}; + /** * Fetch tracking statistics without exposing member emails. */ @@ -383,24 +361,8 @@ export async function getBirthdayEmailStats(): Promise { for (const group of countsByStatus) { const count = group._count.id; + stats[STATS_KEY_BY_STATUS[group.status]] = count; stats.total += count; - switch (group.status) { - case "PENDING": - stats.pending = count; - break; - case "PROCESSING": - stats.processing = count; - break; - case "SENT": - stats.sent = count; - break; - case "FAILED": - stats.failed = count; - break; - case "DEAD": - stats.dead = count; - break; - } } const dueForRetry = await prisma.birthdayEmailLog.count({ diff --git a/tests/BirthdayEmail.test.ts b/tests/BirthdayEmail.test.ts index 6360518..8b0aa13 100644 --- a/tests/BirthdayEmail.test.ts +++ b/tests/BirthdayEmail.test.ts @@ -70,50 +70,67 @@ describe("Central Backend: Birthday Email Tracking & Internal APIs", () => { // ────────────────────────────────────────────── it("1. Initializing the same member/year job twice creates one record", async () => { const memberId = "member-123"; - (prisma.member.findMany as jest.Mock).mockResolvedValue([ - { id: memberId, email: "alice@example.com" }, - ]); + (prisma.member.findMany as jest.Mock).mockResolvedValue([{ id: memberId }]); - // First initialization: no existing log - (prisma.birthdayEmailLog.findMany as jest.Mock).mockResolvedValueOnce([]); - (prisma.birthdayEmailLog.createMany as jest.Mock).mockResolvedValueOnce({ count: 1 }); + // First run inserts; second run hits the unique index and is skipped + (prisma.birthdayEmailLog.createMany as jest.Mock) + .mockResolvedValueOnce({ count: 1 }) + .mockResolvedValueOnce({ count: 0 }); const firstRun = await birthdayService.initializeBirthdayJobs(2026, [memberId]); - expect(firstRun.created).toBe(1); - expect(firstRun.existing).toBe(0); - - // Second initialization: existing log found - (prisma.birthdayEmailLog.findMany as jest.Mock).mockResolvedValueOnce([ - { memberId, status: "PENDING" }, - ]); + expect(firstRun).toEqual({ created: 1, existing: 0, totalEligible: 1 }); const secondRun = await birthdayService.initializeBirthdayJobs(2026, [memberId]); - expect(secondRun.created).toBe(0); - expect(secondRun.existing).toBe(1); - expect(prisma.birthdayEmailLog.createMany).toHaveBeenCalledTimes(1); + expect(secondRun).toEqual({ created: 0, existing: 1, totalEligible: 1 }); + + expect(prisma.birthdayEmailLog.createMany).toHaveBeenCalledWith({ + data: [{ memberId, birthdayYear: 2026 }], + skipDuplicates: true, + }); }); // ────────────────────────────────────────────── - // 2. Existing SENT records are not reset + // 2. Existing records are never reset, and only eligible members get jobs // ────────────────────────────────────────────── - it("2. Existing SENT records are not reset during initialization", async () => { - const memberId = "member-sent"; - (prisma.member.findMany as jest.Mock).mockResolvedValue([ - { id: memberId, email: "sent@example.com" }, - ]); + it("2. Existing records are not reset during initialization", async () => { + (prisma.member.findMany as jest.Mock).mockResolvedValue([{ id: "member-sent" }]); + (prisma.birthdayEmailLog.createMany as jest.Mock).mockResolvedValue({ count: 0 }); - // Existing record is SENT - (prisma.birthdayEmailLog.findMany as jest.Mock).mockResolvedValueOnce([ - { memberId, status: "SENT", attemptCount: 1 }, - ]); - - const res = await birthdayService.initializeBirthdayJobs(2026, [memberId]); + const res = await birthdayService.initializeBirthdayJobs(2026, ["member-sent"]); expect(res.created).toBe(0); expect(res.existing).toBe(1); + // Only inserts with skipDuplicates; never updates existing rows expect(prisma.birthdayEmailLog.update).not.toHaveBeenCalled(); + expect(prisma.birthdayEmailLog.updateMany).not.toHaveBeenCalled(); + }); + + it("2b. Eligibility filter is applied in the member query", async () => { + (prisma.member.findMany as jest.Mock).mockResolvedValue([]); + + const res = await birthdayService.initializeBirthdayJobs(2026, ["m1", "m1", " "]); + expect(res).toEqual({ created: 0, existing: 0, totalEligible: 0 }); + expect(prisma.member.findMany).toHaveBeenCalledWith({ + where: { + id: { in: ["m1"] }, + isApproved: true, + isGhosted: false, + birth_date: { not: null }, + email: { contains: "@" }, + }, + select: { id: true }, + }); expect(prisma.birthdayEmailLog.createMany).not.toHaveBeenCalled(); }); + it("2c. Invalid initialize input is rejected with 400", async () => { + await expect( + birthdayService.initializeBirthdayJobs("2026" as unknown as number, ["m1"]) + ).rejects.toMatchObject({ statusCode: 400 }); + await expect( + birthdayService.initializeBirthdayJobs(2026, undefined as unknown as string[]) + ).rejects.toMatchObject({ statusCode: 400 }); + }); + // ────────────────────────────────────────────── // 3. Two concurrent workers cannot claim the same active job // ────────────────────────────────────────────── @@ -194,18 +211,25 @@ describe("Central Backend: Birthday Email Tracking & Internal APIs", () => { // 5. A stale lock token cannot overwrite a newer worker's result // ────────────────────────────────────────────── it("5. A stale lock token cannot overwrite a newer worker's result (409 conflict)", async () => { - // Record in DB currently has 'newer-lock-token' + // Record in DB currently has 'newer-lock-token', so the guarded write matches nothing (prisma.birthdayEmailLog.findUnique as jest.Mock).mockResolvedValue({ id: "job-conflict", status: "PROCESSING", lockToken: "newer-lock-token", attemptCount: 2, }); + (prisma.birthdayEmailLog.updateMany as jest.Mock).mockResolvedValue({ count: 0 }); await expect( birthdayService.recordBirthdayJobSent("job-conflict", "stale-old-token", "msg-123") ).rejects.toMatchObject({ statusCode: 409 }); + expect(prisma.birthdayEmailLog.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "job-conflict", lockToken: "stale-old-token", status: "PROCESSING" }, + }) + ); + (prisma.birthdayEmailLog.updateMany as jest.Mock).mockClear(); await expect( birthdayService.recordBirthdayJobFailed( "job-conflict", @@ -215,11 +239,11 @@ describe("Central Backend: Birthday Email Tracking & Internal APIs", () => { true ) ).rejects.toMatchObject({ statusCode: 409 }); - + // /failed rejects on its pre-read, before writing expect(prisma.birthdayEmailLog.updateMany).not.toHaveBeenCalled(); }); - it("5b. Losing the lease between the read and the write is rejected (409 conflict)", async () => { + it("5b. /failed losing the lease between the read and the write is rejected (409 conflict)", async () => { // The read still shows our lease... (prisma.birthdayEmailLog.findUnique as jest.Mock).mockResolvedValue({ id: "job-race", @@ -230,32 +254,24 @@ describe("Central Backend: Birthday Email Tracking & Internal APIs", () => { // ...but another worker reclaimed it before our write landed. (prisma.birthdayEmailLog.updateMany as jest.Mock).mockResolvedValue({ count: 0 }); - await expect( - birthdayService.recordBirthdayJobSent("job-race", "my-token", "msg-1") - ).rejects.toMatchObject({ statusCode: 409 }); - await expect( birthdayService.recordBirthdayJobFailed("job-race", "my-token", "TRANSIENT", "boom", true) ).rejects.toMatchObject({ statusCode: 409 }); + }); - expect(prisma.birthdayEmailLog.updateMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: { id: "job-race", lockToken: "my-token", status: "PROCESSING" }, - }) - ); + it("5c. /sent for an unknown job is a 404", async () => { + (prisma.birthdayEmailLog.updateMany as jest.Mock).mockResolvedValue({ count: 0 }); + (prisma.birthdayEmailLog.findUnique as jest.Mock).mockResolvedValue(null); + + await expect( + birthdayService.recordBirthdayJobSent("missing", "tok") + ).rejects.toMatchObject({ statusCode: 404 }); }); // ────────────────────────────────────────────── // 6. Successful sends persist provider IDs and timestamps // ────────────────────────────────────────────── - it("6. Successful sends persist provider IDs and timestamps", async () => { - (prisma.birthdayEmailLog.findUnique as jest.Mock).mockResolvedValue({ - id: "job-success", - status: "PROCESSING", - lockToken: "valid-token-abc", - attemptCount: 0, - providerMessageId: null, - }); + it("6. Successful sends persist provider IDs and timestamps in one write", async () => { (prisma.birthdayEmailLog.updateMany as jest.Mock).mockResolvedValue({ count: 1 }); const sentAt = new Date("2026-10-21T09:05:00.000Z"); @@ -275,12 +291,15 @@ describe("Central Backend: Birthday Email Tracking & Internal APIs", () => { sentAt, lockToken: null, lockedUntil: null, - attemptCount: 1, + attemptCount: { increment: 1 }, }), }); + expect(prisma.birthdayEmailLog.findUnique).not.toHaveBeenCalled(); }); - it("6b. Reporting an already SENT job is a no-op", async () => { + it("6b. Reporting an already SENT job (including overlapping calls) returns alreadySent", async () => { + // The guarded write matches nothing because the job is no longer PROCESSING + (prisma.birthdayEmailLog.updateMany as jest.Mock).mockResolvedValue({ count: 0 }); (prisma.birthdayEmailLog.findUnique as jest.Mock).mockResolvedValue({ id: "job-done", status: "SENT", @@ -290,7 +309,6 @@ describe("Central Backend: Birthday Email Tracking & Internal APIs", () => { const res = await birthdayService.recordBirthdayJobSent("job-done", "any-token"); expect(res.alreadySent).toBe(true); - expect(prisma.birthdayEmailLog.updateMany).not.toHaveBeenCalled(); }); // ────────────────────────────────────────────── @@ -491,14 +509,7 @@ describe("Central Backend: Birthday Email Tracking & Internal APIs", () => { expect(diffMs).toBeLessThanOrEqual(5 * 60 * 1000); }); - it("accepts a null providerMessageId and keeps the stored one", async () => { - (prisma.birthdayEmailLog.findUnique as jest.Mock).mockResolvedValue({ - id: "job-1", - status: "PROCESSING", - lockToken: "tok", - attemptCount: 0, - providerMessageId: null, - }); + it("accepts a null providerMessageId and leaves the stored one unchanged", async () => { (prisma.birthdayEmailLog.updateMany as jest.Mock).mockResolvedValue({ count: 1 }); const req = { @@ -508,11 +519,10 @@ describe("Central Backend: Birthday Email Tracking & Internal APIs", () => { await birthdayCtrl.markJobSent(req, mockResponse()); - expect(prisma.birthdayEmailLog.updateMany).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ status: "SENT", providerMessageId: null }), - }) - ); + const { data } = (prisma.birthdayEmailLog.updateMany as jest.Mock).mock.calls[0][0]; + expect(data.status).toBe("SENT"); + // undefined tells Prisma to leave the column as is + expect(data.providerMessageId).toBeUndefined(); }); it("rejects a non-string providerMessageId with 400", async () => {