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:
parent
5f0ae6bc5a
commit
3fc67cd81a
@ -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;
|
||||
|
||||
@ -228,6 +228,11 @@ model User {
|
||||
petitionsCreated Petition[] @relation("PetitionCreator")
|
||||
petitionsReviewed Petition[] @relation("PetitionReviewer")
|
||||
|
||||
// Volunteer dashboard — action campaigns
|
||||
actionCampaignsCreated ActionCampaign[] @relation("ActionCampaignCreator")
|
||||
actionStepCompletions ActionStepCompletion[] @relation("ActionStepCompleter")
|
||||
documentsUploaded Document[] @relation("DocumentUploader")
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
@ -866,6 +871,7 @@ model Shift {
|
||||
maxVolunteers Int
|
||||
currentVolunteers Int @default(0)
|
||||
status ShiftStatus @default(OPEN)
|
||||
kind ShiftKind @default(CANVASS)
|
||||
isPublic Boolean @default(false)
|
||||
cutId String?
|
||||
cut Cut? @relation(fields: [cutId], references: [id], onDelete: SetNull)
|
||||
@ -913,6 +919,14 @@ enum SignupSource {
|
||||
POLL_CONVERSION
|
||||
}
|
||||
|
||||
enum ShiftKind {
|
||||
CANVASS
|
||||
TRAINING
|
||||
EVENT_STAFFING
|
||||
PHONE_BANK
|
||||
OTHER
|
||||
}
|
||||
|
||||
model ShiftSignup {
|
||||
id String @id @default(cuid())
|
||||
shiftId String
|
||||
@ -1453,6 +1467,7 @@ model TrackPoint {
|
||||
@@index([recordedAt])
|
||||
@@map("track_points")
|
||||
}
|
||||
|
||||
// Enums
|
||||
enum DirectoryType {
|
||||
studios
|
||||
@ -3488,7 +3503,7 @@ model SubscriptionPlan {
|
||||
slug String? @unique
|
||||
coverPhoto String? @map("cover_photo")
|
||||
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")
|
||||
ctaSubtext String? @map("cta_subtext")
|
||||
highlightPlan Boolean @default(false) @map("highlight_plan")
|
||||
@ -3646,7 +3661,7 @@ model Order {
|
||||
buyerName String? @map("buyer_name")
|
||||
|
||||
// Donation-specific
|
||||
donorMessage String? @db.Text @map("donor_message")
|
||||
donorMessage String? @map("donor_message") @db.Text
|
||||
isAnonymous Boolean @default(false) @map("is_anonymous")
|
||||
|
||||
completedAt DateTime? @map("completed_at")
|
||||
@ -3689,7 +3704,7 @@ model DonationPage {
|
||||
// Donation config (per-page, mirrors PaymentSettings fields)
|
||||
suggestedAmounts Json @default("[1000, 2500, 5000, 10000]")
|
||||
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
|
||||
coverPhoto String? @map("cover_photo")
|
||||
@ -3726,8 +3741,8 @@ model PaymentSettings {
|
||||
donationSuggestedAmounts Json @default("[1000, 2500, 5000, 10000]") @map("donation_suggested_amounts")
|
||||
donationMinimum Int @default(500) @map("donation_minimum")
|
||||
donationPageTitle String @default("Support Our Work") @map("donation_page_title")
|
||||
donationPageDescription String? @db.Text @map("donation_page_description")
|
||||
thankYouMessage String @default("Thank you for your support!") @db.Text @map("thank_you_message")
|
||||
donationPageDescription String? @map("donation_page_description") @db.Text
|
||||
thankYouMessage String @default("Thank you for your support!") @map("thank_you_message") @db.Text
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
@ -4951,7 +4966,7 @@ model TicketedEvent {
|
||||
slug String @unique
|
||||
title String
|
||||
description String? @db.Text
|
||||
richDescription String? @db.Text @map("rich_description")
|
||||
richDescription String? @map("rich_description") @db.Text
|
||||
|
||||
// Schedule
|
||||
date DateTime @db.Date
|
||||
@ -4969,6 +4984,7 @@ model TicketedEvent {
|
||||
status TicketedEventStatus @default(DRAFT)
|
||||
visibility TicketedEventVisibility @default(PUBLIC)
|
||||
inviteCode String? @unique @map("invite_code")
|
||||
featured Boolean @default(false)
|
||||
|
||||
// Media
|
||||
coverImageUrl String? @map("cover_image_url")
|
||||
@ -5428,7 +5444,7 @@ model ParticipantNeeds {
|
||||
needsGroundFloor Boolean @default(false) @map("needs_ground_floor")
|
||||
needsHearingLoop Boolean @default(false) @map("needs_hearing_loop")
|
||||
needsSignLanguage Boolean @default(false) @map("needs_sign_language")
|
||||
otherAccessibility String? @db.Text @map("other_accessibility")
|
||||
otherAccessibility String? @map("other_accessibility") @db.Text
|
||||
|
||||
// Dietary
|
||||
isVegan Boolean @default(false) @map("is_vegan")
|
||||
@ -5437,13 +5453,13 @@ model ParticipantNeeds {
|
||||
isHalal Boolean @default(false) @map("is_halal")
|
||||
isKosher Boolean @default(false) @map("is_kosher")
|
||||
hasNutAllergy Boolean @default(false) @map("has_nut_allergy")
|
||||
otherDietary String? @db.Text @map("other_dietary")
|
||||
otherDietary String? @map("other_dietary") @db.Text
|
||||
|
||||
// Care barriers
|
||||
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")
|
||||
transportationNotes String? @db.Text @map("transportation_notes")
|
||||
transportationNotes String? @map("transportation_notes") @db.Text
|
||||
|
||||
// Communication
|
||||
preferredLanguage String? @default("en") @map("preferred_language")
|
||||
@ -5611,7 +5627,7 @@ model StrawPollVote {
|
||||
|
||||
userId String? @map("user_id")
|
||||
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")
|
||||
voterIp String? @map("voter_ip")
|
||||
contactId String? @map("contact_id")
|
||||
@ -5634,7 +5650,7 @@ model StrawPollComment {
|
||||
poll StrawPoll @relation(fields: [pollId], references: [id], onDelete: Cascade)
|
||||
userId String? @map("user_id")
|
||||
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
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@ -5656,3 +5672,123 @@ model StrawPollChallenge {
|
||||
@@unique([pollId, challengerUserId, challengedUserId])
|
||||
@@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")
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user