Bunch of updates to scheduling
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
-- Add POLL to CalendarItemSource enum
|
||||
ALTER TYPE "CalendarItemSource" ADD VALUE IF NOT EXISTS 'POLL';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "scheduling_polls" ADD COLUMN "auto_finalize" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "scheduling_polls" ADD COLUMN "auto_finalize_threshold" INTEGER;
|
||||
ALTER TABLE "scheduling_polls" ADD COLUMN "auto_convert_to_calendar" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "scheduling_polls" ADD COLUMN "auto_convert_to_gancio" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "scheduling_polls" ADD COLUMN "auto_convert_to_shift" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "scheduling_polls" ADD COLUMN "tie_breaker" TEXT NOT NULL DEFAULT 'earliest';
|
||||
ALTER TABLE "scheduling_polls" ADD COLUMN "auto_finalize_job_id" TEXT;
|
||||
ALTER TABLE "scheduling_polls" ADD COLUMN "converted_calendar_item_id" TEXT;
|
||||
@@ -0,0 +1,160 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "ContactActivityType" ADD VALUE 'POLL_VOTED';
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "ContactSource" ADD VALUE 'POLL_VOTE';
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "SignupSource" ADD VALUE 'POLL_CONVERSION';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "contacts" ADD COLUMN "pronouns" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "scheduling_poll_votes" ADD COLUMN "contact_id" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "scheduling_polls" ADD COLUMN "auto_enroll_voters" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "pronouns" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "participant_needs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"contact_id" TEXT,
|
||||
"needs_wheelchair" BOOLEAN NOT NULL DEFAULT false,
|
||||
"needs_ground_floor" BOOLEAN NOT NULL DEFAULT false,
|
||||
"needs_hearing_loop" BOOLEAN NOT NULL DEFAULT false,
|
||||
"needs_sign_language" BOOLEAN NOT NULL DEFAULT false,
|
||||
"other_accessibility" TEXT,
|
||||
"is_vegan" BOOLEAN NOT NULL DEFAULT false,
|
||||
"is_vegetarian" BOOLEAN NOT NULL DEFAULT false,
|
||||
"is_gluten_free" BOOLEAN NOT NULL DEFAULT false,
|
||||
"is_halal" BOOLEAN NOT NULL DEFAULT false,
|
||||
"is_kosher" BOOLEAN NOT NULL DEFAULT false,
|
||||
"has_nut_allergy" BOOLEAN NOT NULL DEFAULT false,
|
||||
"other_dietary" TEXT,
|
||||
"needs_childcare" BOOLEAN NOT NULL DEFAULT false,
|
||||
"childcare_details" TEXT,
|
||||
"needs_transportation" BOOLEAN NOT NULL DEFAULT false,
|
||||
"transportation_notes" TEXT,
|
||||
"preferred_language" TEXT DEFAULT 'en',
|
||||
"needs_translation" BOOLEAN NOT NULL DEFAULT false,
|
||||
"translation_language" TEXT,
|
||||
"visibility_consent" TEXT NOT NULL DEFAULT 'organizer_only',
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "participant_needs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "meeting_agendas" (
|
||||
"id" TEXT NOT NULL,
|
||||
"shift_id" TEXT,
|
||||
"poll_id" TEXT,
|
||||
"title" TEXT NOT NULL,
|
||||
"items" JSONB NOT NULL DEFAULT '[]',
|
||||
"status" TEXT NOT NULL DEFAULT 'draft',
|
||||
"created_by_user_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "meeting_agendas_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "meeting_minutes" (
|
||||
"id" TEXT NOT NULL,
|
||||
"agenda_id" TEXT NOT NULL,
|
||||
"notes" TEXT NOT NULL,
|
||||
"decisions" JSONB NOT NULL DEFAULT '[]',
|
||||
"attendees" JSONB NOT NULL DEFAULT '[]',
|
||||
"approved_at" TIMESTAMP(3),
|
||||
"approved_by_user_id" TEXT,
|
||||
"created_by_user_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "meeting_minutes_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "action_items" (
|
||||
"id" TEXT NOT NULL,
|
||||
"agenda_id" TEXT,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"assignee_user_id" TEXT,
|
||||
"due_date" TIMESTAMP(3),
|
||||
"status" TEXT NOT NULL DEFAULT 'open',
|
||||
"priority" TEXT NOT NULL DEFAULT 'normal',
|
||||
"completed_at" TIMESTAMP(3),
|
||||
"created_by_user_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "action_items_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "participant_needs_user_id_key" ON "participant_needs"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "participant_needs_contact_id_key" ON "participant_needs"("contact_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "meeting_agendas_shift_id_key" ON "meeting_agendas"("shift_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "meeting_agendas_poll_id_key" ON "meeting_agendas"("poll_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "meeting_minutes_agenda_id_key" ON "meeting_minutes"("agenda_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "action_items_assignee_user_id_status_idx" ON "action_items"("assignee_user_id", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "action_items_due_date_idx" ON "action_items"("due_date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "scheduling_poll_votes_contact_id_idx" ON "scheduling_poll_votes"("contact_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "scheduling_poll_votes" ADD CONSTRAINT "scheduling_poll_votes_contact_id_fkey" FOREIGN KEY ("contact_id") REFERENCES "contacts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "participant_needs" ADD CONSTRAINT "participant_needs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "participant_needs" ADD CONSTRAINT "participant_needs_contact_id_fkey" FOREIGN KEY ("contact_id") REFERENCES "contacts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "meeting_agendas" ADD CONSTRAINT "meeting_agendas_shift_id_fkey" FOREIGN KEY ("shift_id") REFERENCES "shifts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "meeting_agendas" ADD CONSTRAINT "meeting_agendas_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "scheduling_polls"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "meeting_agendas" ADD CONSTRAINT "meeting_agendas_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "meeting_minutes" ADD CONSTRAINT "meeting_minutes_agenda_id_fkey" FOREIGN KEY ("agenda_id") REFERENCES "meeting_agendas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "meeting_minutes" ADD CONSTRAINT "meeting_minutes_approved_by_user_id_fkey" FOREIGN KEY ("approved_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "meeting_minutes" ADD CONSTRAINT "meeting_minutes_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "action_items" ADD CONSTRAINT "action_items_agenda_id_fkey" FOREIGN KEY ("agenda_id") REFERENCES "meeting_agendas"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "action_items" ADD CONSTRAINT "action_items_assignee_user_id_fkey" FOREIGN KEY ("assignee_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "action_items" ADD CONSTRAINT "action_items_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
@@ -48,6 +48,7 @@ model User {
|
||||
password String // bcrypt hashed
|
||||
name String?
|
||||
phone String?
|
||||
pronouns String?
|
||||
role UserRole @default(USER)
|
||||
roles Json @default("[]") // Array of UserRole strings for multi-role support
|
||||
status UserStatus @default(ACTIVE)
|
||||
@@ -166,6 +167,16 @@ model User {
|
||||
schedulingPollVotes SchedulingPollVote[] @relation("PollVoter")
|
||||
schedulingPollComments SchedulingPollComment[] @relation("PollCommenter")
|
||||
|
||||
// Participant needs
|
||||
participantNeeds ParticipantNeeds? @relation("UserParticipantNeeds")
|
||||
|
||||
// Meeting agendas & action items
|
||||
agendasCreated MeetingAgenda[] @relation("AgendaCreator")
|
||||
minutesCreated MeetingMinutes[] @relation("MinutesCreator")
|
||||
minutesApproved MeetingMinutes[] @relation("MinutesApprover")
|
||||
actionItemsAssigned ActionItem[] @relation("ActionItemAssignee")
|
||||
actionItemsCreated ActionItem[] @relation("ActionItemCreator")
|
||||
|
||||
// Referral system
|
||||
inviteCodesCreated InviteCode[] @relation("InviteCodesCreated")
|
||||
referralsMade Referral[] @relation("ReferralsMade")
|
||||
@@ -736,6 +747,9 @@ model Shift {
|
||||
// Scheduling poll conversion
|
||||
convertedFromPoll SchedulingPoll? @relation("PollConvertedShift")
|
||||
|
||||
// Meeting agenda
|
||||
agenda MeetingAgenda? @relation("ShiftAgenda")
|
||||
|
||||
@@index([cutId])
|
||||
@@index([seriesId])
|
||||
@@map("shifts")
|
||||
@@ -750,6 +764,7 @@ enum SignupSource {
|
||||
AUTHENTICATED
|
||||
PUBLIC
|
||||
ADMIN
|
||||
POLL_CONVERSION
|
||||
}
|
||||
|
||||
model ShiftSignup {
|
||||
@@ -4168,6 +4183,7 @@ enum ContactSource {
|
||||
SHIFT_SIGNUP
|
||||
SMS_CONTACT
|
||||
DONATION
|
||||
POLL_VOTE
|
||||
MANUAL
|
||||
}
|
||||
|
||||
@@ -4194,6 +4210,7 @@ enum ContactActivityType {
|
||||
PROFILE_SELF_EDIT
|
||||
PROFILE_PHOTO_UPDATED
|
||||
USER_LOGIN
|
||||
POLL_VOTED
|
||||
}
|
||||
|
||||
model Contact {
|
||||
@@ -4203,6 +4220,7 @@ model Contact {
|
||||
lastName String?
|
||||
email String?
|
||||
phone String?
|
||||
pronouns String?
|
||||
|
||||
// CRM data
|
||||
tags Json @default("[]") // String array
|
||||
@@ -4247,6 +4265,8 @@ model Contact {
|
||||
connectionsTo ContactConnection[] @relation("ConnectionTo")
|
||||
activities ContactActivity[]
|
||||
smsConversations SmsConversation[] @relation("ContactSmsConversations")
|
||||
pollVotes SchedulingPollVote[] @relation("PollVoteContact")
|
||||
participantNeeds ParticipantNeeds? @relation("ContactParticipantNeeds")
|
||||
|
||||
@@index([email])
|
||||
@@index([phone])
|
||||
@@ -4404,7 +4424,16 @@ model SchedulingPoll {
|
||||
convertedShiftId String? @unique @map("converted_shift_id")
|
||||
convertedShift Shift? @relation("PollConvertedShift", fields: [convertedShiftId], references: [id], onDelete: SetNull)
|
||||
convertedGancioEventId Int? @map("converted_gancio_event_id")
|
||||
convertedCalendarItemId String? @map("converted_calendar_item_id")
|
||||
votingDeadline DateTime? @map("voting_deadline")
|
||||
autoFinalize Boolean @default(false) @map("auto_finalize")
|
||||
autoFinalizeThreshold Int? @map("auto_finalize_threshold")
|
||||
autoConvertToCalendar Boolean @default(false) @map("auto_convert_to_calendar")
|
||||
autoConvertToGancio Boolean @default(false) @map("auto_convert_to_gancio")
|
||||
autoConvertToShift Boolean @default(false) @map("auto_convert_to_shift")
|
||||
tieBreaker String @default("earliest") @map("tie_breaker")
|
||||
autoEnrollVoters Boolean @default(true) @map("auto_enroll_voters")
|
||||
autoFinalizeJobId String? @map("auto_finalize_job_id")
|
||||
allowAnonymous Boolean @default(true) @map("allow_anonymous")
|
||||
isPrivate Boolean @default(false) @map("is_private")
|
||||
notifyOnVote Boolean @default(true) @map("notify_on_vote")
|
||||
@@ -4416,6 +4445,7 @@ model SchedulingPoll {
|
||||
options SchedulingPollOption[] @relation("PollOptions")
|
||||
votes SchedulingPollVote[] @relation("PollVotes")
|
||||
comments SchedulingPollComment[] @relation("PollComments")
|
||||
agenda MeetingAgenda? @relation("PollAgenda")
|
||||
|
||||
@@index([createdByUserId])
|
||||
@@index([status])
|
||||
@@ -4451,6 +4481,8 @@ model SchedulingPollVote {
|
||||
voterName String @map("voter_name")
|
||||
voterEmail String? @map("voter_email")
|
||||
voterToken String? @map("voter_token") // anonymous edit access (cuid)
|
||||
contactId String? @map("contact_id")
|
||||
contact Contact? @relation("PollVoteContact", fields: [contactId], references: [id], onDelete: SetNull)
|
||||
value PollVoteValue
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
@@ -4458,6 +4490,7 @@ model SchedulingPollVote {
|
||||
@@unique([optionId, userId])
|
||||
@@unique([optionId, voterToken])
|
||||
@@index([pollId])
|
||||
@@index([contactId])
|
||||
@@map("scheduling_poll_votes")
|
||||
}
|
||||
|
||||
@@ -4906,6 +4939,7 @@ enum CalendarShowDetailsTo {
|
||||
enum CalendarItemSource {
|
||||
MANUAL
|
||||
ICS_FEED
|
||||
POLL
|
||||
}
|
||||
|
||||
enum CalendarRecurrenceFrequency {
|
||||
@@ -5131,3 +5165,119 @@ model DocCollabState {
|
||||
|
||||
@@map("doc_collab_state")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PARTICIPANT NEEDS
|
||||
// ============================================================================
|
||||
|
||||
model ParticipantNeeds {
|
||||
id String @id @default(cuid())
|
||||
userId String? @unique @map("user_id")
|
||||
user User? @relation("UserParticipantNeeds", fields: [userId], references: [id], onDelete: SetNull)
|
||||
contactId String? @unique @map("contact_id")
|
||||
contact Contact? @relation("ContactParticipantNeeds", fields: [contactId], references: [id], onDelete: SetNull)
|
||||
|
||||
// Accessibility
|
||||
needsWheelchair Boolean @default(false) @map("needs_wheelchair")
|
||||
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")
|
||||
|
||||
// Dietary
|
||||
isVegan Boolean @default(false) @map("is_vegan")
|
||||
isVegetarian Boolean @default(false) @map("is_vegetarian")
|
||||
isGlutenFree Boolean @default(false) @map("is_gluten_free")
|
||||
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")
|
||||
|
||||
// Care barriers
|
||||
needsChildcare Boolean @default(false) @map("needs_childcare")
|
||||
childcareDetails String? @db.Text @map("childcare_details")
|
||||
needsTransportation Boolean @default(false) @map("needs_transportation")
|
||||
transportationNotes String? @db.Text @map("transportation_notes")
|
||||
|
||||
// Communication
|
||||
preferredLanguage String? @default("en") @map("preferred_language")
|
||||
needsTranslation Boolean @default(false) @map("needs_translation")
|
||||
translationLanguage String? @map("translation_language")
|
||||
|
||||
// Consent
|
||||
visibilityConsent String @default("organizer_only") @map("visibility_consent")
|
||||
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("participant_needs")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MEETING AGENDAS & ACTION ITEMS
|
||||
// ============================================================================
|
||||
|
||||
model MeetingAgenda {
|
||||
id String @id @default(cuid())
|
||||
shiftId String? @unique @map("shift_id")
|
||||
shift Shift? @relation("ShiftAgenda", fields: [shiftId], references: [id], onDelete: SetNull)
|
||||
pollId String? @unique @map("poll_id")
|
||||
poll SchedulingPoll? @relation("PollAgenda", fields: [pollId], references: [id], onDelete: SetNull)
|
||||
|
||||
title String
|
||||
items Json @default("[]") // Array<{ id, title, durationMinutes, presenterUserId, notes, order }>
|
||||
status String @default("draft") // "draft" | "active" | "completed"
|
||||
|
||||
minutes MeetingMinutes?
|
||||
actionItems ActionItem[]
|
||||
|
||||
createdByUserId String @map("created_by_user_id")
|
||||
createdBy User @relation("AgendaCreator", fields: [createdByUserId], references: [id])
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("meeting_agendas")
|
||||
}
|
||||
|
||||
model MeetingMinutes {
|
||||
id String @id @default(cuid())
|
||||
agendaId String @unique @map("agenda_id")
|
||||
agenda MeetingAgenda @relation(fields: [agendaId], references: [id], onDelete: Cascade)
|
||||
|
||||
notes String @db.Text
|
||||
decisions Json @default("[]") // Array<{ id, text, passed: boolean }>
|
||||
attendees Json @default("[]") // Array<{ name, pronouns, userId? }>
|
||||
|
||||
approvedAt DateTime? @map("approved_at")
|
||||
approvedByUserId String? @map("approved_by_user_id")
|
||||
approvedBy User? @relation("MinutesApprover", fields: [approvedByUserId], references: [id], onDelete: SetNull)
|
||||
createdByUserId String @map("created_by_user_id")
|
||||
createdBy User @relation("MinutesCreator", fields: [createdByUserId], references: [id])
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("meeting_minutes")
|
||||
}
|
||||
|
||||
model ActionItem {
|
||||
id String @id @default(cuid())
|
||||
agendaId String? @map("agenda_id")
|
||||
agenda MeetingAgenda? @relation(fields: [agendaId], references: [id], onDelete: SetNull)
|
||||
|
||||
title String
|
||||
description String? @db.Text
|
||||
assigneeUserId String? @map("assignee_user_id")
|
||||
assignee User? @relation("ActionItemAssignee", fields: [assigneeUserId], references: [id], onDelete: SetNull)
|
||||
dueDate DateTime? @map("due_date")
|
||||
status String @default("open") // "open" | "in_progress" | "done" | "blocked"
|
||||
priority String @default("normal") // "low" | "normal" | "high" | "urgent"
|
||||
|
||||
completedAt DateTime? @map("completed_at")
|
||||
createdByUserId String @map("created_by_user_id")
|
||||
createdBy User @relation("ActionItemCreator", fields: [createdByUserId], references: [id])
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([assigneeUserId, status])
|
||||
@@index([dueDate])
|
||||
@@map("action_items")
|
||||
}
|
||||
|
||||
@@ -10,6 +10,13 @@ export const createPollSchema = z.object({
|
||||
isPrivate: z.boolean().optional().default(false),
|
||||
notifyOnVote: z.boolean().optional().default(true),
|
||||
votingDeadline: z.string().datetime().optional(),
|
||||
autoFinalize: z.boolean().optional().default(false),
|
||||
autoFinalizeThreshold: z.number().int().min(1).max(100).nullable().optional(),
|
||||
autoConvertToCalendar: z.boolean().optional().default(false),
|
||||
autoConvertToGancio: z.boolean().optional().default(false),
|
||||
autoConvertToShift: z.boolean().optional().default(false),
|
||||
tieBreaker: z.enum(['earliest', 'organizer_choice']).optional().default('earliest'),
|
||||
autoEnrollVoters: z.boolean().optional().default(true),
|
||||
options: z.array(z.object({
|
||||
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'),
|
||||
@@ -27,6 +34,13 @@ export const updatePollSchema = z.object({
|
||||
notifyOnVote: z.boolean().optional(),
|
||||
votingDeadline: z.string().datetime().nullable().optional(),
|
||||
status: z.nativeEnum(SchedulingPollStatus).optional(),
|
||||
autoFinalize: z.boolean().optional(),
|
||||
autoFinalizeThreshold: z.number().int().min(1).max(100).nullable().optional(),
|
||||
autoConvertToCalendar: z.boolean().optional(),
|
||||
autoConvertToGancio: z.boolean().optional(),
|
||||
autoConvertToShift: z.boolean().optional(),
|
||||
tieBreaker: z.enum(['earliest', 'organizer_choice']).optional(),
|
||||
autoEnrollVoters: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const addOptionsSchema = z.object({
|
||||
@@ -37,6 +51,29 @@ export const addOptionsSchema = z.object({
|
||||
})).min(1).max(20),
|
||||
});
|
||||
|
||||
export const participantNeedsInlineSchema = z.object({
|
||||
needsWheelchair: z.boolean().optional(),
|
||||
needsGroundFloor: z.boolean().optional(),
|
||||
needsHearingLoop: z.boolean().optional(),
|
||||
needsSignLanguage: z.boolean().optional(),
|
||||
otherAccessibility: z.string().max(1000).nullable().optional(),
|
||||
isVegan: z.boolean().optional(),
|
||||
isVegetarian: z.boolean().optional(),
|
||||
isGlutenFree: z.boolean().optional(),
|
||||
isHalal: z.boolean().optional(),
|
||||
isKosher: z.boolean().optional(),
|
||||
hasNutAllergy: z.boolean().optional(),
|
||||
otherDietary: z.string().max(1000).nullable().optional(),
|
||||
needsChildcare: z.boolean().optional(),
|
||||
childcareDetails: z.string().max(1000).nullable().optional(),
|
||||
needsTransportation: z.boolean().optional(),
|
||||
transportationNotes: z.string().max(1000).nullable().optional(),
|
||||
preferredLanguage: z.string().max(10).nullable().optional(),
|
||||
needsTranslation: z.boolean().optional(),
|
||||
translationLanguage: z.string().max(100).nullable().optional(),
|
||||
visibilityConsent: z.enum(['organizer_only', 'shared_with_hosts', 'public']).optional().default('organizer_only'),
|
||||
}).optional();
|
||||
|
||||
export const submitVotesSchema = z.object({
|
||||
voterName: z.string().min(1, 'Name is required').max(100),
|
||||
voterEmail: z.preprocess(
|
||||
@@ -48,6 +85,7 @@ export const submitVotesSchema = z.object({
|
||||
optionId: z.string().min(1),
|
||||
value: z.nativeEnum(PollVoteValue),
|
||||
})).min(1, 'At least one vote required'),
|
||||
participantNeeds: participantNeedsInlineSchema,
|
||||
});
|
||||
|
||||
export const submitCommentSchema = z.object({
|
||||
|
||||
@@ -5,6 +5,8 @@ import { AppError } from '../../middleware/error-handler';
|
||||
import { emailService } from '../../services/email.service';
|
||||
import { generateSlug } from '../../utils/slug';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { pollAutoFinalizeQueueService } from '../../services/poll-auto-finalize-queue.service';
|
||||
import { participantNeedsService } from '../people/participant-needs.service';
|
||||
import type {
|
||||
CreatePollInput,
|
||||
UpdatePollInput,
|
||||
@@ -61,7 +63,7 @@ const pollDetailPublicInclude = {
|
||||
_count: { select: { options: true, votes: true, comments: true } },
|
||||
} as const;
|
||||
|
||||
function aggregateVotes(options: Array<{ id: string; votes: Array<{ value: PollVoteValue }> }>) {
|
||||
function aggregateVotes<T extends { id: string; votes: Array<{ value: PollVoteValue }> }>(options: T[]) {
|
||||
return options.map((opt) => {
|
||||
let yesCount = 0;
|
||||
let ifNeedBeCount = 0;
|
||||
@@ -242,6 +244,13 @@ export const meetingPlannerService = {
|
||||
isPrivate: data.isPrivate,
|
||||
notifyOnVote: data.notifyOnVote,
|
||||
votingDeadline: data.votingDeadline ? new Date(data.votingDeadline) : null,
|
||||
autoFinalize: data.autoFinalize,
|
||||
autoFinalizeThreshold: data.autoFinalizeThreshold,
|
||||
autoConvertToCalendar: data.autoConvertToCalendar,
|
||||
autoConvertToGancio: data.autoConvertToGancio,
|
||||
autoConvertToShift: data.autoConvertToShift,
|
||||
tieBreaker: data.tieBreaker,
|
||||
autoEnrollVoters: data.autoEnrollVoters,
|
||||
createdByUserId: userId,
|
||||
options: {
|
||||
create: data.options.map((opt, i) => ({
|
||||
@@ -255,6 +264,17 @@ export const meetingPlannerService = {
|
||||
include: pollInclude,
|
||||
});
|
||||
|
||||
// Schedule auto-finalize job if enabled with a deadline
|
||||
if (poll.autoFinalize && poll.votingDeadline) {
|
||||
const jobId = await pollAutoFinalizeQueueService.scheduleJob(poll.id, poll.votingDeadline);
|
||||
if (jobId) {
|
||||
await prisma.schedulingPoll.update({
|
||||
where: { id: poll.id },
|
||||
data: { autoFinalizeJobId: jobId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return poll;
|
||||
},
|
||||
|
||||
@@ -274,12 +294,41 @@ export const meetingPlannerService = {
|
||||
updateData.votingDeadline = data.votingDeadline ? new Date(data.votingDeadline) : null;
|
||||
}
|
||||
if (data.status !== undefined) updateData.status = data.status;
|
||||
if (data.autoFinalize !== undefined) updateData.autoFinalize = data.autoFinalize;
|
||||
if (data.autoFinalizeThreshold !== undefined) updateData.autoFinalizeThreshold = data.autoFinalizeThreshold;
|
||||
if (data.autoConvertToCalendar !== undefined) updateData.autoConvertToCalendar = data.autoConvertToCalendar;
|
||||
if (data.autoConvertToGancio !== undefined) updateData.autoConvertToGancio = data.autoConvertToGancio;
|
||||
if (data.autoConvertToShift !== undefined) updateData.autoConvertToShift = data.autoConvertToShift;
|
||||
if (data.tieBreaker !== undefined) updateData.tieBreaker = data.tieBreaker;
|
||||
if (data.autoEnrollVoters !== undefined) updateData.autoEnrollVoters = data.autoEnrollVoters;
|
||||
|
||||
return prisma.schedulingPoll.update({
|
||||
// Cancel existing job if status changed to CANCELLED or autoFinalize disabled
|
||||
if (data.status === 'CANCELLED' || data.autoFinalize === false) {
|
||||
await pollAutoFinalizeQueueService.cancelJob(id);
|
||||
updateData.autoFinalizeJobId = null;
|
||||
}
|
||||
|
||||
const updated = await prisma.schedulingPoll.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: pollInclude,
|
||||
});
|
||||
|
||||
// Re-schedule auto-finalize job if deadline or autoFinalize changed
|
||||
const deadlineChanged = data.votingDeadline !== undefined;
|
||||
const autoFinalizeChanged = data.autoFinalize !== undefined;
|
||||
if ((deadlineChanged || autoFinalizeChanged) && updated.autoFinalize && updated.votingDeadline && updated.status === 'OPEN') {
|
||||
await pollAutoFinalizeQueueService.cancelJob(id);
|
||||
const jobId = await pollAutoFinalizeQueueService.scheduleJob(id, updated.votingDeadline);
|
||||
if (jobId) {
|
||||
await prisma.schedulingPoll.update({
|
||||
where: { id },
|
||||
data: { autoFinalizeJobId: jobId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
},
|
||||
|
||||
async delete(id: string) {
|
||||
@@ -413,6 +462,18 @@ export const meetingPlannerService = {
|
||||
})
|
||||
);
|
||||
|
||||
// Link voter to Contact CRM + upsert participant needs (fire-and-forget)
|
||||
if (data.voterEmail) {
|
||||
this.linkVoterToContact(poll.id, poll.title, data.voterName, data.voterEmail, data.votes.map(v => v.optionId), userId ?? undefined, voterToken ?? undefined, data.participantNeeds).catch((err) =>
|
||||
logger.error('Failed to link voter to contact', { error: err })
|
||||
);
|
||||
} else if (userId && data.participantNeeds) {
|
||||
// Authenticated voter without email — upsert needs by userId
|
||||
participantNeedsService.upsert(data.participantNeeds, userId).catch((err) =>
|
||||
logger.error('Failed to upsert participant needs', { error: err })
|
||||
);
|
||||
}
|
||||
|
||||
// Notify organizer
|
||||
if (poll.notifyOnVote) {
|
||||
this.notifyOrganizer(poll.createdBy.email, poll.title, data.voterName).catch((err) =>
|
||||
@@ -420,6 +481,23 @@ export const meetingPlannerService = {
|
||||
);
|
||||
}
|
||||
|
||||
// Check auto-finalize threshold
|
||||
if (poll.autoFinalize && poll.autoFinalizeThreshold && poll.status === 'OPEN') {
|
||||
const optionsWithVotes = await prisma.schedulingPollOption.findMany({
|
||||
where: { pollId: poll.id },
|
||||
include: { votes: { where: { value: 'YES' } } },
|
||||
});
|
||||
for (const opt of optionsWithVotes) {
|
||||
if (opt.votes.length >= poll.autoFinalizeThreshold) {
|
||||
// Fire-and-forget auto-finalization
|
||||
this.autoFinalizeAndConvert(poll.id, opt.id).catch((err) =>
|
||||
logger.error('Auto-finalize by threshold failed', { error: err, pollId: poll.id })
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { voterToken };
|
||||
},
|
||||
|
||||
@@ -481,11 +559,15 @@ export const meetingPlannerService = {
|
||||
const option = poll.options.find((o) => o.id === data.optionId);
|
||||
if (!option) throw new AppError(400, 'Option not found in this poll');
|
||||
|
||||
// Cancel any pending auto-finalize job
|
||||
await pollAutoFinalizeQueueService.cancelJob(id);
|
||||
|
||||
const updated = await prisma.schedulingPoll.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'FINALIZED',
|
||||
finalizedOptionId: data.optionId,
|
||||
autoFinalizeJobId: null,
|
||||
},
|
||||
include: pollDetailInclude,
|
||||
});
|
||||
@@ -569,6 +651,455 @@ export const meetingPlannerService = {
|
||||
return { gancioEventId: eventId };
|
||||
},
|
||||
|
||||
/**
|
||||
* Called by BullMQ worker when deadline fires. Picks the winning option and auto-finalizes.
|
||||
*/
|
||||
async processAutoFinalize(pollId: string) {
|
||||
const poll = await prisma.schedulingPoll.findUnique({
|
||||
where: { id: pollId },
|
||||
include: {
|
||||
options: {
|
||||
orderBy: { date: 'asc' },
|
||||
include: { votes: true },
|
||||
},
|
||||
createdBy: { select: { email: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!poll || poll.status !== 'OPEN') {
|
||||
logger.info(`Poll ${pollId} skipped auto-finalize (status: ${poll?.status ?? 'not found'})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const scored = aggregateVotes(poll.options);
|
||||
const maxScore = Math.max(...scored.map((o) => o.score));
|
||||
|
||||
// No viable option
|
||||
if (maxScore === 0) {
|
||||
await prisma.schedulingPoll.update({
|
||||
where: { id: pollId },
|
||||
data: { status: 'CLOSED', autoFinalizeJobId: null },
|
||||
});
|
||||
logger.info(`Poll ${pollId} closed with no viable votes`);
|
||||
// Notify organizer
|
||||
await emailService.sendEmail({
|
||||
to: poll.createdBy.email,
|
||||
subject: `Poll expired: "${poll.title}"`,
|
||||
html: `<p>Your scheduling poll "<strong>${escapeHtml(poll.title)}</strong>" has expired with no viable date options.</p>`,
|
||||
text: `Your scheduling poll "${poll.title}" has expired with no viable date options.`,
|
||||
}).catch((err) => logger.error('Failed to send poll expiry email', { error: err }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Find winners (could be tied)
|
||||
const winners = scored.filter((o) => o.score === maxScore);
|
||||
|
||||
if (winners.length === 1) {
|
||||
await this.autoFinalizeAndConvert(pollId, winners[0].id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tie-breaking
|
||||
if (poll.tieBreaker === 'earliest') {
|
||||
// Pick the earliest date+time
|
||||
const earliest = winners.sort((a, b) => {
|
||||
const dateA = new Date(a.date).getTime();
|
||||
const dateB = new Date(b.date).getTime();
|
||||
if (dateA !== dateB) return dateA - dateB;
|
||||
return a.startTime.localeCompare(b.startTime);
|
||||
})[0];
|
||||
await this.autoFinalizeAndConvert(pollId, earliest.id);
|
||||
} else {
|
||||
// organizer_choice — close and notify
|
||||
await prisma.schedulingPoll.update({
|
||||
where: { id: pollId },
|
||||
data: { status: 'CLOSED', autoFinalizeJobId: null },
|
||||
});
|
||||
await emailService.sendEmail({
|
||||
to: poll.createdBy.email,
|
||||
subject: `Tie in poll: "${poll.title}" — choose a winner`,
|
||||
html: `<p>Your scheduling poll "<strong>${escapeHtml(poll.title)}</strong>" has a tie between ${winners.length} options. Please finalize manually.</p>`,
|
||||
text: `Your scheduling poll "${poll.title}" has a tie between ${winners.length} options. Please finalize manually.`,
|
||||
}).catch((err) => logger.error('Failed to send tie notification', { error: err }));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Shared auto-finalize logic used by both deadline and threshold triggers.
|
||||
* Uses status guard to prevent double-finalize from concurrent invocations.
|
||||
*/
|
||||
async autoFinalizeAndConvert(pollId: string, winningOptionId: string) {
|
||||
// Atomic guard: only finalize if still OPEN
|
||||
const updated = await prisma.schedulingPoll.updateMany({
|
||||
where: { id: pollId, status: 'OPEN' },
|
||||
data: {
|
||||
status: 'FINALIZED',
|
||||
finalizedOptionId: winningOptionId,
|
||||
autoFinalizeJobId: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (updated.count === 0) {
|
||||
logger.info(`Poll ${pollId} already finalized (concurrent guard)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel any pending deadline job
|
||||
await pollAutoFinalizeQueueService.cancelJob(pollId);
|
||||
|
||||
// Re-fetch with full details for notifications and conversions
|
||||
const poll = await prisma.schedulingPoll.findUnique({
|
||||
where: { id: pollId },
|
||||
include: {
|
||||
...pollDetailInclude,
|
||||
},
|
||||
});
|
||||
if (!poll) return;
|
||||
|
||||
logger.info(`Auto-finalized poll ${pollId} with option ${winningOptionId}`);
|
||||
|
||||
// Notify voters
|
||||
this.notifyVotersFinalized(poll).catch((err) =>
|
||||
logger.error('Failed to send auto-finalization notifications', { error: err })
|
||||
);
|
||||
|
||||
const finalOption = poll.options.find((o: any) => o.id === winningOptionId);
|
||||
if (!finalOption) return;
|
||||
|
||||
// Auto-convert to CalendarItem
|
||||
if (poll.autoConvertToCalendar) {
|
||||
try {
|
||||
const { calendarService } = await import('../calendar/calendar.service');
|
||||
const { CalendarSystemType } = await import('@prisma/client');
|
||||
await calendarService.ensureSystemLayers(poll.createdByUserId);
|
||||
const layers = await calendarService.getUserLayers(poll.createdByUserId);
|
||||
const pollsLayer = layers.find((l: any) => l.systemType === CalendarSystemType.POLLS);
|
||||
if (pollsLayer) {
|
||||
const item = await prisma.calendarItem.create({
|
||||
data: {
|
||||
userId: poll.createdByUserId,
|
||||
layerId: pollsLayer.id,
|
||||
title: poll.title,
|
||||
description: poll.description,
|
||||
location: poll.location,
|
||||
date: finalOption.date,
|
||||
startTime: finalOption.startTime,
|
||||
endTime: finalOption.endTime,
|
||||
sourceType: 'POLL',
|
||||
sourceId: poll.id,
|
||||
},
|
||||
});
|
||||
await prisma.schedulingPoll.update({
|
||||
where: { id: pollId },
|
||||
data: { convertedCalendarItemId: item.id },
|
||||
});
|
||||
logger.info(`Created calendar item ${item.id} from poll ${pollId}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Auto-convert to calendar failed', { error: err, pollId });
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-convert to Gancio event
|
||||
if (poll.autoConvertToGancio) {
|
||||
try {
|
||||
const { gancioClient } = await import('../../services/gancio.client');
|
||||
const eventId = await gancioClient.createEvent({
|
||||
title: poll.title,
|
||||
description: poll.description,
|
||||
location: poll.location,
|
||||
date: finalOption.date,
|
||||
startTime: finalOption.startTime,
|
||||
endTime: finalOption.endTime,
|
||||
});
|
||||
if (eventId) {
|
||||
await prisma.schedulingPoll.update({
|
||||
where: { id: pollId },
|
||||
data: { convertedGancioEventId: eventId },
|
||||
});
|
||||
logger.info(`Created Gancio event ${eventId} from poll ${pollId}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Auto-convert to Gancio failed', { error: err, pollId });
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-convert to Shift
|
||||
let createdShiftId: string | null = null;
|
||||
if (poll.autoConvertToShift) {
|
||||
try {
|
||||
const shift = await prisma.shift.create({
|
||||
data: {
|
||||
title: poll.title,
|
||||
description: poll.description,
|
||||
date: finalOption.date,
|
||||
startTime: finalOption.startTime,
|
||||
endTime: finalOption.endTime,
|
||||
location: poll.location,
|
||||
maxVolunteers: 10,
|
||||
isPublic: true,
|
||||
},
|
||||
});
|
||||
createdShiftId = shift.id;
|
||||
await prisma.schedulingPoll.update({
|
||||
where: { id: pollId },
|
||||
data: { convertedShiftId: shift.id },
|
||||
});
|
||||
logger.info(`Created shift ${shift.id} from poll ${pollId}`);
|
||||
|
||||
// Auto-enroll YES voters into the shift
|
||||
this.autoEnrollVotersIntoShift(poll, shift.id, winningOptionId).catch((err) =>
|
||||
logger.error('Auto-enroll voters failed', { error: err, pollId })
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error('Auto-convert to shift failed', { error: err, pollId });
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-create meeting agenda
|
||||
try {
|
||||
await prisma.meetingAgenda.create({
|
||||
data: {
|
||||
pollId,
|
||||
shiftId: createdShiftId,
|
||||
title: poll.title,
|
||||
createdByUserId: poll.createdByUserId,
|
||||
items: [
|
||||
{ id: '1', title: 'Check-in / introductions', durationMinutes: 10, order: 0 },
|
||||
{ id: '2', title: 'Report-backs on action items', durationMinutes: 10, order: 1 },
|
||||
{ id: '3', title: `Main discussion: ${poll.title}`, durationMinutes: 30, order: 2 },
|
||||
{ id: '4', title: 'Action items', durationMinutes: 10, order: 3 },
|
||||
{ id: '5', title: 'Next steps', durationMinutes: 5, order: 4 },
|
||||
] as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
logger.info(`Created meeting agenda from poll ${pollId}`);
|
||||
} catch (err) {
|
||||
logger.error('Auto-create agenda failed', { error: err, pollId });
|
||||
}
|
||||
|
||||
// Aggregate participant needs for organizer notification
|
||||
this.sendNeedsSummaryToOrganizer(poll).catch((err) =>
|
||||
logger.error('Failed to send needs summary', { error: err, pollId })
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Find-or-create a Contact for a poll voter and log the vote as an activity.
|
||||
*/
|
||||
async linkVoterToContact(
|
||||
pollId: string,
|
||||
pollTitle: string,
|
||||
voterName: string,
|
||||
voterEmail: string,
|
||||
optionIds: string[],
|
||||
userId?: string,
|
||||
voterToken?: string,
|
||||
participantNeeds?: Record<string, any>,
|
||||
) {
|
||||
const normalizedEmail = voterEmail.trim().toLowerCase();
|
||||
|
||||
// Find existing contact by email
|
||||
let contact = await prisma.contact.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ email: normalizedEmail },
|
||||
{ emails: { some: { email: normalizedEmail } } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (!contact) {
|
||||
// Create new contact
|
||||
const nameParts = voterName.trim().split(/\s+/);
|
||||
contact = await prisma.contact.create({
|
||||
data: {
|
||||
displayName: voterName,
|
||||
firstName: nameParts[0] || null,
|
||||
lastName: nameParts.length > 1 ? nameParts.slice(1).join(' ') : null,
|
||||
email: normalizedEmail,
|
||||
primarySource: 'POLL_VOTE',
|
||||
userId: userId || null,
|
||||
},
|
||||
});
|
||||
// Also create ContactEmail entry
|
||||
await prisma.contactEmail.create({
|
||||
data: {
|
||||
contactId: contact.id,
|
||||
email: normalizedEmail,
|
||||
label: 'Primary',
|
||||
isPrimary: true,
|
||||
},
|
||||
}).catch(() => {}); // ignore if duplicate
|
||||
}
|
||||
|
||||
// Log activity
|
||||
await prisma.contactActivity.create({
|
||||
data: {
|
||||
contactId: contact.id,
|
||||
type: 'POLL_VOTED',
|
||||
title: `Voted on: ${pollTitle}`,
|
||||
metadata: { pollId, optionIds } as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
// Update vote records with contactId
|
||||
const whereClause = userId
|
||||
? { pollId, userId }
|
||||
: voterToken
|
||||
? { pollId, voterToken }
|
||||
: { pollId, voterName, userId: null, voterToken: null };
|
||||
await prisma.schedulingPollVote.updateMany({
|
||||
where: whereClause,
|
||||
data: { contactId: contact.id },
|
||||
});
|
||||
|
||||
// Upsert participant needs if provided
|
||||
if (participantNeeds && Object.keys(participantNeeds).length > 0) {
|
||||
await participantNeedsService.upsert(
|
||||
participantNeeds,
|
||||
userId,
|
||||
contact.id,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Auto-enroll YES voters into a shift created from poll finalization.
|
||||
*/
|
||||
async autoEnrollVotersIntoShift(poll: any, shiftId: string, winningOptionId: string) {
|
||||
const yesVotes = await prisma.schedulingPollVote.findMany({
|
||||
where: {
|
||||
pollId: poll.id,
|
||||
optionId: winningOptionId,
|
||||
value: 'YES',
|
||||
voterEmail: { not: null },
|
||||
},
|
||||
});
|
||||
|
||||
if (yesVotes.length === 0) return;
|
||||
|
||||
const shift = await prisma.shift.findUnique({ where: { id: shiftId } });
|
||||
if (!shift) return;
|
||||
|
||||
for (const vote of yesVotes) {
|
||||
if (!vote.voterEmail) continue;
|
||||
|
||||
try {
|
||||
// Check if already signed up
|
||||
const existing = await prisma.shiftSignup.findUnique({
|
||||
where: { shiftId_userEmail: { shiftId, userEmail: vote.voterEmail } },
|
||||
});
|
||||
if (existing) continue;
|
||||
|
||||
await prisma.shiftSignup.create({
|
||||
data: {
|
||||
shiftId,
|
||||
shiftTitle: shift.title,
|
||||
userId: vote.userId,
|
||||
userEmail: vote.voterEmail,
|
||||
userName: vote.voterName,
|
||||
signupSource: 'POLL_CONVERSION',
|
||||
},
|
||||
});
|
||||
|
||||
// Increment volunteer count
|
||||
await prisma.shift.update({
|
||||
where: { id: shiftId },
|
||||
data: { currentVolunteers: { increment: 1 } },
|
||||
});
|
||||
|
||||
if (poll.autoEnrollVoters) {
|
||||
// Send enrollment notification
|
||||
const dateStr = new Date(shift.date).toLocaleDateString('en-CA', {
|
||||
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
||||
});
|
||||
await emailService.sendEmail({
|
||||
to: vote.voterEmail,
|
||||
subject: `You're signed up for "${shift.title}"`,
|
||||
html: `<p>Based on your poll vote, you've been automatically signed up for "<strong>${escapeHtml(shift.title)}</strong>".</p>
|
||||
<p><strong>${dateStr}</strong><br/>${shift.startTime} - ${shift.endTime}</p>
|
||||
${shift.location ? `<p>Location: ${escapeHtml(shift.location)}</p>` : ''}`,
|
||||
text: `Based on your poll vote, you've been automatically signed up for "${shift.title}".\n${dateStr}\n${shift.startTime} - ${shift.endTime}${shift.location ? `\nLocation: ${shift.location}` : ''}`,
|
||||
}).catch((err) => logger.error('Failed to send auto-enroll email', { error: err }));
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to auto-enroll voter', { error: err, voterEmail: vote.voterEmail });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Aggregate participant needs for YES voters and email the organizer a prep summary.
|
||||
*/
|
||||
async sendNeedsSummaryToOrganizer(poll: any) {
|
||||
// Get all YES voter user/contact IDs on the winning option
|
||||
const yesVotes = await prisma.schedulingPollVote.findMany({
|
||||
where: { pollId: poll.id, optionId: poll.finalizedOptionId, value: 'YES' },
|
||||
select: { userId: true, contactId: true },
|
||||
});
|
||||
|
||||
const userIds = yesVotes.map(v => v.userId).filter(Boolean) as string[];
|
||||
const contactIds = yesVotes.map(v => v.contactId).filter(Boolean) as string[];
|
||||
|
||||
if (userIds.length === 0 && contactIds.length === 0) return;
|
||||
|
||||
const needs = await prisma.participantNeeds.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
...(userIds.length ? [{ userId: { in: userIds } }] : []),
|
||||
...(contactIds.length ? [{ contactId: { in: contactIds } }] : []),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (needs.length === 0) return;
|
||||
|
||||
// Aggregate
|
||||
const summary: string[] = [];
|
||||
const wheelchair = needs.filter(n => n.needsWheelchair).length;
|
||||
const groundFloor = needs.filter(n => n.needsGroundFloor).length;
|
||||
const hearingLoop = needs.filter(n => n.needsHearingLoop).length;
|
||||
const signLanguage = needs.filter(n => n.needsSignLanguage).length;
|
||||
const childcare = needs.filter(n => n.needsChildcare).length;
|
||||
const transportation = needs.filter(n => n.needsTransportation).length;
|
||||
const vegan = needs.filter(n => n.isVegan).length;
|
||||
const vegetarian = needs.filter(n => n.isVegetarian).length;
|
||||
const glutenFree = needs.filter(n => n.isGlutenFree).length;
|
||||
const halal = needs.filter(n => n.isHalal).length;
|
||||
const kosher = needs.filter(n => n.isKosher).length;
|
||||
const nutAllergy = needs.filter(n => n.hasNutAllergy).length;
|
||||
const translation = needs.filter(n => n.needsTranslation).length;
|
||||
|
||||
if (wheelchair) summary.push(`${wheelchair} need wheelchair access`);
|
||||
if (groundFloor) summary.push(`${groundFloor} need ground-floor access`);
|
||||
if (hearingLoop) summary.push(`${hearingLoop} need hearing loop`);
|
||||
if (signLanguage) summary.push(`${signLanguage} need sign language interpretation`);
|
||||
if (childcare) summary.push(`${childcare} need childcare`);
|
||||
if (transportation) summary.push(`${transportation} need transportation`);
|
||||
if (vegan) summary.push(`${vegan} vegan`);
|
||||
if (vegetarian) summary.push(`${vegetarian} vegetarian`);
|
||||
if (glutenFree) summary.push(`${glutenFree} gluten-free`);
|
||||
if (halal) summary.push(`${halal} halal`);
|
||||
if (kosher) summary.push(`${kosher} kosher`);
|
||||
if (nutAllergy) summary.push(`${nutAllergy} nut allergy`);
|
||||
if (translation) summary.push(`${translation} need translation`);
|
||||
|
||||
if (summary.length === 0) return;
|
||||
|
||||
// Send to organizer
|
||||
const organizerEmail = poll.createdBy?.email;
|
||||
if (!organizerEmail) return;
|
||||
|
||||
await emailService.sendEmail({
|
||||
to: organizerEmail,
|
||||
subject: `Prep checklist for "${poll.title}"`,
|
||||
html: `<p>Participant needs for "<strong>${escapeHtml(poll.title)}</strong>":</p>
|
||||
<ul>${summary.map(s => `<li>${escapeHtml(s)}</li>`).join('')}</ul>`,
|
||||
text: `Participant needs for "${poll.title}":\n${summary.map(s => `- ${s}`).join('\n')}`,
|
||||
});
|
||||
},
|
||||
|
||||
async notifyOrganizer(email: string, pollTitle: string, voterName: string) {
|
||||
try {
|
||||
await emailService.sendEmail({
|
||||
|
||||
77
api/src/modules/meetings/action-items.routes.ts
Normal file
77
api/src/modules/meetings/action-items.routes.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { actionItemsService } from './action-items.service';
|
||||
import {
|
||||
createActionItemSchema,
|
||||
updateActionItemSchema,
|
||||
listActionItemsSchema,
|
||||
} from './action-items.schemas';
|
||||
import { validate } from '../../middleware/validate';
|
||||
import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { EVENTS_ROLES } from '../../utils/roles';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// --- Routes requiring EVENTS_ROLES ---
|
||||
|
||||
// List all action items
|
||||
router.get('/', authenticate, requireRole(...EVENTS_ROLES), validate(listActionItemsSchema, 'query'), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await actionItemsService.findAll(req.query as any);
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Current user's action items (authenticate only)
|
||||
router.get('/mine', authenticate, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const status = req.query.status as string | undefined;
|
||||
const items = await actionItemsService.findByUser(req.user!.id, status);
|
||||
res.json(items);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Overdue items
|
||||
router.get('/overdue', authenticate, requireRole(...EVENTS_ROLES), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const items = await actionItemsService.getOverdue();
|
||||
res.json(items);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Get action item detail
|
||||
router.get('/:id', authenticate, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const item = await actionItemsService.findById(id);
|
||||
res.json(item);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Create action item
|
||||
router.post('/', authenticate, requireRole(...EVENTS_ROLES), validate(createActionItemSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const item = await actionItemsService.create(req.body, req.user!.id);
|
||||
res.status(201).json(item);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Update action item (authenticate only - assignees can update their own)
|
||||
router.put('/:id', authenticate, validate(updateActionItemSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const item = await actionItemsService.update(id, req.body);
|
||||
res.json(item);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Delete action item
|
||||
router.delete('/:id', authenticate, requireRole(...EVENTS_ROLES), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
await actionItemsService.delete(id);
|
||||
res.json({ message: 'Action item deleted' });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
export { router as actionItemsRouter };
|
||||
32
api/src/modules/meetings/action-items.schemas.ts
Normal file
32
api/src/modules/meetings/action-items.schemas.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createActionItemSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required').max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
agendaId: z.string().optional(),
|
||||
assigneeUserId: z.string().optional(),
|
||||
dueDate: z.string().datetime().optional(),
|
||||
priority: z.enum(['low', 'normal', 'high', 'urgent']).optional(),
|
||||
});
|
||||
|
||||
export const updateActionItemSchema = z.object({
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
description: z.string().max(2000).nullable().optional(),
|
||||
assigneeUserId: z.string().nullable().optional(),
|
||||
dueDate: z.string().datetime().nullable().optional(),
|
||||
status: z.enum(['open', 'in_progress', 'done', 'blocked']).optional(),
|
||||
priority: z.enum(['low', 'normal', 'high', 'urgent']).optional(),
|
||||
});
|
||||
|
||||
export const listActionItemsSchema = z.object({
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||
search: z.string().optional(),
|
||||
status: z.enum(['open', 'in_progress', 'done', 'blocked']).optional(),
|
||||
assigneeUserId: z.string().optional(),
|
||||
overdue: z.preprocess((val) => val === 'true' || val === true, z.boolean().optional()),
|
||||
});
|
||||
|
||||
export type CreateActionItemInput = z.infer<typeof createActionItemSchema>;
|
||||
export type UpdateActionItemInput = z.infer<typeof updateActionItemSchema>;
|
||||
export type ListActionItemsInput = z.infer<typeof listActionItemsSchema>;
|
||||
140
api/src/modules/meetings/action-items.service.ts
Normal file
140
api/src/modules/meetings/action-items.service.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { prisma } from '../../config/database';
|
||||
import { AppError } from '../../middleware/error-handler';
|
||||
import type {
|
||||
CreateActionItemInput,
|
||||
UpdateActionItemInput,
|
||||
ListActionItemsInput,
|
||||
} from './action-items.schemas';
|
||||
|
||||
const actionItemInclude = {
|
||||
assignee: { select: { id: true, name: true, email: true } },
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
agenda: { select: { id: true, title: true } },
|
||||
} as const;
|
||||
|
||||
export const actionItemsService = {
|
||||
async findAll(filters: ListActionItemsInput) {
|
||||
const { page, limit, search, status, assigneeUserId, overdue } = filters;
|
||||
const where: Prisma.ActionItemWhereInput = {};
|
||||
|
||||
if (status) where.status = status;
|
||||
if (assigneeUserId) where.assigneeUserId = assigneeUserId;
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ title: { contains: search, mode: 'insensitive' } },
|
||||
{ description: { contains: search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
if (overdue) {
|
||||
where.dueDate = { lt: new Date() };
|
||||
where.status = { not: 'done' };
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.actionItem.findMany({
|
||||
where,
|
||||
include: actionItemInclude,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.actionItem.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
actionItems: items,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async findById(id: string) {
|
||||
const item = await prisma.actionItem.findUnique({
|
||||
where: { id },
|
||||
include: actionItemInclude,
|
||||
});
|
||||
if (!item) throw new AppError(404, 'Action item not found');
|
||||
return item;
|
||||
},
|
||||
|
||||
async findByUser(userId: string, status?: string) {
|
||||
const where: Prisma.ActionItemWhereInput = {
|
||||
assigneeUserId: userId,
|
||||
status: status ? status : { not: 'done' },
|
||||
};
|
||||
|
||||
const items = await prisma.actionItem.findMany({
|
||||
where,
|
||||
include: actionItemInclude,
|
||||
orderBy: [{ dueDate: 'asc' }, { createdAt: 'desc' }],
|
||||
});
|
||||
|
||||
return {
|
||||
actionItems: items,
|
||||
pagination: { page: 1, limit: items.length, total: items.length, totalPages: 1 },
|
||||
};
|
||||
},
|
||||
|
||||
async create(data: CreateActionItemInput, userId: string) {
|
||||
return prisma.actionItem.create({
|
||||
data: {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
agendaId: data.agendaId,
|
||||
assigneeUserId: data.assigneeUserId,
|
||||
dueDate: data.dueDate ? new Date(data.dueDate) : null,
|
||||
priority: data.priority,
|
||||
createdByUserId: userId,
|
||||
},
|
||||
include: actionItemInclude,
|
||||
});
|
||||
},
|
||||
|
||||
async update(id: string, data: UpdateActionItemInput) {
|
||||
const existing = await prisma.actionItem.findUnique({ where: { id } });
|
||||
if (!existing) throw new AppError(404, 'Action item not found');
|
||||
|
||||
const updateData: Prisma.ActionItemUncheckedUpdateInput = {};
|
||||
if (data.title !== undefined) updateData.title = data.title;
|
||||
if (data.description !== undefined) updateData.description = data.description;
|
||||
if (data.assigneeUserId !== undefined) updateData.assigneeUserId = data.assigneeUserId;
|
||||
if (data.dueDate !== undefined) updateData.dueDate = data.dueDate ? new Date(data.dueDate) : null;
|
||||
if (data.priority !== undefined) updateData.priority = data.priority;
|
||||
if (data.status !== undefined) {
|
||||
updateData.status = data.status;
|
||||
if (data.status === 'done' && existing.status !== 'done') {
|
||||
updateData.completedAt = new Date();
|
||||
} else if (data.status !== 'done' && existing.status === 'done') {
|
||||
updateData.completedAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
return prisma.actionItem.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: actionItemInclude,
|
||||
});
|
||||
},
|
||||
|
||||
async delete(id: string) {
|
||||
const existing = await prisma.actionItem.findUnique({ where: { id } });
|
||||
if (!existing) throw new AppError(404, 'Action item not found');
|
||||
await prisma.actionItem.delete({ where: { id } });
|
||||
},
|
||||
|
||||
async getOverdue() {
|
||||
return prisma.actionItem.findMany({
|
||||
where: {
|
||||
dueDate: { lt: new Date() },
|
||||
status: { not: 'done' },
|
||||
},
|
||||
include: actionItemInclude,
|
||||
orderBy: { dueDate: 'asc' },
|
||||
});
|
||||
},
|
||||
};
|
||||
98
api/src/modules/meetings/agenda.routes.ts
Normal file
98
api/src/modules/meetings/agenda.routes.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { agendaService } from './agenda.service';
|
||||
import {
|
||||
createAgendaSchema,
|
||||
updateAgendaSchema,
|
||||
createMinutesSchema,
|
||||
updateMinutesSchema,
|
||||
listAgendasSchema,
|
||||
} from './agenda.schemas';
|
||||
import { validate } from '../../middleware/validate';
|
||||
import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { EVENTS_ROLES } from '../../utils/roles';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate);
|
||||
router.use(requireRole(...EVENTS_ROLES));
|
||||
|
||||
// List agendas
|
||||
router.get('/', validate(listAgendasSchema, 'query'), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await agendaService.findAll(req.query as any);
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Get agenda detail
|
||||
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const agenda = await agendaService.findById(id);
|
||||
res.json(agenda);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Create agenda
|
||||
router.post('/', validate(createAgendaSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const agenda = await agendaService.create(req.body, req.user!.id);
|
||||
res.status(201).json(agenda);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Update agenda
|
||||
router.put('/:id', validate(updateAgendaSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const agenda = await agendaService.update(id, req.body);
|
||||
res.json(agenda);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Delete agenda
|
||||
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
await agendaService.delete(id);
|
||||
res.json({ message: 'Agenda deleted' });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Create minutes for agenda
|
||||
router.post('/:id/minutes', validate(createMinutesSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
req.body.agendaId = id;
|
||||
const minutes = await agendaService.createMinutes(req.body, req.user!.id);
|
||||
res.status(201).json(minutes);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Update minutes for agenda
|
||||
router.put('/:id/minutes', validate(updateMinutesSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const agenda = await agendaService.findById(id);
|
||||
if (!agenda.minutes) {
|
||||
return res.status(404).json({ message: 'Minutes not found for this agenda' });
|
||||
}
|
||||
const minutes = await agendaService.updateMinutes(agenda.minutes.id, req.body);
|
||||
res.json(minutes);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Approve minutes
|
||||
router.post('/:id/minutes/approve', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const agenda = await agendaService.findById(id);
|
||||
if (!agenda.minutes) {
|
||||
return res.status(404).json({ message: 'Minutes not found for this agenda' });
|
||||
}
|
||||
const minutes = await agendaService.approveMinutes(agenda.minutes.id, req.user!.id);
|
||||
res.json(minutes);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
export { router as agendaRouter };
|
||||
40
api/src/modules/meetings/agenda.schemas.ts
Normal file
40
api/src/modules/meetings/agenda.schemas.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createAgendaSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required').max(200),
|
||||
shiftId: z.string().optional(),
|
||||
pollId: z.string().optional(),
|
||||
items: z.array(z.any()).optional().default([]),
|
||||
});
|
||||
|
||||
export const updateAgendaSchema = z.object({
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
items: z.array(z.any()).optional(),
|
||||
status: z.enum(['draft', 'active', 'completed']).optional(),
|
||||
});
|
||||
|
||||
export const createMinutesSchema = z.object({
|
||||
agendaId: z.string().min(1, 'Agenda ID is required'),
|
||||
notes: z.string().min(1, 'Notes are required'),
|
||||
decisions: z.array(z.any()).optional().default([]),
|
||||
attendees: z.array(z.any()).optional().default([]),
|
||||
});
|
||||
|
||||
export const updateMinutesSchema = z.object({
|
||||
notes: z.string().min(1).optional(),
|
||||
decisions: z.array(z.any()).optional(),
|
||||
attendees: z.array(z.any()).optional(),
|
||||
});
|
||||
|
||||
export const listAgendasSchema = z.object({
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||
search: z.string().optional(),
|
||||
status: z.enum(['draft', 'active', 'completed']).optional(),
|
||||
});
|
||||
|
||||
export type CreateAgendaInput = z.infer<typeof createAgendaSchema>;
|
||||
export type UpdateAgendaInput = z.infer<typeof updateAgendaSchema>;
|
||||
export type CreateMinutesInput = z.infer<typeof createMinutesSchema>;
|
||||
export type UpdateMinutesInput = z.infer<typeof updateMinutesSchema>;
|
||||
export type ListAgendasInput = z.infer<typeof listAgendasSchema>;
|
||||
171
api/src/modules/meetings/agenda.service.ts
Normal file
171
api/src/modules/meetings/agenda.service.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { prisma } from '../../config/database';
|
||||
import { AppError } from '../../middleware/error-handler';
|
||||
import type {
|
||||
CreateAgendaInput,
|
||||
UpdateAgendaInput,
|
||||
CreateMinutesInput,
|
||||
UpdateMinutesInput,
|
||||
ListAgendasInput,
|
||||
} from './agenda.schemas';
|
||||
|
||||
export const agendaService = {
|
||||
async findAll(filters: ListAgendasInput) {
|
||||
const { page, limit, search, status } = filters;
|
||||
const where: Prisma.MeetingAgendaWhereInput = {};
|
||||
|
||||
if (status) where.status = status;
|
||||
if (search) {
|
||||
where.title = { contains: search, mode: 'insensitive' };
|
||||
}
|
||||
|
||||
const [agendas, total] = await Promise.all([
|
||||
prisma.meetingAgenda.findMany({
|
||||
where,
|
||||
include: {
|
||||
createdBy: { select: { id: true, name: true, email: true } },
|
||||
shift: { select: { id: true, title: true, date: true } },
|
||||
poll: { select: { id: true, title: true } },
|
||||
_count: { select: { actionItems: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.meetingAgenda.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
agendas,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async findById(id: string) {
|
||||
const agenda = await prisma.meetingAgenda.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
createdBy: { select: { id: true, name: true, email: true } },
|
||||
shift: { select: { id: true, title: true, date: true } },
|
||||
poll: { select: { id: true, title: true } },
|
||||
minutes: {
|
||||
include: {
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
approvedBy: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
actionItems: {
|
||||
include: {
|
||||
assignee: { select: { id: true, name: true, email: true } },
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!agenda) throw new AppError(404, 'Agenda not found');
|
||||
return agenda;
|
||||
},
|
||||
|
||||
async create(data: CreateAgendaInput, userId: string) {
|
||||
return prisma.meetingAgenda.create({
|
||||
data: {
|
||||
title: data.title,
|
||||
shiftId: data.shiftId,
|
||||
pollId: data.pollId,
|
||||
items: data.items as unknown as Prisma.InputJsonValue,
|
||||
createdByUserId: userId,
|
||||
},
|
||||
include: {
|
||||
createdBy: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async update(id: string, data: UpdateAgendaInput) {
|
||||
const existing = await prisma.meetingAgenda.findUnique({ where: { id } });
|
||||
if (!existing) throw new AppError(404, 'Agenda not found');
|
||||
|
||||
const updateData: Prisma.MeetingAgendaUncheckedUpdateInput = {};
|
||||
if (data.title !== undefined) updateData.title = data.title;
|
||||
if (data.items !== undefined) updateData.items = data.items as unknown as Prisma.InputJsonValue;
|
||||
if (data.status !== undefined) updateData.status = data.status;
|
||||
|
||||
return prisma.meetingAgenda.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
createdBy: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async delete(id: string) {
|
||||
const existing = await prisma.meetingAgenda.findUnique({ where: { id } });
|
||||
if (!existing) throw new AppError(404, 'Agenda not found');
|
||||
await prisma.meetingAgenda.delete({ where: { id } });
|
||||
},
|
||||
|
||||
async createMinutes(data: CreateMinutesInput, userId: string) {
|
||||
const agenda = await prisma.meetingAgenda.findUnique({ where: { id: data.agendaId } });
|
||||
if (!agenda) throw new AppError(404, 'Agenda not found');
|
||||
|
||||
const existing = await prisma.meetingMinutes.findUnique({ where: { agendaId: data.agendaId } });
|
||||
if (existing) throw new AppError(400, 'Minutes already exist for this agenda');
|
||||
|
||||
return prisma.meetingMinutes.create({
|
||||
data: {
|
||||
agendaId: data.agendaId,
|
||||
notes: data.notes,
|
||||
decisions: data.decisions as unknown as Prisma.InputJsonValue,
|
||||
attendees: data.attendees as unknown as Prisma.InputJsonValue,
|
||||
createdByUserId: userId,
|
||||
},
|
||||
include: {
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async updateMinutes(id: string, data: UpdateMinutesInput) {
|
||||
const existing = await prisma.meetingMinutes.findUnique({ where: { id } });
|
||||
if (!existing) throw new AppError(404, 'Minutes not found');
|
||||
|
||||
const updateData: Prisma.MeetingMinutesUncheckedUpdateInput = {};
|
||||
if (data.notes !== undefined) updateData.notes = data.notes;
|
||||
if (data.decisions !== undefined) updateData.decisions = data.decisions as unknown as Prisma.InputJsonValue;
|
||||
if (data.attendees !== undefined) updateData.attendees = data.attendees as unknown as Prisma.InputJsonValue;
|
||||
|
||||
return prisma.meetingMinutes.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
approvedBy: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async approveMinutes(id: string, userId: string) {
|
||||
const existing = await prisma.meetingMinutes.findUnique({ where: { id } });
|
||||
if (!existing) throw new AppError(404, 'Minutes not found');
|
||||
if (existing.approvedAt) throw new AppError(400, 'Minutes are already approved');
|
||||
|
||||
return prisma.meetingMinutes.update({
|
||||
where: { id },
|
||||
data: {
|
||||
approvedAt: new Date(),
|
||||
approvedByUserId: userId,
|
||||
},
|
||||
include: {
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
approvedBy: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
107
api/src/modules/people/participant-needs.routes.ts
Normal file
107
api/src/modules/people/participant-needs.routes.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
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 { EVENTS_ROLES } from '../../utils/roles';
|
||||
import { participantNeedsService } from './participant-needs.service';
|
||||
import { upsertNeedsSchema } from './participant-needs.schemas';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/people/needs/me
|
||||
router.get(
|
||||
'/me',
|
||||
authenticate,
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const needs = await participantNeedsService.findByUserId(req.user!.id);
|
||||
res.json({ needs });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// PUT /api/people/needs/me
|
||||
router.put(
|
||||
'/me',
|
||||
authenticate,
|
||||
validate(upsertNeedsSchema),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const needs = await participantNeedsService.upsert(req.body, req.user!.id);
|
||||
res.json({ needs });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/people/needs/aggregate
|
||||
router.get(
|
||||
'/aggregate',
|
||||
authenticate,
|
||||
requireRole(...EVENTS_ROLES),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const userIdsParam = req.query.userIds as string | undefined;
|
||||
const contactIdsParam = req.query.contactIds as string | undefined;
|
||||
const userIds = userIdsParam ? userIdsParam.split(',').filter(Boolean) : [];
|
||||
const contactIds = contactIdsParam ? contactIdsParam.split(',').filter(Boolean) : [];
|
||||
const summary = await participantNeedsService.aggregateForVoters(userIds, contactIds);
|
||||
res.json({ summary });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/people/needs/user/:userId
|
||||
router.get(
|
||||
'/user/:userId',
|
||||
authenticate,
|
||||
requireRole(...EVENTS_ROLES),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const userId = req.params.userId as string;
|
||||
const needs = await participantNeedsService.findByUserId(userId);
|
||||
res.json({ needs });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/people/needs/contact/:contactId
|
||||
router.get(
|
||||
'/contact/:contactId',
|
||||
authenticate,
|
||||
requireRole(...EVENTS_ROLES),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const contactId = req.params.contactId as string;
|
||||
const needs = await participantNeedsService.findByContactId(contactId);
|
||||
res.json({ needs });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /api/people/needs/:id
|
||||
router.delete(
|
||||
'/:id',
|
||||
authenticate,
|
||||
requireRole(...EVENTS_ROLES),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
await participantNeedsService.deleteById(id);
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export { router as participantNeedsRouter };
|
||||
35
api/src/modules/people/participant-needs.schemas.ts
Normal file
35
api/src/modules/people/participant-needs.schemas.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const upsertNeedsSchema = z.object({
|
||||
needsWheelchair: z.boolean().optional(),
|
||||
needsGroundFloor: z.boolean().optional(),
|
||||
needsHearingLoop: z.boolean().optional(),
|
||||
needsSignLanguage: z.boolean().optional(),
|
||||
otherAccessibility: z.string().max(1000).nullable().optional(),
|
||||
|
||||
isVegan: z.boolean().optional(),
|
||||
isVegetarian: z.boolean().optional(),
|
||||
isGlutenFree: z.boolean().optional(),
|
||||
isHalal: z.boolean().optional(),
|
||||
isKosher: z.boolean().optional(),
|
||||
hasNutAllergy: z.boolean().optional(),
|
||||
otherDietary: z.string().max(1000).nullable().optional(),
|
||||
|
||||
needsChildcare: z.boolean().optional(),
|
||||
childcareDetails: z.string().max(1000).nullable().optional(),
|
||||
needsTransportation: z.boolean().optional(),
|
||||
transportationNotes: z.string().max(1000).nullable().optional(),
|
||||
|
||||
preferredLanguage: z.string().max(10).nullable().optional(),
|
||||
needsTranslation: z.boolean().optional(),
|
||||
translationLanguage: z.string().max(100).nullable().optional(),
|
||||
|
||||
visibilityConsent: z.enum(['organizer_only', 'shared_with_hosts', 'public']).default('organizer_only'),
|
||||
});
|
||||
|
||||
export const getNeedsSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
});
|
||||
|
||||
export type UpsertNeedsInput = z.infer<typeof upsertNeedsSchema>;
|
||||
export type GetNeedsInput = z.infer<typeof getNeedsSchema>;
|
||||
129
api/src/modules/people/participant-needs.service.ts
Normal file
129
api/src/modules/people/participant-needs.service.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { prisma } from '../../config/database';
|
||||
import { AppError } from '../../middleware/error-handler';
|
||||
import { logger } from '../../utils/logger';
|
||||
import type { UpsertNeedsInput } from './participant-needs.schemas';
|
||||
|
||||
export const participantNeedsService = {
|
||||
|
||||
async upsert(data: UpsertNeedsInput, userId?: string, contactId?: string) {
|
||||
if (!userId && !contactId) {
|
||||
throw new AppError(400, 'Either userId or contactId is required', 'MISSING_IDENTIFIER');
|
||||
}
|
||||
|
||||
const where = userId
|
||||
? { userId }
|
||||
: { contactId: contactId! };
|
||||
|
||||
const existing = await prisma.participantNeeds.findUnique({ where });
|
||||
|
||||
if (existing) {
|
||||
return prisma.participantNeeds.update({
|
||||
where: { id: existing.id },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
return prisma.participantNeeds.create({
|
||||
data: {
|
||||
...data,
|
||||
...(userId ? { userId } : { contactId }),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async findByUserId(userId: string) {
|
||||
return prisma.participantNeeds.findUnique({ where: { userId } });
|
||||
},
|
||||
|
||||
async findByContactId(contactId: string) {
|
||||
return prisma.participantNeeds.findUnique({ where: { contactId } });
|
||||
},
|
||||
|
||||
async aggregateForVoters(userIds: string[], contactIds: string[]) {
|
||||
const records = await prisma.participantNeeds.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
...(userIds.length > 0 ? [{ userId: { in: userIds } }] : []),
|
||||
...(contactIds.length > 0 ? [{ contactId: { in: contactIds } }] : []),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const summary = {
|
||||
total: records.length,
|
||||
accessibility: {
|
||||
wheelchair: 0,
|
||||
groundFloor: 0,
|
||||
hearingLoop: 0,
|
||||
signLanguage: 0,
|
||||
other: 0,
|
||||
},
|
||||
dietary: {
|
||||
vegan: 0,
|
||||
vegetarian: 0,
|
||||
glutenFree: 0,
|
||||
halal: 0,
|
||||
kosher: 0,
|
||||
nutAllergy: 0,
|
||||
other: 0,
|
||||
},
|
||||
childcare: 0,
|
||||
transportation: 0,
|
||||
translation: 0,
|
||||
languages: {} as Record<string, number>,
|
||||
};
|
||||
|
||||
for (const r of records) {
|
||||
if (r.needsWheelchair) summary.accessibility.wheelchair++;
|
||||
if (r.needsGroundFloor) summary.accessibility.groundFloor++;
|
||||
if (r.needsHearingLoop) summary.accessibility.hearingLoop++;
|
||||
if (r.needsSignLanguage) summary.accessibility.signLanguage++;
|
||||
if (r.otherAccessibility) summary.accessibility.other++;
|
||||
|
||||
if (r.isVegan) summary.dietary.vegan++;
|
||||
if (r.isVegetarian) summary.dietary.vegetarian++;
|
||||
if (r.isGlutenFree) summary.dietary.glutenFree++;
|
||||
if (r.isHalal) summary.dietary.halal++;
|
||||
if (r.isKosher) summary.dietary.kosher++;
|
||||
if (r.hasNutAllergy) summary.dietary.nutAllergy++;
|
||||
if (r.otherDietary) summary.dietary.other++;
|
||||
|
||||
if (r.needsChildcare) summary.childcare++;
|
||||
if (r.needsTransportation) summary.transportation++;
|
||||
if (r.needsTranslation) summary.translation++;
|
||||
|
||||
if (r.translationLanguage) {
|
||||
summary.languages[r.translationLanguage] = (summary.languages[r.translationLanguage] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
},
|
||||
|
||||
async deleteById(id: string) {
|
||||
const existing = await prisma.participantNeeds.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
throw new AppError(404, 'Participant needs record not found', 'NOT_FOUND');
|
||||
}
|
||||
await prisma.participantNeeds.delete({ where: { id } });
|
||||
},
|
||||
|
||||
async purgeExpired() {
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - 7);
|
||||
|
||||
const result = await prisma.participantNeeds.deleteMany({
|
||||
where: {
|
||||
updatedAt: { lt: cutoff },
|
||||
userId: null,
|
||||
contactId: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (result.count > 0) {
|
||||
logger.info(`Purged ${result.count} orphaned participant needs records older than 7 days`);
|
||||
}
|
||||
|
||||
return { purged: result.count };
|
||||
},
|
||||
};
|
||||
@@ -10,6 +10,7 @@ export const createUserSchema = z.object({
|
||||
.regex(/[0-9]/, 'Password must contain at least one digit'),
|
||||
name: z.string().optional(),
|
||||
phone: z.string().optional(),
|
||||
pronouns: z.string().max(50).optional(),
|
||||
role: z.nativeEnum(UserRole).optional(),
|
||||
roles: z.array(z.nativeEnum(UserRole)).optional(),
|
||||
status: z.nativeEnum(UserStatus).optional(),
|
||||
@@ -27,6 +28,7 @@ export const updateUserSchema = z.object({
|
||||
.optional(),
|
||||
name: z.string().optional(),
|
||||
phone: z.string().optional(),
|
||||
pronouns: z.string().max(50).nullable().optional(),
|
||||
role: z.nativeEnum(UserRole).optional(),
|
||||
roles: z.array(z.nativeEnum(UserRole)).optional(),
|
||||
status: z.nativeEnum(UserStatus).optional(),
|
||||
|
||||
@@ -13,6 +13,7 @@ const userSelect = {
|
||||
email: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
pronouns: true,
|
||||
role: true,
|
||||
roles: true,
|
||||
status: true,
|
||||
|
||||
@@ -93,6 +93,7 @@ import { socialDigestService } from './services/social-digest.service';
|
||||
import { termuxClient } from './services/termux.client';
|
||||
import { registerProvisioners } from './services/user-provisioning';
|
||||
import { peopleRouter } from './modules/people/people.routes';
|
||||
import { participantNeedsRouter } from './modules/people/participant-needs.routes';
|
||||
import { profilePublicRouter } from './modules/people/profile-public.routes';
|
||||
import { searchRouter } from './modules/search/search.routes';
|
||||
import { activityPublicRouter } from './modules/activity/activity-public.routes';
|
||||
@@ -115,6 +116,9 @@ import { upgradeService } from './modules/upgrade/upgrade.service';
|
||||
import { autoUpgradeService } from './services/auto-upgrade.service';
|
||||
import { calendarFeedQueueService } from './services/calendar-feed-queue.service';
|
||||
import { scheduledJobsQueueService } from './services/scheduled-jobs-queue.service';
|
||||
import { pollAutoFinalizeQueueService } from './services/poll-auto-finalize-queue.service';
|
||||
import { agendaRouter } from './modules/meetings/agenda.routes';
|
||||
import { actionItemsRouter } from './modules/meetings/action-items.routes';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { docsCollabService } from './modules/docs/docs-collab.service';
|
||||
|
||||
@@ -227,6 +231,8 @@ app.use('/api/map/settings', mapSettingsRouter); // Map settings (public
|
||||
app.use('/api/map/events', eventsPublicRouter); // Public map events from Gancio (no auth)
|
||||
app.use('/api/meeting-planner', meetingPlannerPublicRouter); // Public poll viewing + voting (no auth)
|
||||
app.use('/api/meeting-planner', meetingPlannerAdminRouter); // Admin poll CRUD (auth required)
|
||||
app.use('/api/meetings/agendas', agendaRouter); // Meeting agendas + minutes (EVENTS roles)
|
||||
app.use('/api/meetings/action-items', actionItemsRouter); // Action items CRUD (EVENTS roles / auth)
|
||||
app.use('/api/qr', qrRouter); // QR code generation (public)
|
||||
app.use('/api/listmonk', listmonkWebhookRouter); // Listmonk webhook (shared secret, no JWT)
|
||||
app.use('/api/listmonk', listmonkRouter); // Listmonk newsletter sync (SUPER_ADMIN)
|
||||
@@ -268,6 +274,7 @@ app.use('/api/sms/device', smsDeviceRouter); // SMS device sta
|
||||
app.use('/api/sms/templates', smsTemplatesRouter); // SMS template CRUD (ADMIN roles)
|
||||
app.use('/api/sms/setup', smsSetupRouter); // SMS setup wizard (SUPER_ADMIN only)
|
||||
app.use('/api/profile', profilePublicRouter); // Self-service contact profile (no auth, token-based)
|
||||
app.use('/api/people/needs', participantNeedsRouter); // Participant needs (self-service + EVENTS roles)
|
||||
app.use('/api/people', peopleRouter); // People CRM aggregation (ADMIN roles)
|
||||
app.use('/api/search', searchRouter); // Public unified search (no auth, rate-limited)
|
||||
app.use('/api/activity', activityPublicRouter); // Public activity feed (no auth)
|
||||
@@ -325,6 +332,7 @@ async function start() {
|
||||
geocodeQueueService.startWorker();
|
||||
calendarFeedQueueService.startWorker();
|
||||
scheduledJobsQueueService.startWorker();
|
||||
pollAutoFinalizeQueueService.startWorker();
|
||||
startProxy();
|
||||
|
||||
// Load SMS config from DB (env fallback for empty fields)
|
||||
|
||||
132
api/src/services/poll-auto-finalize-queue.service.ts
Normal file
132
api/src/services/poll-auto-finalize-queue.service.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Queue, Worker, type Job } from 'bullmq';
|
||||
import { env } from '../config/env';
|
||||
import { prisma } from '../config/database';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
interface PollAutoFinalizeJobData {
|
||||
pollId: string;
|
||||
}
|
||||
|
||||
class PollAutoFinalizeQueueService {
|
||||
private queue: Queue;
|
||||
private worker: Worker | null = null;
|
||||
|
||||
constructor() {
|
||||
this.queue = new Queue('poll-auto-finalize', {
|
||||
connection: { url: env.REDIS_URL },
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 5000 },
|
||||
removeOnComplete: { age: 7 * 24 * 60 * 60, count: 500 },
|
||||
removeOnFail: { age: 30 * 24 * 60 * 60 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
startWorker() {
|
||||
this.worker = new Worker(
|
||||
'poll-auto-finalize',
|
||||
async (job: Job<PollAutoFinalizeJobData>) => {
|
||||
const { pollId } = job.data;
|
||||
logger.info(`Processing poll auto-finalize job ${job.id}`, { pollId });
|
||||
|
||||
// Dynamic import to avoid circular dependency
|
||||
const { meetingPlannerService } = await import(
|
||||
'../modules/meeting-planner/meeting-planner.service'
|
||||
);
|
||||
await meetingPlannerService.processAutoFinalize(pollId);
|
||||
},
|
||||
{
|
||||
connection: { url: env.REDIS_URL },
|
||||
concurrency: 1,
|
||||
}
|
||||
);
|
||||
|
||||
this.worker.on('completed', (job) => {
|
||||
logger.info(`Poll auto-finalize job ${job.id} completed`);
|
||||
});
|
||||
|
||||
this.worker.on('failed', (job, err) => {
|
||||
logger.error(`Poll auto-finalize job ${job?.id} failed: ${err.message}`);
|
||||
});
|
||||
|
||||
logger.info('Poll auto-finalize queue worker started');
|
||||
|
||||
// Startup recovery: process past-due polls and re-schedule future ones
|
||||
this.recoverOnStartup().catch((err) =>
|
||||
logger.error('Poll auto-finalize startup recovery failed', { error: err })
|
||||
);
|
||||
}
|
||||
|
||||
async scheduleJob(pollId: string, deadline: Date): Promise<string | null> {
|
||||
const delay = deadline.getTime() - Date.now();
|
||||
if (delay <= 0) {
|
||||
// Already past deadline — process immediately
|
||||
const job = await this.queue.add(`finalize-${pollId}`, { pollId }, {
|
||||
jobId: `poll-finalize-${pollId}`,
|
||||
});
|
||||
return job.id ?? null;
|
||||
}
|
||||
|
||||
const job = await this.queue.add(`finalize-${pollId}`, { pollId }, {
|
||||
delay,
|
||||
jobId: `poll-finalize-${pollId}`,
|
||||
});
|
||||
logger.info(`Scheduled poll auto-finalize for ${deadline.toISOString()}`, {
|
||||
pollId,
|
||||
jobId: job.id,
|
||||
delayMs: delay,
|
||||
});
|
||||
return job.id ?? null;
|
||||
}
|
||||
|
||||
async cancelJob(pollId: string): Promise<void> {
|
||||
try {
|
||||
const jobs = await this.queue.getJobs(['delayed', 'waiting']);
|
||||
for (const job of jobs) {
|
||||
if (job.data.pollId === pollId) {
|
||||
await job.remove();
|
||||
logger.info(`Cancelled auto-finalize job for poll ${pollId}`, { jobId: job.id });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to cancel poll auto-finalize job', { error, pollId });
|
||||
}
|
||||
}
|
||||
|
||||
private async recoverOnStartup() {
|
||||
const openPolls = await prisma.schedulingPoll.findMany({
|
||||
where: {
|
||||
autoFinalize: true,
|
||||
status: 'OPEN',
|
||||
votingDeadline: { not: null },
|
||||
},
|
||||
select: { id: true, votingDeadline: true },
|
||||
});
|
||||
|
||||
for (const poll of openPolls) {
|
||||
if (!poll.votingDeadline) continue;
|
||||
const jobId = await this.scheduleJob(poll.id, poll.votingDeadline);
|
||||
if (jobId) {
|
||||
await prisma.schedulingPoll.update({
|
||||
where: { id: poll.id },
|
||||
data: { autoFinalizeJobId: jobId },
|
||||
}).catch(() => {}); // Best-effort
|
||||
}
|
||||
}
|
||||
|
||||
if (openPolls.length > 0) {
|
||||
logger.info(`Recovered ${openPolls.length} poll auto-finalize jobs on startup`);
|
||||
}
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (this.worker) {
|
||||
await this.worker.close();
|
||||
}
|
||||
await this.queue.close();
|
||||
logger.info('Poll auto-finalize queue closed');
|
||||
}
|
||||
}
|
||||
|
||||
export const pollAutoFinalizeQueueService = new PollAutoFinalizeQueueService();
|
||||
@@ -14,7 +14,8 @@ type ScheduledJobType =
|
||||
| 'cleanup-verification-tokens'
|
||||
| 'listmonk-full-sync'
|
||||
| 'validate-mkdocs-exports'
|
||||
| 'cleanup-docs-collab-states';
|
||||
| 'cleanup-docs-collab-states'
|
||||
| 'purge-expired-participant-needs';
|
||||
|
||||
interface ScheduledJobData {
|
||||
type: ScheduledJobType;
|
||||
@@ -33,6 +34,7 @@ const JOB_DEFINITIONS: Array<{ type: ScheduledJobType; every: number; conditiona
|
||||
{ type: 'listmonk-full-sync', every: 6 * HOUR, conditional: true },
|
||||
{ type: 'validate-mkdocs-exports', every: 24 * HOUR },
|
||||
{ type: 'cleanup-docs-collab-states', every: 24 * HOUR },
|
||||
{ type: 'purge-expired-participant-needs', every: 24 * HOUR },
|
||||
];
|
||||
|
||||
async function executeJob(type: ScheduledJobType): Promise<void> {
|
||||
@@ -89,6 +91,11 @@ async function executeJob(type: ScheduledJobType): Promise<void> {
|
||||
await docsCollabService.cleanupStaleStates();
|
||||
break;
|
||||
}
|
||||
case 'purge-expired-participant-needs': {
|
||||
const { participantNeedsService } = await import('../modules/people/participant-needs.service');
|
||||
await participantNeedsService.purgeExpired();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +152,7 @@ class ScheduledJobsQueueService {
|
||||
logger.error(`Scheduled job ${job?.name} failed: ${err.message}`);
|
||||
});
|
||||
|
||||
logger.info('Scheduled jobs queue worker started (10 job types)');
|
||||
logger.info('Scheduled jobs queue worker started (11 job types)');
|
||||
}
|
||||
|
||||
async close() {
|
||||
|
||||
Reference in New Issue
Block a user