Add ActionCampaign + Document models for volunteer dashboard

Foundation schema for the FAFC-style volunteer dashboard. Adds:
- ActionCampaign / ActionStep / ActionStepCompletion (stacked-action
  mini-campaigns where steps reference existing entities like videos,
  petitions, ticketed events; completion is detected at query time
  against the per-user model for each step kind)
- Document model for downloadable resources (PDFs etc.) — Photo's
  EXIF/sharp pipeline can't host non-image files
- Shift.kind discriminator (ShiftKind enum) so training shifts can
  surface separately on the dashboard
- TicketedEvent.featured for the "Take Action" CTA tile

Bunker Admin
This commit is contained in:
bunker-admin 2026-04-10 21:26:56 -06:00
parent 5f0ae6bc5a
commit 3fc67cd81a
2 changed files with 2228 additions and 1962 deletions

View File

@ -0,0 +1,130 @@
-- CreateEnum
CREATE TYPE "ShiftKind" AS ENUM ('CANVASS', 'TRAINING', 'EVENT_STAFFING', 'PHONE_BANK', 'OTHER');
-- CreateEnum
CREATE TYPE "ActionStepKind" AS ENUM ('WATCH_VIDEO', 'SUBMIT_INFLUENCE', 'SIGN_PETITION', 'RSVP_EVENT', 'SIGNUP_SHIFT', 'JOIN_CHALLENGE', 'VISIT_LINK', 'CUSTOM');
-- CreateEnum
CREATE TYPE "ActionStepCompletionSource" AS ENUM ('AUTO', 'SELF_REPORTED');
-- AlterTable
ALTER TABLE "shifts" ADD COLUMN "kind" "ShiftKind" NOT NULL DEFAULT 'CANVASS';
-- AlterTable
ALTER TABLE "ticketed_events" ADD COLUMN "featured" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "action_campaigns" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"reward_text" TEXT,
"is_active" BOOLEAN NOT NULL DEFAULT false,
"starts_at" TIMESTAMP(3),
"ends_at" TIMESTAMP(3),
"min_steps_for_reward" INTEGER,
"created_by_user_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "action_campaigns_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "action_steps" (
"id" TEXT NOT NULL,
"campaign_id" TEXT NOT NULL,
"order" INTEGER NOT NULL,
"kind" "ActionStepKind" NOT NULL,
"label" TEXT NOT NULL,
"description" TEXT,
"target_id" TEXT,
"target_url" TEXT,
"auto_complete" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "action_steps_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "action_step_completions" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"step_id" TEXT NOT NULL,
"source" "ActionStepCompletionSource" NOT NULL DEFAULT 'AUTO',
"completed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "action_step_completions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "documents" (
"id" TEXT NOT NULL,
"path" TEXT NOT NULL,
"filename" TEXT NOT NULL,
"original_filename" TEXT,
"title" TEXT,
"description" TEXT,
"mime_type" TEXT NOT NULL,
"file_size" BIGINT,
"page_count" INTEGER,
"thumbnail_path" TEXT,
"category" TEXT,
"tags" JSONB,
"is_published" BOOLEAN NOT NULL DEFAULT true,
"position" INTEGER DEFAULT 0,
"uploader_id" TEXT,
"download_count" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "documents_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "action_campaigns_slug_key" ON "action_campaigns"("slug");
-- CreateIndex
CREATE INDEX "idx_action_campaigns_active" ON "action_campaigns"("is_active");
-- CreateIndex
CREATE INDEX "idx_action_steps_campaign" ON "action_steps"("campaign_id");
-- CreateIndex
CREATE UNIQUE INDEX "action_steps_campaign_id_order_key" ON "action_steps"("campaign_id", "order");
-- CreateIndex
CREATE INDEX "idx_action_step_completions_user" ON "action_step_completions"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "action_step_completions_user_id_step_id_key" ON "action_step_completions"("user_id", "step_id");
-- CreateIndex
CREATE UNIQUE INDEX "documents_path_key" ON "documents"("path");
-- CreateIndex
CREATE INDEX "idx_documents_published" ON "documents"("is_published");
-- CreateIndex
CREATE INDEX "idx_documents_category" ON "documents"("category");
-- CreateIndex
CREATE INDEX "idx_documents_created_at" ON "documents"("created_at");
-- AddForeignKey
ALTER TABLE "action_campaigns" ADD CONSTRAINT "action_campaigns_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "action_steps" ADD CONSTRAINT "action_steps_campaign_id_fkey" FOREIGN KEY ("campaign_id") REFERENCES "action_campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "action_step_completions" ADD CONSTRAINT "action_step_completions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "action_step_completions" ADD CONSTRAINT "action_step_completions_step_id_fkey" FOREIGN KEY ("step_id") REFERENCES "action_steps"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "documents" ADD CONSTRAINT "documents_uploader_id_fkey" FOREIGN KEY ("uploader_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -228,6 +228,11 @@ model User {
petitionsCreated Petition[] @relation("PetitionCreator") petitionsCreated Petition[] @relation("PetitionCreator")
petitionsReviewed Petition[] @relation("PetitionReviewer") petitionsReviewed Petition[] @relation("PetitionReviewer")
// Volunteer dashboard — action campaigns
actionCampaignsCreated ActionCampaign[] @relation("ActionCampaignCreator")
actionStepCompletions ActionStepCompletion[] @relation("ActionStepCompleter")
documentsUploaded Document[] @relation("DocumentUploader")
@@map("users") @@map("users")
} }
@ -866,6 +871,7 @@ model Shift {
maxVolunteers Int maxVolunteers Int
currentVolunteers Int @default(0) currentVolunteers Int @default(0)
status ShiftStatus @default(OPEN) status ShiftStatus @default(OPEN)
kind ShiftKind @default(CANVASS)
isPublic Boolean @default(false) isPublic Boolean @default(false)
cutId String? cutId String?
cut Cut? @relation(fields: [cutId], references: [id], onDelete: SetNull) cut Cut? @relation(fields: [cutId], references: [id], onDelete: SetNull)
@ -913,6 +919,14 @@ enum SignupSource {
POLL_CONVERSION POLL_CONVERSION
} }
enum ShiftKind {
CANVASS
TRAINING
EVENT_STAFFING
PHONE_BANK
OTHER
}
model ShiftSignup { model ShiftSignup {
id String @id @default(cuid()) id String @id @default(cuid())
shiftId String shiftId String
@ -1453,6 +1467,7 @@ model TrackPoint {
@@index([recordedAt]) @@index([recordedAt])
@@map("track_points") @@map("track_points")
} }
// Enums // Enums
enum DirectoryType { enum DirectoryType {
studios studios
@ -3488,7 +3503,7 @@ model SubscriptionPlan {
slug String? @unique slug String? @unique
coverPhoto String? @map("cover_photo") coverPhoto String? @map("cover_photo")
coverVideoId Int? @map("cover_video_id") coverVideoId Int? @map("cover_video_id")
richDescription String? @db.Text @map("rich_description") richDescription String? @map("rich_description") @db.Text
ctaText String? @map("cta_text") ctaText String? @map("cta_text")
ctaSubtext String? @map("cta_subtext") ctaSubtext String? @map("cta_subtext")
highlightPlan Boolean @default(false) @map("highlight_plan") highlightPlan Boolean @default(false) @map("highlight_plan")
@ -3646,7 +3661,7 @@ model Order {
buyerName String? @map("buyer_name") buyerName String? @map("buyer_name")
// Donation-specific // Donation-specific
donorMessage String? @db.Text @map("donor_message") donorMessage String? @map("donor_message") @db.Text
isAnonymous Boolean @default(false) @map("is_anonymous") isAnonymous Boolean @default(false) @map("is_anonymous")
completedAt DateTime? @map("completed_at") completedAt DateTime? @map("completed_at")
@ -3689,7 +3704,7 @@ model DonationPage {
// Donation config (per-page, mirrors PaymentSettings fields) // Donation config (per-page, mirrors PaymentSettings fields)
suggestedAmounts Json @default("[1000, 2500, 5000, 10000]") suggestedAmounts Json @default("[1000, 2500, 5000, 10000]")
minimumAmount Int @default(500) @map("minimum_amount") minimumAmount Int @default(500) @map("minimum_amount")
thankYouMessage String @default("Thank you for your support!") @db.Text @map("thank_you_message") thankYouMessage String @default("Thank you for your support!") @map("thank_you_message") @db.Text
// Media // Media
coverPhoto String? @map("cover_photo") coverPhoto String? @map("cover_photo")
@ -3726,8 +3741,8 @@ model PaymentSettings {
donationSuggestedAmounts Json @default("[1000, 2500, 5000, 10000]") @map("donation_suggested_amounts") donationSuggestedAmounts Json @default("[1000, 2500, 5000, 10000]") @map("donation_suggested_amounts")
donationMinimum Int @default(500) @map("donation_minimum") donationMinimum Int @default(500) @map("donation_minimum")
donationPageTitle String @default("Support Our Work") @map("donation_page_title") donationPageTitle String @default("Support Our Work") @map("donation_page_title")
donationPageDescription String? @db.Text @map("donation_page_description") donationPageDescription String? @map("donation_page_description") @db.Text
thankYouMessage String @default("Thank you for your support!") @db.Text @map("thank_you_message") thankYouMessage String @default("Thank you for your support!") @map("thank_you_message") @db.Text
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@ -4951,7 +4966,7 @@ model TicketedEvent {
slug String @unique slug String @unique
title String title String
description String? @db.Text description String? @db.Text
richDescription String? @db.Text @map("rich_description") richDescription String? @map("rich_description") @db.Text
// Schedule // Schedule
date DateTime @db.Date date DateTime @db.Date
@ -4969,6 +4984,7 @@ model TicketedEvent {
status TicketedEventStatus @default(DRAFT) status TicketedEventStatus @default(DRAFT)
visibility TicketedEventVisibility @default(PUBLIC) visibility TicketedEventVisibility @default(PUBLIC)
inviteCode String? @unique @map("invite_code") inviteCode String? @unique @map("invite_code")
featured Boolean @default(false)
// Media // Media
coverImageUrl String? @map("cover_image_url") coverImageUrl String? @map("cover_image_url")
@ -5428,7 +5444,7 @@ model ParticipantNeeds {
needsGroundFloor Boolean @default(false) @map("needs_ground_floor") needsGroundFloor Boolean @default(false) @map("needs_ground_floor")
needsHearingLoop Boolean @default(false) @map("needs_hearing_loop") needsHearingLoop Boolean @default(false) @map("needs_hearing_loop")
needsSignLanguage Boolean @default(false) @map("needs_sign_language") needsSignLanguage Boolean @default(false) @map("needs_sign_language")
otherAccessibility String? @db.Text @map("other_accessibility") otherAccessibility String? @map("other_accessibility") @db.Text
// Dietary // Dietary
isVegan Boolean @default(false) @map("is_vegan") isVegan Boolean @default(false) @map("is_vegan")
@ -5437,13 +5453,13 @@ model ParticipantNeeds {
isHalal Boolean @default(false) @map("is_halal") isHalal Boolean @default(false) @map("is_halal")
isKosher Boolean @default(false) @map("is_kosher") isKosher Boolean @default(false) @map("is_kosher")
hasNutAllergy Boolean @default(false) @map("has_nut_allergy") hasNutAllergy Boolean @default(false) @map("has_nut_allergy")
otherDietary String? @db.Text @map("other_dietary") otherDietary String? @map("other_dietary") @db.Text
// Care barriers // Care barriers
needsChildcare Boolean @default(false) @map("needs_childcare") needsChildcare Boolean @default(false) @map("needs_childcare")
childcareDetails String? @db.Text @map("childcare_details") childcareDetails String? @map("childcare_details") @db.Text
needsTransportation Boolean @default(false) @map("needs_transportation") needsTransportation Boolean @default(false) @map("needs_transportation")
transportationNotes String? @db.Text @map("transportation_notes") transportationNotes String? @map("transportation_notes") @db.Text
// Communication // Communication
preferredLanguage String? @default("en") @map("preferred_language") preferredLanguage String? @default("en") @map("preferred_language")
@ -5611,7 +5627,7 @@ model StrawPollVote {
userId String? @map("user_id") userId String? @map("user_id")
user User? @relation("StrawPollVoter", fields: [userId], references: [id], onDelete: SetNull) user User? @relation("StrawPollVoter", fields: [userId], references: [id], onDelete: SetNull)
voterName String? @db.VarChar(100) @map("voter_name") voterName String? @map("voter_name") @db.VarChar(100)
voterToken String? @map("voter_token") voterToken String? @map("voter_token")
voterIp String? @map("voter_ip") voterIp String? @map("voter_ip")
contactId String? @map("contact_id") contactId String? @map("contact_id")
@ -5634,7 +5650,7 @@ model StrawPollComment {
poll StrawPoll @relation(fields: [pollId], references: [id], onDelete: Cascade) poll StrawPoll @relation(fields: [pollId], references: [id], onDelete: Cascade)
userId String? @map("user_id") userId String? @map("user_id")
user User? @relation("StrawPollCommenter", fields: [userId], references: [id], onDelete: SetNull) user User? @relation("StrawPollCommenter", fields: [userId], references: [id], onDelete: SetNull)
authorName String @db.VarChar(100) @map("author_name") authorName String @map("author_name") @db.VarChar(100)
content String @db.Text content String @db.Text
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
@ -5656,3 +5672,123 @@ model StrawPollChallenge {
@@unique([pollId, challengerUserId, challengedUserId]) @@unique([pollId, challengerUserId, challengedUserId])
@@map("straw_poll_challenges") @@map("straw_poll_challenges")
} }
// ============================================================================
// VOLUNTEER DASHBOARD — ACTION CAMPAIGNS
// ============================================================================
// Stacked-action mini-campaigns: an admin defines an ordered list of steps
// (watch a video, sign a petition, RSVP an event, etc.) that volunteers
// complete to earn recognition or a draw entry. Completion is detected by
// querying the per-user model for each step's target entity.
enum ActionStepKind {
WATCH_VIDEO
SUBMIT_INFLUENCE
SIGN_PETITION
RSVP_EVENT
SIGNUP_SHIFT
JOIN_CHALLENGE
VISIT_LINK
CUSTOM
}
enum ActionStepCompletionSource {
AUTO
SELF_REPORTED
}
model ActionCampaign {
id String @id @default(cuid())
slug String @unique
title String
description String? @db.Text
rewardText String? @map("reward_text")
isActive Boolean @default(false) @map("is_active")
startsAt DateTime? @map("starts_at")
endsAt DateTime? @map("ends_at")
minStepsForReward Int? @map("min_steps_for_reward") // null = all steps required
createdByUserId String? @map("created_by_user_id")
createdBy User? @relation("ActionCampaignCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
steps ActionStep[]
@@index([isActive], map: "idx_action_campaigns_active")
@@map("action_campaigns")
}
model ActionStep {
id String @id @default(cuid())
campaignId String @map("campaign_id")
campaign ActionCampaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
order Int
kind ActionStepKind
label String
description String? @db.Text
targetId String? @map("target_id") // entity id/slug for the kind
targetUrl String? @map("target_url") // optional deep-link override for the user
autoComplete Boolean @default(true) @map("auto_complete")
createdAt DateTime @default(now()) @map("created_at")
completions ActionStepCompletion[]
@@unique([campaignId, order])
@@index([campaignId], map: "idx_action_steps_campaign")
@@map("action_steps")
}
model ActionStepCompletion {
id String @id @default(cuid())
userId String @map("user_id")
user User @relation("ActionStepCompleter", fields: [userId], references: [id], onDelete: Cascade)
stepId String @map("step_id")
step ActionStep @relation(fields: [stepId], references: [id], onDelete: Cascade)
source ActionStepCompletionSource @default(AUTO)
completedAt DateTime @default(now()) @map("completed_at")
@@unique([userId, stepId])
@@index([userId], map: "idx_action_step_completions_user")
@@map("action_step_completions")
}
// ============================================================================
// MEDIA — DOCUMENTS (PDFs and other downloadable assets)
// ============================================================================
// Image-specific Photo model can't host PDFs (EXIF/sharp pipeline assumes
// raster images), so documents are a distinct first-class media type with
// their own download endpoint and tag-based categorization.
model Document {
id String @id @default(cuid())
path String @unique // Full path to file on disk
filename String // UUID filename
originalFilename String? @map("original_filename")
title String?
description String? @db.Text
mimeType String @map("mime_type")
fileSize BigInt? @map("file_size")
pageCount Int? @map("page_count") // For PDFs
thumbnailPath String? @map("thumbnail_path")
// Categorization
category String?
tags Json? // String array — used for "volunteer-resource" tagging
// Publishing
isPublished Boolean @default(true) @map("is_published")
position Int? @default(0)
// Tracking
uploaderId String? @map("uploader_id")
uploader User? @relation("DocumentUploader", fields: [uploaderId], references: [id], onDelete: SetNull)
downloadCount Int @default(0) @map("download_count")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([isPublished], map: "idx_documents_published")
@@index([category], map: "idx_documents_category")
@@index([createdAt], map: "idx_documents_created_at")
@@map("documents")
}