Add ticketed events, Jitsi meeting integration, social features, and calendar system

- Ticketed events: full CRUD, ticket tiers (free/paid/donation), Stripe checkout,
  QR-based check-in scanner, public event pages, ticket confirmation emails
- Event formats: IN_PERSON/ONLINE/HYBRID with auto Jitsi meeting room lifecycle,
  ticket-gated meeting access, moderator JWT tokens, feature-flag guarded
- Social engagement: challenges with scoring/leaderboards, referral tracking,
  volunteer spotlight, impact stories, campaign celebrations, wall of fame
- Social calendar: personal calendar layers, shared calendar items with
  recurrence, scheduling polls, mobile day view
- MCP server: events tool pack with full admin CRUD + meeting token generation
- Unified calendar: eventFormat-aware tags, online event indicators
- Updated docs site, pangolin configs, and various admin UI improvements

Bunker Admin
This commit is contained in:
2026-03-06 14:33:33 -07:00
parent 62fc116c06
commit 08d8066157
215 changed files with 23128 additions and 1968 deletions

View File

@@ -0,0 +1,249 @@
-- CreateEnum
CREATE TYPE "ImpactStoryType" AS ENUM ('MILESTONE', 'VICTORY', 'RESPONSE', 'CUSTOM');
-- CreateEnum
CREATE TYPE "ImpactStoryStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'ARCHIVED');
-- CreateEnum
CREATE TYPE "SpotlightStatus" AS ENUM ('NOMINATED', 'APPROVED', 'FEATURED', 'ARCHIVED');
-- CreateEnum
CREATE TYPE "ChallengeStatus" AS ENUM ('DRAFT', 'UPCOMING', 'ACTIVE', 'COMPLETED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "ChallengeMetric" AS ENUM ('DOORS_KNOCKED', 'EMAILS_SENT', 'SHIFTS_ATTENDED', 'RESPONSES_SUBMITTED', 'REFERRALS_MADE');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "NotificationType" ADD VALUE 'impact_story';
ALTER TYPE "NotificationType" ADD VALUE 'referral_completed';
ALTER TYPE "NotificationType" ADD VALUE 'challenge_update';
-- AlterTable
ALTER TABLE "privacy_settings" ADD COLUMN "show_on_leaderboard" BOOLEAN DEFAULT true;
-- CreateTable
CREATE TABLE "invite_codes" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"created_by_user_id" TEXT NOT NULL,
"max_uses" INTEGER NOT NULL DEFAULT 0,
"used_count" INTEGER NOT NULL DEFAULT 0,
"expires_at" TIMESTAMP(3),
"is_active" BOOLEAN NOT NULL DEFAULT true,
"note" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "invite_codes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "referrals" (
"id" SERIAL NOT NULL,
"referrer_id" TEXT NOT NULL,
"referred_user_id" TEXT NOT NULL,
"invite_code_id" TEXT,
"referral_source" TEXT,
"completed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "referrals_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "impact_stories" (
"id" TEXT NOT NULL,
"campaign_id" TEXT NOT NULL,
"type" "ImpactStoryType" NOT NULL,
"status" "ImpactStoryStatus" NOT NULL DEFAULT 'DRAFT',
"title" TEXT NOT NULL,
"body" TEXT NOT NULL,
"cover_image_url" TEXT,
"milestone_value" INTEGER,
"milestone_metric" TEXT,
"created_by_user_id" TEXT,
"published_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "impact_stories_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "campaign_milestones" (
"id" SERIAL NOT NULL,
"campaign_id" TEXT NOT NULL,
"metric" TEXT NOT NULL,
"threshold" INTEGER NOT NULL,
"reached_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"story_generated" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "campaign_milestones_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "volunteer_spotlights" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"status" "SpotlightStatus" NOT NULL DEFAULT 'NOMINATED',
"headline" TEXT,
"story" TEXT,
"featured_month" TEXT,
"nominated_by_user_id" TEXT,
"approved_by_user_id" TEXT,
"approved_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "volunteer_spotlights_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "challenges" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"metric" "ChallengeMetric" NOT NULL,
"status" "ChallengeStatus" NOT NULL DEFAULT 'DRAFT',
"starts_at" TIMESTAMP(3) NOT NULL,
"ends_at" TIMESTAMP(3) NOT NULL,
"min_team_size" INTEGER NOT NULL DEFAULT 2,
"max_team_size" INTEGER NOT NULL DEFAULT 10,
"max_teams" INTEGER,
"created_by_user_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "challenges_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "challenge_teams" (
"id" TEXT NOT NULL,
"challenge_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"captain_user_id" TEXT NOT NULL,
"score" INTEGER NOT NULL DEFAULT 0,
"last_scored_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "challenge_teams_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "challenge_team_members" (
"id" SERIAL NOT NULL,
"team_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"score" INTEGER NOT NULL DEFAULT 0,
"joined_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "challenge_team_members_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "invite_codes_code_key" ON "invite_codes"("code");
-- CreateIndex
CREATE INDEX "idx_invite_codes_code" ON "invite_codes"("code");
-- CreateIndex
CREATE INDEX "idx_invite_codes_created_by" ON "invite_codes"("created_by_user_id");
-- CreateIndex
CREATE UNIQUE INDEX "referrals_referred_user_id_key" ON "referrals"("referred_user_id");
-- CreateIndex
CREATE INDEX "idx_referrals_referrer" ON "referrals"("referrer_id");
-- CreateIndex
CREATE INDEX "idx_impact_stories_campaign" ON "impact_stories"("campaign_id");
-- CreateIndex
CREATE INDEX "idx_impact_stories_status" ON "impact_stories"("status");
-- CreateIndex
CREATE INDEX "idx_impact_stories_type" ON "impact_stories"("type");
-- CreateIndex
CREATE UNIQUE INDEX "campaign_milestones_campaign_id_metric_threshold_key" ON "campaign_milestones"("campaign_id", "metric", "threshold");
-- CreateIndex
CREATE INDEX "idx_volunteer_spotlights_user" ON "volunteer_spotlights"("user_id");
-- CreateIndex
CREATE INDEX "idx_volunteer_spotlights_status" ON "volunteer_spotlights"("status");
-- CreateIndex
CREATE INDEX "idx_volunteer_spotlights_month" ON "volunteer_spotlights"("featured_month");
-- CreateIndex
CREATE INDEX "idx_challenges_status" ON "challenges"("status");
-- CreateIndex
CREATE INDEX "idx_challenges_starts_at" ON "challenges"("starts_at");
-- CreateIndex
CREATE INDEX "idx_challenge_teams_challenge" ON "challenge_teams"("challenge_id");
-- CreateIndex
CREATE INDEX "idx_challenge_teams_score" ON "challenge_teams"("score");
-- CreateIndex
CREATE UNIQUE INDEX "challenge_teams_challenge_id_name_key" ON "challenge_teams"("challenge_id", "name");
-- CreateIndex
CREATE INDEX "idx_challenge_team_members_user" ON "challenge_team_members"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "challenge_team_members_team_id_user_id_key" ON "challenge_team_members"("team_id", "user_id");
-- AddForeignKey
ALTER TABLE "invite_codes" ADD CONSTRAINT "invite_codes_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "referrals" ADD CONSTRAINT "referrals_referrer_id_fkey" FOREIGN KEY ("referrer_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "referrals" ADD CONSTRAINT "referrals_referred_user_id_fkey" FOREIGN KEY ("referred_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "referrals" ADD CONSTRAINT "referrals_invite_code_id_fkey" FOREIGN KEY ("invite_code_id") REFERENCES "invite_codes"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "impact_stories" ADD CONSTRAINT "impact_stories_campaign_id_fkey" FOREIGN KEY ("campaign_id") REFERENCES "campaigns"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "impact_stories" ADD CONSTRAINT "impact_stories_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "campaign_milestones" ADD CONSTRAINT "campaign_milestones_campaign_id_fkey" FOREIGN KEY ("campaign_id") REFERENCES "campaigns"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "volunteer_spotlights" ADD CONSTRAINT "volunteer_spotlights_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "volunteer_spotlights" ADD CONSTRAINT "volunteer_spotlights_nominated_by_user_id_fkey" FOREIGN KEY ("nominated_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "volunteer_spotlights" ADD CONSTRAINT "volunteer_spotlights_approved_by_user_id_fkey" FOREIGN KEY ("approved_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "challenges" ADD CONSTRAINT "challenges_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "challenge_teams" ADD CONSTRAINT "challenge_teams_challenge_id_fkey" FOREIGN KEY ("challenge_id") REFERENCES "challenges"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "challenge_teams" ADD CONSTRAINT "challenge_teams_captain_user_id_fkey" FOREIGN KEY ("captain_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "challenge_team_members" ADD CONSTRAINT "challenge_team_members_team_id_fkey" FOREIGN KEY ("team_id") REFERENCES "challenge_teams"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "challenge_team_members" ADD CONSTRAINT "challenge_team_members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,176 @@
-- CreateEnum
CREATE TYPE "TicketedEventStatus" AS ENUM ('DRAFT', 'PENDING_APPROVAL', 'PUBLISHED', 'CANCELLED', 'COMPLETED');
-- CreateEnum
CREATE TYPE "TicketedEventVisibility" AS ENUM ('PUBLIC', 'UNLISTED', 'PRIVATE');
-- CreateEnum
CREATE TYPE "TicketTierType" AS ENUM ('PAID', 'FREE', 'DONATION');
-- CreateEnum
CREATE TYPE "TicketStatus" AS ENUM ('VALID', 'CHECKED_IN', 'CANCELLED', 'REFUNDED');
-- AlterTable
ALTER TABLE "site_settings" ADD COLUMN "enable_ticketed_events" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "require_event_approval" BOOLEAN NOT NULL DEFAULT true;
-- CreateTable
CREATE TABLE "ticketed_events" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"rich_description" TEXT,
"date" DATE NOT NULL,
"start_time" TEXT NOT NULL,
"end_time" TEXT NOT NULL,
"doors_open_time" TEXT,
"venue_name" TEXT,
"venue_address" TEXT,
"latitude" DECIMAL(10,7),
"longitude" DECIMAL(10,7),
"status" "TicketedEventStatus" NOT NULL DEFAULT 'DRAFT',
"visibility" "TicketedEventVisibility" NOT NULL DEFAULT 'PUBLIC',
"invite_code" TEXT,
"cover_image_url" TEXT,
"max_attendees" INTEGER,
"current_attendees" INTEGER NOT NULL DEFAULT 0,
"gancio_event_id" INTEGER,
"created_by_user_id" TEXT NOT NULL,
"organizer_name" TEXT,
"organizer_email" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ticketed_events_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ticket_tiers" (
"id" TEXT NOT NULL,
"event_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"tier_type" "TicketTierType" NOT NULL,
"price_cad" INTEGER NOT NULL DEFAULT 0,
"min_donation_cad" INTEGER,
"max_quantity" INTEGER,
"sold_count" INTEGER NOT NULL DEFAULT 0,
"max_per_order" INTEGER NOT NULL DEFAULT 10,
"sales_start_at" TIMESTAMP(3),
"sales_end_at" TIMESTAMP(3),
"sort_order" INTEGER NOT NULL DEFAULT 0,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ticket_tiers_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tickets" (
"id" TEXT NOT NULL,
"ticket_code" TEXT NOT NULL,
"token_hash" TEXT NOT NULL,
"event_id" TEXT NOT NULL,
"tier_id" TEXT NOT NULL,
"order_id" TEXT,
"holder_email" TEXT NOT NULL,
"holder_name" TEXT,
"user_id" TEXT,
"status" "TicketStatus" NOT NULL DEFAULT 'VALID',
"checked_in_at" TIMESTAMP(3),
"checked_in_by_user_id" TEXT,
"issued_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "tickets_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "check_ins" (
"id" TEXT NOT NULL,
"ticket_id" TEXT NOT NULL,
"event_id" TEXT NOT NULL,
"checked_in_by_user_id" TEXT,
"method" TEXT NOT NULL,
"checked_in_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"notes" TEXT,
CONSTRAINT "check_ins_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ticketed_events_slug_key" ON "ticketed_events"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "ticketed_events_invite_code_key" ON "ticketed_events"("invite_code");
-- CreateIndex
CREATE INDEX "idx_ticketed_events_status" ON "ticketed_events"("status");
-- CreateIndex
CREATE INDEX "idx_ticketed_events_date" ON "ticketed_events"("date");
-- CreateIndex
CREATE INDEX "idx_ticketed_events_visibility" ON "ticketed_events"("visibility");
-- CreateIndex
CREATE INDEX "idx_ticketed_events_creator" ON "ticketed_events"("created_by_user_id");
-- CreateIndex
CREATE INDEX "idx_ticket_tiers_event" ON "ticket_tiers"("event_id");
-- CreateIndex
CREATE UNIQUE INDEX "tickets_ticket_code_key" ON "tickets"("ticket_code");
-- CreateIndex
CREATE UNIQUE INDEX "tickets_token_hash_key" ON "tickets"("token_hash");
-- CreateIndex
CREATE INDEX "idx_tickets_event" ON "tickets"("event_id");
-- CreateIndex
CREATE INDEX "idx_tickets_tier" ON "tickets"("tier_id");
-- CreateIndex
CREATE INDEX "idx_tickets_order" ON "tickets"("order_id");
-- CreateIndex
CREATE INDEX "idx_tickets_holder_email" ON "tickets"("holder_email");
-- CreateIndex
CREATE INDEX "idx_tickets_status" ON "tickets"("status");
-- CreateIndex
CREATE INDEX "idx_checkins_event" ON "check_ins"("event_id");
-- CreateIndex
CREATE INDEX "idx_checkins_ticket" ON "check_ins"("ticket_id");
-- AddForeignKey
ALTER TABLE "ticketed_events" ADD CONSTRAINT "ticketed_events_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ticket_tiers" ADD CONSTRAINT "ticket_tiers_event_id_fkey" FOREIGN KEY ("event_id") REFERENCES "ticketed_events"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_event_id_fkey" FOREIGN KEY ("event_id") REFERENCES "ticketed_events"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_tier_id_fkey" FOREIGN KEY ("tier_id") REFERENCES "ticket_tiers"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "check_ins" ADD CONSTRAINT "check_ins_ticket_id_fkey" FOREIGN KEY ("ticket_id") REFERENCES "tickets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "check_ins" ADD CONSTRAINT "check_ins_event_id_fkey" FOREIGN KEY ("event_id") REFERENCES "ticketed_events"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "check_ins" ADD CONSTRAINT "check_ins_checked_in_by_user_id_fkey" FOREIGN KEY ("checked_in_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,12 @@
-- CreateEnum
CREATE TYPE "EventFormat" AS ENUM ('IN_PERSON', 'ONLINE', 'HYBRID');
-- AlterTable
ALTER TABLE "ticketed_events" ADD COLUMN "event_format" "EventFormat" NOT NULL DEFAULT 'IN_PERSON',
ADD COLUMN "meeting_id" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "ticketed_events_meeting_id_key" ON "ticketed_events"("meeting_id");
-- AddForeignKey
ALTER TABLE "ticketed_events" ADD CONSTRAINT "ticketed_events_meeting_id_fkey" FOREIGN KEY ("meeting_id") REFERENCES "meetings"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,258 @@
-- CreateEnum
CREATE TYPE "CalendarLayerType" AS ENUM ('SYSTEM', 'USER', 'EXTERNAL');
-- CreateEnum
CREATE TYPE "CalendarSystemType" AS ENUM ('SHIFTS', 'TICKETS', 'POLLS', 'PUBLIC_EVENTS');
-- CreateEnum
CREATE TYPE "CalendarVisibility" AS ENUM ('PRIVATE', 'FRIENDS', 'PUBLIC');
-- CreateEnum
CREATE TYPE "CalendarItemType" AS ENUM ('EVENT', 'TIME_BLOCK', 'REMINDER');
-- CreateEnum
CREATE TYPE "CalendarBusyStatus" AS ENUM ('BUSY', 'TENTATIVE', 'FREE');
-- CreateEnum
CREATE TYPE "CalendarShowDetailsTo" AS ENUM ('NOBODY', 'FRIENDS', 'EVERYONE');
-- CreateEnum
CREATE TYPE "CalendarItemSource" AS ENUM ('MANUAL', 'ICS_FEED');
-- CreateEnum
CREATE TYPE "CalendarRecurrenceFrequency" AS ENUM ('DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY');
-- CreateEnum
CREATE TYPE "CalendarFeedStatus" AS ENUM ('OK', 'ERROR', 'PENDING');
-- CreateEnum
CREATE TYPE "CalendarFeedInterval" AS ENUM ('FIFTEEN_MIN', 'HOURLY', 'SIX_HOUR', 'DAILY');
-- CreateEnum
CREATE TYPE "SharedViewType" AS ENUM ('MANUAL', 'ROLE_BASED');
-- CreateEnum
CREATE TYPE "SharedViewScope" AS ENUM ('MEMBERS', 'PUBLIC');
-- CreateEnum
CREATE TYPE "SharedViewMemberStatus" AS ENUM ('INVITED', 'ACCEPTED', 'DECLINED');
-- AlterTable
ALTER TABLE "site_settings" ADD COLUMN "enable_social_calendar" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "calendar_layers" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"layer_type" "CalendarLayerType" NOT NULL,
"system_type" "CalendarSystemType",
"color" TEXT NOT NULL DEFAULT '#1890ff',
"visibility" "CalendarVisibility" NOT NULL DEFAULT 'PRIVATE',
"is_enabled" BOOLEAN NOT NULL DEFAULT true,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "calendar_layers_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "calendar_items" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"layer_id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"date" DATE NOT NULL,
"start_time" TEXT NOT NULL,
"end_time" TEXT NOT NULL,
"is_all_day" BOOLEAN NOT NULL DEFAULT false,
"item_type" "CalendarItemType" NOT NULL DEFAULT 'EVENT',
"location" TEXT,
"color" TEXT,
"visibility" "CalendarVisibility",
"busy_status" "CalendarBusyStatus" NOT NULL DEFAULT 'BUSY',
"show_details_to" "CalendarShowDetailsTo" NOT NULL DEFAULT 'FRIENDS',
"recurrence_rule" JSONB,
"recurrence_end" TIMESTAMP(3),
"series_id" TEXT,
"is_exception" BOOLEAN NOT NULL DEFAULT false,
"source_type" "CalendarItemSource" NOT NULL DEFAULT 'MANUAL',
"source_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "calendar_items_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "calendar_feeds" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"url" TEXT NOT NULL,
"layer_id" TEXT NOT NULL,
"refresh_interval" "CalendarFeedInterval" NOT NULL DEFAULT 'HOURLY',
"last_fetched_at" TIMESTAMP(3),
"last_status" "CalendarFeedStatus" NOT NULL DEFAULT 'PENDING',
"last_error" TEXT,
"item_count" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "calendar_feeds_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "shared_calendar_views" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"owner_id" TEXT NOT NULL,
"view_type" "SharedViewType" NOT NULL DEFAULT 'MANUAL',
"auto_include_roles" JSONB,
"included_layer_types" JSONB NOT NULL DEFAULT '[]',
"share_scope" "SharedViewScope" NOT NULL DEFAULT 'MEMBERS',
"share_token" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "shared_calendar_views_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "shared_calendar_members" (
"id" TEXT NOT NULL,
"view_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"status" "SharedViewMemberStatus" NOT NULL DEFAULT 'INVITED',
"color" TEXT NOT NULL DEFAULT '#1890ff',
"joined_at" TIMESTAMP(3),
CONSTRAINT "shared_calendar_members_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "shared_view_comments" (
"id" TEXT NOT NULL,
"view_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"item_date" TEXT NOT NULL,
"item_id" TEXT,
"content" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "shared_view_comments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "shared_view_reactions" (
"id" TEXT NOT NULL,
"view_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"item_id" TEXT NOT NULL,
"emoji" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "shared_view_reactions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "calendar_export_tokens" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"include_personal" BOOLEAN NOT NULL DEFAULT false,
"include_layers" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "calendar_export_tokens_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "idx_calendar_layers_user" ON "calendar_layers"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "idx_calendar_layers_user_system" ON "calendar_layers"("user_id", "system_type");
-- CreateIndex
CREATE INDEX "idx_calendar_items_user_date" ON "calendar_items"("user_id", "date");
-- CreateIndex
CREATE INDEX "idx_calendar_items_layer_date" ON "calendar_items"("layer_id", "date");
-- CreateIndex
CREATE INDEX "idx_calendar_items_series" ON "calendar_items"("series_id");
-- CreateIndex
CREATE INDEX "idx_calendar_items_source" ON "calendar_items"("source_type", "source_id");
-- CreateIndex
CREATE UNIQUE INDEX "calendar_feeds_layer_id_key" ON "calendar_feeds"("layer_id");
-- CreateIndex
CREATE INDEX "idx_calendar_feeds_user" ON "calendar_feeds"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "shared_calendar_views_share_token_key" ON "shared_calendar_views"("share_token");
-- CreateIndex
CREATE INDEX "idx_shared_views_owner" ON "shared_calendar_views"("owner_id");
-- CreateIndex
CREATE INDEX "idx_shared_members_user" ON "shared_calendar_members"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "idx_shared_members_view_user" ON "shared_calendar_members"("view_id", "user_id");
-- CreateIndex
CREATE INDEX "idx_shared_comments_view_date" ON "shared_view_comments"("view_id", "item_date");
-- CreateIndex
CREATE UNIQUE INDEX "idx_shared_reactions_unique" ON "shared_view_reactions"("view_id", "user_id", "item_id", "emoji");
-- CreateIndex
CREATE UNIQUE INDEX "calendar_export_tokens_token_key" ON "calendar_export_tokens"("token");
-- CreateIndex
CREATE INDEX "idx_calendar_export_tokens_user" ON "calendar_export_tokens"("user_id");
-- AddForeignKey
ALTER TABLE "calendar_layers" ADD CONSTRAINT "calendar_layers_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "calendar_items" ADD CONSTRAINT "calendar_items_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "calendar_items" ADD CONSTRAINT "calendar_items_layer_id_fkey" FOREIGN KEY ("layer_id") REFERENCES "calendar_layers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "calendar_feeds" ADD CONSTRAINT "calendar_feeds_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "calendar_feeds" ADD CONSTRAINT "calendar_feeds_layer_id_fkey" FOREIGN KEY ("layer_id") REFERENCES "calendar_layers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "shared_calendar_views" ADD CONSTRAINT "shared_calendar_views_owner_id_fkey" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "shared_calendar_members" ADD CONSTRAINT "shared_calendar_members_view_id_fkey" FOREIGN KEY ("view_id") REFERENCES "shared_calendar_views"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "shared_calendar_members" ADD CONSTRAINT "shared_calendar_members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "shared_view_comments" ADD CONSTRAINT "shared_view_comments_view_id_fkey" FOREIGN KEY ("view_id") REFERENCES "shared_calendar_views"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "shared_view_comments" ADD CONSTRAINT "shared_view_comments_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "shared_view_reactions" ADD CONSTRAINT "shared_view_reactions_view_id_fkey" FOREIGN KEY ("view_id") REFERENCES "shared_calendar_views"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "shared_view_reactions" ADD CONSTRAINT "shared_view_reactions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "calendar_export_tokens" ADD CONSTRAINT "calendar_export_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,11 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "NotificationType" ADD VALUE 'shared_view_invite';
ALTER TYPE "NotificationType" ADD VALUE 'shared_view_accepted';
ALTER TYPE "NotificationType" ADD VALUE 'calendar_event_invite';

View File

@@ -160,6 +160,39 @@ model User {
schedulingPollVotes SchedulingPollVote[] @relation("PollVoter")
schedulingPollComments SchedulingPollComment[] @relation("PollCommenter")
// Referral system
inviteCodesCreated InviteCode[] @relation("InviteCodesCreated")
referralsMade Referral[] @relation("ReferralsMade")
referredBy Referral? @relation("ReferredBy")
// Impact Stories
impactStoriesCreated ImpactStory[] @relation("ImpactStoryCreator")
// Volunteer Spotlight
spotlights VolunteerSpotlight[] @relation("SpotlightUser")
spotlightNominations VolunteerSpotlight[] @relation("SpotlightNominator")
spotlightApprovals VolunteerSpotlight[] @relation("SpotlightApprover")
// Team Challenges
challengesCreated Challenge[] @relation("ChallengesCreated")
challengeTeamsCaptained ChallengeTeam[] @relation("ChallengeTeamsCaptained")
challengeParticipations ChallengeTeamMember[] @relation("ChallengeParticipations")
// Ticketed Events
ticketedEventsCreated TicketedEvent[] @relation("EventCreator")
ticketsHeld Ticket[] @relation("TicketHolder")
checkInsMade CheckIn[] @relation("CheckInUser")
// Social Calendar
calendarLayers CalendarLayer[] @relation("CalendarLayerOwner")
calendarItems CalendarItem[] @relation("CalendarItemOwner")
calendarFeeds CalendarFeed[] @relation("CalendarFeedOwner")
sharedCalendarViewsOwned SharedCalendarView[] @relation("SharedViewOwner")
sharedCalendarMemberships SharedCalendarMember[] @relation("SharedViewMember")
sharedViewComments SharedViewComment[] @relation("SharedViewCommentUser")
sharedViewReactions SharedViewReaction[] @relation("SharedViewReactionUser")
calendarExportTokens CalendarExportToken[] @relation("CalendarExportTokenOwner")
@@map("users")
}
@@ -249,6 +282,8 @@ model Campaign {
customRecipients CustomRecipient[]
calls Call[]
smsCampaigns SmsCampaign[] @relation("SmsCampaigns")
stories ImpactStory[] @relation("CampaignStories")
milestones CampaignMilestone[] @relation("CampaignMilestones")
@@index([moderationStatus])
@@index([isUserGenerated])
@@ -898,6 +933,9 @@ model SiteSettings {
enableSocial Boolean @default(false) @map("enable_social")
enableMeet Boolean @default(false) @map("enable_meet")
enableMeetingPlanner Boolean @default(false) @map("enable_meeting_planner")
enableTicketedEvents Boolean @default(false) @map("enable_ticketed_events")
enableSocialCalendar Boolean @default(false) @map("enable_social_calendar")
requireEventApproval Boolean @default(true) @map("require_event_approval")
autoSyncPeopleToMap Boolean @default(false) @map("auto_sync_people_to_map")
// SMS connection config (overrides env vars when non-empty)
@@ -1465,6 +1503,12 @@ enum NotificationType {
achievement
system
group_call
impact_story
referral_completed
challenge_update
shared_view_invite
shared_view_accepted
calendar_event_invite
}
// ============================================================================
@@ -2356,6 +2400,7 @@ model PrivacySettings {
hidePublicFinishes Boolean? @default(false) @map("hide_public_finishes")
allowFriendRequests Boolean? @default(true) @map("allow_friend_requests")
closeFriendsOnlyWatching Boolean? @default(false) @map("close_friends_only_watching")
showOnLeaderboard Boolean? @default(true) @map("show_on_leaderboard")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime? @map("updated_at")
@@ -3414,6 +3459,7 @@ model Order {
product Product? @relation(fields: [productId], references: [id])
donationPageId String? @map("donation_page_id")
donationPage DonationPage? @relation("DonationPageOrders", fields: [donationPageId], references: [id], onDelete: SetNull)
tickets Ticket[] @relation("TicketOrder")
@@index([userId], map: "idx_orders_user")
@@index([productId], map: "idx_orders_product")
@@ -4298,8 +4344,9 @@ model Meeting {
endTime DateTime? @map("end_time")
// Reverse relations (one-to-one)
shift Shift? @relation("ShiftMeeting")
group SocialGroup? @relation("GroupMeeting")
shift Shift? @relation("ShiftMeeting")
group SocialGroup? @relation("GroupMeeting")
ticketedEvent TicketedEvent? @relation("EventMeeting")
@@map("meetings")
}
@@ -4403,3 +4450,646 @@ model SchedulingPollComment {
@@index([pollId])
@@map("scheduling_poll_comments")
}
// ============================================================================
// SOCIAL: INVITE / REFERRAL SYSTEM
// ============================================================================
model InviteCode {
id String @id @default(cuid())
code String @unique
createdByUserId String @map("created_by_user_id")
maxUses Int @default(0) @map("max_uses") // 0 = unlimited
usedCount Int @default(0) @map("used_count")
expiresAt DateTime? @map("expires_at")
isActive Boolean @default(true) @map("is_active")
note String?
createdAt DateTime @default(now()) @map("created_at")
// Relations
createdBy User @relation("InviteCodesCreated", fields: [createdByUserId], references: [id])
referrals Referral[] @relation("InviteCodeReferrals")
@@index([code], map: "idx_invite_codes_code")
@@index([createdByUserId], map: "idx_invite_codes_created_by")
@@map("invite_codes")
}
model Referral {
id Int @id @default(autoincrement())
referrerId String @map("referrer_id")
referredUserId String @unique @map("referred_user_id")
inviteCodeId String? @map("invite_code_id")
referralSource String? @map("referral_source")
completedAt DateTime @default(now()) @map("completed_at")
// Relations
referrer User @relation("ReferralsMade", fields: [referrerId], references: [id])
referredUser User @relation("ReferredBy", fields: [referredUserId], references: [id])
inviteCode InviteCode? @relation("InviteCodeReferrals", fields: [inviteCodeId], references: [id])
@@index([referrerId], map: "idx_referrals_referrer")
@@map("referrals")
}
// ============================================================================
// SOCIAL: IMPACT STORIES / CAMPAIGN VICTORIES
// ============================================================================
enum ImpactStoryType {
MILESTONE
VICTORY
RESPONSE
CUSTOM
}
enum ImpactStoryStatus {
DRAFT
PUBLISHED
ARCHIVED
}
model ImpactStory {
id String @id @default(cuid())
campaignId String @map("campaign_id")
type ImpactStoryType
status ImpactStoryStatus @default(DRAFT)
title String
body String @db.Text
coverImageUrl String? @map("cover_image_url")
milestoneValue Int? @map("milestone_value")
milestoneMetric String? @map("milestone_metric")
createdByUserId String? @map("created_by_user_id")
publishedAt DateTime? @map("published_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
campaign Campaign @relation("CampaignStories", fields: [campaignId], references: [id])
createdBy User? @relation("ImpactStoryCreator", fields: [createdByUserId], references: [id])
@@index([campaignId], map: "idx_impact_stories_campaign")
@@index([status], map: "idx_impact_stories_status")
@@index([type], map: "idx_impact_stories_type")
@@map("impact_stories")
}
model CampaignMilestone {
id Int @id @default(autoincrement())
campaignId String @map("campaign_id")
metric String // "emails_sent", "verified_responses"
threshold Int
reachedAt DateTime @default(now()) @map("reached_at")
storyGenerated Boolean @default(false) @map("story_generated")
// Relations
campaign Campaign @relation("CampaignMilestones", fields: [campaignId], references: [id])
@@unique([campaignId, metric, threshold])
@@map("campaign_milestones")
}
// ============================================================================
// SOCIAL: VOLUNTEER SPOTLIGHT / WALL OF FAME
// ============================================================================
enum SpotlightStatus {
NOMINATED
APPROVED
FEATURED
ARCHIVED
}
model VolunteerSpotlight {
id String @id @default(cuid())
userId String @map("user_id")
status SpotlightStatus @default(NOMINATED)
headline String?
story String? @db.Text
featuredMonth String? @map("featured_month") // "2026-03"
nominatedByUserId String? @map("nominated_by_user_id")
approvedByUserId String? @map("approved_by_user_id")
approvedAt DateTime? @map("approved_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User @relation("SpotlightUser", fields: [userId], references: [id])
nominatedBy User? @relation("SpotlightNominator", fields: [nominatedByUserId], references: [id])
approvedBy User? @relation("SpotlightApprover", fields: [approvedByUserId], references: [id])
@@index([userId], map: "idx_volunteer_spotlights_user")
@@index([status], map: "idx_volunteer_spotlights_status")
@@index([featuredMonth], map: "idx_volunteer_spotlights_month")
@@map("volunteer_spotlights")
}
// ============================================================================
// SOCIAL: TEAM CHALLENGES
// ============================================================================
enum ChallengeStatus {
DRAFT
UPCOMING
ACTIVE
COMPLETED
CANCELLED
}
enum ChallengeMetric {
DOORS_KNOCKED
EMAILS_SENT
SHIFTS_ATTENDED
RESPONSES_SUBMITTED
REFERRALS_MADE
}
model Challenge {
id String @id @default(cuid())
title String
description String? @db.Text
metric ChallengeMetric
status ChallengeStatus @default(DRAFT)
startsAt DateTime @map("starts_at")
endsAt DateTime @map("ends_at")
minTeamSize Int @default(2) @map("min_team_size")
maxTeamSize Int @default(10) @map("max_team_size")
maxTeams Int? @map("max_teams") // null = unlimited
createdByUserId String @map("created_by_user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
createdBy User @relation("ChallengesCreated", fields: [createdByUserId], references: [id])
teams ChallengeTeam[] @relation("ChallengeTeams")
@@index([status], map: "idx_challenges_status")
@@index([startsAt], map: "idx_challenges_starts_at")
@@map("challenges")
}
model ChallengeTeam {
id String @id @default(cuid())
challengeId String @map("challenge_id")
name String
captainUserId String @map("captain_user_id")
score Int @default(0)
lastScoredAt DateTime? @map("last_scored_at")
createdAt DateTime @default(now()) @map("created_at")
// Relations
challenge Challenge @relation("ChallengeTeams", fields: [challengeId], references: [id])
captain User @relation("ChallengeTeamsCaptained", fields: [captainUserId], references: [id])
members ChallengeTeamMember[] @relation("ChallengeTeamMembers")
@@unique([challengeId, name])
@@index([challengeId], map: "idx_challenge_teams_challenge")
@@index([score], map: "idx_challenge_teams_score")
@@map("challenge_teams")
}
model ChallengeTeamMember {
id Int @id @default(autoincrement())
teamId String @map("team_id")
userId String @map("user_id")
score Int @default(0)
joinedAt DateTime @default(now()) @map("joined_at")
// Relations
team ChallengeTeam @relation("ChallengeTeamMembers", fields: [teamId], references: [id], onDelete: Cascade)
user User @relation("ChallengeParticipations", fields: [userId], references: [id])
@@unique([teamId, userId])
@@index([userId], map: "idx_challenge_team_members_user")
@@map("challenge_team_members")
}
// ============================================================================
// TICKETED EVENTS
// ============================================================================
enum TicketedEventStatus {
DRAFT
PENDING_APPROVAL
PUBLISHED
CANCELLED
COMPLETED
}
enum TicketedEventVisibility {
PUBLIC
UNLISTED
PRIVATE
}
enum TicketTierType {
PAID
FREE
DONATION
}
enum TicketStatus {
VALID
CHECKED_IN
CANCELLED
REFUNDED
}
enum EventFormat {
IN_PERSON
ONLINE
HYBRID
}
model TicketedEvent {
id String @id @default(cuid())
slug String @unique
title String
description String? @db.Text
richDescription String? @db.Text @map("rich_description")
// Schedule
date DateTime @db.Date
startTime String @map("start_time")
endTime String @map("end_time")
doorsOpenTime String? @map("doors_open_time")
// Venue
venueName String? @map("venue_name")
venueAddress String? @map("venue_address")
latitude Decimal? @db.Decimal(10, 7)
longitude Decimal? @db.Decimal(10, 7)
// Status
status TicketedEventStatus @default(DRAFT)
visibility TicketedEventVisibility @default(PUBLIC)
inviteCode String? @unique @map("invite_code")
// Media
coverImageUrl String? @map("cover_image_url")
// Capacity
maxAttendees Int? @map("max_attendees")
currentAttendees Int @default(0) @map("current_attendees")
// Gancio sync
gancioEventId Int? @map("gancio_event_id")
// Format & Meeting
eventFormat EventFormat @default(IN_PERSON) @map("event_format")
meetingId String? @unique @map("meeting_id")
// Creator
createdByUserId String @map("created_by_user_id")
organizerName String? @map("organizer_name")
organizerEmail String? @map("organizer_email")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
createdBy User @relation("EventCreator", fields: [createdByUserId], references: [id])
meeting Meeting? @relation("EventMeeting", fields: [meetingId], references: [id], onDelete: SetNull)
ticketTiers TicketTier[] @relation("EventTiers")
tickets Ticket[] @relation("EventTickets")
checkIns CheckIn[] @relation("EventCheckIns")
@@index([status], map: "idx_ticketed_events_status")
@@index([date], map: "idx_ticketed_events_date")
@@index([visibility], map: "idx_ticketed_events_visibility")
@@index([createdByUserId], map: "idx_ticketed_events_creator")
@@map("ticketed_events")
}
model TicketTier {
id String @id @default(cuid())
eventId String @map("event_id")
name String
description String?
tierType TicketTierType @map("tier_type")
priceCAD Int @default(0) @map("price_cad") // In cents
minDonationCAD Int? @map("min_donation_cad") // In cents
maxQuantity Int? @map("max_quantity")
soldCount Int @default(0) @map("sold_count")
maxPerOrder Int @default(10) @map("max_per_order")
salesStartAt DateTime? @map("sales_start_at")
salesEndAt DateTime? @map("sales_end_at")
sortOrder Int @default(0) @map("sort_order")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
event TicketedEvent @relation("EventTiers", fields: [eventId], references: [id], onDelete: Cascade)
tickets Ticket[] @relation("TierTickets")
@@index([eventId], map: "idx_ticket_tiers_event")
@@map("ticket_tiers")
}
model Ticket {
id String @id @default(cuid())
ticketCode String @unique @map("ticket_code")
tokenHash String @unique @map("token_hash")
eventId String @map("event_id")
tierId String @map("tier_id")
orderId String? @map("order_id")
holderEmail String @map("holder_email")
holderName String? @map("holder_name")
userId String? @map("user_id")
status TicketStatus @default(VALID)
checkedInAt DateTime? @map("checked_in_at")
checkedInByUserId String? @map("checked_in_by_user_id")
issuedAt DateTime @default(now()) @map("issued_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
event TicketedEvent @relation("EventTickets", fields: [eventId], references: [id])
tier TicketTier @relation("TierTickets", fields: [tierId], references: [id])
order Order? @relation("TicketOrder", fields: [orderId], references: [id])
holder User? @relation("TicketHolder", fields: [userId], references: [id])
checkIns CheckIn[] @relation("TicketCheckIns")
@@index([eventId], map: "idx_tickets_event")
@@index([tierId], map: "idx_tickets_tier")
@@index([orderId], map: "idx_tickets_order")
@@index([holderEmail], map: "idx_tickets_holder_email")
@@index([status], map: "idx_tickets_status")
@@map("tickets")
}
model CheckIn {
id String @id @default(cuid())
ticketId String @map("ticket_id")
eventId String @map("event_id")
checkedInByUserId String? @map("checked_in_by_user_id")
method String // "QR" | "MANUAL" | "CODE"
checkedInAt DateTime @default(now()) @map("checked_in_at")
notes String?
// Relations
ticket Ticket @relation("TicketCheckIns", fields: [ticketId], references: [id])
event TicketedEvent @relation("EventCheckIns", fields: [eventId], references: [id])
checkedInBy User? @relation("CheckInUser", fields: [checkedInByUserId], references: [id])
@@index([eventId], map: "idx_checkins_event")
@@index([ticketId], map: "idx_checkins_ticket")
@@map("check_ins")
}
// ============================================================================
// SOCIAL CALENDAR
// ============================================================================
enum CalendarLayerType {
SYSTEM
USER
EXTERNAL
}
enum CalendarSystemType {
SHIFTS
TICKETS
POLLS
PUBLIC_EVENTS
}
enum CalendarVisibility {
PRIVATE
FRIENDS
PUBLIC
}
enum CalendarItemType {
EVENT
TIME_BLOCK
REMINDER
}
enum CalendarBusyStatus {
BUSY
TENTATIVE
FREE
}
enum CalendarShowDetailsTo {
NOBODY
FRIENDS
EVERYONE
}
enum CalendarItemSource {
MANUAL
ICS_FEED
}
enum CalendarRecurrenceFrequency {
DAILY
WEEKLY
BIWEEKLY
MONTHLY
}
enum CalendarFeedStatus {
OK
ERROR
PENDING
}
enum CalendarFeedInterval {
FIFTEEN_MIN
HOURLY
SIX_HOUR
DAILY
}
enum SharedViewType {
MANUAL
ROLE_BASED
}
enum SharedViewScope {
MEMBERS
PUBLIC
}
enum SharedViewMemberStatus {
INVITED
ACCEPTED
DECLINED
}
model CalendarLayer {
id String @id @default(cuid())
userId String @map("user_id")
name String
layerType CalendarLayerType @map("layer_type")
systemType CalendarSystemType? @map("system_type")
color String @default("#1890ff")
visibility CalendarVisibility @default(PRIVATE)
isEnabled Boolean @default(true) @map("is_enabled")
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User @relation("CalendarLayerOwner", fields: [userId], references: [id], onDelete: Cascade)
items CalendarItem[] @relation("CalendarLayerItems")
feed CalendarFeed? @relation("CalendarFeedLayer")
@@unique([userId, systemType], map: "idx_calendar_layers_user_system")
@@index([userId], map: "idx_calendar_layers_user")
@@map("calendar_layers")
}
model CalendarItem {
id String @id @default(cuid())
userId String @map("user_id")
layerId String @map("layer_id")
title String
description String? @db.Text
date DateTime @db.Date
startTime String @map("start_time") // HH:MM
endTime String @map("end_time") // HH:MM
isAllDay Boolean @default(false) @map("is_all_day")
itemType CalendarItemType @default(EVENT) @map("item_type")
location String?
color String?
visibility CalendarVisibility? // null = inherit from layer
busyStatus CalendarBusyStatus @default(BUSY) @map("busy_status")
showDetailsTo CalendarShowDetailsTo @default(FRIENDS) @map("show_details_to")
// Recurrence
recurrenceRule Json? @map("recurrence_rule")
recurrenceEnd DateTime? @map("recurrence_end")
seriesId String? @map("series_id")
isException Boolean @default(false) @map("is_exception")
// Source tracking
sourceType CalendarItemSource @default(MANUAL) @map("source_type")
sourceId String? @map("source_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User @relation("CalendarItemOwner", fields: [userId], references: [id], onDelete: Cascade)
layer CalendarLayer @relation("CalendarLayerItems", fields: [layerId], references: [id], onDelete: Cascade)
@@index([userId, date], map: "idx_calendar_items_user_date")
@@index([layerId, date], map: "idx_calendar_items_layer_date")
@@index([seriesId], map: "idx_calendar_items_series")
@@index([sourceType, sourceId], map: "idx_calendar_items_source")
@@map("calendar_items")
}
model CalendarFeed {
id String @id @default(cuid())
userId String @map("user_id")
name String
url String
layerId String @unique @map("layer_id")
refreshInterval CalendarFeedInterval @default(HOURLY) @map("refresh_interval")
lastFetchedAt DateTime? @map("last_fetched_at")
lastStatus CalendarFeedStatus @default(PENDING) @map("last_status")
lastError String? @map("last_error")
itemCount Int @default(0) @map("item_count")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User @relation("CalendarFeedOwner", fields: [userId], references: [id], onDelete: Cascade)
layer CalendarLayer @relation("CalendarFeedLayer", fields: [layerId], references: [id], onDelete: Cascade)
@@index([userId], map: "idx_calendar_feeds_user")
@@map("calendar_feeds")
}
model SharedCalendarView {
id String @id @default(cuid())
name String
description String? @db.Text
ownerId String @map("owner_id")
viewType SharedViewType @default(MANUAL) @map("view_type")
autoIncludeRoles Json? @map("auto_include_roles")
includedLayerTypes Json @default("[]") @map("included_layer_types")
shareScope SharedViewScope @default(MEMBERS) @map("share_scope")
shareToken String? @unique @map("share_token")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
owner User @relation("SharedViewOwner", fields: [ownerId], references: [id], onDelete: Cascade)
members SharedCalendarMember[] @relation("SharedViewMembers")
comments SharedViewComment[] @relation("SharedViewComments")
reactions SharedViewReaction[] @relation("SharedViewReactions")
@@index([ownerId], map: "idx_shared_views_owner")
@@map("shared_calendar_views")
}
model SharedCalendarMember {
id String @id @default(cuid())
viewId String @map("view_id")
userId String @map("user_id")
status SharedViewMemberStatus @default(INVITED)
color String @default("#1890ff")
joinedAt DateTime? @map("joined_at")
// Relations
view SharedCalendarView @relation("SharedViewMembers", fields: [viewId], references: [id], onDelete: Cascade)
user User @relation("SharedViewMember", fields: [userId], references: [id], onDelete: Cascade)
@@unique([viewId, userId], map: "idx_shared_members_view_user")
@@index([userId], map: "idx_shared_members_user")
@@map("shared_calendar_members")
}
model SharedViewComment {
id String @id @default(cuid())
viewId String @map("view_id")
userId String @map("user_id")
itemDate String @map("item_date") // YYYY-MM-DD
itemId String? @map("item_id") // specific item reference
content String @db.Text
createdAt DateTime @default(now()) @map("created_at")
// Relations
view SharedCalendarView @relation("SharedViewComments", fields: [viewId], references: [id], onDelete: Cascade)
user User @relation("SharedViewCommentUser", fields: [userId], references: [id], onDelete: Cascade)
@@index([viewId, itemDate], map: "idx_shared_comments_view_date")
@@map("shared_view_comments")
}
model SharedViewReaction {
id String @id @default(cuid())
viewId String @map("view_id")
userId String @map("user_id")
itemId String @map("item_id")
emoji String
createdAt DateTime @default(now()) @map("created_at")
// Relations
view SharedCalendarView @relation("SharedViewReactions", fields: [viewId], references: [id], onDelete: Cascade)
user User @relation("SharedViewReactionUser", fields: [userId], references: [id], onDelete: Cascade)
@@unique([viewId, userId, itemId, emoji], map: "idx_shared_reactions_unique")
@@map("shared_view_reactions")
}
model CalendarExportToken {
id String @id @default(cuid())
userId String @map("user_id")
token String @unique
includePersonal Boolean @default(false) @map("include_personal")
includeLayers Json? @map("include_layers")
createdAt DateTime @default(now()) @map("created_at")
// Relations
user User @relation("CalendarExportTokenOwner", fields: [userId], references: [id], onDelete: Cascade)
@@index([userId], map: "idx_calendar_export_tokens_user")
@@map("calendar_export_tokens")
}

View File

@@ -15,6 +15,7 @@ export const registerSchema = z.object({
.regex(/[0-9]/, 'Password must contain at least one digit'),
name: z.string().optional(),
phone: z.string().optional(),
inviteCode: z.string().max(20).optional(),
// Role removed from public registration - must be set server-side only
});

View File

@@ -132,6 +132,15 @@ export const authService = {
},
});
// Fire-and-forget: process referral if invite code provided
if (data.inviteCode) {
import('../social/referral.service').then(({ referralService }) => {
referralService.processRegistrationReferral(user.id, data.inviteCode).catch(err => {
logger.warn('Referral processing failed:', err);
});
}).catch(() => {});
}
// Fire-and-forget: auto-link or create Contact if People feature is enabled
siteSettingsService.get().then(async (s) => {
if (!s.enablePeople) return;

View File

@@ -0,0 +1,152 @@
import { Router } from 'express';
import { authenticate } from '../../middleware/auth.middleware';
import { validate } from '../../middleware/validate';
import { calendarService } from './calendar.service';
import {
createLayerSchema,
updateLayerSchema,
createItemSchema,
updateItemSchema,
dateRangeSchema,
seriesEditScopeSchema,
} from './calendar.schemas';
import type { SeriesEditScope } from './calendar.schemas';
const router = Router();
// All routes require authentication
router.use(authenticate);
// =========================================================================
// Layers
// =========================================================================
// GET /api/calendar/layers — get all user layers (ensures system layers exist)
router.get('/layers', async (req, res, next) => {
try {
const layers = await calendarService.ensureSystemLayers(req.user!.id);
res.json({ layers });
} catch (error) {
next(error);
}
});
// POST /api/calendar/layers — create a user layer
router.post('/layers', validate(createLayerSchema), async (req, res, next) => {
try {
const layer = await calendarService.createLayer(req.user!.id, req.body);
res.status(201).json({ layer });
} catch (error) {
next(error);
}
});
// PATCH /api/calendar/layers/:id — update a layer
router.patch('/layers/:id', validate(updateLayerSchema), async (req, res, next) => {
try {
const layer = await calendarService.updateLayer(
req.user!.id,
req.params.id as string,
req.body
);
res.json({ layer });
} catch (error) {
next(error);
}
});
// DELETE /api/calendar/layers/:id — delete a layer (USER/EXTERNAL only)
router.delete('/layers/:id', async (req, res, next) => {
try {
await calendarService.deleteLayer(req.user!.id, req.params.id as string);
res.json({ success: true });
} catch (error) {
next(error);
}
});
// =========================================================================
// Items
// =========================================================================
// GET /api/calendar/items?startDate=&endDate=&layerIds=
router.get('/items', validate(dateRangeSchema, 'query'), async (req, res, next) => {
try {
const { startDate, endDate } = req.query as { startDate: string; endDate: string };
const layerIdsRaw = req.query.layerIds as string | undefined;
const layerIds = layerIdsRaw ? layerIdsRaw.split(',').filter(Boolean) : undefined;
const items = await calendarService.getItems(req.user!.id, startDate, endDate, layerIds);
res.json({ items });
} catch (error) {
next(error);
}
});
// POST /api/calendar/items — create item
router.post('/items', validate(createItemSchema), async (req, res, next) => {
try {
const item = await calendarService.createItem(req.user!.id, req.body);
res.status(201).json({ item });
} catch (error) {
next(error);
}
});
// PATCH /api/calendar/items/:id?scope=THIS_ONLY|THIS_AND_FUTURE|ALL
router.patch('/items/:id', validate(updateItemSchema), async (req, res, next) => {
try {
const scopeRaw = req.query.scope as string | undefined;
let scope: SeriesEditScope | undefined;
if (scopeRaw) {
scope = seriesEditScopeSchema.parse(scopeRaw);
}
const item = await calendarService.updateItem(
req.user!.id,
req.params.id as string,
req.body,
scope
);
res.json({ item });
} catch (error) {
next(error);
}
});
// DELETE /api/calendar/items/:id?scope=THIS_ONLY|THIS_AND_FUTURE|ALL
router.delete('/items/:id', async (req, res, next) => {
try {
const scopeRaw = req.query.scope as string | undefined;
let scope: SeriesEditScope | undefined;
if (scopeRaw) {
scope = seriesEditScopeSchema.parse(scopeRaw);
}
await calendarService.deleteItem(
req.user!.id,
req.params.id as string,
scope
);
res.json({ success: true });
} catch (error) {
next(error);
}
});
// =========================================================================
// Personal calendar (merged view)
// =========================================================================
// GET /api/calendar/my?startDate=&endDate=
router.get('/my', validate(dateRangeSchema, 'query'), async (req, res, next) => {
try {
const { startDate, endDate } = req.query as { startDate: string; endDate: string };
const result = await calendarService.getPersonalCalendar(req.user!.id, startDate, endDate);
res.json(result);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,80 @@
import { z } from 'zod';
// --- Layer schemas ---
export const createLayerSchema = z.object({
name: z.string().min(1).max(100),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Color must be a hex color (#RRGGBB)'),
visibility: z.enum(['PRIVATE', 'FRIENDS', 'PUBLIC']),
});
export const updateLayerSchema = z.object({
name: z.string().min(1).max(100).optional(),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Color must be a hex color (#RRGGBB)').optional(),
visibility: z.enum(['PRIVATE', 'FRIENDS', 'PUBLIC']).optional(),
isEnabled: z.boolean().optional(),
sortOrder: z.number().int().min(0).optional(),
});
// --- Item schemas ---
const recurrenceRuleSchema = z.object({
frequency: z.enum(['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY']),
daysOfWeek: z.array(z.number().int().min(1).max(7)).optional(), // 1=Mon...7=Sun
dayOfMonth: z.number().int().min(1).max(31).optional(),
interval: z.number().int().min(1).max(12).optional(),
});
export const createItemSchema = z.object({
layerId: z.string().min(1),
title: z.string().min(1).max(200),
description: z.string().optional(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'),
startTime: z.string().regex(/^\d{2}:\d{2}$/, 'Start time must be HH:MM'),
endTime: z.string().regex(/^\d{2}:\d{2}$/, 'End time must be HH:MM'),
isAllDay: z.boolean().optional(),
itemType: z.enum(['EVENT', 'TIME_BLOCK', 'REMINDER']),
location: z.string().optional(),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
visibility: z.enum(['PRIVATE', 'FRIENDS', 'PUBLIC']).optional(),
busyStatus: z.enum(['BUSY', 'TENTATIVE', 'FREE']).optional(),
showDetailsTo: z.enum(['NOBODY', 'FRIENDS', 'EVERYONE']).optional(),
recurrenceRule: recurrenceRuleSchema.optional(),
recurrenceEnd: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Recurrence end must be YYYY-MM-DD').optional(),
});
export const updateItemSchema = z.object({
title: z.string().min(1).max(200).optional(),
description: z.string().nullable().optional(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD').optional(),
startTime: z.string().regex(/^\d{2}:\d{2}$/, 'Start time must be HH:MM').optional(),
endTime: z.string().regex(/^\d{2}:\d{2}$/, 'End time must be HH:MM').optional(),
isAllDay: z.boolean().optional(),
itemType: z.enum(['EVENT', 'TIME_BLOCK', 'REMINDER']).optional(),
location: z.string().nullable().optional(),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional(),
visibility: z.enum(['PRIVATE', 'FRIENDS', 'PUBLIC']).nullable().optional(),
busyStatus: z.enum(['BUSY', 'TENTATIVE', 'FREE']).optional(),
showDetailsTo: z.enum(['NOBODY', 'FRIENDS', 'EVERYONE']).optional(),
recurrenceRule: recurrenceRuleSchema.nullable().optional(),
recurrenceEnd: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
});
// --- Scope for recurring item edits ---
export const seriesEditScopeSchema = z.enum(['THIS_ONLY', 'THIS_AND_FUTURE', 'ALL']);
// --- Date range query ---
export const dateRangeSchema = z.object({
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'startDate must be YYYY-MM-DD'),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'endDate must be YYYY-MM-DD'),
});
// --- Exported types ---
export type CreateLayerInput = z.infer<typeof createLayerSchema>;
export type UpdateLayerInput = z.infer<typeof updateLayerSchema>;
export type CreateItemInput = z.infer<typeof createItemSchema>;
export type UpdateItemInput = z.infer<typeof updateItemSchema>;
export type SeriesEditScope = z.infer<typeof seriesEditScopeSchema>;

View File

@@ -0,0 +1,781 @@
import {
CalendarLayerType,
CalendarSystemType,
CalendarItemType,
CalendarBusyStatus,
CalendarShowDetailsTo,
CalendarItemSource,
CalendarVisibility,
SignupStatus,
ShiftStatus,
Prisma,
} from '@prisma/client';
import { prisma } from '../../config/database';
import { logger } from '../../utils/logger';
import { AppError } from '../../middleware/error-handler';
import type {
CreateLayerInput,
UpdateLayerInput,
CreateItemInput,
UpdateItemInput,
SeriesEditScope,
} from './calendar.schemas';
// Shape reused by the personal calendar endpoint
interface PersonalCalendarItem {
id: string;
type: 'calendar_item' | 'shift' | 'ticket' | 'poll' | 'public_event';
title: string;
date: string; // YYYY-MM-DD
startTime: string; // HH:MM
endTime: string; // HH:MM
location: string | null;
color: string | null;
itemType: string;
layerId: string;
layerName: string;
layerColor: string;
}
interface PersonalCalendarResponse {
dates: Record<string, { count: number; items: PersonalCalendarItem[] }>;
}
// Recurrence rule shape stored in JSON
interface RecurrenceRule {
frequency: 'DAILY' | 'WEEKLY' | 'BIWEEKLY' | 'MONTHLY';
daysOfWeek?: number[]; // 1=Mon...7=Sun
dayOfMonth?: number;
interval?: number;
}
const SYSTEM_LAYER_DEFS: { systemType: CalendarSystemType; name: string; color: string }[] = [
{ systemType: CalendarSystemType.SHIFTS, name: 'My Shifts', color: '#52c41a' },
{ systemType: CalendarSystemType.TICKETS, name: 'My Tickets', color: '#fa8c16' },
{ systemType: CalendarSystemType.POLLS, name: 'My Polls', color: '#722ed1' },
{ systemType: CalendarSystemType.PUBLIC_EVENTS, name: 'Public Events', color: '#1890ff' },
];
const MAX_RECURRENCE_DAYS = 92; // ~3 months
export const calendarService = {
// =========================================================================
// Layer management
// =========================================================================
async ensureSystemLayers(userId: string) {
for (const def of SYSTEM_LAYER_DEFS) {
await prisma.calendarLayer.upsert({
where: {
userId_systemType: { userId, systemType: def.systemType },
},
update: {}, // no-op if exists
create: {
userId,
name: def.name,
color: def.color,
layerType: CalendarLayerType.SYSTEM,
systemType: def.systemType,
visibility: CalendarVisibility.PRIVATE,
},
});
}
return this.getUserLayers(userId);
},
async getUserLayers(userId: string) {
return prisma.calendarLayer.findMany({
where: { userId },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
});
},
async createLayer(userId: string, data: CreateLayerInput) {
// Get the next sort order
const maxSort = await prisma.calendarLayer.aggregate({
where: { userId },
_max: { sortOrder: true },
});
const sortOrder = (maxSort._max.sortOrder ?? -1) + 1;
return prisma.calendarLayer.create({
data: {
userId,
name: data.name,
color: data.color,
visibility: data.visibility as CalendarVisibility,
layerType: CalendarLayerType.USER,
sortOrder,
},
});
},
async updateLayer(userId: string, layerId: string, data: UpdateLayerInput) {
const layer = await prisma.calendarLayer.findFirst({
where: { id: layerId, userId },
});
if (!layer) {
throw new AppError(404, 'Layer not found', 'NOT_FOUND');
}
return prisma.calendarLayer.update({
where: { id: layerId },
data: {
...(data.name !== undefined && { name: data.name }),
...(data.color !== undefined && { color: data.color }),
...(data.visibility !== undefined && { visibility: data.visibility as CalendarVisibility }),
...(data.isEnabled !== undefined && { isEnabled: data.isEnabled }),
...(data.sortOrder !== undefined && { sortOrder: data.sortOrder }),
},
});
},
async deleteLayer(userId: string, layerId: string) {
const layer = await prisma.calendarLayer.findFirst({
where: { id: layerId, userId },
});
if (!layer) {
throw new AppError(404, 'Layer not found', 'NOT_FOUND');
}
if (layer.layerType === CalendarLayerType.SYSTEM) {
throw new AppError(400, 'Cannot delete system layers', 'CANNOT_DELETE_SYSTEM');
}
await prisma.calendarLayer.delete({ where: { id: layerId } });
},
// =========================================================================
// Item management
// =========================================================================
async getItems(userId: string, startDate: string, endDate: string, layerIds?: string[]) {
const start = new Date(startDate);
const end = new Date(endDate);
const where: Prisma.CalendarItemWhereInput = {
userId,
date: { gte: start, lte: end },
};
if (layerIds && layerIds.length > 0) {
where.layerId = { in: layerIds };
}
return prisma.calendarItem.findMany({
where,
orderBy: [{ date: 'asc' }, { startTime: 'asc' }],
include: {
layer: { select: { name: true, color: true, layerType: true, systemType: true } },
},
});
},
async createItem(userId: string, data: CreateItemInput) {
// Verify layer ownership
const layer = await prisma.calendarLayer.findFirst({
where: { id: data.layerId, userId },
});
if (!layer) {
throw new AppError(404, 'Layer not found', 'LAYER_NOT_FOUND');
}
if (layer.layerType === CalendarLayerType.SYSTEM) {
throw new AppError(400, 'Cannot add items to system layers', 'CANNOT_ADD_TO_SYSTEM');
}
const recurrenceRule = data.recurrenceRule
? (data.recurrenceRule as unknown as Prisma.InputJsonValue)
: undefined;
// Create the template item
const templateItem = await prisma.calendarItem.create({
data: {
userId,
layerId: data.layerId,
title: data.title,
description: data.description,
date: new Date(data.date),
startTime: data.startTime,
endTime: data.endTime,
isAllDay: data.isAllDay ?? false,
itemType: (data.itemType as CalendarItemType) ?? CalendarItemType.EVENT,
location: data.location,
color: data.color,
visibility: data.visibility as CalendarVisibility | undefined,
busyStatus: (data.busyStatus as CalendarBusyStatus) ?? CalendarBusyStatus.BUSY,
showDetailsTo: (data.showDetailsTo as CalendarShowDetailsTo) ?? CalendarShowDetailsTo.FRIENDS,
recurrenceRule,
recurrenceEnd: data.recurrenceEnd ? new Date(data.recurrenceEnd) : undefined,
sourceType: CalendarItemSource.MANUAL,
},
});
// If recurring, set seriesId to own id and materialize instances
if (data.recurrenceRule) {
await prisma.calendarItem.update({
where: { id: templateItem.id },
data: { seriesId: templateItem.id },
});
const startDate = new Date(data.date);
const endDate = new Date(startDate);
endDate.setDate(endDate.getDate() + MAX_RECURRENCE_DAYS);
// Limit recurrence end
const recEnd = data.recurrenceEnd ? new Date(data.recurrenceEnd) : endDate;
const effectiveEnd = recEnd < endDate ? recEnd : endDate;
await this.materializeRecurrence(
{ ...templateItem, seriesId: templateItem.id },
startDate,
effectiveEnd
);
}
return prisma.calendarItem.findUnique({
where: { id: templateItem.id },
include: {
layer: { select: { name: true, color: true, layerType: true, systemType: true } },
},
});
},
async updateItem(
userId: string,
itemId: string,
data: UpdateItemInput,
scope?: SeriesEditScope
) {
const item = await prisma.calendarItem.findFirst({
where: { id: itemId, userId },
});
if (!item) {
throw new AppError(404, 'Item not found', 'NOT_FOUND');
}
const updateData: Prisma.CalendarItemUncheckedUpdateInput = {};
if (data.title !== undefined) updateData.title = data.title;
if (data.description !== undefined) updateData.description = data.description;
if (data.date !== undefined) updateData.date = new Date(data.date);
if (data.startTime !== undefined) updateData.startTime = data.startTime;
if (data.endTime !== undefined) updateData.endTime = data.endTime;
if (data.isAllDay !== undefined) updateData.isAllDay = data.isAllDay;
if (data.itemType !== undefined) updateData.itemType = data.itemType as CalendarItemType;
if (data.location !== undefined) updateData.location = data.location;
if (data.color !== undefined) updateData.color = data.color;
if (data.visibility !== undefined) updateData.visibility = data.visibility as CalendarVisibility | null;
if (data.busyStatus !== undefined) updateData.busyStatus = data.busyStatus as CalendarBusyStatus;
if (data.showDetailsTo !== undefined) updateData.showDetailsTo = data.showDetailsTo as CalendarShowDetailsTo;
if (data.recurrenceRule !== undefined) {
updateData.recurrenceRule = data.recurrenceRule as unknown as Prisma.InputJsonValue ?? Prisma.JsonNull;
}
if (data.recurrenceEnd !== undefined) {
updateData.recurrenceEnd = data.recurrenceEnd ? new Date(data.recurrenceEnd) : null;
}
// Non-recurring or no scope: simple update
if (!item.seriesId || !scope || scope === 'THIS_ONLY') {
if (item.seriesId && scope === 'THIS_ONLY') {
// Mark as exception so future series updates skip it
updateData.isException = true;
}
return prisma.calendarItem.update({
where: { id: itemId },
data: updateData,
include: {
layer: { select: { name: true, color: true, layerType: true, systemType: true } },
},
});
}
if (scope === 'ALL') {
// Update all non-exception instances in the series
await prisma.calendarItem.updateMany({
where: { seriesId: item.seriesId, userId, isException: false },
data: updateData,
});
return prisma.calendarItem.findFirst({
where: { id: itemId },
include: {
layer: { select: { name: true, color: true, layerType: true, systemType: true } },
},
});
}
if (scope === 'THIS_AND_FUTURE') {
// Update this item and all future non-exception instances
await prisma.calendarItem.updateMany({
where: {
seriesId: item.seriesId,
userId,
isException: false,
date: { gte: item.date },
},
data: updateData,
});
return prisma.calendarItem.findFirst({
where: { id: itemId },
include: {
layer: { select: { name: true, color: true, layerType: true, systemType: true } },
},
});
}
// Fallback: simple update
return prisma.calendarItem.update({
where: { id: itemId },
data: updateData,
include: {
layer: { select: { name: true, color: true, layerType: true, systemType: true } },
},
});
},
async deleteItem(userId: string, itemId: string, scope?: SeriesEditScope) {
const item = await prisma.calendarItem.findFirst({
where: { id: itemId, userId },
});
if (!item) {
throw new AppError(404, 'Item not found', 'NOT_FOUND');
}
// Non-recurring or no scope: delete single item
if (!item.seriesId || !scope || scope === 'THIS_ONLY') {
await prisma.calendarItem.delete({ where: { id: itemId } });
return;
}
if (scope === 'ALL') {
await prisma.calendarItem.deleteMany({
where: { seriesId: item.seriesId, userId },
});
return;
}
if (scope === 'THIS_AND_FUTURE') {
await prisma.calendarItem.deleteMany({
where: {
seriesId: item.seriesId,
userId,
date: { gte: item.date },
},
});
}
},
// =========================================================================
// Recurrence materialization
// =========================================================================
async materializeRecurrence(
templateItem: {
id: string;
userId: string;
layerId: string;
seriesId: string;
title: string;
description: string | null;
startTime: string;
endTime: string;
isAllDay: boolean;
itemType: CalendarItemType;
location: string | null;
color: string | null;
visibility: CalendarVisibility | null;
busyStatus: CalendarBusyStatus;
showDetailsTo: CalendarShowDetailsTo;
recurrenceRule: unknown;
date: Date;
},
startDate: Date,
endDate: Date
) {
const rule = templateItem.recurrenceRule as RecurrenceRule | null;
if (!rule) return;
const interval = rule.interval ?? 1;
const dates = generateRecurrenceDates(rule, startDate, endDate, interval);
// Remove the template item's own date (it already exists)
const templateDateStr = templateItem.date.toISOString().split('T')[0];
const filteredDates = dates.filter(d => d.toISOString().split('T')[0] !== templateDateStr);
if (filteredDates.length === 0) return;
// Fetch existing instance dates to avoid duplicates
const existing = await prisma.calendarItem.findMany({
where: {
seriesId: templateItem.seriesId,
userId: templateItem.userId,
},
select: { date: true },
});
const existingDateSet = new Set(existing.map(e => e.date.toISOString().split('T')[0]));
const newDates = filteredDates.filter(
d => !existingDateSet.has(d.toISOString().split('T')[0])
);
if (newDates.length === 0) return;
// Batch create
await prisma.calendarItem.createMany({
data: newDates.map(date => ({
userId: templateItem.userId,
layerId: templateItem.layerId,
title: templateItem.title,
description: templateItem.description,
date,
startTime: templateItem.startTime,
endTime: templateItem.endTime,
isAllDay: templateItem.isAllDay,
itemType: templateItem.itemType,
location: templateItem.location,
color: templateItem.color,
visibility: templateItem.visibility,
busyStatus: templateItem.busyStatus,
showDetailsTo: templateItem.showDetailsTo,
seriesId: templateItem.seriesId,
sourceType: CalendarItemSource.MANUAL,
})),
});
logger.debug(`Materialized ${newDates.length} recurrence instances for series ${templateItem.seriesId}`);
},
// =========================================================================
// Personal calendar (merged view)
// =========================================================================
async getPersonalCalendar(
userId: string,
startDate: string,
endDate: string
): Promise<PersonalCalendarResponse> {
const start = new Date(startDate);
const end = new Date(endDate);
// Fetch user's layers
const layers = await prisma.calendarLayer.findMany({
where: { userId, isEnabled: true },
});
const layerMap = new Map(layers.map(l => [l.id, l]));
const systemLayersByType = new Map(
layers.filter(l => l.systemType).map(l => [l.systemType!, l])
);
const allItems: PersonalCalendarItem[] = [];
// 1. User's CalendarItems from enabled layers
const enabledLayerIds = layers.map(l => l.id);
if (enabledLayerIds.length > 0) {
const items = await prisma.calendarItem.findMany({
where: {
userId,
layerId: { in: enabledLayerIds },
date: { gte: start, lte: end },
},
orderBy: [{ date: 'asc' }, { startTime: 'asc' }],
});
for (const item of items) {
const layer = layerMap.get(item.layerId);
if (!layer) continue;
allItems.push({
id: item.id,
type: 'calendar_item',
title: item.title,
date: item.date.toISOString().split('T')[0],
startTime: item.startTime,
endTime: item.endTime,
location: item.location,
color: item.color ?? layer.color,
itemType: item.itemType,
layerId: layer.id,
layerName: layer.name,
layerColor: layer.color,
});
}
}
// 2. System layer: SHIFTS (from ShiftSignup where userId matches)
const shiftsLayer = systemLayersByType.get(CalendarSystemType.SHIFTS);
if (shiftsLayer) {
try {
const signups = await prisma.shiftSignup.findMany({
where: {
userId,
status: SignupStatus.CONFIRMED,
shift: {
status: { not: ShiftStatus.CANCELLED },
date: { gte: start, lte: end },
},
},
include: {
shift: {
select: { id: true, title: true, date: true, startTime: true, endTime: true, location: true },
},
},
});
for (const signup of signups) {
const s = signup.shift;
allItems.push({
id: `shift-${s.id}`,
type: 'shift',
title: s.title,
date: s.date.toISOString().split('T')[0],
startTime: s.startTime,
endTime: s.endTime,
location: s.location,
color: shiftsLayer.color,
itemType: 'EVENT',
layerId: shiftsLayer.id,
layerName: shiftsLayer.name,
layerColor: shiftsLayer.color,
});
}
} catch (err) {
logger.debug('Failed to fetch shifts for personal calendar:', err);
}
}
// 3. System layer: TICKETS (from Ticket where userId matches)
const ticketsLayer = systemLayersByType.get(CalendarSystemType.TICKETS);
if (ticketsLayer) {
try {
const tickets = await prisma.ticket.findMany({
where: {
userId,
status: 'VALID',
event: {
date: { gte: start, lte: end },
},
},
include: {
event: {
select: { id: true, title: true, date: true, startTime: true, endTime: true, venueName: true },
},
},
});
for (const ticket of tickets) {
const e = ticket.event;
allItems.push({
id: `ticket-${ticket.id}`,
type: 'ticket',
title: e.title,
date: e.date.toISOString().split('T')[0],
startTime: e.startTime,
endTime: e.endTime,
location: e.venueName,
color: ticketsLayer.color,
itemType: 'EVENT',
layerId: ticketsLayer.id,
layerName: ticketsLayer.name,
layerColor: ticketsLayer.color,
});
}
} catch (err) {
logger.debug('Failed to fetch tickets for personal calendar:', err);
}
}
// 4. System layer: POLLS (from SchedulingPollVote where userId matches)
const pollsLayer = systemLayersByType.get(CalendarSystemType.POLLS);
if (pollsLayer) {
try {
const votes = await prisma.schedulingPollVote.findMany({
where: {
userId,
option: {
date: { gte: start, lte: end },
},
},
include: {
poll: { select: { id: true, title: true, location: true } },
option: { select: { id: true, date: true, startTime: true, endTime: true } },
},
});
// Deduplicate by poll+option (a user may have multiple votes per poll but same option)
const seen = new Set<string>();
for (const vote of votes) {
const key = `${vote.poll.id}-${vote.option.id}`;
if (seen.has(key)) continue;
seen.add(key);
allItems.push({
id: `poll-${vote.poll.id}-${vote.option.id}`,
type: 'poll',
title: vote.poll.title,
date: vote.option.date.toISOString().split('T')[0],
startTime: vote.option.startTime,
endTime: vote.option.endTime,
location: vote.poll.location,
color: pollsLayer.color,
itemType: 'EVENT',
layerId: pollsLayer.id,
layerName: pollsLayer.name,
layerColor: pollsLayer.color,
});
}
} catch (err) {
logger.debug('Failed to fetch polls for personal calendar:', err);
}
}
// Sort all items by date then time
allItems.sort((a, b) => {
const dateCompare = a.date.localeCompare(b.date);
if (dateCompare !== 0) return dateCompare;
return a.startTime.localeCompare(b.startTime);
});
// Group by date
const dates: Record<string, { count: number; items: PersonalCalendarItem[] }> = {};
for (const item of allItems) {
if (!dates[item.date]) {
dates[item.date] = { count: 0, items: [] };
}
dates[item.date].count++;
dates[item.date].items.push(item);
}
return { dates };
},
};
// =========================================================================
// Helpers
// =========================================================================
/**
* Generate dates matching a recurrence rule within a range.
*/
function generateRecurrenceDates(
rule: RecurrenceRule,
startDate: Date,
endDate: Date,
interval: number
): Date[] {
const dates: Date[] = [];
const maxOccurrences = 200; // safety limit
const current = new Date(startDate);
let count = 0;
switch (rule.frequency) {
case 'DAILY': {
while (current <= endDate && count < maxOccurrences) {
dates.push(new Date(current));
count++;
current.setDate(current.getDate() + interval);
}
break;
}
case 'WEEKLY': {
const daysOfWeek = rule.daysOfWeek ?? [];
if (daysOfWeek.length === 0) break;
// Walk day-by-day, tracking week boundaries for interval
const weekStart = new Date(startDate);
let weekNum = 0;
while (current <= endDate && count < maxOccurrences) {
// Check if we moved to a new week (ISO: Monday=1)
const daysSinceStart = Math.floor(
(current.getTime() - weekStart.getTime()) / (1000 * 60 * 60 * 24)
);
const currentWeek = Math.floor(daysSinceStart / 7);
if (currentWeek !== weekNum) {
weekNum = currentWeek;
}
// Only include if this week matches the interval
if (weekNum % interval === 0) {
// Convert JS day (0=Sun) to ISO day (1=Mon...7=Sun)
const jsDay = current.getDay();
const isoDay = jsDay === 0 ? 7 : jsDay;
if (daysOfWeek.includes(isoDay)) {
dates.push(new Date(current));
count++;
}
}
current.setDate(current.getDate() + 1);
}
break;
}
case 'BIWEEKLY': {
const biDays = rule.daysOfWeek ?? [];
if (biDays.length === 0) break;
const biWeekStart = new Date(startDate);
let biWeekNum = 0;
while (current <= endDate && count < maxOccurrences) {
const daysSinceStart = Math.floor(
(current.getTime() - biWeekStart.getTime()) / (1000 * 60 * 60 * 24)
);
const currentWeek = Math.floor(daysSinceStart / 7);
if (currentWeek !== biWeekNum) {
biWeekNum = currentWeek;
}
// Every 2 weeks
if (biWeekNum % 2 === 0) {
const jsDay = current.getDay();
const isoDay = jsDay === 0 ? 7 : jsDay;
if (biDays.includes(isoDay)) {
dates.push(new Date(current));
count++;
}
}
current.setDate(current.getDate() + 1);
}
break;
}
case 'MONTHLY': {
const dayOfMonth = rule.dayOfMonth ?? startDate.getDate();
while (current <= endDate && count < maxOccurrences) {
// Set to the target day of month
const targetDate = new Date(current.getFullYear(), current.getMonth(), dayOfMonth);
// Handle months with fewer days (e.g., Feb 30 → clamp to last day)
if (targetDate.getMonth() !== current.getMonth()) {
// Day overflowed — use last day of month
targetDate.setDate(0); // goes to last day of previous month
targetDate.setMonth(targetDate.getMonth() + 1);
targetDate.setDate(0);
}
if (targetDate >= startDate && targetDate <= endDate) {
dates.push(targetDate);
count++;
}
current.setMonth(current.getMonth() + interval);
}
break;
}
}
return dates;
}

View File

@@ -0,0 +1,61 @@
import { z } from 'zod';
export const createSharedViewSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().optional(),
includedLayerTypes: z.array(z.string()),
shareScope: z.enum(['MEMBERS', 'PUBLIC']),
});
export const updateSharedViewSchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().nullable().optional(),
includedLayerTypes: z.array(z.string()).optional(),
shareScope: z.enum(['MEMBERS', 'PUBLIC']).optional(),
});
export const inviteMembersSchema = z.object({
userIds: z.array(z.string().min(1)).min(1).max(20),
});
export const respondToInviteSchema = z.object({
status: z.enum(['ACCEPTED', 'DECLINED']),
});
export const createCommentSchema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'),
itemId: z.string().optional(),
content: z.string().min(1).max(500),
});
export const createReactionSchema = z.object({
itemId: z.string().min(1),
emoji: z.string().min(1).max(4),
});
export const shareItemSchema = z.object({
friendIds: z.array(z.string().min(1)).min(1).max(10),
message: z.string().max(200).optional(),
});
export const availabilityQuerySchema = z.object({
start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'start must be YYYY-MM-DD'),
end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'end must be YYYY-MM-DD'),
dayStart: z.string().regex(/^\d{2}:\d{2}$/).default('09:00'),
dayEnd: z.string().regex(/^\d{2}:\d{2}$/).default('17:00'),
slotDuration: z.coerce.number().refine(v => [15, 30, 60].includes(v)).default(30),
});
export const dateRangeQuerySchema = z.object({
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'startDate must be YYYY-MM-DD'),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'endDate must be YYYY-MM-DD'),
});
export type CreateSharedViewInput = z.infer<typeof createSharedViewSchema>;
export type UpdateSharedViewInput = z.infer<typeof updateSharedViewSchema>;
export type InviteMembersInput = z.infer<typeof inviteMembersSchema>;
export type RespondToInviteInput = z.infer<typeof respondToInviteSchema>;
export type CreateCommentInput = z.infer<typeof createCommentSchema>;
export type CreateReactionInput = z.infer<typeof createReactionSchema>;
export type ShareItemInput = z.infer<typeof shareItemSchema>;
export type AvailabilityQueryInput = z.infer<typeof availabilityQuerySchema>;

View File

@@ -9,7 +9,7 @@ import { logger } from '../../utils/logger';
export interface UnifiedCalendarItem {
id: string;
type: 'shift' | 'event' | 'poll';
type: 'shift' | 'event' | 'poll' | 'ticketed_event';
title: string;
date: string; // YYYY-MM-DD
startTime: string; // HH:MM
@@ -28,6 +28,14 @@ export interface UnifiedCalendarItem {
pollSlug?: string;
pollStatus?: string;
pollVoteCount?: number;
// Ticketed event-specific
ticketedEventId?: string;
eventSlug?: string;
eventFormat?: string;
hasPaidTiers?: boolean;
isSoldOut?: boolean;
maxAttendees?: number | null;
currentAttendees?: number;
}
export interface UnifiedCalendarResponse {
@@ -55,19 +63,23 @@ export const unifiedCalendarService = {
// Set end to end of day
end.setHours(23, 59, 59, 999);
// Fetch shifts, Gancio events, and polls in parallel
const [shifts, gancioEvents, pollItems] = await Promise.all([
// Fetch shifts, Gancio events, polls, and ticketed events in parallel
const [shifts, gancioEvents, pollItems, ticketedEventItems] = await Promise.all([
this.fetchShifts(start, end),
this.fetchGancioEvents(start, end),
this.fetchPolls(start, end),
this.fetchTicketedEvents(start, end),
]);
// Build set of Gancio event IDs that correspond to synced shifts (to deduplicate)
const syncedGancioIds = new Set(
shifts
// Build set of Gancio event IDs that correspond to synced shifts or ticketed events (to deduplicate)
const syncedGancioIds = new Set([
...shifts
.filter(s => s.gancioEventId !== null)
.map(s => s.gancioEventId!),
);
...ticketedEventItems
.filter(te => te.gancioEventId)
.map(te => te.gancioEventId!),
]);
// Normalize shifts into calendar items
const shiftItems: UnifiedCalendarItem[] = shifts.map(s => ({
@@ -105,7 +117,7 @@ export const unifiedCalendarService = {
});
// Merge and group by date
const allItems = [...shiftItems, ...eventItems, ...pollItems];
const allItems = [...shiftItems, ...eventItems, ...pollItems, ...ticketedEventItems];
allItems.sort((a, b) => a.startTime.localeCompare(b.startTime));
const dates: Record<string, { count: number; items: UnifiedCalendarItem[] }> = {};
@@ -216,6 +228,64 @@ export const unifiedCalendarService = {
}
},
async fetchTicketedEvents(start: Date, end: Date): Promise<UnifiedCalendarItem[]> {
try {
const events = await prisma.ticketedEvent.findMany({
where: {
status: 'PUBLISHED',
date: { gte: start, lte: end },
},
select: {
id: true,
slug: true,
title: true,
date: true,
startTime: true,
endTime: true,
eventFormat: true,
venueName: true,
maxAttendees: true,
currentAttendees: true,
gancioEventId: true,
ticketTiers: {
select: { tierType: true, soldCount: true, maxQuantity: true },
},
},
orderBy: [{ date: 'asc' }, { startTime: 'asc' }],
});
return events.map(e => {
const hasPaidTiers = e.ticketTiers.some(t => t.tierType === 'PAID');
const isSoldOut = e.maxAttendees ? e.currentAttendees >= e.maxAttendees : false;
const formatTags: string[] = [];
if (e.eventFormat === 'ONLINE') formatTags.push('online');
if (e.eventFormat === 'HYBRID') formatTags.push('hybrid');
const location = e.eventFormat === 'ONLINE' ? 'Online Event' : e.venueName;
return {
id: `ticketed-${e.id}`,
type: 'ticketed_event' as const,
title: e.title,
date: e.date.toISOString().split('T')[0],
startTime: e.startTime,
endTime: e.endTime,
location,
tags: ['ticketed', ...(hasPaidTiers ? ['paid'] : ['free']), ...formatTags],
ticketedEventId: e.id,
eventSlug: e.slug,
eventFormat: e.eventFormat,
gancioEventId: e.gancioEventId ?? undefined,
hasPaidTiers,
isSoldOut,
maxAttendees: e.maxAttendees,
currentAttendees: e.currentAttendees,
};
});
} catch (err) {
logger.debug('Failed to fetch ticketed events for calendar:', err);
return [];
}
},
async fetchGancioEvents(start: Date, end: Date): Promise<GancioEvent[]> {
try {
const events = await gancioClient.fetchPublicEvents();

View File

@@ -99,6 +99,11 @@ export const campaignEmailsService = {
})
.catch(() => {});
// Fire-and-forget: check campaign milestones
import('../../social/impact-stories.service').then(({ impactStoriesService }) => {
impactStoriesService.checkMilestones(campaign.id).catch(() => {});
}).catch(() => {});
return {
id: campaignEmail.id,
status: campaignEmail.status,
@@ -150,6 +155,11 @@ export const campaignEmailsService = {
// Social group sync (fire-and-forget)
groupService.syncCampaignTeam(campaign.id).catch(() => {});
// Fire-and-forget: check campaign milestones
import('../../social/impact-stories.service').then(({ impactStoriesService }) => {
impactStoriesService.checkMilestones(campaign.id).catch(() => {});
}).catch(() => {});
return {
id: campaignEmail.id,
status: campaignEmail.status,

File diff suppressed because it is too large Load Diff

View File

@@ -47,6 +47,9 @@ export const webhookService = {
case 'charge.refunded':
await this.handleChargeRefunded(event.data.object as Stripe.Charge);
break;
case 'checkout.session.expired':
await this.handleCheckoutExpired(event.data.object as Stripe.Checkout.Session);
break;
default:
logger.debug(`Unhandled Stripe event type: ${event.type}`);
}
@@ -61,6 +64,8 @@ export const webhookService = {
await this.handleProductCheckout(session);
} else if (type === 'donation') {
await this.handleDonationCheckout(session);
} else if (type === 'event_ticket') {
await this.handleEventTicketCheckout(session);
} else {
logger.warn(`Unknown checkout type: ${type}`);
}
@@ -359,6 +364,87 @@ export const webhookService = {
logger.info(`Subscription cancelled: ${subscription.id}`);
},
async handleEventTicketCheckout(session: Stripe.Checkout.Session) {
const order = await prisma.order.findUnique({
where: { stripeCheckoutSessionId: session.id },
});
if (!order) {
logger.error('Order not found for event ticket checkout', { sessionId: session.id });
return;
}
if (order.status === 'COMPLETED') return; // idempotent
const { eventId, tierId, quantity, buyerEmail, buyerName, userId } = session.metadata || {};
if (!eventId || !tierId || !quantity) {
logger.error('Missing metadata in event ticket checkout', { sessionId: session.id });
return;
}
const paymentIntentId = typeof session.payment_intent === 'string'
? session.payment_intent
: (session.payment_intent as { id: string } | null)?.id || null;
// Complete the order
await prisma.order.update({
where: { id: order.id },
data: {
status: 'COMPLETED',
stripePaymentIntentId: paymentIntentId,
completedAt: new Date(),
},
});
// Create tickets
try {
const { ticketsService } = await import('../ticketed-events/tickets.service');
const { ticketEmailService } = await import('../ticketed-events/ticket-email.service');
const tickets = await ticketsService.createTickets({
eventId,
tierId,
quantity: parseInt(quantity, 10),
holderEmail: buyerEmail || order.buyerEmail,
holderName: buyerName || order.buyerName || undefined,
userId: userId || order.userId || undefined,
orderId: order.id,
});
// Fetch event + tier for email
const event = await prisma.ticketedEvent.findUnique({ where: { id: eventId } });
const tier = await prisma.ticketTier.findUnique({ where: { id: tierId } });
if (event && tier) {
for (const ticket of tickets) {
ticketEmailService.sendTicketConfirmation({
holderEmail: ticket.holderEmail,
holderName: ticket.holderName,
ticketCode: ticket.ticketCode,
token: (ticket as Record<string, unknown>).token as string,
eventTitle: event.title,
eventDate: event.date,
eventStartTime: event.startTime,
eventEndTime: event.endTime,
eventSlug: event.slug,
venueName: event.venueName,
venueAddress: event.venueAddress,
tierName: tier.name,
}).catch(() => {});
}
}
await this.createAuditLog('event_ticket_purchased', {
orderId: order.id,
eventId,
tierId,
ticketCount: tickets.length,
amount: order.amountCAD,
});
logger.info(`Event ticket order completed: ${order.id}, ${tickets.length} tickets`);
} catch (err) {
logger.error('Failed to create tickets after checkout:', err);
}
},
async handleChargeRefunded(charge: Stripe.Charge) {
const paymentIntentId = typeof charge.payment_intent === 'string'
? charge.payment_intent
@@ -375,6 +461,30 @@ export const webhookService = {
data: { status: 'REFUNDED' },
});
await this.createAuditLog('order_refunded', { orderId: order.id });
// If this was an event ticket order, refund the linked tickets
if (order.type === 'event_ticket') {
const tickets = await prisma.ticket.findMany({
where: { orderId: order.id, status: 'VALID' },
});
for (const ticket of tickets) {
await prisma.ticket.update({
where: { id: ticket.id },
data: { status: 'REFUNDED' },
});
await prisma.ticketTier.update({
where: { id: ticket.tierId },
data: { soldCount: { decrement: 1 } },
});
await prisma.ticketedEvent.update({
where: { id: ticket.eventId },
data: { currentAttendees: { decrement: 1 } },
});
}
if (tickets.length > 0) {
logger.info(`Refunded ${tickets.length} event tickets for order ${order.id}`);
}
}
}
// Check payments
@@ -389,6 +499,20 @@ export const webhookService = {
}
},
async handleCheckoutExpired(session: Stripe.Checkout.Session) {
// Clean up pending orders for expired checkout sessions
const order = await prisma.order.findUnique({
where: { stripeCheckoutSessionId: session.id },
});
if (order && order.status === 'PENDING') {
await prisma.order.update({
where: { id: order.id },
data: { status: 'FAILED' },
});
logger.info(`Checkout expired, order marked failed: ${order.id}`);
}
},
async createAuditLog(action: string, metadata: Record<string, unknown>) {
try {
logger.info(`Payment audit: ${action}`, metadata);

View File

@@ -57,6 +57,9 @@ export const updateSiteSettingsSchema = z.object({
enableSocial: z.boolean().optional(),
enableMeet: z.boolean().optional(),
enableMeetingPlanner: z.boolean().optional(),
enableTicketedEvents: z.boolean().optional(),
enableSocialCalendar: z.boolean().optional(),
requireEventApproval: z.boolean().optional(),
autoSyncPeopleToMap: z.boolean().optional(),
// SMS connection config

View File

@@ -169,6 +169,113 @@ const ACHIEVEMENTS: AchievementDef[] = [
where: { userId },
}),
},
// Referral achievements
{
id: 'FIRST_REFERRAL',
name: 'Ambassador',
description: 'Refer your first friend to the platform',
icon: 'usergroup-add',
category: 'social',
threshold: 1,
getProgress: async (userId) =>
prisma.referral.count({ where: { referrerId: userId } }),
},
{
id: 'REFERRAL_5',
name: 'Recruiter',
description: 'Refer 5 friends to the platform',
icon: 'usergroup-add',
category: 'social',
threshold: 5,
getProgress: async (userId) =>
prisma.referral.count({ where: { referrerId: userId } }),
},
{
id: 'REFERRAL_25',
name: 'Movement Builder',
description: 'Refer 25 friends to the platform',
icon: 'star',
category: 'social',
threshold: 25,
getProgress: async (userId) =>
prisma.referral.count({ where: { referrerId: userId } }),
},
// Campaign milestone achievement
{
id: 'MILESTONE_CONTRIBUTOR',
name: 'Milestone Maker',
description: 'Participate in a campaign that reaches a milestone',
icon: 'trophy',
category: 'campaigns',
threshold: 1,
getProgress: async (userId) => {
const result = await prisma.campaignEmail.findMany({
where: {
userId,
campaign: { milestones: { some: {} } },
},
distinct: ['campaignId'],
select: { campaignId: true },
});
return result.length;
},
},
// Spotlight achievement
{
id: 'SPOTLIGHT_STAR',
name: 'Spotlight Star',
description: 'Be featured as a volunteer spotlight',
icon: 'star',
category: 'social',
threshold: 1,
getProgress: async (userId) =>
prisma.volunteerSpotlight.count({
where: { userId, status: 'FEATURED' },
}),
},
// Challenge achievements
{
id: 'FIRST_CHALLENGE',
name: 'Challenger',
description: 'Join your first team challenge',
icon: 'trophy',
category: 'social',
threshold: 1,
getProgress: async (userId) =>
prisma.challengeTeamMember.count({ where: { userId } }),
},
{
id: 'CHALLENGE_WINNER',
name: 'Champion',
description: 'Win a team challenge',
icon: 'crown',
category: 'social',
threshold: 1,
getProgress: async (userId) => {
// Count challenges where user's team has the highest score and challenge is COMPLETED
const teams = await prisma.challengeTeamMember.findMany({
where: { userId },
include: {
team: {
include: {
challenge: { select: { id: true, status: true } },
},
},
},
});
let wins = 0;
for (const membership of teams) {
if (membership.team.challenge.status !== 'COMPLETED') continue;
const topTeam = await prisma.challengeTeam.findFirst({
where: { challengeId: membership.team.challenge.id },
orderBy: { score: 'desc' },
select: { id: true },
});
if (topTeam?.id === membership.teamId) wins++;
}
return wins;
},
},
];
/** Achievement map for quick lookup */

View File

@@ -0,0 +1,177 @@
import { Router } from 'express';
import type { Request, Response } from 'express';
import { requireRole } from '../../middleware/rbac.middleware';
import { challengeService } from './challenge.service';
import {
createChallengeSchema,
updateChallengeSchema,
createTeamSchema,
listChallengesSchema,
} from './challenge.schemas';
const router = Router();
// ── Authenticated (any logged-in user) ───────────────────────────────
/** GET / — list challenges */
router.get('/', async (req: Request, res: Response) => {
try {
const { page, limit, status } = listChallengesSchema.parse(req.query);
const result = await challengeService.listChallenges(page, limit, status);
res.json(result);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'CHALLENGES_LIST_ERROR' } });
}
});
/** GET /:id — get challenge detail */
router.get('/:id', async (req: Request, res: Response) => {
try {
const challenge = await challengeService.findById(req.params.id as string);
res.json(challenge);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'CHALLENGE_GET_ERROR' } });
}
});
/** GET /:id/leaderboard — leaderboard */
router.get('/:id/leaderboard', async (req: Request, res: Response) => {
try {
const result = await challengeService.getLeaderboard(req.params.id as string);
res.json(result);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'LEADERBOARD_ERROR' } });
}
});
/** GET /:id/my-team — get my team for this challenge */
router.get('/:id/my-team', async (req: Request, res: Response) => {
try {
const team = await challengeService.getMyTeam(req.params.id as string, req.user!.id);
res.json({ team });
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'MY_TEAM_ERROR' } });
}
});
/** POST /:id/teams — create team */
router.post('/:id/teams', async (req: Request, res: Response) => {
try {
const { name } = createTeamSchema.parse(req.body);
const team = await challengeService.createTeam(req.params.id as string, req.user!.id, name);
res.status(201).json(team);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'CREATE_TEAM_ERROR' } });
}
});
/** POST /:id/teams/:teamId/join — join team */
router.post('/:id/teams/:teamId/join', async (req: Request, res: Response) => {
try {
const team = await challengeService.joinTeam(req.params.teamId as string, req.user!.id);
res.json(team);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'JOIN_TEAM_ERROR' } });
}
});
/** POST /:id/teams/:teamId/leave — leave team */
router.post('/:id/teams/:teamId/leave', async (req: Request, res: Response) => {
try {
const result = await challengeService.leaveTeam(req.params.teamId as string, req.user!.id);
res.json(result);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'LEAVE_TEAM_ERROR' } });
}
});
/** GET /:id/teams/:teamId — team detail */
router.get('/:id/teams/:teamId', async (req: Request, res: Response) => {
try {
const team = await challengeService.getTeamDetail(req.params.teamId as string);
res.json(team);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'TEAM_DETAIL_ERROR' } });
}
});
// ── Admin ────────────────────────────────────────────────────────────
const adminRouter = Router();
adminRouter.use(requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'));
/** POST /admin — create challenge */
adminRouter.post('/', async (req: Request, res: Response) => {
try {
const data = createChallengeSchema.parse(req.body);
const challenge = await challengeService.create(data, req.user!.id);
res.status(201).json(challenge);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'CREATE_CHALLENGE_ERROR' } });
}
});
/** PUT /admin/:id — update challenge */
adminRouter.put('/:id', async (req: Request, res: Response) => {
try {
const data = updateChallengeSchema.parse(req.body);
const challenge = await challengeService.update(req.params.id as string, data);
res.json(challenge);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'UPDATE_CHALLENGE_ERROR' } });
}
});
/** DELETE /admin/:id — delete challenge */
adminRouter.delete('/:id', async (req: Request, res: Response) => {
try {
const result = await challengeService.delete(req.params.id as string);
res.json(result);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'DELETE_CHALLENGE_ERROR' } });
}
});
/** POST /admin/:id/activate — activate challenge */
adminRouter.post('/:id/activate', async (req: Request, res: Response) => {
try {
const challenge = await challengeService.activate(req.params.id as string);
res.json(challenge);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'ACTIVATE_ERROR' } });
}
});
/** POST /admin/:id/complete — force complete */
adminRouter.post('/:id/complete', async (req: Request, res: Response) => {
try {
const challenge = await challengeService.complete(req.params.id as string);
res.json(challenge);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'COMPLETE_ERROR' } });
}
});
/** POST /admin/:id/cancel — cancel challenge */
adminRouter.post('/:id/cancel', async (req: Request, res: Response) => {
try {
const challenge = await challengeService.cancel(req.params.id as string);
res.json(challenge);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'CANCEL_ERROR' } });
}
});
/** POST /admin/:id/rescore — force rescore */
adminRouter.post('/:id/rescore', async (req: Request, res: Response) => {
try {
const result = await challengeService.rescore(req.params.id as string);
res.json(result);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'RESCORE_ERROR' } });
}
});
router.use('/admin', adminRouter);
export { router as challengeRouter };

View File

@@ -0,0 +1,37 @@
import { z } from 'zod';
export const createChallengeSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
metric: z.enum([
'DOORS_KNOCKED',
'EMAILS_SENT',
'SHIFTS_ATTENDED',
'RESPONSES_SUBMITTED',
'REFERRALS_MADE',
]),
startsAt: z.string().datetime(),
endsAt: z.string().datetime(),
minTeamSize: z.number().int().min(1).max(50).default(2),
maxTeamSize: z.number().int().min(2).max(100).default(10),
maxTeams: z.number().int().min(1).optional(),
});
export const updateChallengeSchema = createChallengeSchema.partial();
export const createTeamSchema = z.object({
name: z.string().min(1).max(100),
});
export const listChallengesSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
status: z
.enum(['DRAFT', 'UPCOMING', 'ACTIVE', 'COMPLETED', 'CANCELLED'])
.optional(),
});
export type CreateChallengeInput = z.infer<typeof createChallengeSchema>;
export type UpdateChallengeInput = z.infer<typeof updateChallengeSchema>;
export type CreateTeamInput = z.infer<typeof createTeamSchema>;
export type ListChallengesInput = z.infer<typeof listChallengesSchema>;

View File

@@ -0,0 +1,347 @@
import { prisma } from '../../config/database';
import { challengeScoringService } from '../../services/challenge-scoring.service';
import type { ChallengeStatus } from '@prisma/client';
import type { CreateChallengeInput, UpdateChallengeInput } from './challenge.schemas';
const USER_SELECT = { id: true, email: true, name: true } as const;
export const challengeService = {
// ── Admin ──────────────────────────────────────────────────────────
async create(data: CreateChallengeInput, userId: string) {
return prisma.challenge.create({
data: {
title: data.title,
description: data.description,
metric: data.metric,
startsAt: new Date(data.startsAt),
endsAt: new Date(data.endsAt),
minTeamSize: data.minTeamSize,
maxTeamSize: data.maxTeamSize,
maxTeams: data.maxTeams,
createdByUserId: userId,
status: 'DRAFT',
},
});
},
async update(id: string, data: UpdateChallengeInput) {
const challenge = await prisma.challenge.findUnique({ where: { id } });
if (!challenge) {
throw Object.assign(new Error('Challenge not found'), { statusCode: 404 });
}
if (challenge.status !== 'DRAFT' && challenge.status !== 'UPCOMING') {
throw Object.assign(new Error('Can only edit DRAFT or UPCOMING challenges'), { statusCode: 400 });
}
return prisma.challenge.update({
where: { id },
data: {
...(data.title !== undefined && { title: data.title }),
...(data.description !== undefined && { description: data.description }),
...(data.metric !== undefined && { metric: data.metric }),
...(data.startsAt !== undefined && { startsAt: new Date(data.startsAt) }),
...(data.endsAt !== undefined && { endsAt: new Date(data.endsAt) }),
...(data.minTeamSize !== undefined && { minTeamSize: data.minTeamSize }),
...(data.maxTeamSize !== undefined && { maxTeamSize: data.maxTeamSize }),
...(data.maxTeams !== undefined && { maxTeams: data.maxTeams }),
},
});
},
async delete(id: string) {
const challenge = await prisma.challenge.findUnique({ where: { id } });
if (!challenge) {
throw Object.assign(new Error('Challenge not found'), { statusCode: 404 });
}
if (challenge.status !== 'DRAFT' && challenge.status !== 'CANCELLED') {
throw Object.assign(new Error('Can only delete DRAFT or CANCELLED challenges'), { statusCode: 400 });
}
await prisma.challenge.delete({ where: { id } });
return { success: true };
},
async activate(id: string) {
const challenge = await prisma.challenge.findUnique({ where: { id } });
if (!challenge) {
throw Object.assign(new Error('Challenge not found'), { statusCode: 404 });
}
if (challenge.status !== 'DRAFT') {
throw Object.assign(new Error('Can only activate DRAFT challenges'), { statusCode: 400 });
}
const now = new Date();
const newStatus: ChallengeStatus = challenge.startsAt <= now ? 'ACTIVE' : 'UPCOMING';
return prisma.challenge.update({
where: { id },
data: { status: newStatus },
});
},
async complete(id: string) {
const challenge = await prisma.challenge.findUnique({ where: { id } });
if (!challenge) {
throw Object.assign(new Error('Challenge not found'), { statusCode: 404 });
}
if (challenge.status !== 'ACTIVE') {
throw Object.assign(new Error('Can only complete ACTIVE challenges'), { statusCode: 400 });
}
await challengeScoringService.scoreChallenge(id);
return prisma.challenge.update({
where: { id },
data: { status: 'COMPLETED' },
});
},
async cancel(id: string) {
const challenge = await prisma.challenge.findUnique({ where: { id } });
if (!challenge) {
throw Object.assign(new Error('Challenge not found'), { statusCode: 404 });
}
if (challenge.status === 'COMPLETED' || challenge.status === 'CANCELLED') {
throw Object.assign(new Error('Challenge is already completed or cancelled'), { statusCode: 400 });
}
return prisma.challenge.update({
where: { id },
data: { status: 'CANCELLED' },
});
},
async rescore(id: string) {
const challenge = await prisma.challenge.findUnique({ where: { id } });
if (!challenge) {
throw Object.assign(new Error('Challenge not found'), { statusCode: 404 });
}
await challengeScoringService.scoreChallenge(id);
return { success: true };
},
// ── Queries ────────────────────────────────────────────────────────
async findById(id: string) {
const challenge = await prisma.challenge.findUnique({
where: { id },
include: {
createdBy: { select: USER_SELECT },
teams: {
include: {
captain: { select: USER_SELECT },
members: {
include: { user: { select: USER_SELECT } },
orderBy: { score: 'desc' },
},
},
orderBy: { score: 'desc' },
},
},
});
if (!challenge) {
throw Object.assign(new Error('Challenge not found'), { statusCode: 404 });
}
return challenge;
},
async listChallenges(page: number, limit: number, status?: ChallengeStatus) {
const where = status ? { status } : {};
const skip = (page - 1) * limit;
const [challenges, total] = await Promise.all([
prisma.challenge.findMany({
where,
include: {
_count: { select: { teams: true } },
createdBy: { select: USER_SELECT },
},
orderBy: { createdAt: 'desc' },
skip,
take: limit,
}),
prisma.challenge.count({ where }),
]);
return {
challenges,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
async getLeaderboard(challengeId: string) {
const teams = await prisma.challengeTeam.findMany({
where: { challengeId },
include: {
captain: { select: USER_SELECT },
members: {
include: { user: { select: USER_SELECT } },
orderBy: { score: 'desc' },
},
},
orderBy: { score: 'desc' },
});
return { teams };
},
async getTeamDetail(teamId: string) {
const team = await prisma.challengeTeam.findUnique({
where: { id: teamId },
include: {
challenge: true,
captain: { select: USER_SELECT },
members: {
include: { user: { select: USER_SELECT } },
orderBy: { score: 'desc' },
},
},
});
if (!team) {
throw Object.assign(new Error('Team not found'), { statusCode: 404 });
}
return team;
},
// ── User actions ───────────────────────────────────────────────────
async createTeam(challengeId: string, userId: string, teamName: string) {
const challenge = await prisma.challenge.findUnique({
where: { id: challengeId },
include: { _count: { select: { teams: true } } },
});
if (!challenge) {
throw Object.assign(new Error('Challenge not found'), { statusCode: 404 });
}
if (challenge.status !== 'UPCOMING' && challenge.status !== 'ACTIVE') {
throw Object.assign(new Error('Challenge is not accepting teams'), { statusCode: 400 });
}
if (challenge.maxTeams && challenge._count.teams >= challenge.maxTeams) {
throw Object.assign(new Error('Maximum number of teams reached'), { statusCode: 400 });
}
// Check user not already on a team for this challenge
const existing = await prisma.challengeTeamMember.findFirst({
where: { userId, team: { challengeId } },
});
if (existing) {
throw Object.assign(new Error('You are already on a team for this challenge'), { statusCode: 409 });
}
const team = await prisma.challengeTeam.create({
data: {
challengeId,
name: teamName,
captainUserId: userId,
members: {
create: { userId },
},
},
include: {
captain: { select: USER_SELECT },
members: { include: { user: { select: USER_SELECT } } },
},
});
return team;
},
async joinTeam(teamId: string, userId: string) {
const team = await prisma.challengeTeam.findUnique({
where: { id: teamId },
include: {
challenge: true,
_count: { select: { members: true } },
},
});
if (!team) {
throw Object.assign(new Error('Team not found'), { statusCode: 404 });
}
if (team.challenge.status !== 'UPCOMING' && team.challenge.status !== 'ACTIVE') {
throw Object.assign(new Error('Challenge is not accepting members'), { statusCode: 400 });
}
if (team._count.members >= team.challenge.maxTeamSize) {
throw Object.assign(new Error('Team is full'), { statusCode: 400 });
}
// Check user not already on another team for this challenge
const existing = await prisma.challengeTeamMember.findFirst({
where: { userId, team: { challengeId: team.challengeId } },
});
if (existing) {
throw Object.assign(new Error('You are already on a team for this challenge'), { statusCode: 409 });
}
await prisma.challengeTeamMember.create({
data: { teamId, userId },
});
return prisma.challengeTeam.findUnique({
where: { id: teamId },
include: {
captain: { select: USER_SELECT },
members: { include: { user: { select: USER_SELECT } } },
},
});
},
async leaveTeam(teamId: string, userId: string) {
const member = await prisma.challengeTeamMember.findUnique({
where: { teamId_userId: { teamId, userId } },
});
if (!member) {
throw Object.assign(new Error('You are not on this team'), { statusCode: 404 });
}
const team = await prisma.challengeTeam.findUnique({
where: { id: teamId },
include: { _count: { select: { members: true } } },
});
if (!team) {
throw Object.assign(new Error('Team not found'), { statusCode: 404 });
}
// If captain and last member, delete entire team
if (team.captainUserId === userId && team._count.members === 1) {
await prisma.challengeTeam.delete({ where: { id: teamId } });
return { success: true, teamDeleted: true };
}
// Remove member
await prisma.challengeTeamMember.delete({
where: { teamId_userId: { teamId, userId } },
});
// If captain, transfer to next member
if (team.captainUserId === userId) {
const nextMember = await prisma.challengeTeamMember.findFirst({
where: { teamId },
orderBy: { joinedAt: 'asc' },
});
if (nextMember) {
await prisma.challengeTeam.update({
where: { id: teamId },
data: { captainUserId: nextMember.userId },
});
}
}
return { success: true, teamDeleted: false };
},
async getMyTeam(challengeId: string, userId: string) {
const member = await prisma.challengeTeamMember.findFirst({
where: { userId, team: { challengeId } },
include: {
team: {
include: {
captain: { select: USER_SELECT },
members: {
include: { user: { select: USER_SELECT } },
orderBy: { score: 'desc' },
},
},
},
},
});
return member?.team ?? null;
},
};

View File

@@ -5,7 +5,7 @@ import { friendshipService } from './friendship.service';
/** A unified feed item representing any activity type */
export interface FeedItem {
id: string;
type: 'shift_signup' | 'campaign_email' | 'canvass_session' | 'response_submitted';
type: 'shift_signup' | 'campaign_email' | 'canvass_session' | 'response_submitted' | 'impact_story' | 'volunteer_featured' | 'referral_completed' | 'challenge_completed';
userId: string;
userName: string | null;
userEmail: string;
@@ -56,11 +56,15 @@ export const feedService = {
since.setDate(since.getDate() - FEED_MAX_AGE_DAYS);
// Query all activity types in parallel
const [shiftSignups, campaignEmails, canvassSessions, responses] = await Promise.all([
const [shiftSignups, campaignEmails, canvassSessions, responses, impactStories, spotlights, referrals, challenges] = await Promise.all([
this.getShiftSignupActivities(visibleFriendIds, since),
this.getCampaignEmailActivities(visibleFriendIds, since),
this.getCanvassSessionActivities(visibleFriendIds, since),
this.getResponseActivities(visibleFriendIds, since),
this.getImpactStoryActivities(since),
this.getSpotlightActivities(since),
this.getReferralActivities(visibleFriendIds, since),
this.getChallengeActivities(since),
]);
// Merge and sort by timestamp descending
@@ -69,6 +73,10 @@ export const feedService = {
...campaignEmails,
...canvassSessions,
...responses,
...impactStories,
...spotlights,
...referrals,
...challenges,
].sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
// Cap total items
@@ -94,11 +102,12 @@ export const feedService = {
const since = new Date();
since.setDate(since.getDate() - FEED_MAX_AGE_DAYS);
const [shiftSignups, campaignEmails, canvassSessions, responses] = await Promise.all([
const [shiftSignups, campaignEmails, canvassSessions, responses, referrals] = await Promise.all([
this.getShiftSignupActivities([userId], since),
this.getCampaignEmailActivities([userId], since),
this.getCanvassSessionActivities([userId], since),
this.getResponseActivities([userId], since),
this.getReferralActivities([userId], since),
]);
const allItems: FeedItem[] = [
@@ -106,6 +115,7 @@ export const feedService = {
...campaignEmails,
...canvassSessions,
...responses,
...referrals,
].sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
const total = allItems.length;
@@ -235,4 +245,121 @@ export const feedService = {
timestamp: r.createdAt,
}));
},
async getImpactStoryActivities(since: Date): Promise<FeedItem[]> {
const stories = await prisma.impactStory.findMany({
where: {
status: 'PUBLISHED',
publishedAt: { gte: since },
},
include: {
campaign: { select: { id: true, title: true, slug: true } },
createdBy: { select: { id: true, name: true, email: true } },
},
orderBy: { publishedAt: 'desc' },
take: FEED_MAX_ITEMS,
});
return stories.map((s) => ({
id: `impact_story:${s.id}`,
type: 'impact_story' as const,
userId: s.createdBy?.id || '',
userName: s.createdBy?.name || null,
userEmail: s.createdBy?.email || '',
title: s.title,
description: `${s.campaign.title}${s.milestoneValue ? `${s.milestoneValue} ${s.milestoneMetric || 'milestone'}` : ''}`,
metadata: { campaignId: s.campaign.id, campaignSlug: s.campaign.slug, storyType: s.type },
timestamp: s.publishedAt || s.createdAt,
}));
},
async getSpotlightActivities(since: Date): Promise<FeedItem[]> {
const spotlights = await prisma.volunteerSpotlight.findMany({
where: {
status: 'FEATURED',
updatedAt: { gte: since },
},
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: { updatedAt: 'desc' },
take: FEED_MAX_ITEMS,
});
return spotlights.map((s) => ({
id: `spotlight:${s.id}`,
type: 'volunteer_featured' as const,
userId: s.user.id,
userName: s.user.name,
userEmail: s.user.email,
title: 'Featured as Volunteer Spotlight',
description: s.headline || `Spotlight for ${s.featuredMonth || 'this month'}`,
metadata: { spotlightId: s.id, featuredMonth: s.featuredMonth },
timestamp: s.updatedAt,
}));
},
async getReferralActivities(userIds: string[], since: Date): Promise<FeedItem[]> {
const referrals = await prisma.referral.findMany({
where: {
referrerId: { in: userIds },
completedAt: { gte: since },
},
include: {
referrer: { select: { id: true, name: true, email: true } },
referredUser: { select: { id: true, name: true } },
},
orderBy: { completedAt: 'desc' },
take: FEED_MAX_ITEMS,
});
return referrals.map((r) => ({
id: `referral:${r.id}`,
type: 'referral_completed' as const,
userId: r.referrer.id,
userName: r.referrer.name,
userEmail: r.referrer.email,
title: 'Referred a new member',
description: `${r.referredUser.name || 'A new member'} joined the platform`,
metadata: { referredUserId: r.referredUser.id },
timestamp: r.completedAt,
}));
},
async getChallengeActivities(since: Date): Promise<FeedItem[]> {
const challenges = await prisma.challenge.findMany({
where: {
status: 'COMPLETED',
updatedAt: { gte: since },
},
include: {
teams: {
orderBy: { score: 'desc' },
take: 1,
include: {
captain: { select: { id: true, name: true, email: true } },
},
},
},
orderBy: { updatedAt: 'desc' },
take: FEED_MAX_ITEMS,
});
return challenges
.filter((c) => c.teams.length > 0)
.map((c) => {
const winner = c.teams[0];
return {
id: `challenge:${c.id}`,
type: 'challenge_completed' as const,
userId: winner.captain.id,
userName: winner.captain.name,
userEmail: winner.captain.email,
title: `Challenge completed: ${c.title}`,
description: `Team "${winner.name}" won with ${winner.score} points`,
metadata: { challengeId: c.id, winningTeamId: winner.id, metric: c.metric },
timestamp: c.updatedAt,
};
});
},
};

View File

@@ -0,0 +1,102 @@
import { Router } from 'express';
import { requireRole } from '../../middleware/rbac.middleware';
import { impactStoriesService } from './impact-stories.service';
import { createStorySchema, updateStorySchema, listStoriesSchema } from './impact-stories.schemas';
const router = Router();
// --- Admin routes (require admin role) ---
router.post('/', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req, res, next) => {
try {
const data = createStorySchema.parse(req.body);
const story = await impactStoriesService.create(data, req.user!.id);
res.status(201).json(story);
} catch (err) {
next(err);
}
});
router.put('/:id', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req, res, next) => {
try {
const data = updateStorySchema.parse(req.body);
const story = await impactStoriesService.update(req.params.id as string, data);
res.json(story);
} catch (err) {
next(err);
}
});
router.delete('/:id', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req, res, next) => {
try {
const result = await impactStoriesService.delete(req.params.id as string);
res.json(result);
} catch (err) {
next(err);
}
});
router.post('/:id/publish', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req, res, next) => {
try {
const story = await impactStoriesService.publish(req.params.id as string);
// Fire-and-forget: notify participants
impactStoriesService.notifyParticipants(story.id).catch(() => {});
res.json(story);
} catch (err) {
next(err);
}
});
router.post('/:id/archive', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req, res, next) => {
try {
const story = await impactStoriesService.archive(req.params.id as string);
res.json(story);
} catch (err) {
next(err);
}
});
// --- Authenticated routes (any logged-in user) ---
router.get('/', async (req, res, next) => {
try {
const { page, limit, campaignId, status } = listStoriesSchema.parse(req.query);
// Admin users can filter by status; regular users see published only
const userRoles = req.user!.roles || [req.user!.role];
const isAdmin = userRoles.some((r: string) =>
['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'].includes(r),
);
if (isAdmin && (campaignId || status)) {
const result = await impactStoriesService.listAdmin(page, limit, campaignId, status);
res.json(result);
} else {
const result = await impactStoriesService.listPublished(page, limit);
res.json(result);
}
} catch (err) {
next(err);
}
});
router.get('/campaign/:campaignId', async (req, res, next) => {
try {
const { page, limit } = listStoriesSchema.parse(req.query);
const campaignId = req.params.campaignId as string;
const result = await impactStoriesService.listByCampaign(campaignId, page, limit);
res.json(result);
} catch (err) {
next(err);
}
});
router.get('/:id', async (req, res, next) => {
try {
const story = await impactStoriesService.findById(req.params.id as string);
res.json(story);
} catch (err) {
next(err);
}
});
export const impactStoriesRouter = router;

View File

@@ -0,0 +1,32 @@
import { z } from 'zod';
export const createStorySchema = z.object({
campaignId: z.string().min(1),
type: z.enum(['MILESTONE', 'VICTORY', 'RESPONSE', 'CUSTOM']),
title: z.string().min(1).max(200),
body: z.string().min(1).max(5000),
coverImageUrl: z.string().url().optional(),
milestoneValue: z.number().int().positive().optional(),
milestoneMetric: z.string().max(100).optional(),
});
export const updateStorySchema = z.object({
campaignId: z.string().min(1).optional(),
type: z.enum(['MILESTONE', 'VICTORY', 'RESPONSE', 'CUSTOM']).optional(),
title: z.string().min(1).max(200).optional(),
body: z.string().min(1).max(5000).optional(),
coverImageUrl: z.string().url().nullable().optional(),
milestoneValue: z.number().int().positive().nullable().optional(),
milestoneMetric: z.string().max(100).nullable().optional(),
});
export const listStoriesSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
campaignId: z.string().optional(),
status: z.enum(['DRAFT', 'PUBLISHED', 'ARCHIVED']).optional(),
});
export type CreateStoryInput = z.infer<typeof createStorySchema>;
export type UpdateStoryInput = z.infer<typeof updateStorySchema>;
export type ListStoriesInput = z.infer<typeof listStoriesSchema>;

View File

@@ -0,0 +1,308 @@
import { prisma } from '../../config/database';
import { ImpactStoryStatus, type Prisma } from '@prisma/client';
import { AppError } from '../../middleware/error-handler';
import { notificationService } from './notification.service';
import { logger } from '../../utils/logger';
import type { CreateStoryInput, UpdateStoryInput } from './impact-stories.schemas';
const MILESTONE_THRESHOLDS = [50, 100, 250, 500, 1000, 2500, 5000];
export const impactStoriesService = {
async create(data: CreateStoryInput, userId: string) {
// Verify campaign exists
const campaign = await prisma.campaign.findUnique({
where: { id: data.campaignId },
select: { id: true },
});
if (!campaign) {
throw new AppError(404, 'Campaign not found', 'CAMPAIGN_NOT_FOUND');
}
return prisma.impactStory.create({
data: {
campaignId: data.campaignId,
type: data.type,
title: data.title,
body: data.body,
coverImageUrl: data.coverImageUrl,
milestoneValue: data.milestoneValue,
milestoneMetric: data.milestoneMetric,
createdByUserId: userId,
},
include: {
campaign: { select: { title: true } },
createdBy: { select: { name: true, email: true } },
},
});
},
async update(id: string, data: UpdateStoryInput) {
const existing = await prisma.impactStory.findUnique({ where: { id } });
if (!existing) {
throw new AppError(404, 'Story not found', 'STORY_NOT_FOUND');
}
return prisma.impactStory.update({
where: { id },
data: {
...(data.campaignId !== undefined && { campaignId: data.campaignId }),
...(data.type !== undefined && { type: data.type }),
...(data.title !== undefined && { title: data.title }),
...(data.body !== undefined && { body: data.body }),
...(data.coverImageUrl !== undefined && { coverImageUrl: data.coverImageUrl }),
...(data.milestoneValue !== undefined && { milestoneValue: data.milestoneValue }),
...(data.milestoneMetric !== undefined && { milestoneMetric: data.milestoneMetric }),
},
include: {
campaign: { select: { title: true } },
createdBy: { select: { name: true, email: true } },
},
});
},
async delete(id: string) {
const existing = await prisma.impactStory.findUnique({ where: { id } });
if (!existing) {
throw new AppError(404, 'Story not found', 'STORY_NOT_FOUND');
}
await prisma.impactStory.delete({ where: { id } });
return { success: true };
},
async publish(id: string) {
const existing = await prisma.impactStory.findUnique({ where: { id } });
if (!existing) {
throw new AppError(404, 'Story not found', 'STORY_NOT_FOUND');
}
return prisma.impactStory.update({
where: { id },
data: {
status: ImpactStoryStatus.PUBLISHED,
publishedAt: new Date(),
},
include: {
campaign: { select: { title: true } },
createdBy: { select: { name: true, email: true } },
},
});
},
async archive(id: string) {
const existing = await prisma.impactStory.findUnique({ where: { id } });
if (!existing) {
throw new AppError(404, 'Story not found', 'STORY_NOT_FOUND');
}
return prisma.impactStory.update({
where: { id },
data: { status: ImpactStoryStatus.ARCHIVED },
include: {
campaign: { select: { title: true } },
createdBy: { select: { name: true, email: true } },
},
});
},
async findById(id: string) {
const story = await prisma.impactStory.findUnique({
where: { id },
include: {
campaign: { select: { title: true } },
createdBy: { select: { name: true, email: true } },
},
});
if (!story) {
throw new AppError(404, 'Story not found', 'STORY_NOT_FOUND');
}
return story;
},
async listByCampaign(campaignId: string, page: number, limit: number) {
const skip = (page - 1) * limit;
const where: Prisma.ImpactStoryWhereInput = { campaignId };
const [stories, total] = await Promise.all([
prisma.impactStory.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
campaign: { select: { title: true } },
createdBy: { select: { name: true, email: true } },
},
}),
prisma.impactStory.count({ where }),
]);
return {
stories,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
async listPublished(page: number, limit: number) {
const skip = (page - 1) * limit;
const where: Prisma.ImpactStoryWhereInput = { status: ImpactStoryStatus.PUBLISHED };
const [stories, total] = await Promise.all([
prisma.impactStory.findMany({
where,
skip,
take: limit,
orderBy: { publishedAt: 'desc' },
include: {
campaign: { select: { title: true } },
createdBy: { select: { name: true, email: true } },
},
}),
prisma.impactStory.count({ where }),
]);
return {
stories,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
async listAdmin(page: number, limit: number, campaignId?: string, status?: string) {
const skip = (page - 1) * limit;
const where: Prisma.ImpactStoryWhereInput = {};
if (campaignId) where.campaignId = campaignId;
if (status) where.status = status as ImpactStoryStatus;
const [stories, total] = await Promise.all([
prisma.impactStory.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
campaign: { select: { title: true } },
createdBy: { select: { name: true, email: true } },
},
}),
prisma.impactStory.count({ where }),
]);
return {
stories,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
async checkMilestones(campaignId: string) {
try {
// Count total emails for this campaign
const emailCount = await prisma.campaignEmail.count({
where: { campaignId },
});
// Get campaign info for auto-generated story titles
const campaign = await prisma.campaign.findUnique({
where: { id: campaignId },
select: { id: true, title: true, createdByUserId: true },
});
if (!campaign) return;
for (const threshold of MILESTONE_THRESHOLDS) {
if (emailCount < threshold) break; // Thresholds are sorted, no point checking higher
// Upsert milestone using the @@unique constraint
const milestone = await prisma.campaignMilestone.upsert({
where: {
campaignId_metric_threshold: {
campaignId,
metric: 'emails_sent',
threshold,
},
},
update: {},
create: {
campaignId,
metric: 'emails_sent',
threshold,
reachedAt: new Date(),
storyGenerated: false,
},
});
// If milestone exists but no story generated yet, create a draft
if (!milestone.storyGenerated) {
await prisma.$transaction(async (tx) => {
await tx.impactStory.create({
data: {
campaignId,
type: 'MILESTONE',
title: `${campaign.title} reached ${threshold} emails!`,
body: `A major milestone has been reached! The campaign "${campaign.title}" has now received ${threshold} advocacy emails from supporters.`,
milestoneValue: threshold,
milestoneMetric: 'emails_sent',
createdByUserId: campaign.createdByUserId,
},
});
await tx.campaignMilestone.update({
where: { id: milestone.id },
data: { storyGenerated: true },
});
});
// Notify campaign creator
if (campaign.createdByUserId) {
notificationService.createNotification(
campaign.createdByUserId,
'system',
'Campaign Milestone Reached!',
`"${campaign.title}" has reached ${threshold} emails!`,
{ campaignId, milestone: threshold, type: 'campaign_milestone' },
).catch(() => {});
}
logger.info(`Milestone ${threshold} reached for campaign ${campaignId}`);
}
}
} catch (err) {
logger.warn(`Failed to check milestones for campaign ${campaignId}:`, err);
}
},
async notifyParticipants(storyId: string) {
const story = await prisma.impactStory.findUnique({
where: { id: storyId },
include: { campaign: { select: { id: true, title: true } } },
});
if (!story) {
throw new AppError(404, 'Story not found', 'STORY_NOT_FOUND');
}
// Get unique user IDs from campaign emails (only registered users)
const participants = await prisma.campaignEmail.findMany({
where: { campaignId: story.campaignId, userId: { not: null } },
distinct: ['userId'],
select: { userId: true },
take: 100,
});
let notified = 0;
for (const p of participants) {
if (!p.userId) continue;
try {
await notificationService.createNotification(
p.userId,
'system',
story.title,
`New update for "${story.campaign.title}": ${story.title}`,
{ storyId: story.id, campaignId: story.campaignId, type: 'impact_story' },
);
notified++;
} catch {
// Skip individual notification failures
}
}
logger.info(`Notified ${notified} participants about story ${storyId}`);
return { notified };
},
};

View File

@@ -13,6 +13,12 @@ const TYPE_TO_PREF: Record<string, string> = {
achievement: 'enableAchievements',
system: 'enableSystemUpdates',
group_call: 'enableSystemUpdates',
impact_story: 'enableSystemUpdates',
referral_completed: 'enableSystemUpdates',
challenge_update: 'enableSystemUpdates',
shared_view_invite: 'enableFriendRequests',
shared_view_accepted: 'enableFriendRequests',
calendar_event_invite: 'enableFriendRequests',
};
export const notificationService = {

View File

@@ -11,6 +11,7 @@ const DEFAULTS: Omit<PrivacySettings, 'id' | 'userId' | 'createdAt' | 'updatedAt
hidePublicFinishes: false,
allowFriendRequests: true,
closeFriendsOnlyWatching: false,
showOnLeaderboard: true,
};
export const privacyService = {

View File

@@ -0,0 +1,96 @@
import { Router } from 'express';
import type { Request, Response } from 'express';
import { requireRole } from '../../middleware/rbac.middleware';
import { referralService } from './referral.service';
import { createInviteCodeSchema, validateCodeSchema, paginationSchema } from './referral.schemas';
const router = Router();
/** POST /api/social/referrals/codes — create invite code */
router.post('/codes', async (req: Request, res: Response) => {
try {
const data = createInviteCodeSchema.parse(req.body);
const code = await referralService.createInviteCode(req.user!.id, data);
res.status(201).json(code);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'CREATE_CODE_ERROR' } });
}
});
/** GET /api/social/referrals/codes — list my codes */
router.get('/codes', async (req: Request, res: Response) => {
try {
const { page, limit } = paginationSchema.parse(req.query);
const result = await referralService.listMyCodes(req.user!.id, page, limit);
res.json(result);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'LIST_CODES_ERROR' } });
}
});
/** DELETE /api/social/referrals/codes/:id — deactivate code */
router.delete('/codes/:id', async (req: Request, res: Response) => {
try {
const codeId = req.params.id as string;
const result = await referralService.deactivateCode(req.user!.id, codeId);
res.json(result);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'DEACTIVATE_CODE_ERROR' } });
}
});
/** GET /api/social/referrals/validate/:code — validate an invite code */
router.get('/validate/:code', async (req: Request, res: Response) => {
try {
const { code } = validateCodeSchema.parse(req.params);
const result = await referralService.validateCode(code);
res.json(result);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'VALIDATE_CODE_ERROR' } });
}
});
/** GET /api/social/referrals/my-referrals — list people I referred */
router.get('/my-referrals', async (req: Request, res: Response) => {
try {
const { page, limit } = paginationSchema.parse(req.query);
const result = await referralService.getMyReferrals(req.user!.id, page, limit);
res.json(result);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'MY_REFERRALS_ERROR' } });
}
});
/** GET /api/social/referrals/stats — my referral stats */
router.get('/stats', async (req: Request, res: Response) => {
try {
const stats = await referralService.getReferralStats(req.user!.id);
res.json(stats);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'REFERRAL_STATS_ERROR' } });
}
});
/** GET /api/social/referrals/admin/all — all referrals (admin only) */
router.get('/admin/all', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req: Request, res: Response) => {
try {
const { page, limit } = paginationSchema.parse(req.query);
const result = await referralService.listAllReferrals(page, limit);
res.json(result);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'ADMIN_REFERRALS_ERROR' } });
}
});
/** GET /api/social/referrals/admin/leaderboard — top referrers (admin only) */
router.get('/admin/leaderboard', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req: Request, res: Response) => {
try {
const limit = parseInt((req.query.limit as string) || '10', 10);
const leaderboard = await referralService.getReferralLeaderboard(Math.min(limit, 50));
res.json({ leaderboard });
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'LEADERBOARD_ERROR' } });
}
});
export { router as referralRouter };

View File

@@ -0,0 +1,20 @@
import { z } from 'zod';
export const createInviteCodeSchema = z.object({
maxUses: z.coerce.number().int().min(0).optional(),
expiresInDays: z.coerce.number().int().min(1).max(365).optional(),
note: z.string().max(200).optional(),
});
export const validateCodeSchema = z.object({
code: z.string().min(1).max(20),
});
export const paginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
export type CreateInviteCodeInput = z.infer<typeof createInviteCodeSchema>;
export type ValidateCodeInput = z.infer<typeof validateCodeSchema>;
export type PaginationInput = z.infer<typeof paginationSchema>;

View File

@@ -0,0 +1,209 @@
import crypto from 'crypto';
import { prisma } from '../../config/database';
import { logger } from '../../utils/logger';
import { AppError } from '../../middleware/error-handler';
import { notificationService } from './notification.service';
import { achievementsService } from './achievements.service';
function generateCode(): string {
return crypto.randomBytes(6).toString('base64url').slice(0, 8).toUpperCase();
}
export const referralService = {
async createInviteCode(
userId: string,
opts: { maxUses?: number; expiresInDays?: number; note?: string },
) {
const code = generateCode();
const expiresAt = opts.expiresInDays
? new Date(Date.now() + opts.expiresInDays * 24 * 60 * 60 * 1000)
: null;
return prisma.inviteCode.create({
data: {
code,
createdByUserId: userId,
maxUses: opts.maxUses ?? 0,
expiresAt,
note: opts.note,
},
});
},
async listMyCodes(userId: string, page: number, limit: number) {
const skip = (page - 1) * limit;
const [codes, total] = await Promise.all([
prisma.inviteCode.findMany({
where: { createdByUserId: userId },
orderBy: { createdAt: 'desc' },
skip,
take: limit,
include: { _count: { select: { referrals: true } } },
}),
prisma.inviteCode.count({ where: { createdByUserId: userId } }),
]);
return {
codes,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
async deactivateCode(userId: string, codeId: string) {
const code = await prisma.inviteCode.findUnique({ where: { id: codeId } });
if (!code) throw new AppError(404, 'Invite code not found', 'CODE_NOT_FOUND');
if (code.createdByUserId !== userId) throw new AppError(403, 'Not your invite code', 'FORBIDDEN');
return prisma.inviteCode.update({
where: { id: codeId },
data: { isActive: false },
});
},
async validateCode(code: string): Promise<{ valid: boolean; referrerId?: string; error?: string }> {
const inviteCode = await prisma.inviteCode.findUnique({ where: { code } });
if (!inviteCode) return { valid: false, error: 'Code not found' };
if (!inviteCode.isActive) return { valid: false, error: 'Code is no longer active' };
if (inviteCode.expiresAt && inviteCode.expiresAt < new Date()) {
return { valid: false, error: 'Code has expired' };
}
if (inviteCode.maxUses > 0 && inviteCode.usedCount >= inviteCode.maxUses) {
return { valid: false, error: 'Code has reached maximum uses' };
}
return { valid: true, referrerId: inviteCode.createdByUserId };
},
async processRegistrationReferral(newUserId: string, inviteCode?: string) {
if (!inviteCode) return;
const validation = await this.validateCode(inviteCode);
if (!validation.valid || !validation.referrerId) {
logger.warn(`Invalid invite code "${inviteCode}" used during registration for user ${newUserId}`);
return;
}
const codeRecord = await prisma.inviteCode.findUnique({ where: { code: inviteCode } });
if (!codeRecord) return;
// Check if user was already referred (unique constraint on referredUserId)
const existing = await prisma.referral.findUnique({ where: { referredUserId: newUserId } });
if (existing) return;
await prisma.inviteCode.update({
where: { id: codeRecord.id },
data: { usedCount: { increment: 1 } },
});
await prisma.referral.create({
data: {
referrerId: validation.referrerId,
referredUserId: newUserId,
inviteCodeId: codeRecord.id,
referralSource: 'invite_code',
},
});
// Notify the referrer
const newUser = await prisma.user.findUnique({
where: { id: newUserId },
select: { name: true, email: true },
});
const displayName = newUser?.name || newUser?.email || 'Someone';
await notificationService.createNotification(
validation.referrerId,
'referral_completed',
'New Referral!',
`${displayName} joined using your invite code`,
{ referredUserId: newUserId, inviteCode },
);
// Check social achievements
achievementsService.checkAndUnlock(validation.referrerId, ['social']).catch((err) => {
logger.warn('Referral achievement check failed:', err);
});
},
async getMyReferrals(userId: string, page: number, limit: number) {
const skip = (page - 1) * limit;
const [referrals, total] = await Promise.all([
prisma.referral.findMany({
where: { referrerId: userId },
orderBy: { completedAt: 'desc' },
skip,
take: limit,
include: {
referredUser: { select: { id: true, name: true, email: true } },
},
}),
prisma.referral.count({ where: { referrerId: userId } }),
]);
return {
referrals,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
async getReferralStats(userId: string) {
const now = new Date();
const firstOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const [totalReferrals, thisMonth] = await Promise.all([
prisma.referral.count({ where: { referrerId: userId } }),
prisma.referral.count({
where: { referrerId: userId, completedAt: { gte: firstOfMonth } },
}),
]);
return { totalReferrals, thisMonth };
},
async listAllReferrals(page: number, limit: number) {
const skip = (page - 1) * limit;
const [referrals, total] = await Promise.all([
prisma.referral.findMany({
orderBy: { completedAt: 'desc' },
skip,
take: limit,
include: {
referrer: { select: { id: true, name: true, email: true } },
referredUser: { select: { id: true, name: true, email: true } },
},
}),
prisma.referral.count(),
]);
return {
referrals,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
async getReferralLeaderboard(limit: number = 10) {
const results = await prisma.$queryRaw<{ referrer_id: string; count: bigint }[]>`
SELECT referrer_id, COUNT(*) as count
FROM referrals
GROUP BY referrer_id
ORDER BY count DESC
LIMIT ${limit}
`;
if (results.length === 0) return [];
const userIds = results.map((r) => r.referrer_id);
const users = await prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, name: true, email: true },
});
const userMap = new Map(users.map((u) => [u.id, u]));
return results.map((r, i) => ({
rank: i + 1,
userId: r.referrer_id,
name: userMap.get(r.referrer_id)?.name || null,
email: userMap.get(r.referrer_id)?.email || '',
referralCount: Number(r.count),
}));
},
};

View File

@@ -15,6 +15,10 @@ import { groupRouter } from './group.routes';
import { achievementsRouter } from './achievements.routes';
import { sseRouter } from './sse.routes';
import { socialAdminRouter } from './social-admin.routes';
import { referralRouter } from './referral.routes';
import { impactStoriesRouter } from './impact-stories.routes';
import { spotlightRouter } from './spotlight.routes';
import { challengeRouter } from './challenge.routes';
const router = Router();
@@ -47,5 +51,9 @@ router.use('/integration', integrationRouter);
router.use('/groups', groupRouter);
router.use('/achievements', achievementsRouter);
router.use('/sse', sseRouter);
router.use('/referrals', referralRouter);
router.use('/stories', impactStoriesRouter);
router.use('/spotlight', spotlightRouter);
router.use('/challenges', challengeRouter);
export { router as socialRouter };

View File

@@ -0,0 +1,190 @@
import { Router } from 'express';
import type { Request, Response, NextFunction } from 'express';
import { requireRole } from '../../middleware/rbac.middleware';
import { spotlightService } from './spotlight.service';
import {
nominateSchema,
updateSpotlightSchema,
featureSchema,
listSpotlightsSchema,
} from './spotlight.schemas';
const router = Router();
// ── Public / authenticated user routes ──────────────────────────────
/** GET /api/social/spotlight/featured — current month's featured spotlights */
router.get('/featured', async (_req: Request, res: Response, next: NextFunction) => {
try {
const spotlights = await spotlightService.getCurrentFeatured();
res.json({ spotlights });
} catch (err) {
next(err);
}
});
/** GET /api/social/spotlight/leaderboard — public leaderboard (filtered by opt-in) */
router.get('/leaderboard', async (req: Request, res: Response, next: NextFunction) => {
try {
const type = (req.query.type as string) || 'canvass';
if (!['canvass', 'shifts', 'campaigns'].includes(type)) {
return res.status(400).json({ error: { message: 'Invalid leaderboard type' } });
}
const limit = Math.min(parseInt(req.query.limit as string) || 10, 50);
const leaderboard = await spotlightService.getPublicLeaderboard(
type as 'canvass' | 'shifts' | 'campaigns',
limit,
);
res.json({ leaderboard, type });
} catch (err) {
next(err);
}
});
/** GET /api/social/spotlight/wall-of-fame — all featured spotlights */
router.get('/wall-of-fame', async (req: Request, res: Response, next: NextFunction) => {
try {
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const limit = Math.min(Math.max(1, parseInt(req.query.limit as string) || 20), 100);
const result = await spotlightService.getWallOfFame(page, limit);
res.json(result);
} catch (err) {
next(err);
}
});
/** GET /api/social/spotlight/opt-in-status — current user's leaderboard opt-in */
router.get('/opt-in-status', async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await spotlightService.getOptInStatus(req.user!.id);
res.json(result);
} catch (err) {
next(err);
}
});
/** POST /api/social/spotlight/opt-in — opt in to leaderboard */
router.post('/opt-in', async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await spotlightService.optIn(req.user!.id);
res.json(result);
} catch (err) {
next(err);
}
});
/** POST /api/social/spotlight/opt-out — opt out of leaderboard */
router.post('/opt-out', async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await spotlightService.optOut(req.user!.id);
res.json(result);
} catch (err) {
next(err);
}
});
// ── Admin routes ────────────────────────────────────────────────────
/** GET /api/social/spotlight/admin — list all spotlights */
router.get(
'/admin',
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { page, limit, status } = listSpotlightsSchema.parse(req.query);
const result = await spotlightService.listAll(page, limit, status as any);
res.json(result);
} catch (err) {
next(err);
}
},
);
/** POST /api/social/spotlight/admin/nominate — nominate a volunteer */
router.post(
'/admin/nominate',
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const data = nominateSchema.parse(req.body);
const spotlight = await spotlightService.nominate(data, req.user!.id);
res.status(201).json(spotlight);
} catch (err) {
next(err);
}
},
);
/** PUT /api/social/spotlight/admin/:id — update headline/story */
router.put(
'/admin/:id',
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const data = updateSpotlightSchema.parse(req.body);
const spotlight = await spotlightService.update(req.params.id as string, data);
res.json(spotlight);
} catch (err) {
next(err);
}
},
);
/** POST /api/social/spotlight/admin/:id/approve — approve a nomination */
router.post(
'/admin/:id/approve',
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const spotlight = await spotlightService.approve(req.params.id as string, req.user!.id);
res.json(spotlight);
} catch (err) {
next(err);
}
},
);
/** POST /api/social/spotlight/admin/:id/feature — feature for a month */
router.post(
'/admin/:id/feature',
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { month } = featureSchema.parse(req.body);
const spotlight = await spotlightService.feature(req.params.id as string, month);
res.json(spotlight);
} catch (err) {
next(err);
}
},
);
/** POST /api/social/spotlight/admin/:id/archive — archive a spotlight */
router.post(
'/admin/:id/archive',
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const spotlight = await spotlightService.archive(req.params.id as string);
res.json(spotlight);
} catch (err) {
next(err);
}
},
);
/** DELETE /api/social/spotlight/admin/:id — delete a spotlight */
router.delete(
'/admin/:id',
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await spotlightService.delete(req.params.id as string);
res.json(result);
} catch (err) {
next(err);
}
},
);
export const spotlightRouter = router;

View File

@@ -0,0 +1,27 @@
import { z } from 'zod';
export const nominateSchema = z.object({
userId: z.string().cuid(),
headline: z.string().max(200).optional(),
story: z.string().max(2000).optional(),
});
export const updateSpotlightSchema = z.object({
headline: z.string().max(200).optional(),
story: z.string().max(2000).optional(),
});
export const featureSchema = z.object({
month: z.string().regex(/^\d{4}-\d{2}$/, 'Month must be in YYYY-MM format'),
});
export const listSpotlightsSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
status: z.enum(['NOMINATED', 'APPROVED', 'FEATURED', 'ARCHIVED']).optional(),
});
export type NominateInput = z.infer<typeof nominateSchema>;
export type UpdateSpotlightInput = z.infer<typeof updateSpotlightSchema>;
export type FeatureInput = z.infer<typeof featureSchema>;
export type ListSpotlightsInput = z.infer<typeof listSpotlightsSchema>;

View File

@@ -0,0 +1,245 @@
import { prisma } from '../../config/database';
import { SpotlightStatus } from '@prisma/client';
import { notificationService } from './notification.service';
import { achievementsService } from './achievements.service';
import { logger } from '../../utils/logger';
import { AppError } from '../../middleware/error-handler';
export const spotlightService = {
/** Admin: nominate a volunteer for spotlight */
async nominate(
data: { userId: string; headline?: string; story?: string },
nominatedByUserId: string,
) {
// Verify the user exists
const user = await prisma.user.findUnique({ where: { id: data.userId } });
if (!user) throw new AppError(404, 'User not found', 'USER_NOT_FOUND');
const spotlight = await prisma.volunteerSpotlight.create({
data: {
userId: data.userId,
status: SpotlightStatus.NOMINATED,
headline: data.headline,
story: data.story,
nominatedByUserId,
},
include: { user: { select: { id: true, name: true, email: true } } },
});
logger.info(`Volunteer ${data.userId} nominated for spotlight by ${nominatedByUserId}`);
return spotlight;
},
/** Admin: approve a nomination */
async approve(id: string, approvedByUserId: string) {
const spotlight = await prisma.volunteerSpotlight.findUnique({ where: { id } });
if (!spotlight) throw new AppError(404, 'Spotlight not found', 'SPOTLIGHT_NOT_FOUND');
if (spotlight.status !== SpotlightStatus.NOMINATED) {
throw new AppError(400, 'Only nominated spotlights can be approved', 'INVALID_STATUS');
}
const updated = await prisma.volunteerSpotlight.update({
where: { id },
data: {
status: SpotlightStatus.APPROVED,
approvedByUserId,
approvedAt: new Date(),
},
include: { user: { select: { id: true, name: true, email: true } } },
});
await notificationService.createNotification(
spotlight.userId,
'achievement',
'Spotlight Approved',
'Your volunteer spotlight nomination has been approved!',
{ spotlightId: id },
);
return updated;
},
/** Admin: feature a spotlight for a specific month */
async feature(id: string, month: string) {
const spotlight = await prisma.volunteerSpotlight.findUnique({ where: { id } });
if (!spotlight) throw new AppError(404, 'Spotlight not found', 'SPOTLIGHT_NOT_FOUND');
if (spotlight.status !== SpotlightStatus.APPROVED) {
throw new AppError(400, 'Only approved spotlights can be featured', 'INVALID_STATUS');
}
const updated = await prisma.volunteerSpotlight.update({
where: { id },
data: {
status: SpotlightStatus.FEATURED,
featuredMonth: month,
},
include: { user: { select: { id: true, name: true, email: true } } },
});
await notificationService.createNotification(
spotlight.userId,
'achievement',
'You\'re Featured!',
`You have been featured as a Volunteer Spotlight for ${month}!`,
{ spotlightId: id, featuredMonth: month },
);
logger.info(`Spotlight ${id} featured for ${month}`);
return updated;
},
/** Admin: archive a spotlight */
async archive(id: string) {
const spotlight = await prisma.volunteerSpotlight.findUnique({ where: { id } });
if (!spotlight) throw new AppError(404, 'Spotlight not found', 'SPOTLIGHT_NOT_FOUND');
return prisma.volunteerSpotlight.update({
where: { id },
data: { status: SpotlightStatus.ARCHIVED },
include: { user: { select: { id: true, name: true, email: true } } },
});
},
/** Admin: update headline/story */
async update(id: string, data: { headline?: string; story?: string }) {
const spotlight = await prisma.volunteerSpotlight.findUnique({ where: { id } });
if (!spotlight) throw new AppError(404, 'Spotlight not found', 'SPOTLIGHT_NOT_FOUND');
return prisma.volunteerSpotlight.update({
where: { id },
data,
include: { user: { select: { id: true, name: true, email: true } } },
});
},
/** Admin: delete a spotlight */
async delete(id: string) {
const spotlight = await prisma.volunteerSpotlight.findUnique({ where: { id } });
if (!spotlight) throw new AppError(404, 'Spotlight not found', 'SPOTLIGHT_NOT_FOUND');
await prisma.volunteerSpotlight.delete({ where: { id } });
return { success: true };
},
/** Admin: list all spotlights (paginated, filterable) */
async listAll(page: number, limit: number, status?: SpotlightStatus) {
const where = status ? { status } : {};
const skip = (page - 1) * limit;
const [spotlights, total] = await Promise.all([
prisma.volunteerSpotlight.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: limit,
include: {
user: { select: { id: true, name: true, email: true } },
nominatedBy: { select: { id: true, name: true } },
approvedBy: { select: { id: true, name: true } },
},
}),
prisma.volunteerSpotlight.count({ where }),
]);
return {
spotlights,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
/** Public: get currently featured spotlights (this month) */
async getCurrentFeatured() {
const now = new Date();
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
return prisma.volunteerSpotlight.findMany({
where: {
status: SpotlightStatus.FEATURED,
featuredMonth: currentMonth,
},
include: {
user: { select: { id: true, name: true } },
},
orderBy: { createdAt: 'desc' },
});
},
/** Public: get leaderboard filtered by showOnLeaderboard privacy setting */
async getPublicLeaderboard(type: 'canvass' | 'shifts' | 'campaigns', limit: number) {
// Get a larger set from achievements service, then filter by privacy
const entries = await achievementsService.getLeaderboard(type, limit * 3);
if (entries.length === 0) return [];
// Filter by showOnLeaderboard
const userIds = entries.map((e) => e.userId);
const hiddenIds = new Set(
(await prisma.privacySettings.findMany({
where: {
userId: { in: userIds },
showOnLeaderboard: false,
},
select: { userId: true },
})).map((p) => p.userId),
);
const visible = entries
.filter((e) => !hiddenIds.has(e.userId))
.slice(0, limit)
.map((e, i) => ({ ...e, rank: i + 1 }));
return visible;
},
/** Public: wall of fame — all featured spotlights */
async getWallOfFame(page: number, limit: number) {
const skip = (page - 1) * limit;
const [spotlights, total] = await Promise.all([
prisma.volunteerSpotlight.findMany({
where: { status: SpotlightStatus.FEATURED },
orderBy: { featuredMonth: 'desc' },
skip,
take: limit,
include: {
user: { select: { id: true, name: true } },
},
}),
prisma.volunteerSpotlight.count({ where: { status: SpotlightStatus.FEATURED } }),
]);
return {
spotlights,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
/** User: get opt-in status for leaderboard */
async getOptInStatus(userId: string) {
const settings = await prisma.privacySettings.findUnique({
where: { userId },
select: { showOnLeaderboard: true },
});
return { showOnLeaderboard: settings?.showOnLeaderboard ?? true };
},
/** User: opt in to leaderboard */
async optIn(userId: string) {
await prisma.privacySettings.upsert({
where: { userId },
update: { showOnLeaderboard: true },
create: { userId, showOnLeaderboard: true },
});
return { showOnLeaderboard: true };
},
/** User: opt out of leaderboard */
async optOut(userId: string) {
await prisma.privacySettings.upsert({
where: { userId },
update: { showOnLeaderboard: false },
create: { userId, showOnLeaderboard: false },
});
return { showOnLeaderboard: false };
},
};

View File

@@ -0,0 +1,67 @@
import { Router, Request, Response, NextFunction } from 'express';
import { authenticate } from '../../middleware/auth.middleware';
import { validate } from '../../middleware/validate';
import { ticketsService } from './tickets.service';
import { validateTokenSchema, confirmCheckinSchema, manualCheckinSchema } from './ticketed-events.schemas';
import { prisma } from '../../config/database';
const router = Router();
// All check-in routes require authentication
router.use(authenticate);
// POST /validate — validate QR token (preview without marking checked in)
router.post('/validate', validate(validateTokenSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await ticketsService.validateToken(req.body.token);
res.json(result);
} catch (err) { next(err); }
});
// POST /confirm — confirm check-in via QR token
router.post('/confirm', validate(confirmCheckinSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await ticketsService.confirmCheckin(
req.body.token,
req.user!.id,
req.body.notes,
);
res.json(result);
} catch (err) { next(err); }
});
// POST /manual — manual check-in by code or email
router.post('/manual', validate(manualCheckinSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await ticketsService.manualCheckin({
eventId: req.body.eventId,
ticketCode: req.body.ticketCode,
holderEmail: req.body.holderEmail,
checkedInByUserId: req.user!.id,
notes: req.body.notes,
});
res.json(result);
} catch (err) { next(err); }
});
// GET /event/:eventId/stats — live check-in stats
router.get('/event/:eventId/stats', async (req: Request, res: Response, next: NextFunction) => {
try {
const eventId = req.params.eventId as string;
const [totalTickets, checkedIn, recentCheckIns] = await Promise.all([
prisma.ticket.count({ where: { eventId, status: { in: ['VALID', 'CHECKED_IN'] } } }),
prisma.ticket.count({ where: { eventId, status: 'CHECKED_IN' } }),
prisma.checkIn.findMany({
where: { eventId },
orderBy: { checkedInAt: 'desc' },
take: 5,
include: {
ticket: { select: { ticketCode: true, holderName: true } },
},
}),
]);
res.json({ totalTickets, checkedIn, remaining: totalTickets - checkedIn, recentCheckIns });
} catch (err) { next(err); }
});
export { router as checkinRouter };

View File

@@ -0,0 +1,148 @@
import { emailService } from '../../services/email.service';
import { siteSettingsService } from '../settings/settings.service';
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
export const ticketEmailService = {
/** Send ticket confirmation email with QR code */
async sendTicketConfirmation(params: {
holderEmail: string;
holderName?: string | null;
ticketCode: string;
token: string;
eventTitle: string;
eventDate: Date;
eventStartTime: string;
eventEndTime: string;
eventSlug: string;
venueName?: string | null;
venueAddress?: string | null;
tierName: string;
eventFormat?: string;
}): Promise<void> {
try {
const orgName = await this.getOrgName();
const baseUrl = env.ADMIN_URL || 'http://localhost:3000';
const checkinUrl = `${baseUrl}/event/${params.eventSlug}/checkin?token=${encodeURIComponent(params.token)}`;
const qrImageUrl = `${env.API_URL}/api/qr?text=${encodeURIComponent(checkinUrl)}&size=300`;
const eventUrl = `${baseUrl}/event/${params.eventSlug}`;
const ticketUrl = `${baseUrl}/event/${params.eventSlug}/ticket/${params.ticketCode}`;
const hasOnlineAccess = params.eventFormat === 'ONLINE' || params.eventFormat === 'HYBRID';
const vars: Record<string, string> = {
RECIPIENT_NAME: params.holderName || 'Guest',
EVENT_TITLE: params.eventTitle,
EVENT_DATE: params.eventDate.toLocaleDateString('en-CA', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
}),
EVENT_TIME: `${params.eventStartTime} ${params.eventEndTime}`,
VENUE_NAME: params.venueName || '',
VENUE_ADDRESS: params.venueAddress || '',
TICKET_TIER: params.tierName,
TICKET_CODE: params.ticketCode,
QR_IMAGE_URL: qrImageUrl,
EVENT_URL: eventUrl,
TICKET_URL: ticketUrl,
ORGANIZATION_NAME: orgName,
ONLINE_ACCESS: hasOnlineAccess
? 'This event has online access. Visit the event page and enter your ticket code to join the meeting.'
: '',
};
// Try DB template first, fall back to inline template
const dbTemplate = await emailService['loadTemplateFromDatabase']('ticket-confirmation');
let html: string, text: string, subject: string;
if (dbTemplate) {
html = await emailService.processTemplate(dbTemplate.html, vars);
text = await emailService.processTextTemplate(dbTemplate.text, vars);
subject = emailService.processSubject(dbTemplate.subject, vars);
} else {
// Inline fallback template
subject = `Your Ticket: ${params.eventTitle}${orgName}`;
html = this.getDefaultHtmlTemplate(vars);
text = this.getDefaultTextTemplate(vars);
}
await emailService.sendEmail({ to: params.holderEmail, subject, html, text });
logger.info(`Ticket confirmation sent to ${params.holderEmail} for ${params.ticketCode}`);
} catch (err) {
logger.error('Failed to send ticket confirmation email:', err);
}
},
getDefaultHtmlTemplate(vars: Record<string, string>): string {
const venue = vars.VENUE_NAME
? `<p style="margin:0;color:#999">📍 ${vars.VENUE_NAME}${vars.VENUE_ADDRESS ? `, ${vars.VENUE_ADDRESS}` : ''}</p>`
: '';
const onlineAccessHtml = vars.ONLINE_ACCESS
? `<div style="background:#f3e8ff;padding:15px;border-radius:8px;margin:20px 0;border-left:4px solid #9d4edd"><p style="margin:0;color:#6b21a8">🎥 ${vars.ONLINE_ACCESS}</p></div>`
: '';
return `
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#f5f5f5;font-family:Arial,sans-serif">
<div style="max-width:600px;margin:0 auto;padding:20px">
<div style="background:#1a1025;color:#fff;padding:30px;border-radius:12px 12px 0 0;text-align:center">
<h1 style="margin:0;font-size:24px">${vars.EVENT_TITLE}</h1>
<p style="margin:10px 0 0;opacity:0.8">${vars.EVENT_DATE} at ${vars.EVENT_TIME}</p>
${venue}
</div>
<div style="background:#fff;padding:30px;border-radius:0 0 12px 12px">
<p>Hi ${vars.RECIPIENT_NAME},</p>
<p>Your ticket is confirmed! Here are your details:</p>
${onlineAccessHtml}
<div style="background:#f8f8f8;padding:20px;border-radius:8px;margin:20px 0;text-align:center">
<p style="margin:0 0 10px;font-size:14px;color:#666">Ticket Code</p>
<p style="margin:0;font-size:28px;font-weight:bold;letter-spacing:2px;color:#9d4edd">${vars.TICKET_CODE}</p>
<p style="margin:10px 0 0;font-size:13px;color:#999">${vars.TICKET_TIER}</p>
</div>
<div style="text-align:center;margin:20px 0">
<p style="margin:0 0 10px;font-size:14px;color:#666">Show this QR code at the door:</p>
<img src="${vars.QR_IMAGE_URL}" alt="Ticket QR Code" width="200" height="200" style="border:1px solid #eee;border-radius:8px">
</div>
<div style="text-align:center;margin:20px 0">
<a href="${vars.TICKET_URL}" style="display:inline-block;background:#9d4edd;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;font-weight:bold">View Your Ticket</a>
</div>
<hr style="border:none;border-top:1px solid #eee;margin:20px 0">
<p style="font-size:13px;color:#999;text-align:center">${vars.ORGANIZATION_NAME}</p>
</div>
</div>
</body>
</html>`;
},
getDefaultTextTemplate(vars: Record<string, string>): string {
const venue = vars.VENUE_NAME
? `Venue: ${vars.VENUE_NAME}${vars.VENUE_ADDRESS ? `, ${vars.VENUE_ADDRESS}` : ''}\n`
: '';
const onlineAccessText = vars.ONLINE_ACCESS ? `\n${vars.ONLINE_ACCESS}\n` : '';
return `Your Ticket: ${vars.EVENT_TITLE}
Hi ${vars.RECIPIENT_NAME},
Your ticket is confirmed!
Event: ${vars.EVENT_TITLE}
Date: ${vars.EVENT_DATE}
Time: ${vars.EVENT_TIME}
${venue}Ticket: ${vars.TICKET_TIER}
Code: ${vars.TICKET_CODE}
${onlineAccessText}
View your ticket: ${vars.TICKET_URL}
${vars.ORGANIZATION_NAME}`;
},
async getOrgName(): Promise<string> {
try {
const settings = await siteSettingsService.get();
return settings.organizationName || 'Changemaker Lite';
} catch {
return 'Changemaker Lite';
}
},
};

View File

@@ -0,0 +1,279 @@
import { Router, Request, Response, NextFunction } from 'express';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { validate } from '../../middleware/validate';
import { ticketedEventsService } from './ticketed-events.service';
import { ticketsService } from './tickets.service';
import { ticketEmailService } from './ticket-email.service';
import {
createEventSchema,
updateEventSchema,
createTierSchema,
updateTierSchema,
} from './ticketed-events.schemas';
import { prisma } from '../../config/database';
import { UserRole } from '@prisma/client';
const router = Router();
const ADMIN_ROLES: UserRole[] = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'];
/** Middleware: require admin role OR canCreateTicketedEvents permission */
async function requireEventPermission(req: Request, _res: Response, next: NextFunction) {
if (!req.user) return next(new Error('Auth required'));
const userRoles = req.user.roles || [req.user.role];
if (userRoles.some(r => ADMIN_ROLES.includes(r as UserRole))) {
return next();
}
// Check user permissions
const user = await prisma.user.findUnique({
where: { id: req.user.id },
select: { permissions: true },
});
const perms = (user?.permissions as Record<string, unknown>) || {};
if (perms.canCreateTicketedEvents) {
return next();
}
return next({ status: 403, message: 'Insufficient permissions' });
}
// All routes require auth + event permission
router.use(authenticate, requireEventPermission);
// GET / — list events (admin sees all, users see own)
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const status = req.query.status as string | undefined;
const search = req.query.search as string | undefined;
const userRoles = req.user!.roles || [req.user!.role];
const isAdmin = userRoles.some(r => ADMIN_ROLES.includes(r as UserRole));
const result = await ticketedEventsService.list({
page,
limit,
status: status as never,
search,
createdByUserId: isAdmin ? undefined : req.user!.id,
});
res.json(result);
} catch (err) { next(err); }
});
// POST / — create event
router.post('/', validate(createEventSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const event = await ticketedEventsService.create(req.body, req.user!.id);
res.status(201).json(event);
} catch (err) { next(err); }
});
// GET /:id — event detail
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const event = await ticketedEventsService.findById(req.params.id as string);
res.json(event);
} catch (err) { next(err); }
});
// PUT /:id — update event
router.put('/:id', validate(updateEventSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const event = await ticketedEventsService.update(
req.params.id as string,
req.body,
req.user!.id,
req.user!.role,
);
res.json(event);
} catch (err) { next(err); }
});
// DELETE /:id — delete event (draft only)
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
await ticketedEventsService.deleteEvent(req.params.id as string, req.user!.id, req.user!.role);
res.json({ success: true });
} catch (err) { next(err); }
});
// POST /:id/publish
router.post('/:id/publish', async (req: Request, res: Response, next: NextFunction) => {
try {
const event = await ticketedEventsService.publish(req.params.id as string, req.user!.id, req.user!.role);
res.json(event);
} catch (err) { next(err); }
});
// POST /:id/approve (admin only)
router.post('/:id/approve', requireRole(...ADMIN_ROLES), async (req: Request, res: Response, next: NextFunction) => {
try {
const event = await ticketedEventsService.approve(req.params.id as string);
res.json(event);
} catch (err) { next(err); }
});
// POST /:id/reject (admin only)
router.post('/:id/reject', requireRole(...ADMIN_ROLES), async (req: Request, res: Response, next: NextFunction) => {
try {
const event = await ticketedEventsService.reject(req.params.id as string);
res.json(event);
} catch (err) { next(err); }
});
// POST /:id/cancel
router.post('/:id/cancel', async (req: Request, res: Response, next: NextFunction) => {
try {
const event = await ticketedEventsService.cancel(req.params.id as string, req.user!.id, req.user!.role);
res.json(event);
} catch (err) { next(err); }
});
// POST /:id/complete (admin only)
router.post('/:id/complete', requireRole(...ADMIN_ROLES), async (req: Request, res: Response, next: NextFunction) => {
try {
const event = await ticketedEventsService.complete(req.params.id as string);
res.json(event);
} catch (err) { next(err); }
});
// --- Meeting ---
// POST /:id/meeting-token — generate moderator JWT for Jitsi
router.post('/:id/meeting-token', async (req: Request, res: Response, next: NextFunction) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
select: { id: true, email: true, name: true },
});
if (!user) throw new Error('User not found');
const result = await ticketedEventsService.getModeratorToken(req.params.id as string, user);
res.json(result);
} catch (err) { next(err); }
});
// --- Tiers ---
// POST /:id/tiers
router.post('/:id/tiers', validate(createTierSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const tier = await ticketedEventsService.addTier(req.params.id as string, req.body);
res.status(201).json(tier);
} catch (err) { next(err); }
});
// PUT /:id/tiers/:tierId
router.put('/:id/tiers/:tierId', validate(updateTierSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const tier = await ticketedEventsService.updateTier(req.params.tierId as string, req.body);
res.json(tier);
} catch (err) { next(err); }
});
// DELETE /:id/tiers/:tierId
router.delete('/:id/tiers/:tierId', async (req: Request, res: Response, next: NextFunction) => {
try {
await ticketedEventsService.deleteTier(req.params.tierId as string);
res.json({ success: true });
} catch (err) { next(err); }
});
// --- Tickets ---
// GET /:id/tickets
router.get('/:id/tickets', async (req: Request, res: Response, next: NextFunction) => {
try {
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const search = req.query.search as string | undefined;
const status = req.query.status as string | undefined;
const result = await ticketedEventsService.getTickets(req.params.id as string, { page, limit, search, status });
res.json(result);
} catch (err) { next(err); }
});
// GET /:id/checkins
router.get('/:id/checkins', async (req: Request, res: Response, next: NextFunction) => {
try {
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const result = await ticketedEventsService.getCheckIns(req.params.id as string, { page, limit });
res.json(result);
} catch (err) { next(err); }
});
// GET /:id/stats
router.get('/:id/stats', async (req: Request, res: Response, next: NextFunction) => {
try {
const stats = await ticketedEventsService.getEventStats(req.params.id as string);
res.json(stats);
} catch (err) { next(err); }
});
// POST /:id/resend-ticket/:ticketId
router.post('/:id/resend-ticket/:ticketId', async (req: Request, res: Response, next: NextFunction) => {
try {
const ticket = await prisma.ticket.findUnique({
where: { id: req.params.ticketId as string },
include: {
event: true,
tier: { select: { name: true } },
},
});
if (!ticket) { res.status(404).json({ error: { message: 'Ticket not found' } }); return; }
if (ticket.eventId !== req.params.id) { res.status(400).json({ error: { message: 'Ticket does not belong to this event' } }); return; }
// Re-generate token for the email (token not stored, we need tokenHash lookup)
// For resend, we regenerate the token and update the hash
const crypto = await import('crypto');
const { env: envConfig } = await import('../../config/env');
const nonce = crypto.randomBytes(16);
const hmac = crypto.createHmac('sha256', envConfig.ENCRYPTION_KEY || envConfig.JWT_ACCESS_SECRET);
hmac.update(ticket.id);
hmac.update(nonce);
const token = Buffer.concat([
Buffer.from(ticket.id),
Buffer.from(':'),
nonce,
Buffer.from(':'),
hmac.digest(),
]).toString('base64url');
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
await prisma.ticket.update({
where: { id: ticket.id },
data: { tokenHash },
});
await ticketEmailService.sendTicketConfirmation({
holderEmail: ticket.holderEmail,
holderName: ticket.holderName,
ticketCode: ticket.ticketCode,
token,
eventTitle: ticket.event.title,
eventDate: ticket.event.date,
eventStartTime: ticket.event.startTime,
eventEndTime: ticket.event.endTime,
eventSlug: ticket.event.slug,
venueName: ticket.event.venueName,
venueAddress: ticket.event.venueAddress,
tierName: ticket.tier.name,
});
res.json({ success: true });
} catch (err) { next(err); }
});
// POST /:id/tickets/:ticketId/cancel
router.post('/:id/tickets/:ticketId/cancel', async (req: Request, res: Response, next: NextFunction) => {
try {
await ticketsService.cancelTicket(req.params.ticketId as string);
res.json({ success: true });
} catch (err) { next(err); }
});
export { router as ticketedEventsAdminRouter };

View File

@@ -0,0 +1,261 @@
import { Router, Request, Response, NextFunction } from 'express';
import { validate } from '../../middleware/validate';
import { optionalAuth } from '../../middleware/auth.middleware';
import { ticketedEventsService } from './ticketed-events.service';
import { ticketsService } from './tickets.service';
import { ticketEmailService } from './ticket-email.service';
import { checkoutSchema, registerFreeSchema } from './ticketed-events.schemas';
import { getStripe } from '../../services/stripe.client';
import { prisma } from '../../config/database';
import { env } from '../../config/env';
import { AppError } from '../../middleware/error-handler';
const router = Router();
// GET / — list published events
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const upcoming = req.query.upcoming === 'true';
const result = await ticketedEventsService.listPublished({ page, limit, upcoming });
res.json(result);
} catch (err) { next(err); }
});
// GET /my-tickets — authenticated user's tickets (must be before /:slug)
router.get('/my-tickets', optionalAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
if (!req.user) {
throw new AppError(401, 'Authentication required', 'UNAUTHORIZED');
}
const tickets = await ticketsService.getUserTickets(req.user.id);
const ticketsWithQr = tickets.map(t => ({
...t,
qrUrl: `${env.API_URL}/api/qr?text=${encodeURIComponent(t.ticketCode)}&size=300`,
}));
res.json({ tickets: ticketsWithQr });
} catch (err) { next(err); }
});
// GET /:slug — event detail by slug
router.get('/:slug', async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
const event = await ticketedEventsService.findBySlug(slug);
// Check private event access
if (event.visibility === 'PRIVATE') {
const inviteCode = req.query.inviteCode as string | undefined;
if (!inviteCode || inviteCode !== event.inviteCode) {
// Return limited info without invite code
res.json({
id: event.id,
slug: event.slug,
title: event.title,
visibility: 'PRIVATE',
requiresInviteCode: true,
});
return;
}
}
// Only show published events publicly
if (event.status !== 'PUBLISHED') {
throw new AppError(404, 'Event not found', 'NOT_FOUND');
}
// Include format + hasMeeting (but NOT the room name — that's gated)
const { meeting, ...rest } = event;
res.json({
...rest,
hasMeeting: !!(meeting?.isActive),
});
} catch (err) { next(err); }
});
// GET /:slug/meeting-access — ticket-gated Jitsi room access
router.get('/:slug/meeting-access', async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
const ticketCode = req.query.ticketCode as string;
if (!ticketCode) {
throw new AppError(400, 'ticketCode query parameter is required', 'MISSING_TICKET_CODE');
}
const access = await ticketedEventsService.getMeetingAccess(slug, ticketCode);
res.json(access);
} catch (err) { next(err); }
});
// GET /:slug/availability — ticket availability
router.get('/:slug/availability', async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
const event = await prisma.ticketedEvent.findUnique({ where: { slug } });
if (!event || event.status !== 'PUBLISHED') {
throw new AppError(404, 'Event not found', 'NOT_FOUND');
}
const availability = await ticketedEventsService.getAvailability(event.id);
res.json(availability);
} catch (err) { next(err); }
});
// POST /:slug/checkout — create Stripe checkout for paid ticket
router.post('/:slug/checkout', optionalAuth, validate(checkoutSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
const { tierId, quantity, buyerEmail, buyerName } = req.body;
const event = await prisma.ticketedEvent.findUnique({ where: { slug } });
if (!event || event.status !== 'PUBLISHED') {
throw new AppError(404, 'Event not found', 'NOT_FOUND');
}
const tier = await prisma.ticketTier.findUnique({ where: { id: tierId } });
if (!tier || tier.eventId !== event.id) {
throw new AppError(400, 'Invalid tier', 'INVALID_TIER');
}
if (tier.tierType === 'FREE') {
throw new AppError(400, 'Use /register for free tickets', 'USE_REGISTER');
}
// Check availability
if (tier.maxQuantity && tier.soldCount + quantity > tier.maxQuantity) {
throw new AppError(400, 'Not enough tickets available', 'SOLD_OUT');
}
if (event.maxAttendees && event.currentAttendees + quantity > event.maxAttendees) {
throw new AppError(400, 'Event is at full capacity', 'SOLD_OUT');
}
// Check sales window
const now = new Date();
if (tier.salesStartAt && tier.salesStartAt > now) {
throw new AppError(400, 'Ticket sales have not started yet', 'NOT_ON_SALE');
}
if (tier.salesEndAt && tier.salesEndAt < now) {
throw new AppError(400, 'Ticket sales have ended', 'SALES_ENDED');
}
const unitAmount = tier.tierType === 'DONATION'
? Math.max(tier.priceCAD, tier.minDonationCAD || 0)
: tier.priceCAD;
const stripe = await getStripe();
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{
price_data: {
currency: 'cad',
product_data: {
name: `${event.title}${tier.name}`,
description: tier.description || `Ticket for ${event.title}`,
},
unit_amount: unitAmount,
},
quantity,
}],
customer_email: buyerEmail,
success_url: `${env.ADMIN_URL}/event/${slug}/ticket/{CHECKOUT_SESSION_ID}?success=true`,
cancel_url: `${env.ADMIN_URL}/event/${slug}`,
metadata: {
type: 'event_ticket',
eventId: event.id,
tierId: tier.id,
quantity: String(quantity),
buyerEmail,
buyerName: buyerName || '',
userId: req.user?.id || '',
},
expires_after: 1800, // 30 minutes
} as never);
// Create pending order
await prisma.order.create({
data: {
userId: req.user?.id || null,
amountCAD: unitAmount * quantity,
status: 'PENDING',
stripeCheckoutSessionId: session.id,
type: 'event_ticket',
buyerEmail,
buyerName: buyerName || null,
},
});
res.json({ sessionId: session.id, url: session.url });
} catch (err) { next(err); }
});
// POST /:slug/register — register for free ticket (no Stripe)
router.post('/:slug/register', optionalAuth, validate(registerFreeSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
const { tierId, quantity, holderEmail, holderName } = req.body;
const event = await prisma.ticketedEvent.findUnique({ where: { slug } });
if (!event || event.status !== 'PUBLISHED') {
throw new AppError(404, 'Event not found', 'NOT_FOUND');
}
const tier = await prisma.ticketTier.findUnique({ where: { id: tierId } });
if (!tier || tier.eventId !== event.id) {
throw new AppError(400, 'Invalid tier', 'INVALID_TIER');
}
if (tier.tierType !== 'FREE') {
throw new AppError(400, 'This tier requires payment', 'REQUIRES_PAYMENT');
}
const tickets = await ticketsService.createTickets({
eventId: event.id,
tierId,
quantity,
holderEmail,
holderName,
userId: req.user?.id,
});
// Send confirmation emails (fire-and-forget)
for (const ticket of tickets) {
ticketEmailService.sendTicketConfirmation({
holderEmail: ticket.holderEmail,
holderName: ticket.holderName,
ticketCode: ticket.ticketCode,
token: (ticket as Record<string, unknown>).token as string,
eventTitle: event.title,
eventDate: event.date,
eventStartTime: event.startTime,
eventEndTime: event.endTime,
eventSlug: event.slug,
venueName: event.venueName,
venueAddress: event.venueAddress,
tierName: tier.name,
eventFormat: event.eventFormat,
}).catch(() => {});
}
res.status(201).json({
tickets: tickets.map(t => ({
id: t.id,
ticketCode: t.ticketCode,
holderEmail: t.holderEmail,
holderName: t.holderName,
status: t.status,
})),
});
} catch (err) { next(err); }
});
// GET /:slug/ticket/:ticketCode — ticket confirmation page data
router.get('/:slug/ticket/:ticketCode', async (req: Request, res: Response, next: NextFunction) => {
try {
const ticket = await ticketsService.findByCode(req.params.ticketCode as string);
if (ticket.event.slug !== req.params.slug) {
throw new AppError(404, 'Ticket not found', 'NOT_FOUND');
}
// Generate QR URL from ticket code (check-in scanner handles code-based lookup)
const qrUrl = `${env.API_URL}/api/qr?text=${encodeURIComponent(ticket.ticketCode)}&size=300`;
res.json({ ...ticket, qrUrl });
} catch (err) { next(err); }
});
export { router as ticketedEventsPublicRouter };

View File

@@ -0,0 +1,117 @@
import { z } from 'zod';
export const createEventSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
richDescription: z.string().max(50000).optional(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
startTime: z.string().regex(/^\d{2}:\d{2}$/),
endTime: z.string().regex(/^\d{2}:\d{2}$/),
doorsOpenTime: z.string().regex(/^\d{2}:\d{2}$/).optional(),
eventFormat: z.enum(['IN_PERSON', 'ONLINE', 'HYBRID']).default('IN_PERSON'),
venueName: z.string().max(200).optional(),
venueAddress: z.string().max(500).optional(),
latitude: z.number().min(-90).max(90).optional(),
longitude: z.number().min(-180).max(180).optional(),
visibility: z.enum(['PUBLIC', 'UNLISTED', 'PRIVATE']).optional(),
coverImageUrl: z.string().url().optional(),
maxAttendees: z.number().int().positive().optional(),
organizerName: z.string().max(200).optional(),
organizerEmail: z.string().email().optional(),
tiers: z.array(z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
tierType: z.enum(['PAID', 'FREE', 'DONATION']),
priceCAD: z.number().int().min(0).default(0),
minDonationCAD: z.number().int().min(0).optional(),
maxQuantity: z.number().int().positive().optional(),
maxPerOrder: z.number().int().min(1).max(100).default(10),
salesStartAt: z.string().datetime().optional(),
salesEndAt: z.string().datetime().optional(),
sortOrder: z.number().int().default(0),
})).optional(),
});
export const updateEventSchema = z.object({
title: z.string().min(1).max(200).optional(),
description: z.string().max(2000).nullable().optional(),
richDescription: z.string().max(50000).nullable().optional(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
startTime: z.string().regex(/^\d{2}:\d{2}$/).optional(),
endTime: z.string().regex(/^\d{2}:\d{2}$/).optional(),
doorsOpenTime: z.string().regex(/^\d{2}:\d{2}$/).nullable().optional(),
eventFormat: z.enum(['IN_PERSON', 'ONLINE', 'HYBRID']).optional(),
venueName: z.string().max(200).nullable().optional(),
venueAddress: z.string().max(500).nullable().optional(),
latitude: z.number().min(-90).max(90).nullable().optional(),
longitude: z.number().min(-180).max(180).nullable().optional(),
visibility: z.enum(['PUBLIC', 'UNLISTED', 'PRIVATE']).optional(),
coverImageUrl: z.string().url().nullable().optional(),
maxAttendees: z.number().int().positive().nullable().optional(),
organizerName: z.string().max(200).nullable().optional(),
organizerEmail: z.string().email().nullable().optional(),
});
export const createTierSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
tierType: z.enum(['PAID', 'FREE', 'DONATION']),
priceCAD: z.number().int().min(0).default(0),
minDonationCAD: z.number().int().min(0).optional(),
maxQuantity: z.number().int().positive().optional(),
maxPerOrder: z.number().int().min(1).max(100).default(10),
salesStartAt: z.string().datetime().optional(),
salesEndAt: z.string().datetime().optional(),
sortOrder: z.number().int().default(0),
});
export const updateTierSchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().max(500).nullable().optional(),
tierType: z.enum(['PAID', 'FREE', 'DONATION']).optional(),
priceCAD: z.number().int().min(0).optional(),
minDonationCAD: z.number().int().min(0).nullable().optional(),
maxQuantity: z.number().int().positive().nullable().optional(),
maxPerOrder: z.number().int().min(1).max(100).optional(),
salesStartAt: z.string().datetime().nullable().optional(),
salesEndAt: z.string().datetime().nullable().optional(),
sortOrder: z.number().int().optional(),
isActive: z.boolean().optional(),
});
export const checkoutSchema = z.object({
tierId: z.string().min(1),
quantity: z.number().int().min(1).max(100).default(1),
buyerEmail: z.string().email(),
buyerName: z.string().max(200).optional(),
});
export const registerFreeSchema = z.object({
tierId: z.string().min(1),
quantity: z.number().int().min(1).max(10).default(1),
holderEmail: z.string().email(),
holderName: z.string().max(200).optional(),
});
export const validateTokenSchema = z.object({
token: z.string().min(1),
});
export const confirmCheckinSchema = z.object({
token: z.string().min(1),
notes: z.string().max(500).optional(),
});
export const manualCheckinSchema = z.object({
eventId: z.string().min(1),
ticketCode: z.string().optional(),
holderEmail: z.string().email().optional(),
notes: z.string().max(500).optional(),
}).refine(
data => data.ticketCode || data.holderEmail,
{ message: 'Either ticketCode or holderEmail is required' },
);
export const meetingAccessSchema = z.object({
ticketCode: z.string().min(1),
});

View File

@@ -0,0 +1,829 @@
import { prisma } from '../../config/database';
import { TicketedEventStatus, TicketedEventVisibility, EventFormat, Prisma } from '@prisma/client';
import { logger } from '../../utils/logger';
import { AppError } from '../../middleware/error-handler';
import { unifiedCalendarService } from '../events/unified-calendar.service';
import { siteSettingsService } from '../settings/settings.service';
import { generateModeratorToken } from '../jitsi/jitsi.utils';
import { generateSlug as generateMeetingSlug } from '../../utils/slug';
import { env } from '../../config/env';
import crypto from 'crypto';
function generateSlug(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 80);
}
async function uniqueSlug(base: string): Promise<string> {
let slug = base;
let suffix = 0;
while (await prisma.ticketedEvent.findUnique({ where: { slug } })) {
suffix++;
slug = `${base}-${suffix}`;
}
return slug;
}
function generateInviteCode(): string {
return crypto.randomBytes(6).toString('hex').toUpperCase();
}
const ADMIN_ROLES = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'];
/** Validate that enableMeet is on when format requires Jitsi */
async function validateMeetFormat(format: EventFormat | string) {
if (format === 'IN_PERSON') return;
const settings = await siteSettingsService.get();
if (!settings.enableMeet) {
throw new AppError(400, 'Jitsi Meet must be enabled in Settings to create ONLINE or HYBRID events', 'MEET_DISABLED');
}
}
/** Create a Meeting record for a ticketed event */
async function ensureMeeting(event: { id: string; title: string; date: Date; startTime: string; endTime: string }, userId: string): Promise<string> {
const jitsiRoom = crypto.randomUUID();
const slug = generateMeetingSlug(event.title);
// Compose start/end DateTimes from event date + time strings
const dateStr = event.date.toISOString().split('T')[0];
const startTime = new Date(`${dateStr}T${event.startTime}:00`);
const endTime = new Date(`${dateStr}T${event.endTime}:00`);
const meeting = await prisma.meeting.create({
data: {
slug,
title: `${event.title} — Live`,
jitsiRoom,
isActive: true,
createdByUserId: userId,
startTime,
endTime,
},
});
return meeting.id;
}
export const ticketedEventsService = {
async create(data: {
title: string;
description?: string;
richDescription?: string;
date: string;
startTime: string;
endTime: string;
doorsOpenTime?: string;
eventFormat?: EventFormat;
venueName?: string;
venueAddress?: string;
latitude?: number;
longitude?: number;
visibility?: TicketedEventVisibility;
coverImageUrl?: string;
maxAttendees?: number;
organizerName?: string;
organizerEmail?: string;
tiers?: Array<{
name: string;
description?: string;
tierType: 'PAID' | 'FREE' | 'DONATION';
priceCAD?: number;
minDonationCAD?: number;
maxQuantity?: number;
maxPerOrder?: number;
salesStartAt?: string;
salesEndAt?: string;
sortOrder?: number;
}>;
}, userId: string) {
const eventFormat = data.eventFormat || 'IN_PERSON';
await validateMeetFormat(eventFormat);
const slug = await uniqueSlug(generateSlug(data.title));
const visibility = data.visibility || 'PUBLIC';
const inviteCode = visibility === 'PRIVATE' ? generateInviteCode() : null;
const event = await prisma.ticketedEvent.create({
data: {
slug,
title: data.title,
description: data.description,
richDescription: data.richDescription,
date: new Date(data.date),
startTime: data.startTime,
endTime: data.endTime,
doorsOpenTime: data.doorsOpenTime,
eventFormat,
venueName: data.venueName,
venueAddress: data.venueAddress,
latitude: data.latitude,
longitude: data.longitude,
status: 'DRAFT',
visibility,
inviteCode,
coverImageUrl: data.coverImageUrl,
maxAttendees: data.maxAttendees,
createdByUserId: userId,
organizerName: data.organizerName,
organizerEmail: data.organizerEmail,
ticketTiers: data.tiers?.length ? {
create: data.tiers.map((t, i) => ({
name: t.name,
description: t.description,
tierType: t.tierType,
priceCAD: t.priceCAD || 0,
minDonationCAD: t.minDonationCAD,
maxQuantity: t.maxQuantity,
maxPerOrder: t.maxPerOrder || 10,
salesStartAt: t.salesStartAt ? new Date(t.salesStartAt) : undefined,
salesEndAt: t.salesEndAt ? new Date(t.salesEndAt) : undefined,
sortOrder: t.sortOrder ?? i,
})),
} : undefined,
},
include: { ticketTiers: true },
});
// Auto-create meeting for ONLINE/HYBRID events
if (eventFormat !== 'IN_PERSON') {
const meetingId = await ensureMeeting(
{ id: event.id, title: event.title, date: event.date, startTime: event.startTime, endTime: event.endTime },
userId,
);
return prisma.ticketedEvent.update({
where: { id: event.id },
data: { meetingId },
include: { ticketTiers: true, meeting: { select: { jitsiRoom: true, isActive: true, slug: true } } },
});
}
return event;
},
async update(id: string, data: Record<string, unknown>, userId: string, userRole: string) {
const event = await prisma.ticketedEvent.findUnique({ where: { id } });
if (!event) throw new AppError(404, 'Event not found', 'NOT_FOUND');
// Non-admin users can only edit their own events
if (!ADMIN_ROLES.includes(userRole) && event.createdByUserId !== userId) {
throw new AppError(403, 'Cannot edit events you did not create', 'FORBIDDEN');
}
// If switching to PRIVATE, generate invite code if not present
if (data.visibility === 'PRIVATE' && !event.inviteCode) {
data.inviteCode = generateInviteCode();
}
// Convert date string to Date object
if (typeof data.date === 'string') {
data.date = new Date(data.date as string);
}
// Handle event format changes
const newFormat = data.eventFormat as EventFormat | undefined;
if (newFormat) {
await validateMeetFormat(newFormat);
// Switching TO ONLINE/HYBRID — create meeting if not present
if (newFormat !== 'IN_PERSON' && !event.meetingId) {
const meetingId = await ensureMeeting(
{ id: event.id, title: event.title, date: event.date, startTime: event.startTime, endTime: event.endTime },
userId,
);
data.meetingId = meetingId;
}
// Switching TO IN_PERSON — deactivate meeting (keep the record)
if (newFormat === 'IN_PERSON' && event.meetingId) {
await prisma.meeting.update({
where: { id: event.meetingId },
data: { isActive: false },
});
}
// Re-activate meeting if switching back to ONLINE/HYBRID
if (newFormat !== 'IN_PERSON' && event.meetingId) {
await prisma.meeting.update({
where: { id: event.meetingId },
data: { isActive: true },
});
}
}
const updated = await prisma.ticketedEvent.update({
where: { id },
data: data as Prisma.TicketedEventUncheckedUpdateInput,
include: {
ticketTiers: { orderBy: { sortOrder: 'asc' } },
meeting: { select: { jitsiRoom: true, isActive: true, slug: true } },
},
});
// Bust calendar cache if event is published
if (updated.status === 'PUBLISHED') {
unifiedCalendarService.bustCache().catch(() => {});
}
return updated;
},
async findById(id: string) {
const event = await prisma.ticketedEvent.findUnique({
where: { id },
include: {
ticketTiers: { orderBy: { sortOrder: 'asc' } },
createdBy: { select: { id: true, name: true, email: true } },
meeting: { select: { id: true, jitsiRoom: true, isActive: true, slug: true } },
_count: { select: { tickets: true, checkIns: true } },
},
});
if (!event) throw new AppError(404, 'Event not found', 'NOT_FOUND');
return event;
},
async findBySlug(slug: string) {
const event = await prisma.ticketedEvent.findUnique({
where: { slug },
include: {
ticketTiers: {
where: { isActive: true },
orderBy: { sortOrder: 'asc' },
},
createdBy: { select: { id: true, name: true } },
meeting: { select: { isActive: true } },
},
});
if (!event) throw new AppError(404, 'Event not found', 'NOT_FOUND');
return event;
},
async list(filters: {
page: number;
limit: number;
status?: TicketedEventStatus;
search?: string;
createdByUserId?: string;
}) {
const where: Prisma.TicketedEventWhereInput = {};
if (filters.status) where.status = filters.status;
if (filters.createdByUserId) where.createdByUserId = filters.createdByUserId;
if (filters.search) {
where.OR = [
{ title: { contains: filters.search, mode: 'insensitive' } },
{ venueName: { contains: filters.search, mode: 'insensitive' } },
];
}
const [events, total] = await Promise.all([
prisma.ticketedEvent.findMany({
where,
skip: (filters.page - 1) * filters.limit,
take: filters.limit,
orderBy: { date: 'desc' },
include: {
ticketTiers: { orderBy: { sortOrder: 'asc' } },
_count: { select: { tickets: true, checkIns: true } },
},
}),
prisma.ticketedEvent.count({ where }),
]);
return {
events,
pagination: {
page: filters.page,
limit: filters.limit,
total,
totalPages: Math.ceil(total / filters.limit),
},
};
},
async listPublished(filters: {
page: number;
limit: number;
upcoming?: boolean;
}) {
const where: Prisma.TicketedEventWhereInput = {
status: 'PUBLISHED',
visibility: { in: ['PUBLIC', 'UNLISTED'] },
};
if (filters.upcoming) {
where.date = { gte: new Date() };
}
const [events, total] = await Promise.all([
prisma.ticketedEvent.findMany({
where,
skip: (filters.page - 1) * filters.limit,
take: filters.limit,
orderBy: { date: 'asc' },
include: {
ticketTiers: {
where: { isActive: true },
orderBy: { sortOrder: 'asc' },
},
},
}),
prisma.ticketedEvent.count({ where }),
]);
return {
events,
pagination: {
page: filters.page,
limit: filters.limit,
total,
totalPages: Math.ceil(total / filters.limit),
},
};
},
async publish(id: string, userId: string, userRole: string) {
const event = await prisma.ticketedEvent.findUnique({
where: { id },
include: { ticketTiers: true },
});
if (!event) throw new AppError(404, 'Event not found', 'NOT_FOUND');
if (!ADMIN_ROLES.includes(userRole) && event.createdByUserId !== userId) {
throw new AppError(403, 'Cannot publish events you did not create', 'FORBIDDEN');
}
if (event.status !== 'DRAFT' && event.status !== 'PENDING_APPROVAL') {
throw new AppError(400, `Cannot publish event in ${event.status} status`, 'INVALID_STATUS');
}
if (!event.ticketTiers.length) {
throw new AppError(400, 'Event must have at least one ticket tier before publishing', 'NO_TIERS');
}
// Check if approval is required for non-admin users
if (!ADMIN_ROLES.includes(userRole)) {
const settings = await prisma.siteSettings.findFirst();
if (settings?.requireEventApproval) {
const updated = await prisma.ticketedEvent.update({
where: { id },
data: { status: 'PENDING_APPROVAL' },
include: { ticketTiers: true },
});
return updated;
}
}
const updated = await prisma.ticketedEvent.update({
where: { id },
data: { status: 'PUBLISHED' },
include: { ticketTiers: true },
});
// Gancio sync + calendar cache bust (fire-and-forget)
this.syncToGancio(updated).catch(() => {});
unifiedCalendarService.bustCache().catch(() => {});
return updated;
},
async approve(id: string) {
const event = await prisma.ticketedEvent.findUnique({ where: { id } });
if (!event) throw new AppError(404, 'Event not found', 'NOT_FOUND');
if (event.status !== 'PENDING_APPROVAL') {
throw new AppError(400, 'Event is not pending approval', 'INVALID_STATUS');
}
const updated = await prisma.ticketedEvent.update({
where: { id },
data: { status: 'PUBLISHED' },
include: { ticketTiers: true },
});
this.syncToGancio(updated).catch(() => {});
unifiedCalendarService.bustCache().catch(() => {});
return updated;
},
async reject(id: string) {
const event = await prisma.ticketedEvent.findUnique({ where: { id } });
if (!event) throw new AppError(404, 'Event not found', 'NOT_FOUND');
if (event.status !== 'PENDING_APPROVAL') {
throw new AppError(400, 'Event is not pending approval', 'INVALID_STATUS');
}
return prisma.ticketedEvent.update({
where: { id },
data: { status: 'DRAFT' },
});
},
async cancel(id: string, userId: string, userRole: string) {
const event = await prisma.ticketedEvent.findUnique({ where: { id } });
if (!event) throw new AppError(404, 'Event not found', 'NOT_FOUND');
if (!ADMIN_ROLES.includes(userRole) && event.createdByUserId !== userId) {
throw new AppError(403, 'Cannot cancel events you did not create', 'FORBIDDEN');
}
if (event.status === 'CANCELLED' || event.status === 'COMPLETED') {
throw new AppError(400, `Cannot cancel event in ${event.status} status`, 'INVALID_STATUS');
}
// Cancel all valid tickets
await prisma.ticket.updateMany({
where: { eventId: id, status: 'VALID' },
data: { status: 'CANCELLED' },
});
const updated = await prisma.ticketedEvent.update({
where: { id },
data: { status: 'CANCELLED' },
});
// Delete from Gancio if synced + bust calendar cache
if (event.gancioEventId) {
this.deleteFromGancio(event.gancioEventId).catch(() => {});
}
unifiedCalendarService.bustCache().catch(() => {});
return updated;
},
async complete(id: string) {
const event = await prisma.ticketedEvent.findUnique({ where: { id } });
if (!event) throw new AppError(404, 'Event not found', 'NOT_FOUND');
if (event.status !== 'PUBLISHED') {
throw new AppError(400, 'Only published events can be completed', 'INVALID_STATUS');
}
const updated = await prisma.ticketedEvent.update({
where: { id },
data: { status: 'COMPLETED' },
});
unifiedCalendarService.bustCache().catch(() => {});
return updated;
},
async deleteEvent(id: string, userId: string, userRole: string) {
const event = await prisma.ticketedEvent.findUnique({
where: { id },
include: { _count: { select: { tickets: true } } },
});
if (!event) throw new AppError(404, 'Event not found', 'NOT_FOUND');
if (!ADMIN_ROLES.includes(userRole) && event.createdByUserId !== userId) {
throw new AppError(403, 'Cannot delete events you did not create', 'FORBIDDEN');
}
if (event.status !== 'DRAFT') {
throw new AppError(400, 'Only draft events can be deleted. Cancel the event instead.', 'INVALID_STATUS');
}
if (event.gancioEventId) {
this.deleteFromGancio(event.gancioEventId).catch(() => {});
}
await prisma.ticketedEvent.delete({ where: { id } });
},
// --- Tier Management ---
async addTier(eventId: string, data: {
name: string;
description?: string;
tierType: 'PAID' | 'FREE' | 'DONATION';
priceCAD?: number;
minDonationCAD?: number;
maxQuantity?: number;
maxPerOrder?: number;
salesStartAt?: string;
salesEndAt?: string;
sortOrder?: number;
}) {
const event = await prisma.ticketedEvent.findUnique({ where: { id: eventId } });
if (!event) throw new AppError(404, 'Event not found', 'NOT_FOUND');
return prisma.ticketTier.create({
data: {
eventId,
name: data.name,
description: data.description,
tierType: data.tierType,
priceCAD: data.priceCAD || 0,
minDonationCAD: data.minDonationCAD,
maxQuantity: data.maxQuantity,
maxPerOrder: data.maxPerOrder || 10,
salesStartAt: data.salesStartAt ? new Date(data.salesStartAt) : undefined,
salesEndAt: data.salesEndAt ? new Date(data.salesEndAt) : undefined,
sortOrder: data.sortOrder ?? 0,
},
});
},
async updateTier(tierId: string, data: Record<string, unknown>) {
const tier = await prisma.ticketTier.findUnique({ where: { id: tierId } });
if (!tier) throw new AppError(404, 'Tier not found', 'NOT_FOUND');
// Convert date strings
if (typeof data.salesStartAt === 'string') data.salesStartAt = new Date(data.salesStartAt as string);
if (typeof data.salesEndAt === 'string') data.salesEndAt = new Date(data.salesEndAt as string);
return prisma.ticketTier.update({
where: { id: tierId },
data: data as Prisma.TicketTierUncheckedUpdateInput,
});
},
async deleteTier(tierId: string) {
const tier = await prisma.ticketTier.findUnique({
where: { id: tierId },
include: { _count: { select: { tickets: true } } },
});
if (!tier) throw new AppError(404, 'Tier not found', 'NOT_FOUND');
if (tier._count.tickets > 0) {
throw new AppError(400, 'Cannot delete a tier that has sold tickets', 'HAS_TICKETS');
}
await prisma.ticketTier.delete({ where: { id: tierId } });
},
// --- Stats ---
async getEventStats(eventId: string) {
const [ticketCounts, revenue, checkInCount, tierStats] = await Promise.all([
prisma.ticket.groupBy({
by: ['status'],
where: { eventId },
_count: true,
}),
prisma.order.aggregate({
where: {
tickets: { some: { eventId } },
status: 'COMPLETED',
type: 'event_ticket',
},
_sum: { amountCAD: true },
}),
prisma.checkIn.count({ where: { eventId } }),
prisma.ticketTier.findMany({
where: { eventId },
orderBy: { sortOrder: 'asc' },
select: {
id: true,
name: true,
tierType: true,
priceCAD: true,
maxQuantity: true,
soldCount: true,
isActive: true,
},
}),
]);
const statusMap: Record<string, number> = {};
for (const g of ticketCounts) statusMap[g.status] = g._count;
return {
totalTickets: Object.values(statusMap).reduce((a, b) => a + b, 0),
validTickets: statusMap['VALID'] || 0,
checkedIn: statusMap['CHECKED_IN'] || 0,
cancelled: statusMap['CANCELLED'] || 0,
refunded: statusMap['REFUNDED'] || 0,
totalRevenue: revenue._sum.amountCAD || 0,
checkInCount,
tierStats,
};
},
async getTickets(eventId: string, filters: {
page: number;
limit: number;
search?: string;
status?: string;
}) {
const where: Prisma.TicketWhereInput = { eventId };
if (filters.status) where.status = filters.status as never;
if (filters.search) {
where.OR = [
{ holderEmail: { contains: filters.search, mode: 'insensitive' } },
{ holderName: { contains: filters.search, mode: 'insensitive' } },
{ ticketCode: { contains: filters.search, mode: 'insensitive' } },
];
}
const [tickets, total] = await Promise.all([
prisma.ticket.findMany({
where,
skip: (filters.page - 1) * filters.limit,
take: filters.limit,
orderBy: { issuedAt: 'desc' },
include: {
tier: { select: { name: true, tierType: true } },
order: { select: { id: true, amountCAD: true, status: true } },
},
}),
prisma.ticket.count({ where }),
]);
return {
tickets,
pagination: {
page: filters.page,
limit: filters.limit,
total,
totalPages: Math.ceil(total / filters.limit),
},
};
},
async getCheckIns(eventId: string, filters: { page: number; limit: number }) {
const [checkIns, total] = await Promise.all([
prisma.checkIn.findMany({
where: { eventId },
skip: (filters.page - 1) * filters.limit,
take: filters.limit,
orderBy: { checkedInAt: 'desc' },
include: {
ticket: {
select: { ticketCode: true, holderName: true, holderEmail: true },
},
checkedInBy: { select: { id: true, name: true } },
},
}),
prisma.checkIn.count({ where: { eventId } }),
]);
return {
checkIns,
pagination: {
page: filters.page,
limit: filters.limit,
total,
totalPages: Math.ceil(total / filters.limit),
},
};
},
// --- Availability ---
async getAvailability(eventId: string) {
const event = await prisma.ticketedEvent.findUnique({
where: { id: eventId },
include: {
ticketTiers: {
where: { isActive: true },
orderBy: { sortOrder: 'asc' },
},
},
});
if (!event) throw new AppError(404, 'Event not found', 'NOT_FOUND');
const now = new Date();
const tiers = event.ticketTiers.map(t => {
const available = t.maxQuantity ? t.maxQuantity - t.soldCount : null;
const onSale = (!t.salesStartAt || t.salesStartAt <= now) &&
(!t.salesEndAt || t.salesEndAt >= now);
return {
id: t.id,
name: t.name,
tierType: t.tierType,
priceCAD: t.priceCAD,
minDonationCAD: t.minDonationCAD,
maxPerOrder: t.maxPerOrder,
available,
soldOut: available !== null && available <= 0,
onSale,
};
});
const eventSoldOut = event.maxAttendees
? event.currentAttendees >= event.maxAttendees
: false;
return { eventId, eventSoldOut, tiers };
},
// --- Meeting Access ---
async getMeetingAccess(slug: string, ticketCode: string) {
const event = await prisma.ticketedEvent.findUnique({
where: { slug },
include: {
meeting: { select: { jitsiRoom: true, isActive: true } },
},
});
if (!event) throw new AppError(404, 'Event not found', 'NOT_FOUND');
if (event.status !== 'PUBLISHED') throw new AppError(404, 'Event not found', 'NOT_FOUND');
if (event.eventFormat === 'IN_PERSON') throw new AppError(400, 'This event does not have online access', 'NO_ONLINE_ACCESS');
if (!event.meeting || !event.meeting.isActive) throw new AppError(400, 'Meeting room is not active', 'MEETING_INACTIVE');
// Validate ticket code
const ticket = await prisma.ticket.findUnique({ where: { ticketCode } });
if (!ticket || ticket.eventId !== event.id) {
throw new AppError(403, 'Invalid ticket code', 'INVALID_TICKET');
}
if (ticket.status !== 'VALID' && ticket.status !== 'CHECKED_IN') {
throw new AppError(403, 'Ticket is not valid', 'TICKET_INVALID_STATUS');
}
const domain = env.DOMAIN || 'cmlite.org';
return {
jitsiRoom: event.meeting.jitsiRoom,
domain: `meet.${domain}`,
eventTitle: event.title,
};
},
async getModeratorToken(eventId: string, user: { id: string; email: string; name: string | null }) {
const event = await prisma.ticketedEvent.findUnique({
where: { id: eventId },
include: { meeting: true },
});
if (!event) throw new AppError(404, 'Event not found', 'NOT_FOUND');
if (!event.meeting) throw new AppError(400, 'No meeting room associated with this event', 'NO_MEETING');
const token = generateModeratorToken(user, event.meeting.jitsiRoom);
const domain = env.DOMAIN || 'cmlite.org';
return {
token,
jitsiUrl: `https://meet.${domain}/${event.meeting.jitsiRoom}?jwt=${token}`,
};
},
// --- Gancio Sync ---
async syncToGancio(event: {
id: string;
title: string;
description?: string | null;
venueAddress?: string | null;
venueName?: string | null;
eventFormat?: EventFormat;
date: Date;
startTime: string;
endTime: string;
gancioEventId?: number | null;
}) {
try {
const { gancioClient } = await import('../../services/gancio.client');
if (!gancioClient.enabled) return;
// Determine location based on event format
let location: string | null;
const format = event.eventFormat || 'IN_PERSON';
if (format === 'ONLINE') {
location = 'Online Event';
} else if (format === 'HYBRID') {
const venue = event.venueAddress || event.venueName || '';
location = venue ? `${venue} (also streaming online)` : 'Online + In-Person';
} else {
location = event.venueAddress || event.venueName || null;
}
const tags = ['ticketed', 'community'];
if (format === 'ONLINE') tags.push('online');
if (format === 'HYBRID') tags.push('hybrid');
if (event.gancioEventId) {
await gancioClient.updateEvent(event.gancioEventId, {
title: event.title,
description: event.description,
location,
date: event.date,
startTime: event.startTime,
endTime: event.endTime,
});
} else {
const gancioId = await gancioClient.createEvent({
title: event.title,
description: event.description,
location,
date: event.date,
startTime: event.startTime,
endTime: event.endTime,
tags,
});
if (gancioId) {
await prisma.ticketedEvent.update({
where: { id: event.id },
data: { gancioEventId: gancioId },
});
}
}
} catch (err) {
logger.warn('Gancio sync failed for ticketed event:', err instanceof Error ? err.message : err);
}
},
async deleteFromGancio(gancioEventId: number) {
try {
const { gancioClient } = await import('../../services/gancio.client');
if (!gancioClient.enabled) return;
await gancioClient.deleteEvent(gancioEventId);
} catch (err) {
logger.warn(`Gancio delete failed for event ${gancioEventId}:`, err instanceof Error ? err.message : err);
}
},
};

View File

@@ -0,0 +1,373 @@
import crypto from 'crypto';
import { prisma } from '../../config/database';
import { env } from '../../config/env';
import { AppError } from '../../middleware/error-handler';
import { logger } from '../../utils/logger';
function getEncryptionKey(): string {
return env.ENCRYPTION_KEY || env.JWT_ACCESS_SECRET;
}
/** Generate a human-readable ticket code like "ABCD-1234" */
function generateTicketCode(): string {
const letters = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; // No I, O for readability
const part1 = Array.from({ length: 4 }, () => letters[crypto.randomInt(letters.length)]).join('');
const part2 = String(crypto.randomInt(1000, 9999));
return `${part1}-${part2}`;
}
/** Generate HMAC token for QR code validation */
function generateToken(ticketId: string): { token: string; tokenHash: string } {
const nonce = crypto.randomBytes(16);
const hmac = crypto.createHmac('sha256', getEncryptionKey());
hmac.update(ticketId);
hmac.update(nonce);
const token = Buffer.concat([
Buffer.from(ticketId),
Buffer.from(':'),
nonce,
Buffer.from(':'),
hmac.digest(),
]).toString('base64url');
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
return { token, tokenHash };
}
/** Hash an incoming token for lookup */
function hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
export const ticketsService = {
/**
* Create one or more tickets for an event + tier.
* Used by both free registration and post-checkout webhook.
*/
async createTickets(params: {
eventId: string;
tierId: string;
quantity: number;
holderEmail: string;
holderName?: string;
userId?: string;
orderId?: string;
}) {
const { eventId, tierId, quantity, holderEmail, holderName, userId, orderId } = params;
// Validate tier exists and belongs to event
const tier = await prisma.ticketTier.findUnique({ where: { id: tierId } });
if (!tier || tier.eventId !== eventId) {
throw new AppError(400, 'Invalid tier for this event', 'INVALID_TIER');
}
// Check quantity limits
if (quantity > tier.maxPerOrder) {
throw new AppError(400, `Maximum ${tier.maxPerOrder} tickets per order`, 'MAX_PER_ORDER');
}
// Atomically check + reserve capacity using a transaction
const tickets = await prisma.$transaction(async (tx) => {
// Lock the tier row and check availability
const currentTier = await tx.ticketTier.findUnique({ where: { id: tierId } });
if (!currentTier) throw new AppError(400, 'Tier not found', 'NOT_FOUND');
if (currentTier.maxQuantity && currentTier.soldCount + quantity > currentTier.maxQuantity) {
throw new AppError(400, 'Not enough tickets available', 'SOLD_OUT');
}
// Check event-level capacity
const event = await tx.ticketedEvent.findUnique({ where: { id: eventId } });
if (!event) throw new AppError(404, 'Event not found', 'NOT_FOUND');
if (event.maxAttendees && event.currentAttendees + quantity > event.maxAttendees) {
throw new AppError(400, 'Event is at full capacity', 'SOLD_OUT');
}
// Generate tickets
const ticketData = [];
for (let i = 0; i < quantity; i++) {
let ticketCode = generateTicketCode();
// Ensure uniqueness (extremely unlikely collision but safe)
while (await tx.ticket.findUnique({ where: { ticketCode } })) {
ticketCode = generateTicketCode();
}
const { token, tokenHash } = generateToken(`${eventId}:${ticketCode}`);
ticketData.push({
ticketCode,
tokenHash,
token, // Not stored — returned for QR generation
eventId,
tierId,
orderId,
holderEmail,
holderName,
userId,
});
}
// Create all tickets
const created = [];
for (const td of ticketData) {
const { token, ...dbData } = td;
const ticket = await tx.ticket.create({ data: dbData });
created.push({ ...ticket, token });
}
// Increment sold count + attendee count
await tx.ticketTier.update({
where: { id: tierId },
data: { soldCount: { increment: quantity } },
});
await tx.ticketedEvent.update({
where: { id: eventId },
data: { currentAttendees: { increment: quantity } },
});
return created;
});
return tickets;
},
/** Validate a QR token — returns ticket info without marking checked in */
async validateToken(token: string) {
const hash = hashToken(token);
const ticket = await prisma.ticket.findUnique({
where: { tokenHash: hash },
include: {
event: { select: { id: true, title: true, slug: true, date: true, startTime: true } },
tier: { select: { name: true, tierType: true } },
},
});
if (!ticket) {
return { valid: false, error: 'Invalid ticket' };
}
if (ticket.status === 'CHECKED_IN') {
return {
valid: false,
error: 'Already checked in',
ticket: {
id: ticket.id,
ticketCode: ticket.ticketCode,
holderName: ticket.holderName,
holderEmail: ticket.holderEmail,
status: ticket.status,
checkedInAt: ticket.checkedInAt,
event: ticket.event,
tier: ticket.tier,
},
};
}
if (ticket.status === 'CANCELLED' || ticket.status === 'REFUNDED') {
return {
valid: false,
error: `Ticket is ${ticket.status.toLowerCase()}`,
ticket: {
id: ticket.id,
ticketCode: ticket.ticketCode,
status: ticket.status,
},
};
}
return {
valid: true,
ticket: {
id: ticket.id,
ticketCode: ticket.ticketCode,
holderName: ticket.holderName,
holderEmail: ticket.holderEmail,
status: ticket.status,
event: ticket.event,
tier: ticket.tier,
},
};
},
/** Confirm check-in — marks ticket as CHECKED_IN */
async confirmCheckin(token: string, checkedInByUserId?: string, notes?: string) {
const hash = hashToken(token);
const ticket = await prisma.ticket.findUnique({
where: { tokenHash: hash },
include: {
event: { select: { id: true, title: true } },
tier: { select: { name: true } },
},
});
if (!ticket) throw new AppError(400, 'Invalid ticket', 'INVALID_TICKET');
if (ticket.status === 'CHECKED_IN') throw new AppError(400, 'Ticket already checked in', 'ALREADY_CHECKED_IN');
if (ticket.status !== 'VALID') throw new AppError(400, `Ticket is ${ticket.status.toLowerCase()}`, 'INVALID_STATUS');
const [updatedTicket, checkIn] = await prisma.$transaction([
prisma.ticket.update({
where: { id: ticket.id },
data: {
status: 'CHECKED_IN',
checkedInAt: new Date(),
checkedInByUserId,
},
}),
prisma.checkIn.create({
data: {
ticketId: ticket.id,
eventId: ticket.eventId,
checkedInByUserId,
method: 'QR',
notes,
},
}),
]);
return {
ticket: updatedTicket,
checkIn,
event: ticket.event,
tier: ticket.tier,
};
},
/** Manual check-in by ticket code or email */
async manualCheckin(params: {
eventId: string;
ticketCode?: string;
holderEmail?: string;
checkedInByUserId?: string;
notes?: string;
}) {
const { eventId, ticketCode, holderEmail, checkedInByUserId, notes } = params;
let ticket;
if (ticketCode) {
ticket = await prisma.ticket.findUnique({
where: { ticketCode },
include: {
event: { select: { id: true, title: true } },
tier: { select: { name: true } },
},
});
} else if (holderEmail) {
ticket = await prisma.ticket.findFirst({
where: { eventId, holderEmail, status: 'VALID' },
include: {
event: { select: { id: true, title: true } },
tier: { select: { name: true } },
},
});
}
if (!ticket) throw new AppError(404, 'Ticket not found', 'NOT_FOUND');
if (ticket.eventId !== eventId) throw new AppError(400, 'Ticket does not belong to this event', 'WRONG_EVENT');
if (ticket.status === 'CHECKED_IN') throw new AppError(400, 'Ticket already checked in', 'ALREADY_CHECKED_IN');
if (ticket.status !== 'VALID') throw new AppError(400, `Ticket is ${ticket.status.toLowerCase()}`, 'INVALID_STATUS');
const [updatedTicket, checkIn] = await prisma.$transaction([
prisma.ticket.update({
where: { id: ticket.id },
data: {
status: 'CHECKED_IN',
checkedInAt: new Date(),
checkedInByUserId,
},
}),
prisma.checkIn.create({
data: {
ticketId: ticket.id,
eventId,
checkedInByUserId,
method: ticketCode ? 'CODE' : 'MANUAL',
notes,
},
}),
]);
return {
ticket: updatedTicket,
checkIn,
event: ticket.event,
tier: ticket.tier,
};
},
/** Cancel a specific ticket */
async cancelTicket(ticketId: string) {
const ticket = await prisma.ticket.findUnique({ where: { id: ticketId } });
if (!ticket) throw new AppError(404, 'Ticket not found', 'NOT_FOUND');
if (ticket.status !== 'VALID') {
throw new AppError(400, `Cannot cancel ticket in ${ticket.status} status`, 'INVALID_STATUS');
}
return prisma.$transaction([
prisma.ticket.update({
where: { id: ticketId },
data: { status: 'CANCELLED' },
}),
prisma.ticketTier.update({
where: { id: ticket.tierId },
data: { soldCount: { decrement: 1 } },
}),
prisma.ticketedEvent.update({
where: { id: ticket.eventId },
data: { currentAttendees: { decrement: 1 } },
}),
]);
},
/** Get user's tickets */
async getUserTickets(userId: string) {
return prisma.ticket.findMany({
where: { userId },
orderBy: { issuedAt: 'desc' },
include: {
event: {
select: {
id: true, slug: true, title: true, date: true,
startTime: true, endTime: true, venueName: true, venueAddress: true,
},
},
tier: { select: { name: true, tierType: true, priceCAD: true } },
},
});
},
/** Get user's tickets for a specific email (for guests) */
async getTicketsByEmail(email: string) {
return prisma.ticket.findMany({
where: { holderEmail: email },
orderBy: { issuedAt: 'desc' },
include: {
event: {
select: {
id: true, slug: true, title: true, date: true,
startTime: true, endTime: true, venueName: true,
},
},
tier: { select: { name: true, tierType: true } },
},
});
},
/** Find a ticket by code (for confirmation page) */
async findByCode(ticketCode: string) {
const ticket = await prisma.ticket.findUnique({
where: { ticketCode },
include: {
event: {
select: {
id: true, slug: true, title: true, date: true,
startTime: true, endTime: true, venueName: true, venueAddress: true,
organizerName: true,
},
},
tier: { select: { name: true, tierType: true, priceCAD: true } },
},
});
if (!ticket) throw new AppError(404, 'Ticket not found', 'NOT_FOUND');
return ticket;
},
};

View File

@@ -103,6 +103,10 @@ import { homepageRouter } from './modules/homepage/homepage.routes';
import { ogRouter } from './modules/og/og.routes';
import { socialRouter } from './modules/social/social.routes';
import { errorReportRouter } from './modules/reports/error-report.routes';
import calendarRoutes from './modules/calendar/calendar.routes';
import { ticketedEventsPublicRouter } from './modules/ticketed-events/ticketed-events-public.routes';
import { ticketedEventsAdminRouter } from './modules/ticketed-events/ticketed-events-admin.routes';
import { checkinRouter } from './modules/ticketed-events/checkin.routes';
import { sseService } from './modules/social/sse.service';
import { presenceService } from './modules/social/presence.service';
import { upgradeService } from './modules/upgrade/upgrade.service';
@@ -265,7 +269,11 @@ app.use('/api/events', eventsListPublicRouter); // Public event
app.use('/api/homepage', homepageRouter); // Public homepage aggregation (no auth, cached)
app.use('/api/og', ogRouter); // OG meta tags for social sharing bots (no auth, cached)
app.use('/api/social', socialRouter); // Social connections (auth required)
app.use('/api/ticketed-events/admin', ticketedEventsAdminRouter); // Admin ticketed event CRUD (auth + permission) — MUST be before public /:slug
app.use('/api/ticketed-events/checkin', checkinRouter); // Check-in scanner routes (auth required)
app.use('/api/ticketed-events', ticketedEventsPublicRouter); // Public ticketed event listing + checkout (no auth)
app.use('/api/public/error-report', errorReportRouter); // Public 404 error reporting (rate-limited)
app.use('/api/calendar', calendarRoutes); // Personal calendar layers + items (auth required)
// --- API 404 Handler (catch unmatched /api/* routes) ---
app.use('/api/*', (_req, res) => {
@@ -369,6 +377,13 @@ async function start() {
sseService.startHeartbeat();
setInterval(() => presenceService.cleanupStale().catch(() => {}), 60 * 1000); // every 1 min
// Challenge lifecycle: activate/complete/score every 5 minutes
import('./services/challenge-scoring.service').then(({ challengeScoringService }) => {
challengeScoringService.processLifecycle().catch(() => {});
setInterval(() => challengeScoringService.processLifecycle().catch(() => {}), 5 * 60 * 1000);
logger.info('Challenge lifecycle processor started (every 5min)');
}).catch(() => {});
// Clean up stale upgrade progress on startup
upgradeService.clearStaleProgress();

View File

@@ -0,0 +1,167 @@
import { prisma } from '../config/database';
import { logger } from '../utils/logger';
import type { ChallengeMetric } from '@prisma/client';
async function computeScore(
userId: string,
metric: ChallengeMetric,
startsAt: Date,
endsAt: Date,
): Promise<number> {
const between = { gte: startsAt, lte: endsAt };
switch (metric) {
case 'DOORS_KNOCKED':
return prisma.canvassVisit.count({
where: {
session: { userId },
visitedAt: between,
},
});
case 'EMAILS_SENT':
return prisma.campaignEmail.count({
where: {
userId,
sentAt: between,
},
});
case 'SHIFTS_ATTENDED':
return prisma.shiftSignup.count({
where: {
userId,
status: 'CONFIRMED',
shift: { startTime: { gte: startsAt.toISOString(), lte: endsAt.toISOString() } },
},
});
case 'RESPONSES_SUBMITTED':
return prisma.representativeResponse.count({
where: {
submittedByUserId: userId,
createdAt: between,
},
});
case 'REFERRALS_MADE':
return prisma.referral.count({
where: {
referrerId: userId,
completedAt: between,
},
});
default:
return 0;
}
}
async function scoreChallenge(challengeId: string): Promise<void> {
const challenge = await prisma.challenge.findUnique({
where: { id: challengeId },
include: {
teams: {
include: {
members: { select: { id: true, userId: true, score: true } },
},
},
},
});
if (!challenge) return;
let anyChanged = false;
const participantUserIds: string[] = [];
for (const team of challenge.teams) {
let teamTotal = 0;
for (const member of team.members) {
const newScore = await computeScore(
member.userId,
challenge.metric,
challenge.startsAt,
challenge.endsAt,
);
if (newScore !== member.score) {
await prisma.challengeTeamMember.update({
where: { id: member.id },
data: { score: newScore },
});
anyChanged = true;
}
teamTotal += newScore;
participantUserIds.push(member.userId);
}
if (teamTotal !== team.score) {
await prisma.challengeTeam.update({
where: { id: team.id },
data: { score: teamTotal, lastScoredAt: new Date() },
});
anyChanged = true;
}
}
if (anyChanged && participantUserIds.length > 0) {
try {
const { sseService } = await import('../modules/social/sse.service');
sseService.sendToUsers(participantUserIds, 'challenge_scores_updated', {
challengeId,
});
} catch {
// SSE not available
}
}
}
async function processLifecycle(): Promise<void> {
const now = new Date();
// UPCOMING -> ACTIVE
const toActivate = await prisma.challenge.findMany({
where: { status: 'UPCOMING', startsAt: { lte: now } },
});
for (const c of toActivate) {
await prisma.challenge.update({
where: { id: c.id },
data: { status: 'ACTIVE' },
});
logger.info(`Challenge ${c.id} "${c.title}" activated`);
}
// ACTIVE -> COMPLETED (past end date)
const toComplete = await prisma.challenge.findMany({
where: { status: 'ACTIVE', endsAt: { lte: now } },
});
for (const c of toComplete) {
await scoreChallenge(c.id);
await prisma.challenge.update({
where: { id: c.id },
data: { status: 'COMPLETED' },
});
logger.info(`Challenge ${c.id} "${c.title}" completed`);
}
// Score remaining ACTIVE challenges
const active = await prisma.challenge.findMany({
where: { status: 'ACTIVE' },
select: { id: true },
});
for (const c of active) {
await scoreChallenge(c.id);
}
logger.info(
`Challenge lifecycle: ${toActivate.length} activated, ${toComplete.length} completed, ${active.length} active scored`,
);
}
export const challengeScoringService = {
computeScore,
scoreChallenge,
processLifecycle,
};

View File

@@ -0,0 +1,82 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { join } from 'path';
import { logger } from '../utils/logger';
/**
* Service to auto-update the .env file with Pangolin credentials.
* Reads the existing .env, updates/adds specific keys, preserves everything else.
*/
const ENV_FILE_PATH = join(__dirname, '../../../.env');
/**
* Update specific keys in the .env file.
* - Preserves all existing keys and comments
* - Updates keys if they already exist (replaces the value)
* - Appends new keys at the end with a section comment
* - Does NOT remove any existing keys
*/
export function updateEnvFile(updates: Record<string, string>): { success: boolean; updated: string[]; added: string[]; error?: string } {
const updated: string[] = [];
const added: string[] = [];
try {
if (!existsSync(ENV_FILE_PATH)) {
return { success: false, updated: [], added: [], error: `.env file not found at ${ENV_FILE_PATH}` };
}
const content = readFileSync(ENV_FILE_PATH, 'utf8');
const lines = content.split('\n');
const keysToUpdate = new Set(Object.keys(updates));
const processedKeys = new Set<string>();
// Update existing keys in-place
const updatedLines = lines.map(line => {
// Skip comments and empty lines
if (line.startsWith('#') || line.trim() === '') {
return line;
}
// Match KEY=value pattern (handle quoted values too)
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)/);
if (match && match[1] && keysToUpdate.has(match[1])) {
const key = match[1];
const oldValue = match[2];
const newValue = updates[key]!;
processedKeys.add(key);
if (oldValue !== newValue) {
updated.push(key);
logger.info(`env-writer: updated ${key}`);
return `${key}=${newValue}`;
}
return line; // Value unchanged
}
return line;
});
// Append any keys that weren't already in the file
const keysToAdd = [...keysToUpdate].filter(k => !processedKeys.has(k));
if (keysToAdd.length > 0) {
// Add a blank line and comment section if we're adding new keys
updatedLines.push('');
updatedLines.push('# --- Pangolin Tunnel (auto-configured) ---');
for (const key of keysToAdd) {
updatedLines.push(`${key}=${updates[key]}`);
added.push(key);
logger.info(`env-writer: added ${key}`);
}
}
// Write back
writeFileSync(ENV_FILE_PATH, updatedLines.join('\n'), 'utf8');
logger.info(`env-writer: ${updated.length} updated, ${added.length} added`);
return { success: true, updated, added };
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Unknown error';
logger.error(`env-writer failed: ${errorMsg}`);
return { success: false, updated: [], added: [], error: errorMsg };
}
}

View File

@@ -15,6 +15,7 @@ export interface PangolinSite {
lastSeen?: string;
online?: boolean;
type?: string;
address?: string;
}
export interface PangolinExitNode {
@@ -28,10 +29,7 @@ export interface PangolinExitNode {
}
export interface PangolinResource {
siteResourceId: string; // Fixed: Pangolin API uses "siteResourceId", not "resourceId"
resourceId?: string; // Legacy alias for backwards compatibility
siteId: string;
orgId: string;
resourceId: string;
name: string;
subdomain?: string;
fullDomain?: string;
@@ -41,6 +39,19 @@ export interface PangolinResource {
proxyPort?: number;
protocol?: string;
domainBindings?: string[];
http?: boolean;
// Target info (returned by list endpoints)
targets?: PangolinTarget[];
}
export interface PangolinTarget {
targetId: string;
resourceId: string;
siteId: string;
ip: string;
port: number;
method: string;
enabled?: boolean;
}
export interface PangolinNewt {
@@ -49,47 +60,45 @@ export interface PangolinNewt {
siteId: string;
}
export interface PangolinSiteDefaults {
newtId: string;
newtSecret: string;
address: string;
}
export interface CreateSitePayload {
name: string;
type?: string;
subnet?: string; // CIDR notation subnet (e.g., "100.90.128.0/24")
exitNodeId?: string; // Exit node ID for tunneled sites
subnet?: string;
exitNodeId?: string;
// Newt credentials from pickSiteDefaults
newtId?: string;
secret?: string;
address?: string;
}
// HTTP Resource (for web services) - Correct Pangolin API schema for CREATE
// Uses endpoint: PUT /org/{orgId}/site-resource
// The 'http: true' field differentiates HTTP resources from client-only resources
// HTTP Resource (public proxy) - Correct Pangolin API schema
// Uses endpoint: PUT /org/{orgId}/resource (NOT /site-resource)
export interface CreateHttpResourcePayload {
name: string;
type: 'http'; // Internal routing flag only, not sent to API
domainId: string; // Domain ID from listDomains()
domainId: string;
subdomain: string; // Subdomain only (e.g., "app") or empty string for root
http: true; // REQUIRED: Set to true to create HTTP proxy resource (shows in Public Resources)
ssl?: boolean; // Enable SSL/TLS (default: false)
enabled?: boolean; // Enable the resource (default: false)
// To make resource publicly accessible (not protected), use updateResource() with blockAccess: false
http: true; // REQUIRED: marks as HTTP proxy resource
protocol: 'tcp'; // REQUIRED for HTTP resources
// Note: ssl and enabled are NOT valid creation fields — set via updateResource()
}
// Raw TCP/UDP Resource
export interface CreateRawResourcePayload {
name: string;
type: 'tcp' | 'udp';
proxyPort: number;
stickySession?: boolean;
enabled?: boolean;
}
export type CreateResourcePayload = CreateHttpResourcePayload | CreateRawResourcePayload;
export interface CreateTargetPayload {
siteId: string | number; // REQUIRED: which site routes this traffic (Pangolin expects numeric)
ip: string; // Target hostname/IP (e.g., "nginx")
port: number; // Target port (e.g., 80)
method: 'http' | 'https';
host: string;
port: number;
enabled?: boolean;
}
export interface PangolinDomain {
domainId: string;
baseDomain: string; // Fixed: was "domain"
baseDomain: string;
verified: boolean;
type?: string;
failed?: boolean;
@@ -160,9 +169,11 @@ class PangolinClient {
const url = `${this.baseUrl}${path}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const timeout = setTimeout(() => controller.abort(), 15000);
try {
logger.debug(`Pangolin ${method} ${path}${body ? ` body=${JSON.stringify(body)}` : ''}`);
const res = await fetch(url, {
method,
headers: {
@@ -175,12 +186,16 @@ class PangolinClient {
if (!res.ok) {
const text = await res.text().catch(() => '');
logger.error(`Pangolin API ${method} ${path} returned ${res.status}: ${text}`);
throw new Error(`Pangolin API ${method} ${path} returned ${res.status}: ${text}`);
}
const contentType = res.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
return await res.json() as T;
const json = await res.json();
// Pangolin wraps responses in { data: {...}, success, status }
// Unwrap if present
return this.unwrapResponse<T>(json);
}
return {} as T;
} finally {
@@ -188,6 +203,22 @@ class PangolinClient {
}
}
/**
* Unwrap Pangolin's response envelope: { data: {...}, success, status }
* Returns the inner data object, or the raw response if not wrapped.
*/
private unwrapResponse<T>(json: unknown): T {
if (json && typeof json === 'object' && !Array.isArray(json)) {
const obj = json as Record<string, unknown>;
// If it has a 'data' key and 'success' key, it's a wrapped response
if ('data' in obj && 'success' in obj) {
logger.debug('Unwrapped Pangolin response envelope');
return obj.data as T;
}
}
return json as T;
}
async healthCheck(): Promise<boolean> {
try {
const controller = new AbortController();
@@ -207,105 +238,48 @@ class PangolinClient {
}
}
// --- Site Defaults ---
/**
* Get pre-generated Newt credentials for a new site.
* Must be called BEFORE createSite() and passed into it.
* Endpoint: GET /org/{orgId}/pick-site-defaults
*/
async pickSiteDefaults(): Promise<PangolinSiteDefaults> {
const res = await this.request<unknown>('GET', `/org/${this.orgId}/pick-site-defaults`);
const obj = res as Record<string, unknown>;
// Response format: { newtId, newtSecret, clientAddress, subnet, ... }
// Note: `address` is the exit node address, `clientAddress` is the site address
const newtId = obj.newtId as string || '';
const newtSecret = obj.newtSecret as string || obj.secret as string || '';
const address = obj.clientAddress as string || obj.address as string || '';
if (!newtId || !newtSecret) {
logger.warn('pickSiteDefaults response missing newtId/newtSecret:', JSON.stringify(res));
throw new Error('Pangolin did not return Newt credentials from pick-site-defaults');
}
logger.info(`pickSiteDefaults: newtId=${newtId}, address=${address}`);
return { newtId, newtSecret, address };
}
// --- Sites ---
async listSites(): Promise<PangolinSite[]> {
const res = await this.request<unknown>('GET', `/org/${this.orgId}/sites`);
// Handle direct array (edge case)
if (Array.isArray(res)) {
logger.info('listSites: received direct array');
return res as PangolinSite[];
}
const obj = res as Record<string, unknown>;
// Official Pangolin format: { data: { sites: [...], pagination: {...} } }
if (obj.data && typeof obj.data === 'object') {
const dataObj = obj.data as Record<string, unknown>;
if (Array.isArray(dataObj.sites)) {
logger.info(`listSites: extracted ${dataObj.sites.length} sites from data.sites`);
return dataObj.sites as PangolinSite[];
}
}
// Fallback: { sites: [...] } or { data: [...] }
if (Array.isArray(obj.sites)) {
logger.info(`listSites: extracted ${obj.sites.length} sites from sites`);
return obj.sites as PangolinSite[];
}
if (Array.isArray(obj.data)) {
logger.info(`listSites: extracted ${obj.data.length} sites from data`);
return obj.data as PangolinSite[];
}
logger.warn('listSites: could not extract sites array from response, returning empty');
return [];
return this.extractArray(res, 'sites', 'listSites');
}
async listExitNodes(): Promise<PangolinExitNode[]> {
try {
const res = await this.request<unknown>(
'GET',
`/org/${this.orgId}/exit-nodes`,
);
// Handle direct array (edge case)
if (Array.isArray(res)) {
logger.info('listExitNodes: received direct array');
return res.filter(node =>
node &&
typeof node.exitNodeId === 'string' &&
typeof node.name === 'string' &&
typeof node.online === 'boolean'
) as PangolinExitNode[];
}
const obj = res as Record<string, unknown>;
// Official Pangolin format: { data: { exitNodes: [...], pagination: {...} } }
if (obj.data && typeof obj.data === 'object') {
const dataObj = obj.data as Record<string, unknown>;
if (Array.isArray(dataObj.exitNodes)) {
logger.info(`listExitNodes: extracted ${dataObj.exitNodes.length} exit nodes from data.exitNodes`);
return dataObj.exitNodes.filter(node =>
node &&
typeof node.exitNodeId === 'string' &&
typeof node.name === 'string' &&
typeof node.online === 'boolean'
) as PangolinExitNode[];
}
}
// Fallback: { exitNodes: [...] } or { data: [...] }
if (Array.isArray(obj.exitNodes)) {
logger.info(`listExitNodes: extracted ${obj.exitNodes.length} exit nodes from exitNodes`);
return obj.exitNodes.filter(node =>
node &&
typeof node.exitNodeId === 'string' &&
typeof node.name === 'string' &&
typeof node.online === 'boolean'
) as PangolinExitNode[];
}
if (Array.isArray(obj.data)) {
logger.info(`listExitNodes: extracted ${obj.data.length} exit nodes from data`);
return obj.data.filter(node =>
node &&
typeof node.exitNodeId === 'string' &&
typeof node.name === 'string' &&
typeof node.online === 'boolean'
) as PangolinExitNode[];
}
logger.warn('listExitNodes: could not extract exit nodes array from response, returning empty');
return [];
const res = await this.request<unknown>('GET', `/org/${this.orgId}/exit-nodes`);
return this.extractArray(res, 'exitNodes', 'listExitNodes');
} catch (err) {
// Exit nodes endpoint not available (404) - this is OK for self-hosted
if (err instanceof Error && err.message.includes('404')) {
logger.info('Pangolin exit-nodes endpoint not available (self-hosted mode without separate exit nodes)');
logger.info('Pangolin exit-nodes endpoint not available (self-hosted mode)');
return [];
}
// Other errors - log but don't fail
logger.warn('Failed to fetch exit nodes:', err);
return [];
}
@@ -327,186 +301,81 @@ class PangolinClient {
await this.request<void>('DELETE', `/site/${siteId}`);
}
// --- Resources ---
// --- HTTP Resources (public proxy) ---
/**
* List HTTP proxy resources (public resources).
* Endpoint: GET /org/{orgId}/resources (NOT /site-resources)
*/
async listResources(): Promise<PangolinResource[]> {
const res = await this.request<unknown>(
'GET',
`/org/${this.orgId}/site-resources`,
);
// DEBUG: Log full response structure
logger.info(`listResources raw response: ${JSON.stringify(res, null, 2)}`);
// Handle direct array (edge case)
if (Array.isArray(res)) {
logger.info(`listResources: received direct array with ${res.length} items`);
if (res.length > 0) {
logger.info(`First resource: ${JSON.stringify(res[0], null, 2)}`);
}
return res as PangolinResource[];
}
const obj = res as Record<string, unknown>;
// Official Pangolin format: { data: { siteResources: [...], pagination: {...} } }
if (obj.data && typeof obj.data === 'object') {
const dataObj = obj.data as Record<string, unknown>;
if (Array.isArray(dataObj.siteResources)) {
logger.info(`listResources: extracted ${dataObj.siteResources.length} resources from data.siteResources`);
if (dataObj.siteResources.length > 0) {
logger.info(`First resource from data.siteResources: ${JSON.stringify(dataObj.siteResources[0], null, 2)}`);
}
return dataObj.siteResources as PangolinResource[];
}
}
// Fallback: { resources: [...] } or { data: [...] }
if (Array.isArray(obj.resources)) {
logger.info(`listResources: extracted ${obj.resources.length} resources from .resources`);
if (obj.resources.length > 0) {
logger.info(`First resource from .resources: ${JSON.stringify(obj.resources[0], null, 2)}`);
}
return obj.resources as PangolinResource[];
}
if (Array.isArray(obj.data)) {
logger.info(`listResources: extracted ${obj.data.length} resources from .data`);
if (obj.data.length > 0) {
logger.info(`First resource from .data: ${JSON.stringify(obj.data[0], null, 2)}`);
}
return obj.data as PangolinResource[];
}
logger.warn('listResources: could not extract resources array from response, returning empty');
return [];
const res = await this.request<unknown>('GET', `/org/${this.orgId}/resources`);
return this.extractArray(res, 'resources', 'listResources');
}
async getResource(resourceId: string): Promise<PangolinResource> {
return this.request<PangolinResource>('GET', `/site-resource/${resourceId}`);
return this.request<PangolinResource>('GET', `/resource/${resourceId}`);
}
async createResource(siteId: string, data: CreateResourcePayload): Promise<PangolinResource> {
// All resources use the same endpoint: PUT /org/{orgId}/site-resource
// HTTP resources are differentiated by the 'http: true' field in the payload
const endpoint = `/org/${this.orgId}/site-resource`;
// Remove 'type' from payload - it's only for internal routing logic, not sent to API
const { type, ...payload } = data;
const isHttpResource = (data as CreateHttpResourcePayload).http === true;
logger.info(`createResource endpoint: ${endpoint} (${isHttpResource ? 'HTTP' : 'CLIENT'} resource)`);
logger.info(`createResource payload: ${JSON.stringify(payload, null, 2)}`);
const result = await this.request<PangolinResource>(
/**
* Create an HTTP proxy resource (public resource).
* Endpoint: PUT /org/{orgId}/resource (NOT /site-resource)
*/
async createResource(data: CreateHttpResourcePayload): Promise<PangolinResource> {
logger.info(`createResource: ${data.name} (subdomain: ${data.subdomain || '(root)'})`);
return this.request<PangolinResource>(
'PUT',
endpoint,
payload,
);
// DEBUG: Log the response to see what fields are returned
logger.info(`createResource response: ${JSON.stringify(result, null, 2)}`);
return result;
}
async updateResource(resourceId: string, data: UpdateResourcePayload): Promise<PangolinResource> {
return this.request<PangolinResource>('POST', `/site-resource/${resourceId}`, data);
}
async deleteResource(resourceId: string): Promise<void> {
await this.request<void>('DELETE', `/site-resource/${resourceId}`);
}
async createTarget(resourceId: string, data: CreateTargetPayload): Promise<unknown> {
// Try the standard endpoint first
return this.request<unknown>(
'POST',
`/resource/${resourceId}/target`,
`/org/${this.orgId}/resource`,
data,
);
}
async updateResource(resourceId: string, data: UpdateResourcePayload): Promise<PangolinResource> {
return this.request<PangolinResource>('POST', `/resource/${resourceId}`, data);
}
async deleteResource(resourceId: string): Promise<void> {
await this.request<void>('DELETE', `/resource/${resourceId}`);
}
// --- Targets ---
/**
* Alternative target creation method that tries different endpoint formats
* Used for debugging target creation failures
* Create a target for a resource (routes traffic to a backend).
* Endpoint: PUT /resource/{resourceId}/target (PUT, not POST)
*/
async createTargetAlt(resourceId: string, data: CreateTargetPayload, format: 'standard' | 'site-resource' = 'standard'): Promise<{ success: boolean; endpoint: string; response: unknown; error?: string }> {
const endpoints = {
'standard': `/resource/${resourceId}/target`,
'site-resource': `/site-resource/${resourceId}/target`,
};
async createTarget(resourceId: string, data: CreateTargetPayload): Promise<PangolinTarget> {
logger.info(`createTarget: resource=${resourceId}, ip=${data.ip}:${data.port}, method=${data.method}`);
// Pangolin expects siteId as a number, not a string
const payload = { ...data, siteId: Number(data.siteId) };
return this.request<PangolinTarget>(
'PUT',
`/resource/${resourceId}/target`,
payload,
);
}
const endpoint = endpoints[format];
logger.info(`Trying target creation with format "${format}" at ${endpoint}`);
/**
* List targets for a resource.
* Endpoint: GET /resource/{resourceId}/targets (plural — NOT /target)
*/
async listTargets(resourceId: string): Promise<PangolinTarget[]> {
const res = await this.request<unknown>('GET', `/resource/${resourceId}/targets`);
return this.extractArray(res, 'targets', 'listTargets');
}
try {
const response = await this.request<unknown>('POST', endpoint, data);
logger.info(`✅ Target creation succeeded at ${endpoint}`);
return {
success: true,
endpoint,
response,
};
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
logger.warn(`❌ Target creation failed at ${endpoint}: ${error}`);
return {
success: false,
endpoint,
response: null,
error,
};
}
/**
* Delete a target by ID.
* Endpoint: DELETE /target/{targetId} (NOT nested under /resource/)
*/
async deleteTarget(targetId: string): Promise<void> {
await this.request<void>('DELETE', `/target/${targetId}`);
}
// --- Domains ---
async listDomains(): Promise<PangolinDomain[]> {
const res = await this.request<unknown>(
'GET',
`/org/${this.orgId}/domains`,
);
// Handle direct array
if (Array.isArray(res)) {
return res as PangolinDomain[];
}
const obj = res as Record<string, unknown>;
// Check nested data.domains
if (obj.data && typeof obj.data === 'object') {
const dataObj = obj.data as Record<string, unknown>;
if (Array.isArray(dataObj.domains)) {
return dataObj.domains as PangolinDomain[];
}
}
// Fallback
if (Array.isArray(obj.domains)) {
return obj.domains as PangolinDomain[];
}
if (Array.isArray(obj.data)) {
return obj.data as PangolinDomain[];
}
logger.warn('listDomains: could not extract domains array, returning empty');
return [];
}
async listResourcesForSite(siteId: string): Promise<PangolinResource[]> {
const res = await this.request<{ data: PangolinResource[] } | PangolinResource[]>(
'GET',
`/org/${this.orgId}/site/${siteId}/resources`,
);
return Array.isArray(res) ? res : (res.data || []);
}
async getResourceByNiceId(siteId: string, niceId: string): Promise<PangolinResource> {
return this.request<PangolinResource>(
'GET',
`/org/${this.orgId}/site/${siteId}/resource/nice/${niceId}`,
);
const res = await this.request<unknown>('GET', `/org/${this.orgId}/domains`);
return this.extractArray(res, 'domains', 'listDomains');
}
// --- Certificates ---
@@ -524,12 +393,48 @@ class PangolinClient {
// --- Clients ---
async listClients(siteResourceId: string): Promise<PangolinConnectedClient[]> {
const res = await this.request<{ data: PangolinConnectedClient[] } | PangolinConnectedClient[]>(
'GET',
`/site-resource/${siteResourceId}/clients`,
);
return Array.isArray(res) ? res : (res.data || []);
async listClients(resourceId: string): Promise<PangolinConnectedClient[]> {
const res = await this.request<unknown>('GET', `/resource/${resourceId}/clients`);
return this.extractArray(res, 'clients', 'listClients');
}
// --- Helpers ---
/**
* Extract an array from a Pangolin response that may be:
* - A direct array
* - An object with the array under a named key (e.g., { sites: [...] })
* - An object with { data: [...] }
*/
private extractArray<T>(res: unknown, key: string, context: string): T[] {
if (Array.isArray(res)) {
return res as T[];
}
if (res && typeof res === 'object') {
const obj = res as Record<string, unknown>;
// Check named key (e.g., "sites", "resources", "domains")
if (Array.isArray(obj[key])) {
return obj[key] as T[];
}
// Check nested data.{key}
if (obj.data && typeof obj.data === 'object') {
const dataObj = obj.data as Record<string, unknown>;
if (Array.isArray(dataObj[key])) {
return dataObj[key] as T[];
}
}
// Fallback: { data: [...] }
if (Array.isArray(obj.data)) {
return obj.data as T[];
}
}
logger.warn(`${context}: could not extract array from response, returning empty`);
return [];
}
}

5
api/upgrade/trigger.json Normal file
View File

@@ -0,0 +1,5 @@
{
"action": "check",
"triggeredAt": "2026-03-03T22:28:23.863Z",
"triggeredBy": "admin@bnkops.ca"
}