Bunch of updates to scheduling

This commit is contained in:
2026-03-15 13:50:09 -06:00
parent 12734aca16
commit 28e4bc9475
202 changed files with 4568 additions and 226 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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")
}

View File

@@ -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({

View File

@@ -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({

View 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 };

View 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>;

View 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' },
});
},
};

View 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 };

View 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>;

View 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 } },
},
});
},
};

View 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 };

View 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>;

View 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 };
},
};

View File

@@ -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(),

View File

@@ -13,6 +13,7 @@ const userSelect = {
email: true,
name: true,
phone: true,
pronouns: true,
role: true,
roles: true,
status: true,

View File

@@ -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)

View 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();

View File

@@ -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() {