diff --git a/README.md b/README.md index 0a967771..dee7f972 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,21 @@ bun run dev:server # starts the actual dev server (vite&sveltekit) bunx lefthook install ``` +## Running on Windows + +Some `package.json` scripts in this repository assume a Unix-like shell (bash) and may not work out of the box on Windows `cmd.exe` or PowerShell. If you develop on Windows, consider one of the options below: + +- **Use WSL or Git Bash:** Install Windows Subsystem for Linux (WSL) or Git for Windows and run the development commands from there. This is the easiest option when scripts use shell operators (like `&&`, `rm`, or `sed`). +- **Modify `package.json` scripts for PowerShell / cmd:** Replace Unix-only commands with cross-platform equivalents or use Node-based packages. + +if you are running on windows then change copy the following script tags and replace the cooresponding script tags in package.json + +```bash +"dev": "concurrently \"bun run dev:server\" \"bun run dev:docker\"", +"dev:docker": "docker compose -f ./dev.docker-compose.yml up", +"dev:server": "bunx tsx -e \"const {spawn}=require('child_process'); const sleep=(ms)=>new Promise(r=>setTimeout(r,ms)); const run=(cmd,args)=>new Promise(res=>{const p=spawn(cmd,args,{stdio:'inherit',shell:process.platform==='win32'}); p.on('exit',c=>res(c??0));}); (async()=>{for(;;){let c=await run('bunx',['svelte-kit','sync']); if(c!==0){console.log('🔄 sync failed, retrying...'); await sleep(1000); continue;} c=await run('bunx',['vite']); console.log('🔄 Server exited, restarting...'); await sleep(1000);}})();\"", +``` + ## Deployment The easiest way to deploy delegator on your own hardware is to use our provided [docker images](https://hub.docker.com/r/deutschemodelunitednations/delegator). You can find an example docker compose file in the [example](./example/) directoy. Please note that delegator relies on an [OIDC](https://auth0.com/intro-to-iam/what-is-openid-connect-oidc) issuer to be connected and properly configured. We recommend [ZITADEL](https://zitadel.com/) but any issuer of your choice will work. There are some additional instructions on this topic to be found in the example compose file. diff --git a/messages/de.json b/messages/de.json index ed4213b2..399fa342 100644 --- a/messages/de.json +++ b/messages/de.json @@ -56,6 +56,9 @@ "allPiecesCollected": "Alle Puzzlestücke wurden gesammelt!", "allRightsReservedby": "Alle Rechte vorbehalten von", "allStudentsAcceptedMessage": "Alle deine Schüler*innen wurden angenommen!", + "allowDelegationMailer": "Nachrichten von anderen Delegierten empfangen", + "allowDelegationMailerDescription": "Wenn du dies aktivierst, können andere Delegierte dir Nachrichten per E-Mail senden. Deine E-Mail-Adresse wird dabei nicht weitergegeben. Du kannst direkt über die E-Mail-Benachrichtigung antworten.", + "allowMessaging": "Nachrichten für Teilnehmende erlauben", "alpha3Code": "ISO Alpha 3 Code", "alphabetical": "Alphabetisch", "alreadRegistered": "Bereits angemeldet", @@ -347,6 +350,8 @@ "committeesAndAgendaItems": "Gremien und Themen", "committeesSucessfullyAssigned": "Gremien wurden erfolgreich verteilt.", "communication": "Kommunikation", + "communicationPreferences": "Kommunikationseinstellungen", + "communicationPreferencesDescription": "Wähle, welche Updates du zu deiner Delegation erhalten möchtest.", "compareVersion": "Mit einer anderen Version vergleichen", "compareVersions": "Versionen vergleichen", "complete": "Vollständig", @@ -364,6 +369,7 @@ "completeSignupWarningText": "Obwohl du bereits eine Delegation angelegt hast oder einer beigetreten bist, ist die Registrierung noch nicht abgeschlossen. Bitte schließe die Registrierung rechtzeitig vor Ablauf der Anmeldefrist ab, damit wir die Anmeldung deiner Delegation im Auswahlverfahren berücksichtigen können.", "completeSignupWarningTextSingleParticipant": "Obwohl du dich bereits für eine Rolle beworben haben, ist die Registrierung noch nicht abgeschlossen. Bitte schließe deine Registrierung rechtzeitig vor Ablauf der Anmeldefrist ab, damit wir die Bewerbung im Auswahlverfahren berücksichtigen können.", "completedFlags": "Vollständige Flaggen", + "compose": "Verfassen", "conference": "Konferenz", "conferenceAge": "Konferenzalter", "conferenceCompleteMessage": "Wir hoffen, du hattest eine tolle Zeit und hast viel gelernt. Danke, dass du dabei warst!", @@ -754,6 +760,114 @@ "mediaConsentStatus": "Fotostatus", "members": "Mitglieder", "membersPerDelegation": "Plätze pro Delegation", + "messageCancelButton": "Abbrechen", + "messageCompose": "Verfassen", + "messageComposeAnnouncement": "Ankündigung verfassen", + "messageComposeMessage": "Nachricht verfassen", + "messageDraft": "Entwurf", + "messageDraftAMessage": "Eine neue Nachricht entwerfen", + "messageHistory": "Verlauf", + "messageMessageBody": "Nachricht", + "messageMessagePlaceholder": "Sehr geehrte Delegierte,\n\nich möchte ein bilaterales Treffen vorschlagen, um über...", + "messageNoMessagesSent": "Noch keine Nachrichten versendet. Sende deine erste Nachricht von der Verfassen-Seite.", + "messageRecipient": "Empfänger*in", + "messageRecipientNotEnabled": "Empfänger*in hat Messaging nicht aktiviert.", + "messageReviewDeliveryStatus": "Überprüfe den Zustellungsstatus und die Nachrichtenaktivität für diese Konferenz.", + "messageReviewSent": "Gesendete überprüfen", + "messageSendButton": "Nachricht senden", + "messageSendToParticipants": "Eine Nachricht an Konferenzteilnehmende senden", + "messageSent": "Nachricht gesendet.", + "messageSentLog": "Sendelog", + "messageStatusDelivered": "Zugestellt", + "messageSubject": "Betreff", + "messageSubjectPlaceholder": "z.B. Vorschlag für bilaterales Treffen zu Resolution A/RES/1", + "messaging": "Nachrichten senden", + "messagingAboutDeliveryLog": "Jede Nachricht wird protokolliert — überprüfe deinen Sendverlauf jederzeit.", + "messagingAboutDescription": "Nutze die Konferenznachrichten, um mit anderen Delegierten, Nichtstaatlichen Akteuren und der Presse zu kommunizieren — vor und während der Konferenz.", + "messagingAboutGrouped": "Empfangende sind nach Gremium, Nichtstaatlichem Akteur und Rolle sortiert.", + "messagingAboutPrivacy": "Deine E-Mail-Adresse wird niemals weitergegeben — Empfangende sehen nur deine Konferenzrolle.", + "messagingAboutRecipientNotice": "Empfangende erhalten deine Nachricht als E-Mail-Benachrichtigung.", + "messagingAboutThreading": "Antworten enthalten die ursprüngliche Nachricht für einen nahtlosen Gesprächsverlauf.", + "messagingAboutTitle": "Konferenznachrichten", + "messagingActivationNotice": "Du musst E-Mail-Nachrichten in deinen Kontoeinstellungen aktivieren, um Nachrichten empfangen und senden zu können.", + "messagingBackToGroups": "Zurück zu Gruppen", + "messagingCannotReceiveReplies": "Du kannst keine weiteren Antworten empfangen.", + "messagingCategoryCommittee": "Gremium", + "messagingCategoryCustomRole": "Sonderrolle", + "messagingCategoryNSA": "Nichtstaatlicher Akteur", + "messagingCenter": "Nachrichtenzentrale", + "messagingCheckDeliveryStatus": "Zustellungsstatus prüfen", + "messagingClearActionDeadlines": "Fristen bei Bedarf angeben", + "messagingConferenceMessaging": "Konferenznachrichten", + "messagingDataNotice": "Nachrichten werden im Klartext (unverschlüsselt) zur Missbrauchsprävention und Compliance-Nachverfolgung gespeichert.", + "messagingDeliveryLog": "Zustellungsprotokoll", + "messagingDescription": "Tritt mit anderen Konferenzteilnehmenden in Kontakt.", + "messagingDisabledForAccount": "Messaging ist für dein Konto deaktiviert.", + "messagingEnableInSettings": "In den Einstellungen aktivieren", + "messagingGuidelineConcise": "Halte Nachrichten kurz und zielgerichtet", + "messagingGuidelineDeadlines": "Verwende klare Handlungsaufforderungen und Fristen, um Hin und Her zu reduzieren.", + "messagingGuidelineDiplomaticCode": "Wahre den diplomatischen Verhaltenskodex — bleibe professionell", + "messagingGuidelineNoSpam": "Kein Spam — sende nur relevante Nachrichten", + "messagingGuidelineOptIn": "Sende nur an Empfänger*innen, die zugestimmt haben, und vermeide Duplikate.", + "messagingGuidelineRespectful": "Sei respektvoll und höflich in aller Kommunikation", + "messagingGuidelineShort": "Halte Betreffzeilen kurz und spezifisch, um die Sichtbarkeit zu verbessern.", + "messagingGuidelines": "Richtlinien", + "messagingGuidelinesDescription": "Bitte beachte diese Regeln bei der Nutzung der Konferenznachrichten", + "messagingHideThread": "Gesprächsverlauf ausblenden", + "messagingInbox": "Posteingang", + "messagingInboxDescription": "Zeige Nachrichten an, die du von anderen Teilnehmenden erhalten hast.", + "messagingInboxEmpty": "Keine Nachrichten empfangen", + "messagingInboxEmptyDescription": "Du hast noch keine Nachrichten erhalten. Sobald andere Teilnehmende dir Nachrichten senden, erscheinen sie hier.", + "messagingInboxExplanation": "Der Posteingang ist ein schreibgeschütztes Archiv der an dich gesendeten Nachrichten. Alle Nachrichten werden auch per E-Mail zugestellt — diese Ansicht dient deiner Übersicht.", + "messagingInboxReceived": "Empfangen", + "messagingInboxSender": "Von", + "messagingInboxTitle": "Deine Nachrichten", + "messagingKeepConcise": "Halte es prägnant und handlungsorientiert", + "messagingLogsStored": "Nachrichtenprotokolle werden zur Prüfung und Compliance gespeichert.", + "messagingMessageDetails": "Nachrichtendetails", + "messagingMessaging": "Nachrichten", + "messagingNewMessage": "Neue Nachricht", + "messagingNoEligibleRecipients": "Keine geeigneten Empfänger*innen gefunden", + "messagingNoGroupsAvailable": "Keine Gruppen mit berechtigten Empfangenden gefunden", + "messagingNoMessagesYet": "Noch keine Nachrichten", + "messagingNoRecipientsInGroup": "Keine berechtigten Empfangenden in dieser Gruppe", + "messagingNoReplyWarning": "Du kannst keine Antworten erhalten.", + "messagingOnlyEnabledUsers": "Nur Nutzer*innen, die Messaging aktiviert haben, werden in dieser Liste angezeigt", + "messagingOriginalMessage": "Ursprüngliche Nachricht", + "messagingOverview": "Übersicht", + "messagingPreview": "Vorschau", + "messagingQuickActions": "Schnellaktionen", + "messagingRecipientCount": "{count} Empfangende", + "messagingRecipientLabel": "Empfänger*in", + "messagingRecipientNotEnabledToast": "Empfänger*in hat Messaging nicht aktiviert.", + "messagingRecipientRequired": "Empfänger*in auswählen...", + "messagingReply": "Antwort", + "messagingReplySent": "Antwort gesendet.", + "messagingReplyToMessage": "Auf Nachricht antworten", + "messagingRespondingTo": "Antwort an {recipientName}", + "messagingSavedInHistory": "Diese Nachricht wird im Zustellungsverlauf gespeichert", + "messagingSelectGroup": "Gruppe auswählen...", + "messagingSelectRecipient": "Empfänger*in auswählen...", + "messagingSelectRecipientDrawer": "Empfänger*in auswählen", + "messagingSendFirstMessage": "Sende deine erste Nachricht", + "messagingSendReply": "Antwort senden", + "messagingSenderNotEnabled": "Du musst Messaging in deinen Kontoeinstellungen aktivieren, bevor du Nachrichten senden kannst.", + "messagingSent": "Gesendet", + "messagingSentHistory": "Sendverlauf", + "messagingShowThread": "Gesprächsverlauf anzeigen", + "messagingStartTargetedMessage": "Starte eine gezielte Nachricht an Delegationen, Gremien oder einzelne Teilnehmende.", + "messagingStatus": "Status", + "messagingThreadTitle": "Gesprächsverlauf", + "messagingTipSubjectLine": "Tipp: Verwende klare Betreffzeilen wie \"Gremienagenda-Sperrung - 18:00\", um die Rücklaufquote zu verbessern", + "messagingTo": "An", + "messagingToggleDisabled": "E-Mail-Nachrichten sind deaktiviert — aktiviere sie, um Nachrichten senden und empfangen zu können.", + "messagingToggleEnabled": "E-Mail-Nachrichten sind aktiviert — du kannst Nachrichten senden und empfangen.", + "messagingUnableToLoadRecipients": "Empfänger*in konnte nicht geladen werden", + "messagingVerifyRecipient": "Empfänger*in und Konferenz überprüfen", + "messagingViewInbox": "Posteingang anzeigen", + "messagingViewSentHistory": "Sendverlauf anzeigen", + "messagingWriteReplyPlaceholder": "Schreibe deine Antwort hier...", + "messagingYou": "Du", "missingInformation": "Fehlende Informationen", "motivation": "Motivation", "myAccount": "Mein Konto", diff --git a/messages/en.json b/messages/en.json index 8145fec8..4d1360d2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -56,6 +56,9 @@ "allPiecesCollected": "All puzzle pieces have been collected!", "allRightsReservedby": "All rights reserved by", "allStudentsAcceptedMessage": "All your students were accepted!", + "allowDelegationMailer": "Receive messages from other delegates", + "allowDelegationMailerDescription": "By enabling this, other delegates can send you messages via email. Your email address will not be shared with them. You can reply directly from the email notification.", + "allowMessaging": "Allow messaging for participants", "alpha3Code": "ISO Alpha 3 Code", "alphabetical": "alphabetical", "alreadRegistered": "Already registered", @@ -347,6 +350,8 @@ "committeesAndAgendaItems": "Committees and Agenda Items", "committeesSucessfullyAssigned": "Committees successfully assigned", "communication": "Communication", + "communicationPreferences": "Communication preferences", + "communicationPreferencesDescription": "Choose which updates you want to receive about your delegation.", "compareVersion": "Compare with another version", "compareVersions": "Compare Versions", "complete": "Complete", @@ -364,6 +369,7 @@ "completeSignupWarningText": "Although you have already created a delegation or joined one, the registration is not yet complete. Please finalize your registration in a timely manner before the registration deadline so that we can consider your delegation's registration.", "completeSignupWarningTextSingleParticipant": "Although you have already applied for a role, registration is not yet complete. Please complete your registration in time before the application deadline so that we can consider your application in the selection process.", "completedFlags": "Completed Flags", + "compose": "Compose", "conference": "Conference", "conferenceAge": "Age at Conference", "conferenceCompleteMessage": "We hope you had a great time and learned a lot. Thank you for being part of this experience!", @@ -754,6 +760,114 @@ "mediaConsentStatus": "Media Status", "members": "Members", "membersPerDelegation": "Seats per Delegation", + "messageCancelButton": "Cancel", + "messageCompose": "Compose", + "messageComposeAnnouncement": "Compose announcement", + "messageComposeMessage": "Compose Message", + "messageDraft": "Draft", + "messageDraftAMessage": "Draft a new message", + "messageHistory": "History", + "messageMessageBody": "Message", + "messageMessagePlaceholder": "Dear delegates,\n\nI would like to propose a bilateral meeting to discuss...", + "messageNoMessagesSent": "No messages sent yet. Send your first update from the compose page.", + "messageRecipient": "Recipient", + "messageRecipientNotEnabled": "Recipient has not enabled messaging.", + "messageReviewDeliveryStatus": "Review delivery status and message activity for this conference.", + "messageReviewSent": "Review sent", + "messageSendButton": "Send Message", + "messageSendToParticipants": "Send a message to conference participants", + "messageSent": "Message sent.", + "messageSentLog": "Sent log", + "messageStatusDelivered": "Delivered", + "messageSubject": "Subject", + "messageSubjectPlaceholder": "e.g., Proposal for bilateral meeting on Resolution A/RES/1", + "messaging": "Send messages", + "messagingAboutDeliveryLog": "Every message is logged — check your delivery history anytime.", + "messagingAboutDescription": "Use conference messaging to communicate with other delegates, Non-State Actors, and the press — before and during the conference.", + "messagingAboutGrouped": "Recipients are organized by committee, Non-State Actor, and role for quick discovery.", + "messagingAboutPrivacy": "Your email address is never shared — recipients only see your conference role.", + "messagingAboutRecipientNotice": "Recipients will receive your message as an email notification.", + "messagingAboutThreading": "Replies include the original message for seamless conversation threading.", + "messagingAboutTitle": "About Conference Messaging", + "messagingActivationNotice": "You need to activate Email-Messaging in your account settings to receive and send messages.", + "messagingBackToGroups": "Back to groups", + "messagingCannotReceiveReplies": "You cannot receive further replies.", + "messagingCategoryCommittee": "Committee", + "messagingCategoryCustomRole": "Role", + "messagingCategoryNSA": "Non-State Actor", + "messagingCenter": "Messaging Center", + "messagingCheckDeliveryStatus": "Check delivery status", + "messagingClearActionDeadlines": "Include deadlines when applicable", + "messagingConferenceMessaging": "Conference Messaging", + "messagingDataNotice": "Messages are saved in plain text (unencrypted) for abuse protection and compliance tracking.", + "messagingDeliveryLog": "Delivery log", + "messagingDescription": "Get in touch with other conference participants.", + "messagingDisabledForAccount": "Messaging is disabled for your account.", + "messagingEnableInSettings": "Enable it in settings", + "messagingGuidelineConcise": "Keep messages concise and purposeful", + "messagingGuidelineDeadlines": "Use clear actions and deadlines to reduce back-and-forth.", + "messagingGuidelineDiplomaticCode": "Honor the diplomatic code — maintain professionalism", + "messagingGuidelineNoSpam": "Don't spam — only send relevant messages", + "messagingGuidelineOptIn": "Send only to opt-in recipients and avoid duplicates.", + "messagingGuidelineRespectful": "Be respectful and courteous in all communications", + "messagingGuidelineShort": "Keep subject lines short and specific to improve visibility.", + "messagingGuidelines": "Guidelines", + "messagingGuidelinesDescription": "Please follow these rules when using conference messaging", + "messagingHideThread": "Hide conversation", + "messagingInbox": "Inbox", + "messagingInboxDescription": "View messages you've received from other participants.", + "messagingInboxEmpty": "No Messages Received", + "messagingInboxEmptyDescription": "You haven't received any messages yet. Once other participants send you messages, they'll appear here.", + "messagingInboxExplanation": "The inbox is a read-only archive of messages sent to you. All messages are also delivered to your email — this view is for your convenience.", + "messagingInboxReceived": "Received", + "messagingInboxSender": "From", + "messagingInboxTitle": "Your Messages", + "messagingKeepConcise": "Keep it concise and actionable", + "messagingLogsStored": "Messaging logs are stored for auditing and compliance.", + "messagingMessageDetails": "Message Details", + "messagingMessaging": "Messaging", + "messagingNewMessage": "New message", + "messagingNoEligibleRecipients": "No eligible recipients found", + "messagingNoGroupsAvailable": "No groups with eligible recipients found", + "messagingNoMessagesYet": "No Messages Yet", + "messagingNoRecipientsInGroup": "No eligible recipients in this group", + "messagingNoReplyWarning": "You cannot receive replies.", + "messagingOnlyEnabledUsers": "Only users who have enabled messaging will appear in this list", + "messagingOriginalMessage": "Original Message", + "messagingOverview": "Overview", + "messagingPreview": "Preview", + "messagingQuickActions": "Quick actions", + "messagingRecipientCount": "{count} recipients", + "messagingRecipientLabel": "Recipient", + "messagingRecipientNotEnabledToast": "Recipient has not enabled messaging.", + "messagingRecipientRequired": "Select a recipient...", + "messagingReply": "Reply", + "messagingReplySent": "Reply sent.", + "messagingReplyToMessage": "Reply to Message", + "messagingRespondingTo": "Responding to {recipientName}", + "messagingSavedInHistory": "This message will be saved in delivery history", + "messagingSelectGroup": "Select a group...", + "messagingSelectRecipient": "Select a recipient...", + "messagingSelectRecipientDrawer": "Select Recipient", + "messagingSendFirstMessage": "Send Your First Message", + "messagingSendReply": "Send Reply", + "messagingSenderNotEnabled": "You must enable messaging in your account settings before sending messages.", + "messagingSent": "Sent", + "messagingSentHistory": "Sent history", + "messagingShowThread": "Show conversation", + "messagingStartTargetedMessage": "Start a targeted message to delegations, committees, or individual participants.", + "messagingStatus": "Status", + "messagingThreadTitle": "Conversation", + "messagingTipSubjectLine": "Tip: Use clear subject lines like \"Committee agenda lock - 18:00\" to improve response rates", + "messagingTo": "To", + "messagingToggleDisabled": "Email messaging is disabled — enable it to send and receive messages.", + "messagingToggleEnabled": "Email messaging is enabled — you can send and receive messages.", + "messagingUnableToLoadRecipients": "Unable to load recipient", + "messagingVerifyRecipient": "Verify recipient and conference", + "messagingViewInbox": "View Inbox", + "messagingViewSentHistory": "View Sent History", + "messagingWriteReplyPlaceholder": "Write your reply here...", + "messagingYou": "You", "missingInformation": "Missing information", "motivation": "Motivation", "myAccount": "My Account", diff --git a/prisma/migrations/20260111130406_schema_for_messages_between_delegates_in_conference/migration.sql b/prisma/migrations/20260111130406_schema_for_messages_between_delegates_in_conference/migration.sql new file mode 100644 index 00000000..c3366bcc --- /dev/null +++ b/prisma/migrations/20260111130406_schema_for_messages_between_delegates_in_conference/migration.sql @@ -0,0 +1,30 @@ +-- CreateEnum +CREATE TYPE "MessageStatus" AS ENUM ('SENT', 'FAILED', 'BLOCKED'); + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "canReceiveDelegationMail" BOOLEAN NOT NULL DEFAULT false; + +-- CreateTable +CREATE TABLE "MessageAudit" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "subject" TEXT NOT NULL, + "body" TEXT NOT NULL, + "senderUserId" TEXT NOT NULL, + "recipientUserId" TEXT NOT NULL, + "conferenceId" TEXT NOT NULL, + "messageId" TEXT, + "status" "MessageStatus" NOT NULL DEFAULT 'SENT', + + CONSTRAINT "MessageAudit_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "MessageAudit" ADD CONSTRAINT "MessageAudit_senderUserId_fkey" FOREIGN KEY ("senderUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MessageAudit" ADD CONSTRAINT "MessageAudit_recipientUserId_fkey" FOREIGN KEY ("recipientUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MessageAudit" ADD CONSTRAINT "MessageAudit_conferenceId_fkey" FOREIGN KEY ("conferenceId") REFERENCES "Conference"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260219225324_add_allow_messaging_to_conference/migration.sql b/prisma/migrations/20260219225324_add_allow_messaging_to_conference/migration.sql new file mode 100644 index 00000000..18e28d18 --- /dev/null +++ b/prisma/migrations/20260219225324_add_allow_messaging_to_conference/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Conference" ADD COLUMN "allowMessaging" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/migrations/20260219235346_add_reply_to_message_thread/migration.sql b/prisma/migrations/20260219235346_add_reply_to_message_thread/migration.sql new file mode 100644 index 00000000..242186c7 --- /dev/null +++ b/prisma/migrations/20260219235346_add_reply_to_message_thread/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "MessageAudit" ADD COLUMN "replyToMessageId" TEXT; + +-- AddForeignKey +ALTER TABLE "MessageAudit" ADD CONSTRAINT "MessageAudit_replyToMessageId_fkey" FOREIGN KEY ("replyToMessageId") REFERENCES "MessageAudit"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6acf7259..2de56b1d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -62,6 +62,7 @@ model Conference { linkToPaperInbox String? isOpenPaperSubmission Boolean @default(false) showCalendar Boolean @default(false) + allowMessaging Boolean @default(false) timezone String @default("Europe/Berlin") state ConferenceState @default(PRE) startAssignment DateTime @@ -111,8 +112,9 @@ model Conference { calendarDays CalendarDay[] places Place[] - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + messageAudits MessageAudit[] } /// A committee in a conference. E.g. the human rights council @@ -215,6 +217,9 @@ model User { papers Paper[] paperReviews PaperReview[] reviewerSnippets ReviewerSnippet[] + canReceiveDelegationMail Boolean @default(false) + sentDelegationMessages MessageAudit[] @relation("SentMessages") + receivedDelegationMessages MessageAudit[] @relation("ReceivedMessages") invitationsSent TeamMemberInvitation[] @relation("InvitedByUser") invitationsAccepted TeamMemberInvitation[] @relation("AcceptedByUser") @@ -657,6 +662,42 @@ model TeamMember { @@unique([conferenceId, userId]) } +model MessageAudit { + id String @id @default(nanoid()) + + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + subject String + body String + + //tracibility + senderUserId String + senderUser User @relation("SentMessages", fields: [senderUserId], references: [id], onDelete: Cascade) + + recipientUserId String + recipientUser User @relation("ReceivedMessages", fields: [recipientUserId], references: [id], onDelete: Cascade) + + conferenceId String + conference Conference @relation(fields: [conferenceId], references: [id], onDelete: Cascade) + + // Technical meta-data + messageId String? // The ID from the email provider + status MessageStatus @default(SENT) + + // Threading + replyToMessageId String? + replyToMessage MessageAudit? @relation("MessageThread", fields: [replyToMessageId], references: [id], onDelete: SetNull) + replies MessageAudit[] @relation("MessageThread") +} + +/// Status for message audit entries +enum MessageStatus { + SENT + FAILED + BLOCKED +} + /// An invitation to join the team for a conference model TeamMemberInvitation { id String @id @default(nanoid()) diff --git a/prisma/seed/dev/conferenceSupervisor.ts b/prisma/seed/dev/conferenceSupervisor.ts index fbb648f6..a61b37da 100644 --- a/prisma/seed/dev/conferenceSupervisor.ts +++ b/prisma/seed/dev/conferenceSupervisor.ts @@ -4,14 +4,14 @@ import { faker } from '@faker-js/faker'; export function makeSeedConferenceSupervisor( options: Pick & Partial<{ - delegations: { connect: { id: string }[] }; - postAssignmentDelegeationMembers: { connect: { id: string }[] }; + supervisedDelegationMembers: { connect: { id: string }[] }; }> ): ConferenceSupervisor { return { ...options, id: faker.database.mongodbObjectId(), plansOwnAttendenceAtConference: faker.datatype.boolean(), + connectionCode: faker.string.numeric(6), createdAt: faker.date.past(), updatedAt: faker.date.past() }; diff --git a/prisma/seed/dev/seed.ts b/prisma/seed/dev/seed.ts index 0295b483..922fd198 100644 --- a/prisma/seed/dev/seed.ts +++ b/prisma/seed/dev/seed.ts @@ -30,897 +30,891 @@ faker.seed(123); const _db = new PrismaClient(); -await _db.$transaction(async (db) => { - // creating default data - const { nations } = await createDefaultData(db); - - const users: User[] = []; - for (let i = 0; i < 1000; i++) { - users.push(makeSeedUser()); - } - - function takeXUsers(x: number) { - return users.splice(0, x); - } - - await Promise.all( - users.map(async (user) => - db.user.upsert({ - where: { - id: user.id - }, - update: user, - create: user - }) - ) - ); - - await Promise.all( - [ - makeSeedConference({ state: 'PRE' }), - makeSeedConference({ state: 'PARTICIPANT_REGISTRATION' }), - makeSeedConference({ state: 'PREPARATION' }), - makeSeedConference({ state: 'ACTIVE' }), - makeSeedConference({ state: 'POST' }) - ].map(async (conference) => { - await db.conference.upsert({ - where: { - id: conference.id - }, - update: conference, - create: conference - }); - - const conferenceIsInAssignedState = !( - [ConferenceState.PRE, ConferenceState.PARTICIPANT_REGISTRATION] as ConferenceState[] - ).includes(conference.state); - - const committees = [ - makeSeedCommittee({ - conferenceId: conference.id, - nations: { - connect: faker.helpers - .arrayElements(nations, { min: 6, max: 36 }) - .map((nation) => ({ alpha3Code: nation.alpha3Code })) - } - }), - makeSeedCommittee({ - conferenceId: conference.id, - nations: { - connect: faker.helpers - .arrayElements(nations, { min: 6, max: 36 }) - .map((nation) => ({ alpha3Code: nation.alpha3Code })) - } - }), - makeSeedCommittee({ - conferenceId: conference.id, - nations: { - connect: faker.helpers - .arrayElements(nations, { min: 6, max: 36 }) - .map((nation) => ({ alpha3Code: nation.alpha3Code })) - } - }), - makeSeedCommittee({ - conferenceId: conference.id, - nations: { - connect: faker.helpers - .arrayElements(nations, { min: 6, max: 36 }) - .map((nation) => ({ alpha3Code: nation.alpha3Code })) - } - }), - makeSeedCommittee({ - conferenceId: conference.id, - nations: { - connect: faker.helpers - .arrayElements(nations, { min: 6, max: 36 }) - .map((nation) => ({ alpha3Code: nation.alpha3Code })) - } - }), - makeSeedCommittee({ - conferenceId: conference.id, - nations: { - connect: faker.helpers - .arrayElements(nations, { min: 6, max: 36 }) - .map((nation) => ({ alpha3Code: nation.alpha3Code })) - } - }), - makeSeedCommittee({ - conferenceId: conference.id, - nations: { - connect: faker.helpers - .arrayElements(nations, { min: 6, max: 36 }) - .map((nation) => ({ alpha3Code: nation.alpha3Code })) - } - }), - makeSeedCommittee({ - conferenceId: conference.id, - nations: { - connect: faker.helpers - .arrayElements(nations, { min: 6, max: 36 }) - .map((nation) => ({ alpha3Code: nation.alpha3Code })) - } - }), - makeSeedCommittee({ - conferenceId: conference.id, - nations: { - connect: faker.helpers - .arrayElements(nations, { min: 6, max: 36 }) - .map((nation) => ({ alpha3Code: nation.alpha3Code })) - } - }), - makeSeedCommittee({ - conferenceId: conference.id, - nations: { - connect: faker.helpers - .arrayElements(nations, { min: 6, max: 36 }) - .map((nation) => ({ alpha3Code: nation.alpha3Code })) - } - }) - ]; - - const dbcommittees = await Promise.all( - committees.map((committee) => { - return db.committee.upsert({ - where: { - id: committee.id - }, - update: committee, - create: committee, - include: { - nations: true - } - }); - }) - ); - - const allNationsRepresentedInCommittees = dbcommittees.flatMap((c) => c.nations); - - const nonStateActors = [ - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }) - ]; - - await Promise.all( - nonStateActors.map(async (nonStateActor) => { - await db.nonStateActor.upsert({ - where: { - id: nonStateActor.id - }, - update: nonStateActor, - create: nonStateActor - }); - }) - ); - - const customConferenceRoles = [ - makeSeedCustomConferenceRole({ conferenceId: conference.id }), - makeSeedCustomConferenceRole({ conferenceId: conference.id }), - makeSeedCustomConferenceRole({ conferenceId: conference.id }), - makeSeedCustomConferenceRole({ conferenceId: conference.id }), - makeSeedCustomConferenceRole({ conferenceId: conference.id }), - makeSeedCustomConferenceRole({ conferenceId: conference.id }) - ]; - - await Promise.all( - customConferenceRoles.map(async (customConferenceRole) => { - await db.customConferenceRole.upsert({ - where: { - id: customConferenceRole.id - }, - update: customConferenceRole, - create: customConferenceRole - }); +await _db.$transaction( + async (db) => { + // creating default data + const { nations } = await createDefaultData(db); + + const users: User[] = []; + for (let i = 0; i < 1000; i++) { + users.push(makeSeedUser()); + } + + function takeXUsers(x: number) { + return users.splice(0, x); + } + + await Promise.all( + users.map(async (user) => + db.user.upsert({ + where: { + id: user.id + }, + update: user, + create: user }) - ); - - const delegations = [ - makeSeedDelegation({ conferenceId: conference.id }), - makeSeedDelegation({ conferenceId: conference.id }), - makeSeedDelegation({ conferenceId: conference.id }), - makeSeedDelegation({ conferenceId: conference.id }), - makeSeedDelegation({ conferenceId: conference.id }), - makeSeedDelegation({ conferenceId: conference.id }) - ]; - - const dbdelegations = await Promise.all( - delegations.map(async (delegation) => { - await db.delegation.upsert({ - where: { - id: delegation.id - }, - update: delegation, - create: delegation - }); - - const delegationMembers: DelegationMember[] = []; - - for (let i = 0; i < faker.number.int({ min: 1, max: 6 }); i++) { - delegationMembers.push( - makeSeedDelegationMember({ - delegationId: delegation.id, - conferenceId: conference.id, - userId: takeXUsers(1)[0].id, - isHeadDelegate: false, - assignedCommitteeId: conferenceIsInAssignedState ? committees[4].id : undefined - }) - ); - } - - await Promise.all( - delegationMembers.map(async (delegationMember) => { - await db.delegationMember.upsert({ - where: { - id: delegationMember.id - }, - update: delegationMember, - create: delegationMember - }); - }) - ); + ) + ); + + await Promise.all( + [ + makeSeedConference({ state: 'PRE' }), + makeSeedConference({ state: 'PARTICIPANT_REGISTRATION' }), + makeSeedConference({ state: 'PREPARATION' }), + makeSeedConference({ state: 'ACTIVE' }), + makeSeedConference({ state: 'POST' }) + ].map(async (conference) => { + await db.conference.upsert({ + where: { + id: conference.id + }, + update: conference, + create: conference + }); - const delegationRoleApplications: RoleApplication[] = [ - { - id: faker.database.mongodbObjectId(), - delegationId: delegation.id, - rank: 0, - nationId: allNationsRepresentedInCommittees[0].alpha3Code, - nonStateActorId: null, - createdAt: faker.date.past(), - updatedAt: faker.date.past() - }, - { - id: faker.database.mongodbObjectId(), - delegationId: delegation.id, - rank: 1, - nationId: allNationsRepresentedInCommittees[1].alpha3Code, - nonStateActorId: null, - createdAt: faker.date.past(), - updatedAt: faker.date.past() - }, - { - id: faker.database.mongodbObjectId(), - delegationId: delegation.id, - rank: 2, - nationId: null, - nonStateActorId: nonStateActors[0].id, - createdAt: faker.date.past(), - updatedAt: faker.date.past() + const conferenceIsInAssignedState = !( + [ConferenceState.PRE, ConferenceState.PARTICIPANT_REGISTRATION] as ConferenceState[] + ).includes(conference.state); + + const committees = [ + makeSeedCommittee({ + conferenceId: conference.id, + nations: { + connect: faker.helpers + .arrayElements(nations, { min: 6, max: 36 }) + .map((nation) => ({ alpha3Code: nation.alpha3Code })) } - ]; - - await Promise.all( - delegationRoleApplications.map(async (delegationRoleApplication) => { - await db.roleApplication.upsert({ - where: { - id: delegationRoleApplication.id - }, - update: delegationRoleApplication, - create: delegationRoleApplication - }); - }) - ); - - if (conferenceIsInAssignedState) { - if (faker.datatype.boolean()) { - await db.delegation.update({ - where: { - id: delegation.id - }, - data: { - assignedNationAlpha3Code: faker.helpers.arrayElement( - allNationsRepresentedInCommittees - ).alpha3Code - } - }); - } else { - await db.delegation.update({ - where: { - id: delegation.id - }, - data: { - assignedNonStateActorId: faker.helpers.arrayElement(nonStateActors).id - } - }); + }), + makeSeedCommittee({ + conferenceId: conference.id, + nations: { + connect: faker.helpers + .arrayElements(nations, { min: 6, max: 36 }) + .map((nation) => ({ alpha3Code: nation.alpha3Code })) } - } - - return { - ...delegation, - members: delegationMembers, - appliedForRoles: delegationRoleApplications - }; - }) - ); - - const conferenceSupervisors = [ - makeSeedConferenceSupervisor({ - conferenceId: conference.id, - userId: takeXUsers(1)[0].id, - delegations: conferenceIsInAssignedState - ? undefined - : { - connect: [{ id: delegations[0].id }, { id: delegations[1].id }] - }, - postAssignmentDelegeationMembers: conferenceIsInAssignedState - ? { - connect: [ - { id: dbdelegations[1].members[0].id }, - { id: dbdelegations[1].members[1].id } - ] - } - : undefined - }), - makeSeedConferenceSupervisor({ - conferenceId: conference.id, - userId: takeXUsers(1)[0].id, - delegations: conferenceIsInAssignedState - ? undefined - : { - connect: [{ id: delegations[0].id }, { id: delegations[1].id }] - }, - postAssignmentDelegeationMembers: conferenceIsInAssignedState - ? { - connect: [ - { id: dbdelegations[1].members[0].id }, - { id: dbdelegations[1].members[1].id } - ] - } - : undefined - }), - makeSeedConferenceSupervisor({ - conferenceId: conference.id, - userId: takeXUsers(1)[0].id, - delegations: conferenceIsInAssignedState - ? undefined - : { - connect: [{ id: delegations[0].id }, { id: delegations[1].id }] - }, - postAssignmentDelegeationMembers: conferenceIsInAssignedState - ? { - connect: [ - { id: dbdelegations[1].members[0].id }, - { id: dbdelegations[1].members[1].id } - ] - } - : undefined - }), - makeSeedConferenceSupervisor({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }), - makeSeedConferenceSupervisor({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }), - makeSeedConferenceSupervisor({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }) - ]; - - await Promise.all( - conferenceSupervisors.map(async (conferenceSupervisor) => { - await db.conferenceSupervisor.upsert({ - where: { - id: conferenceSupervisor.id - }, - update: conferenceSupervisor, - create: conferenceSupervisor - }); - }) - ); - - const singleApplications = [ - makeSeedSingleParticipant({ - conferenceId: conference.id, - userId: takeXUsers(1)[0].id, - assignedRoleId: conferenceIsInAssignedState - ? faker.helpers.arrayElement(customConferenceRoles).id - : null - }), - makeSeedSingleParticipant({ - conferenceId: conference.id, - userId: takeXUsers(1)[0].id, - assignedRoleId: conferenceIsInAssignedState - ? faker.helpers.arrayElement(customConferenceRoles).id - : null - }), - makeSeedSingleParticipant({ - conferenceId: conference.id, - userId: takeXUsers(1)[0].id, - assignedRoleId: conferenceIsInAssignedState - ? faker.helpers.arrayElement(customConferenceRoles).id - : null - }), - makeSeedSingleParticipant({ - conferenceId: conference.id, - userId: takeXUsers(1)[0].id, - assignedRoleId: conferenceIsInAssignedState - ? faker.helpers.arrayElement(customConferenceRoles).id - : null - }), - makeSeedSingleParticipant({ - conferenceId: conference.id, - userId: takeXUsers(1)[0].id, - assignedRoleId: conferenceIsInAssignedState - ? faker.helpers.arrayElement(customConferenceRoles).id - : null - }), - makeSeedSingleParticipant({ - conferenceId: conference.id, - userId: takeXUsers(1)[0].id, - assignedRoleId: conferenceIsInAssignedState - ? faker.helpers.arrayElement(customConferenceRoles).id - : null - }), - makeSeedSingleParticipant({ - conferenceId: conference.id, - userId: takeXUsers(1)[0].id, - assignedRoleId: conferenceIsInAssignedState - ? faker.helpers.arrayElement(customConferenceRoles).id - : null - }), - makeSeedSingleParticipant({ - conferenceId: conference.id, - userId: takeXUsers(1)[0].id, - assignedRoleId: conferenceIsInAssignedState - ? faker.helpers.arrayElement(customConferenceRoles).id - : null - }), - makeSeedSingleParticipant({ - conferenceId: conference.id, - userId: takeXUsers(1)[0].id, - assignedRoleId: conferenceIsInAssignedState - ? faker.helpers.arrayElement(customConferenceRoles).id - : null - }), - makeSeedSingleParticipant({ - conferenceId: conference.id, - userId: takeXUsers(1)[0].id, - assignedRoleId: conferenceIsInAssignedState - ? faker.helpers.arrayElement(customConferenceRoles).id - : null - }), - makeSeedSingleParticipant({ - conferenceId: conference.id, - userId: takeXUsers(1)[0].id, - assignedRoleId: conferenceIsInAssignedState - ? faker.helpers.arrayElement(customConferenceRoles).id - : null - }) - ]; - - await Promise.all( - singleApplications.map(async (singleApplication) => { - await db.singleParticipant.upsert({ - where: { - id: singleApplication.id - }, - update: singleApplication, - create: singleApplication - }); - }) - ); - - const teamMembers = [ - makeSeedTeamMember({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }), - makeSeedTeamMember({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }), - makeSeedTeamMember({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }), - makeSeedTeamMember({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }), - makeSeedTeamMember({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }), - makeSeedTeamMember({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }), - makeSeedTeamMember({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }), - makeSeedTeamMember({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }) - ]; - - await Promise.all( - teamMembers.map(async (teamMember) => { - await db.teamMember.upsert({ - where: { - id: teamMember.id - }, - update: teamMember, - create: teamMember - }); - }) - ); - - const paymentTransaction = [ - makeSeedPaymentTransaction({ - conferenceId: conference.id, - userId: dbdelegations[0].members[0].userId, - paymentFor: { - createMany: { - data: dbdelegations[0].members.slice(1).map((m) => ({ userId: m.userId })) + }), + makeSeedCommittee({ + conferenceId: conference.id, + nations: { + connect: faker.helpers + .arrayElements(nations, { min: 6, max: 36 }) + .map((nation) => ({ alpha3Code: nation.alpha3Code })) } - } - }), - makeSeedPaymentTransaction({ - conferenceId: conference.id, - userId: dbdelegations[1].members[0].userId, - paymentFor: { - createMany: { - data: dbdelegations[1].members.slice(1).map((m) => ({ userId: m.userId })) + }), + makeSeedCommittee({ + conferenceId: conference.id, + nations: { + connect: faker.helpers + .arrayElements(nations, { min: 6, max: 36 }) + .map((nation) => ({ alpha3Code: nation.alpha3Code })) } - } - }), - makeSeedPaymentTransaction({ - conferenceId: conference.id, - userId: dbdelegations[2].members[0].userId, - paymentFor: { - createMany: { - data: dbdelegations[2].members.slice(1).map((m) => ({ userId: m.userId })) + }), + makeSeedCommittee({ + conferenceId: conference.id, + nations: { + connect: faker.helpers + .arrayElements(nations, { min: 6, max: 36 }) + .map((nation) => ({ alpha3Code: nation.alpha3Code })) } - } - }), - makeSeedPaymentTransaction({ - conferenceId: conference.id, - userId: dbdelegations[3].members[0].userId, - paymentFor: { - createMany: { - data: dbdelegations[3].members.slice(1).map((m) => ({ userId: m.userId })) + }), + makeSeedCommittee({ + conferenceId: conference.id, + nations: { + connect: faker.helpers + .arrayElements(nations, { min: 6, max: 36 }) + .map((nation) => ({ alpha3Code: nation.alpha3Code })) } - } - }) - ]; - - await Promise.all( - paymentTransaction.map(async (paymentTransaction) => { - await db.paymentTransaction.upsert({ - where: { - id: paymentTransaction.id - }, - update: paymentTransaction, - create: paymentTransaction - }); - }) - ); + }), + makeSeedCommittee({ + conferenceId: conference.id, + nations: { + connect: faker.helpers + .arrayElements(nations, { min: 6, max: 36 }) + .map((nation) => ({ alpha3Code: nation.alpha3Code })) + } + }), + makeSeedCommittee({ + conferenceId: conference.id, + nations: { + connect: faker.helpers + .arrayElements(nations, { min: 6, max: 36 }) + .map((nation) => ({ alpha3Code: nation.alpha3Code })) + } + }), + makeSeedCommittee({ + conferenceId: conference.id, + nations: { + connect: faker.helpers + .arrayElements(nations, { min: 6, max: 36 }) + .map((nation) => ({ alpha3Code: nation.alpha3Code })) + } + }), + makeSeedCommittee({ + conferenceId: conference.id, + nations: { + connect: faker.helpers + .arrayElements(nations, { min: 6, max: 36 }) + .map((nation) => ({ alpha3Code: nation.alpha3Code })) + } + }) + ]; - // Calendar data for ACTIVE conference - if (conference.state === 'ACTIVE') { - const baseDate = new Date(conference.startConference); + const dbcommittees = await Promise.all( + committees.map((committee) => { + return db.committee.upsert({ + where: { + id: committee.id + }, + update: committee, + create: committee, + include: { + nations: true + } + }); + }) + ); - const day1 = makeSeedCalendarDay({ - conferenceId: conference.id, - name: 'Donnerstag', - date: baseDate, - sortOrder: 0 - }); - const day2Date = new Date(baseDate); - day2Date.setDate(day2Date.getDate() + 1); - const day2 = makeSeedCalendarDay({ - conferenceId: conference.id, - name: 'Freitag', - date: day2Date, - sortOrder: 1 - }); - const day3Date = new Date(baseDate); - day3Date.setDate(day3Date.getDate() + 2); - const day3 = makeSeedCalendarDay({ - conferenceId: conference.id, - name: 'Samstag', - date: day3Date, - sortOrder: 2 - }); + const allNationsRepresentedInCommittees = dbcommittees.flatMap((c) => c.nations); + + const nonStateActors = [ + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }) + ]; - const calendarDays = [day1, day2, day3]; await Promise.all( - calendarDays.map((d) => - db.calendarDay.upsert({ where: { id: d.id }, update: d, create: d }) - ) + nonStateActors.map(async (nonStateActor) => { + await db.nonStateActor.upsert({ + where: { + id: nonStateActor.id + }, + update: nonStateActor, + create: nonStateActor + }); + }) ); - // Tracks for each day - const makeTracksForDay = (dayId: string) => [ - makeSeedCalendarTrack({ calendarDayId: dayId, name: 'GV Presse', sortOrder: 0 }), - makeSeedCalendarTrack({ calendarDayId: dayId, name: 'IMO MRR', sortOrder: 1 }), - makeSeedCalendarTrack({ - calendarDayId: dayId, - name: 'Sicherheitsrat', - sortOrder: 2 - }) + const customConferenceRoles = [ + makeSeedCustomConferenceRole({ conferenceId: conference.id }), + makeSeedCustomConferenceRole({ conferenceId: conference.id }), + makeSeedCustomConferenceRole({ conferenceId: conference.id }), + makeSeedCustomConferenceRole({ conferenceId: conference.id }), + makeSeedCustomConferenceRole({ conferenceId: conference.id }), + makeSeedCustomConferenceRole({ conferenceId: conference.id }) ]; - const day1Tracks = makeTracksForDay(day1.id); - const day2Tracks = makeTracksForDay(day2.id); - const day3Tracks = makeTracksForDay(day3.id); - const allTracks = [...day1Tracks, ...day2Tracks, ...day3Tracks]; - await Promise.all( - allTracks.map((t) => - db.calendarTrack.upsert({ where: { id: t.id }, update: t, create: t }) - ) + customConferenceRoles.map(async (customConferenceRole) => { + await db.customConferenceRole.upsert({ + where: { + id: customConferenceRole.id + }, + update: customConferenceRole, + create: customConferenceRole + }); + }) ); - // Places - const landtag = makeSeedPlace({ - conferenceId: conference.id, - name: 'Landtag Niedersachsen', - address: 'Hannah-Arendt-Platz 1, 30159 Hannover', - latitude: 52.3613, - longitude: 9.7414, - directions: - 'Vom Hauptbahnhof mit der U-Bahn Linie 3 oder 7 bis Waterloo, dann 5 Min zu Fuß.', - info: 'Bitte Personalausweis mitbringen. Keine Getränke im Plenarsaal.' - }); + const delegations = [ + makeSeedDelegation({ conferenceId: conference.id }), + makeSeedDelegation({ conferenceId: conference.id }), + makeSeedDelegation({ conferenceId: conference.id }), + makeSeedDelegation({ conferenceId: conference.id }), + makeSeedDelegation({ conferenceId: conference.id }), + makeSeedDelegation({ conferenceId: conference.id }) + ]; - const kulturzentrum = makeSeedPlace({ - conferenceId: conference.id, - name: 'Kulturzentrum', - address: 'Beispielstr. 42, 30159 Hannover', - latitude: 52.374, - longitude: 9.7385 - }); + const dbdelegations = await Promise.all( + delegations.map(async (delegation) => { + await db.delegation.upsert({ + where: { + id: delegation.id + }, + update: delegation, + create: delegation + }); + + const delegationMembers: DelegationMember[] = []; + + for (let i = 0; i < faker.number.int({ min: 1, max: 6 }); i++) { + delegationMembers.push( + makeSeedDelegationMember({ + delegationId: delegation.id, + conferenceId: conference.id, + userId: takeXUsers(1)[0].id, + isHeadDelegate: false, + assignedCommitteeId: conferenceIsInAssignedState ? committees[4].id : undefined + }) + ); + } - const allPlaces = [landtag, kulturzentrum]; - await Promise.all( - allPlaces.map((p) => db.place.upsert({ where: { id: p.id }, update: p, create: p })) - ); + await Promise.all( + delegationMembers.map(async (delegationMember) => { + await db.delegationMember.upsert({ + where: { + id: delegationMember.id + }, + update: delegationMember, + create: delegationMember + }); + }) + ); - // Helper to create a datetime on a specific day at a given hour:minute - function makeTime(date: Date, hours: number, minutes: number): Date { - const d = new Date(date); - d.setHours(hours, minutes, 0, 0); - return d; - } + const delegationRoleApplications: RoleApplication[] = [ + { + id: faker.database.mongodbObjectId(), + delegationId: delegation.id, + rank: 0, + nationId: allNationsRepresentedInCommittees[0].alpha3Code, + nonStateActorId: null, + createdAt: faker.date.past(), + updatedAt: faker.date.past() + }, + { + id: faker.database.mongodbObjectId(), + delegationId: delegation.id, + rank: 1, + nationId: allNationsRepresentedInCommittees[1].alpha3Code, + nonStateActorId: null, + createdAt: faker.date.past(), + updatedAt: faker.date.past() + }, + { + id: faker.database.mongodbObjectId(), + delegationId: delegation.id, + rank: 2, + nationId: null, + nonStateActorId: nonStateActors[0].id, + createdAt: faker.date.past(), + updatedAt: faker.date.past() + } + ]; + + await Promise.all( + delegationRoleApplications.map(async (delegationRoleApplication) => { + await db.roleApplication.upsert({ + where: { + id: delegationRoleApplication.id + }, + update: delegationRoleApplication, + create: delegationRoleApplication + }); + }) + ); - const day1Entries = [ - // Opening ceremony (cross-track) - makeSeedCalendarEntry({ - calendarDayId: day1.id, - name: 'Eröffnungsfeier', - startTime: makeTime(baseDate, 10, 0), - endTime: makeTime(baseDate, 11, 30), - color: 'CEREMONY', - fontAwesomeIcon: 'flag', - placeId: landtag.id, - room: 'Plenarsaal' - }), - makeSeedCalendarEntry({ - calendarDayId: day1.id, - name: 'Mittagspause', - startTime: makeTime(baseDate, 11, 30), - endTime: makeTime(baseDate, 12, 30), - color: 'BREAK', - fontAwesomeIcon: 'utensils' - }), - // Afternoon sessions (per-track) - makeSeedCalendarEntry({ - calendarDayId: day1.id, - calendarTrackId: day1Tracks[0].id, - name: 'Sitzung I', - startTime: makeTime(baseDate, 12, 30), - endTime: makeTime(baseDate, 14, 30), - color: 'SESSION', - fontAwesomeIcon: 'gavel', - room: 'Raum 201' - }), - makeSeedCalendarEntry({ - calendarDayId: day1.id, - calendarTrackId: day1Tracks[1].id, - name: 'Sitzung I', - startTime: makeTime(baseDate, 12, 30), - endTime: makeTime(baseDate, 14, 30), - color: 'SESSION', - fontAwesomeIcon: 'gavel', - room: 'Raum 202' - }), - makeSeedCalendarEntry({ - calendarDayId: day1.id, - calendarTrackId: day1Tracks[2].id, - name: 'Sitzung I', - startTime: makeTime(baseDate, 12, 30), - endTime: makeTime(baseDate, 14, 30), - color: 'SESSION', - fontAwesomeIcon: 'gavel', - room: 'Raum 203' + if (conferenceIsInAssignedState) { + if (faker.datatype.boolean()) { + await db.delegation.update({ + where: { + id: delegation.id + }, + data: { + assignedNationAlpha3Code: faker.helpers.arrayElement( + allNationsRepresentedInCommittees + ).alpha3Code + } + }); + } else { + await db.delegation.update({ + where: { + id: delegation.id + }, + data: { + assignedNonStateActorId: faker.helpers.arrayElement(nonStateActors).id + } + }); + } + } + + return { + ...delegation, + members: delegationMembers, + appliedForRoles: delegationRoleApplications + }; + }) + ); + + const conferenceSupervisors = [ + makeSeedConferenceSupervisor({ + conferenceId: conference.id, + userId: takeXUsers(1)[0].id, + supervisedDelegationMembers: { + connect: conferenceIsInAssignedState + ? dbdelegations[1].members.slice(0, 2).map((member) => ({ id: member.id })) + : [ + ...dbdelegations[0].members.slice(0, 2).map((member) => ({ id: member.id })), + ...dbdelegations[1].members.slice(0, 2).map((member) => ({ id: member.id })) + ] + } }), - makeSeedCalendarEntry({ - calendarDayId: day1.id, - name: 'Kaffeepause', - startTime: makeTime(baseDate, 14, 30), - endTime: makeTime(baseDate, 15, 0), - color: 'BREAK', - fontAwesomeIcon: 'mug-hot' + makeSeedConferenceSupervisor({ + conferenceId: conference.id, + userId: takeXUsers(1)[0].id, + supervisedDelegationMembers: { + connect: conferenceIsInAssignedState + ? dbdelegations[1].members.slice(0, 2).map((member) => ({ id: member.id })) + : [ + ...dbdelegations[0].members.slice(0, 2).map((member) => ({ id: member.id })), + ...dbdelegations[1].members.slice(0, 2).map((member) => ({ id: member.id })) + ] + } }), - makeSeedCalendarEntry({ - calendarDayId: day1.id, - calendarTrackId: day1Tracks[0].id, - name: 'Sitzung II', - startTime: makeTime(baseDate, 15, 0), - endTime: makeTime(baseDate, 17, 0), - color: 'SESSION', - fontAwesomeIcon: 'gavel', - room: 'Raum 201' + makeSeedConferenceSupervisor({ + conferenceId: conference.id, + userId: takeXUsers(1)[0].id, + supervisedDelegationMembers: { + connect: conferenceIsInAssignedState + ? dbdelegations[1].members.slice(0, 2).map((member) => ({ id: member.id })) + : [ + ...dbdelegations[0].members.slice(0, 2).map((member) => ({ id: member.id })), + ...dbdelegations[1].members.slice(0, 2).map((member) => ({ id: member.id })) + ] + } }), - makeSeedCalendarEntry({ - calendarDayId: day1.id, - calendarTrackId: day1Tracks[1].id, - name: 'Sitzung II', - startTime: makeTime(baseDate, 15, 0), - endTime: makeTime(baseDate, 17, 0), - color: 'SESSION', - fontAwesomeIcon: 'gavel', - room: 'Raum 202' + makeSeedConferenceSupervisor({ + conferenceId: conference.id, + userId: takeXUsers(1)[0].id }), - makeSeedCalendarEntry({ - calendarDayId: day1.id, - calendarTrackId: day1Tracks[2].id, - name: 'Sitzung II', - startTime: makeTime(baseDate, 15, 0), - endTime: makeTime(baseDate, 17, 0), - color: 'SESSION', - fontAwesomeIcon: 'gavel', - room: 'Raum 203' + makeSeedConferenceSupervisor({ + conferenceId: conference.id, + userId: takeXUsers(1)[0].id }), - makeSeedCalendarEntry({ - calendarDayId: day1.id, - name: 'Abendessen', - startTime: makeTime(baseDate, 17, 30), - endTime: makeTime(baseDate, 18, 30), - color: 'SOCIAL', - fontAwesomeIcon: 'utensils' - }) + makeSeedConferenceSupervisor({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }) ]; - const day2Entries = [ - makeSeedCalendarEntry({ - calendarDayId: day2.id, - calendarTrackId: day2Tracks[0].id, - name: 'Sitzung III', - startTime: makeTime(day2Date, 9, 0), - endTime: makeTime(day2Date, 11, 0), - color: 'SESSION', - fontAwesomeIcon: 'gavel', - room: 'Raum 201' + await Promise.all( + conferenceSupervisors.map(async (conferenceSupervisor) => { + await db.conferenceSupervisor.upsert({ + where: { + id: conferenceSupervisor.id + }, + update: conferenceSupervisor, + create: conferenceSupervisor + }); + }) + ); + + const singleApplications = [ + makeSeedSingleParticipant({ + conferenceId: conference.id, + userId: takeXUsers(1)[0].id, + assignedRoleId: conferenceIsInAssignedState + ? faker.helpers.arrayElement(customConferenceRoles).id + : null + }), + makeSeedSingleParticipant({ + conferenceId: conference.id, + userId: takeXUsers(1)[0].id, + assignedRoleId: conferenceIsInAssignedState + ? faker.helpers.arrayElement(customConferenceRoles).id + : null + }), + makeSeedSingleParticipant({ + conferenceId: conference.id, + userId: takeXUsers(1)[0].id, + assignedRoleId: conferenceIsInAssignedState + ? faker.helpers.arrayElement(customConferenceRoles).id + : null }), - makeSeedCalendarEntry({ - calendarDayId: day2.id, - calendarTrackId: day2Tracks[1].id, - name: 'Sitzung III', - startTime: makeTime(day2Date, 9, 0), - endTime: makeTime(day2Date, 11, 0), - color: 'SESSION', - fontAwesomeIcon: 'gavel', - room: 'Raum 202' + makeSeedSingleParticipant({ + conferenceId: conference.id, + userId: takeXUsers(1)[0].id, + assignedRoleId: conferenceIsInAssignedState + ? faker.helpers.arrayElement(customConferenceRoles).id + : null }), - makeSeedCalendarEntry({ - calendarDayId: day2.id, - calendarTrackId: day2Tracks[2].id, - name: 'Sitzung III', - startTime: makeTime(day2Date, 9, 0), - endTime: makeTime(day2Date, 11, 0), - color: 'SESSION', - fontAwesomeIcon: 'gavel', - room: 'Raum 203' + makeSeedSingleParticipant({ + conferenceId: conference.id, + userId: takeXUsers(1)[0].id, + assignedRoleId: conferenceIsInAssignedState + ? faker.helpers.arrayElement(customConferenceRoles).id + : null }), - makeSeedCalendarEntry({ - calendarDayId: day2.id, - name: 'Mittagspause', - startTime: makeTime(day2Date, 11, 0), - endTime: makeTime(day2Date, 12, 0), - color: 'BREAK', - fontAwesomeIcon: 'utensils' + makeSeedSingleParticipant({ + conferenceId: conference.id, + userId: takeXUsers(1)[0].id, + assignedRoleId: conferenceIsInAssignedState + ? faker.helpers.arrayElement(customConferenceRoles).id + : null }), - makeSeedCalendarEntry({ - calendarDayId: day2.id, - name: 'Workshop: Diplomatische Verhandlungen', - startTime: makeTime(day2Date, 12, 0), - endTime: makeTime(day2Date, 13, 30), - color: 'WORKSHOP', - fontAwesomeIcon: 'chalkboard-user', - room: 'Saal A' + makeSeedSingleParticipant({ + conferenceId: conference.id, + userId: takeXUsers(1)[0].id, + assignedRoleId: conferenceIsInAssignedState + ? faker.helpers.arrayElement(customConferenceRoles).id + : null }), - makeSeedCalendarEntry({ - calendarDayId: day2.id, - calendarTrackId: day2Tracks[0].id, - name: 'Sitzung IV', - startTime: makeTime(day2Date, 13, 30), - endTime: makeTime(day2Date, 15, 30), - color: 'SESSION', - fontAwesomeIcon: 'gavel', - room: 'Raum 201' + makeSeedSingleParticipant({ + conferenceId: conference.id, + userId: takeXUsers(1)[0].id, + assignedRoleId: conferenceIsInAssignedState + ? faker.helpers.arrayElement(customConferenceRoles).id + : null }), - makeSeedCalendarEntry({ - calendarDayId: day2.id, - calendarTrackId: day2Tracks[1].id, - name: 'Sitzung IV', - startTime: makeTime(day2Date, 13, 30), - endTime: makeTime(day2Date, 15, 30), - color: 'SESSION', - fontAwesomeIcon: 'gavel', - room: 'Raum 202' + makeSeedSingleParticipant({ + conferenceId: conference.id, + userId: takeXUsers(1)[0].id, + assignedRoleId: conferenceIsInAssignedState + ? faker.helpers.arrayElement(customConferenceRoles).id + : null }), - makeSeedCalendarEntry({ - calendarDayId: day2.id, - calendarTrackId: day2Tracks[2].id, - name: 'Sitzung IV', - startTime: makeTime(day2Date, 13, 30), - endTime: makeTime(day2Date, 15, 30), - color: 'SESSION', - fontAwesomeIcon: 'gavel', - room: 'Raum 203' + makeSeedSingleParticipant({ + conferenceId: conference.id, + userId: takeXUsers(1)[0].id, + assignedRoleId: conferenceIsInAssignedState + ? faker.helpers.arrayElement(customConferenceRoles).id + : null }), - makeSeedCalendarEntry({ - calendarDayId: day2.id, - name: 'Delegiertenabend', - startTime: makeTime(day2Date, 18, 0), - endTime: makeTime(day2Date, 22, 0), - color: 'SOCIAL', - fontAwesomeIcon: 'party-horn', - placeId: kulturzentrum.id + makeSeedSingleParticipant({ + conferenceId: conference.id, + userId: takeXUsers(1)[0].id, + assignedRoleId: conferenceIsInAssignedState + ? faker.helpers.arrayElement(customConferenceRoles).id + : null }) ]; - const day3Entries = [ - makeSeedCalendarEntry({ - calendarDayId: day3.id, - calendarTrackId: day3Tracks[0].id, - name: 'Sitzung V', - startTime: makeTime(day3Date, 9, 0), - endTime: makeTime(day3Date, 11, 0), - color: 'SESSION', - fontAwesomeIcon: 'gavel', - room: 'Raum 201' - }), - makeSeedCalendarEntry({ - calendarDayId: day3.id, - calendarTrackId: day3Tracks[1].id, - name: 'Sitzung V', - startTime: makeTime(day3Date, 9, 0), - endTime: makeTime(day3Date, 11, 0), - color: 'SESSION', - fontAwesomeIcon: 'gavel', - room: 'Raum 202' + await Promise.all( + singleApplications.map(async (singleApplication) => { + await db.singleParticipant.upsert({ + where: { + id: singleApplication.id + }, + update: singleApplication, + create: singleApplication + }); + }) + ); + + const teamMembers = [ + makeSeedTeamMember({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }), + makeSeedTeamMember({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }), + makeSeedTeamMember({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }), + makeSeedTeamMember({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }), + makeSeedTeamMember({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }), + makeSeedTeamMember({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }), + makeSeedTeamMember({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }), + makeSeedTeamMember({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }) + ]; + + await Promise.all( + teamMembers.map(async (teamMember) => { + await db.teamMember.upsert({ + where: { + id: teamMember.id + }, + update: teamMember, + create: teamMember + }); + }) + ); + + const paymentTransaction = [ + makeSeedPaymentTransaction({ + conferenceId: conference.id, + userId: dbdelegations[0].members[0].userId, + paymentFor: { + createMany: { + data: dbdelegations[0].members.slice(1).map((m) => ({ userId: m.userId })) + } + } }), - makeSeedCalendarEntry({ - calendarDayId: day3.id, - calendarTrackId: day3Tracks[2].id, - name: 'Sitzung V', - startTime: makeTime(day3Date, 9, 0), - endTime: makeTime(day3Date, 11, 0), - color: 'SESSION', - fontAwesomeIcon: 'gavel', - room: 'Raum 203' + makeSeedPaymentTransaction({ + conferenceId: conference.id, + userId: dbdelegations[1].members[0].userId, + paymentFor: { + createMany: { + data: dbdelegations[1].members.slice(1).map((m) => ({ userId: m.userId })) + } + } }), - makeSeedCalendarEntry({ - calendarDayId: day3.id, - name: 'Mittagspause', - startTime: makeTime(day3Date, 11, 0), - endTime: makeTime(day3Date, 12, 0), - color: 'BREAK', - fontAwesomeIcon: 'utensils' + makeSeedPaymentTransaction({ + conferenceId: conference.id, + userId: dbdelegations[2].members[0].userId, + paymentFor: { + createMany: { + data: dbdelegations[2].members.slice(1).map((m) => ({ userId: m.userId })) + } + } }), - makeSeedCalendarEntry({ - calendarDayId: day3.id, - name: 'Abschlussfeier', - startTime: makeTime(day3Date, 12, 0), - endTime: makeTime(day3Date, 14, 0), - color: 'CEREMONY', - fontAwesomeIcon: 'award', - placeId: landtag.id, - room: 'Plenarsaal' + makeSeedPaymentTransaction({ + conferenceId: conference.id, + userId: dbdelegations[3].members[0].userId, + paymentFor: { + createMany: { + data: dbdelegations[3].members.slice(1).map((m) => ({ userId: m.userId })) + } + } }) ]; - const allEntries = [...day1Entries, ...day2Entries, ...day3Entries]; await Promise.all( - allEntries.map((e) => - db.calendarEntry.upsert({ where: { id: e.id }, update: e, create: e }) - ) + paymentTransaction.map(async (paymentTransaction) => { + await db.paymentTransaction.upsert({ + where: { + id: paymentTransaction.id + }, + update: paymentTransaction, + create: paymentTransaction + }); + }) ); - } - }) - ); -}); + + // Calendar data for ACTIVE conference + if (conference.state === 'ACTIVE') { + const baseDate = new Date(conference.startConference); + + const day1 = makeSeedCalendarDay({ + conferenceId: conference.id, + name: 'Donnerstag', + date: baseDate, + sortOrder: 0 + }); + const day2Date = new Date(baseDate); + day2Date.setDate(day2Date.getDate() + 1); + const day2 = makeSeedCalendarDay({ + conferenceId: conference.id, + name: 'Freitag', + date: day2Date, + sortOrder: 1 + }); + const day3Date = new Date(baseDate); + day3Date.setDate(day3Date.getDate() + 2); + const day3 = makeSeedCalendarDay({ + conferenceId: conference.id, + name: 'Samstag', + date: day3Date, + sortOrder: 2 + }); + + const calendarDays = [day1, day2, day3]; + await Promise.all( + calendarDays.map((d) => + db.calendarDay.upsert({ where: { id: d.id }, update: d, create: d }) + ) + ); + + // Tracks for each day + const makeTracksForDay = (dayId: string) => [ + makeSeedCalendarTrack({ calendarDayId: dayId, name: 'GV Presse', sortOrder: 0 }), + makeSeedCalendarTrack({ calendarDayId: dayId, name: 'IMO MRR', sortOrder: 1 }), + makeSeedCalendarTrack({ + calendarDayId: dayId, + name: 'Sicherheitsrat', + sortOrder: 2 + }) + ]; + + const day1Tracks = makeTracksForDay(day1.id); + const day2Tracks = makeTracksForDay(day2.id); + const day3Tracks = makeTracksForDay(day3.id); + const allTracks = [...day1Tracks, ...day2Tracks, ...day3Tracks]; + + await Promise.all( + allTracks.map((t) => + db.calendarTrack.upsert({ where: { id: t.id }, update: t, create: t }) + ) + ); + + // Places + const landtag = makeSeedPlace({ + conferenceId: conference.id, + name: 'Landtag Niedersachsen', + address: 'Hannah-Arendt-Platz 1, 30159 Hannover', + latitude: 52.3613, + longitude: 9.7414, + directions: + 'Vom Hauptbahnhof mit der U-Bahn Linie 3 oder 7 bis Waterloo, dann 5 Min zu Fuß.', + info: 'Bitte Personalausweis mitbringen. Keine Getränke im Plenarsaal.' + }); + + const kulturzentrum = makeSeedPlace({ + conferenceId: conference.id, + name: 'Kulturzentrum', + address: 'Beispielstr. 42, 30159 Hannover', + latitude: 52.374, + longitude: 9.7385 + }); + + const allPlaces = [landtag, kulturzentrum]; + await Promise.all( + allPlaces.map((p) => db.place.upsert({ where: { id: p.id }, update: p, create: p })) + ); + + // Helper to create a datetime on a specific day at a given hour:minute + function makeTime(date: Date, hours: number, minutes: number): Date { + const d = new Date(date); + d.setHours(hours, minutes, 0, 0); + return d; + } + + const day1Entries = [ + // Opening ceremony (cross-track) + makeSeedCalendarEntry({ + calendarDayId: day1.id, + name: 'Eröffnungsfeier', + startTime: makeTime(baseDate, 10, 0), + endTime: makeTime(baseDate, 11, 30), + color: 'CEREMONY', + fontAwesomeIcon: 'flag', + placeId: landtag.id, + room: 'Plenarsaal' + }), + makeSeedCalendarEntry({ + calendarDayId: day1.id, + name: 'Mittagspause', + startTime: makeTime(baseDate, 11, 30), + endTime: makeTime(baseDate, 12, 30), + color: 'BREAK', + fontAwesomeIcon: 'utensils' + }), + // Afternoon sessions (per-track) + makeSeedCalendarEntry({ + calendarDayId: day1.id, + calendarTrackId: day1Tracks[0].id, + name: 'Sitzung I', + startTime: makeTime(baseDate, 12, 30), + endTime: makeTime(baseDate, 14, 30), + color: 'SESSION', + fontAwesomeIcon: 'gavel', + room: 'Raum 201' + }), + makeSeedCalendarEntry({ + calendarDayId: day1.id, + calendarTrackId: day1Tracks[1].id, + name: 'Sitzung I', + startTime: makeTime(baseDate, 12, 30), + endTime: makeTime(baseDate, 14, 30), + color: 'SESSION', + fontAwesomeIcon: 'gavel', + room: 'Raum 202' + }), + makeSeedCalendarEntry({ + calendarDayId: day1.id, + calendarTrackId: day1Tracks[2].id, + name: 'Sitzung I', + startTime: makeTime(baseDate, 12, 30), + endTime: makeTime(baseDate, 14, 30), + color: 'SESSION', + fontAwesomeIcon: 'gavel', + room: 'Raum 203' + }), + makeSeedCalendarEntry({ + calendarDayId: day1.id, + name: 'Kaffeepause', + startTime: makeTime(baseDate, 14, 30), + endTime: makeTime(baseDate, 15, 0), + color: 'BREAK', + fontAwesomeIcon: 'mug-hot' + }), + makeSeedCalendarEntry({ + calendarDayId: day1.id, + calendarTrackId: day1Tracks[0].id, + name: 'Sitzung II', + startTime: makeTime(baseDate, 15, 0), + endTime: makeTime(baseDate, 17, 0), + color: 'SESSION', + fontAwesomeIcon: 'gavel', + room: 'Raum 201' + }), + makeSeedCalendarEntry({ + calendarDayId: day1.id, + calendarTrackId: day1Tracks[1].id, + name: 'Sitzung II', + startTime: makeTime(baseDate, 15, 0), + endTime: makeTime(baseDate, 17, 0), + color: 'SESSION', + fontAwesomeIcon: 'gavel', + room: 'Raum 202' + }), + makeSeedCalendarEntry({ + calendarDayId: day1.id, + calendarTrackId: day1Tracks[2].id, + name: 'Sitzung II', + startTime: makeTime(baseDate, 15, 0), + endTime: makeTime(baseDate, 17, 0), + color: 'SESSION', + fontAwesomeIcon: 'gavel', + room: 'Raum 203' + }), + makeSeedCalendarEntry({ + calendarDayId: day1.id, + name: 'Abendessen', + startTime: makeTime(baseDate, 17, 30), + endTime: makeTime(baseDate, 18, 30), + color: 'SOCIAL', + fontAwesomeIcon: 'utensils' + }) + ]; + + const day2Entries = [ + makeSeedCalendarEntry({ + calendarDayId: day2.id, + calendarTrackId: day2Tracks[0].id, + name: 'Sitzung III', + startTime: makeTime(day2Date, 9, 0), + endTime: makeTime(day2Date, 11, 0), + color: 'SESSION', + fontAwesomeIcon: 'gavel', + room: 'Raum 201' + }), + makeSeedCalendarEntry({ + calendarDayId: day2.id, + calendarTrackId: day2Tracks[1].id, + name: 'Sitzung III', + startTime: makeTime(day2Date, 9, 0), + endTime: makeTime(day2Date, 11, 0), + color: 'SESSION', + fontAwesomeIcon: 'gavel', + room: 'Raum 202' + }), + makeSeedCalendarEntry({ + calendarDayId: day2.id, + calendarTrackId: day2Tracks[2].id, + name: 'Sitzung III', + startTime: makeTime(day2Date, 9, 0), + endTime: makeTime(day2Date, 11, 0), + color: 'SESSION', + fontAwesomeIcon: 'gavel', + room: 'Raum 203' + }), + makeSeedCalendarEntry({ + calendarDayId: day2.id, + name: 'Mittagspause', + startTime: makeTime(day2Date, 11, 0), + endTime: makeTime(day2Date, 12, 0), + color: 'BREAK', + fontAwesomeIcon: 'utensils' + }), + makeSeedCalendarEntry({ + calendarDayId: day2.id, + name: 'Workshop: Diplomatische Verhandlungen', + startTime: makeTime(day2Date, 12, 0), + endTime: makeTime(day2Date, 13, 30), + color: 'WORKSHOP', + fontAwesomeIcon: 'chalkboard-user', + room: 'Saal A' + }), + makeSeedCalendarEntry({ + calendarDayId: day2.id, + calendarTrackId: day2Tracks[0].id, + name: 'Sitzung IV', + startTime: makeTime(day2Date, 13, 30), + endTime: makeTime(day2Date, 15, 30), + color: 'SESSION', + fontAwesomeIcon: 'gavel', + room: 'Raum 201' + }), + makeSeedCalendarEntry({ + calendarDayId: day2.id, + calendarTrackId: day2Tracks[1].id, + name: 'Sitzung IV', + startTime: makeTime(day2Date, 13, 30), + endTime: makeTime(day2Date, 15, 30), + color: 'SESSION', + fontAwesomeIcon: 'gavel', + room: 'Raum 202' + }), + makeSeedCalendarEntry({ + calendarDayId: day2.id, + calendarTrackId: day2Tracks[2].id, + name: 'Sitzung IV', + startTime: makeTime(day2Date, 13, 30), + endTime: makeTime(day2Date, 15, 30), + color: 'SESSION', + fontAwesomeIcon: 'gavel', + room: 'Raum 203' + }), + makeSeedCalendarEntry({ + calendarDayId: day2.id, + name: 'Delegiertenabend', + startTime: makeTime(day2Date, 18, 0), + endTime: makeTime(day2Date, 22, 0), + color: 'SOCIAL', + fontAwesomeIcon: 'party-horn', + placeId: kulturzentrum.id + }) + ]; + + const day3Entries = [ + makeSeedCalendarEntry({ + calendarDayId: day3.id, + calendarTrackId: day3Tracks[0].id, + name: 'Sitzung V', + startTime: makeTime(day3Date, 9, 0), + endTime: makeTime(day3Date, 11, 0), + color: 'SESSION', + fontAwesomeIcon: 'gavel', + room: 'Raum 201' + }), + makeSeedCalendarEntry({ + calendarDayId: day3.id, + calendarTrackId: day3Tracks[1].id, + name: 'Sitzung V', + startTime: makeTime(day3Date, 9, 0), + endTime: makeTime(day3Date, 11, 0), + color: 'SESSION', + fontAwesomeIcon: 'gavel', + room: 'Raum 202' + }), + makeSeedCalendarEntry({ + calendarDayId: day3.id, + calendarTrackId: day3Tracks[2].id, + name: 'Sitzung V', + startTime: makeTime(day3Date, 9, 0), + endTime: makeTime(day3Date, 11, 0), + color: 'SESSION', + fontAwesomeIcon: 'gavel', + room: 'Raum 203' + }), + makeSeedCalendarEntry({ + calendarDayId: day3.id, + name: 'Mittagspause', + startTime: makeTime(day3Date, 11, 0), + endTime: makeTime(day3Date, 12, 0), + color: 'BREAK', + fontAwesomeIcon: 'utensils' + }), + makeSeedCalendarEntry({ + calendarDayId: day3.id, + name: 'Abschlussfeier', + startTime: makeTime(day3Date, 12, 0), + endTime: makeTime(day3Date, 14, 0), + color: 'CEREMONY', + fontAwesomeIcon: 'award', + placeId: landtag.id, + room: 'Plenarsaal' + }) + ]; + + const allEntries = [...day1Entries, ...day2Entries, ...day3Entries]; + await Promise.all( + allEntries.map((e) => + db.calendarEntry.upsert({ where: { id: e.id }, update: e, create: e }) + ) + ); + } + }) + ); + }, + { timeout: 50_0000 } +); console.info('Done!'); diff --git a/schema.graphql b/schema.graphql index 00898337..a4171a6c 100644 --- a/schema.graphql +++ b/schema.graphql @@ -775,6 +775,7 @@ input CommitteeWhereUniqueInput { type Conference { accountHolder: String + allowMessaging: Boolean! bankName: String bic: String @@ -856,6 +857,7 @@ type Conference { input ConferenceCreateInput { WaitingListEntry: WaitingListEntryCreateNestedManyWithoutConferenceInput accountHolder: String + allowMessaging: Boolean bankName: String bic: String calendarDays: CalendarDayCreateNestedManyWithoutConferenceInput @@ -887,6 +889,7 @@ input ConferenceCreateInput { logoDataURL: String longTitle: String mediaConsentContent: String + messageAudits: MessageAuditCreateNestedManyWithoutConferenceInput nonStateActors: NonStateActorCreateNestedManyWithoutConferenceInput papers: PaperCreateNestedManyWithoutConferenceInput paymentTransactions: PaymentTransactionCreateNestedManyWithoutConferenceInput @@ -919,6 +922,7 @@ input ConferenceCreateInput { input ConferenceOrderByWithRelationInput { WaitingListEntry: WaitingListEntryOrderByRelationAggregateInput accountHolder: SortOrder + allowMessaging: SortOrder bankName: SortOrder bic: SortOrder calendarDays: CalendarDayOrderByRelationAggregateInput @@ -950,6 +954,7 @@ input ConferenceOrderByWithRelationInput { logoDataURL: SortOrder longTitle: SortOrder mediaConsentContent: SortOrder + messageAudits: MessageAuditOrderByRelationAggregateInput nonStateActors: NonStateActorOrderByRelationAggregateInput papers: PaperOrderByRelationAggregateInput paymentTransactions: PaymentTransactionOrderByRelationAggregateInput @@ -1158,6 +1163,7 @@ input ConferenceParticipantStatusWhereUniqueInputNotRequired { enum ConferenceScalarFieldEnum { accountHolder + allowMessaging bankName bic certificateContent @@ -1396,6 +1402,7 @@ input ConferenceSupervisorWhereUniqueInput { input ConferenceUpdateDataInput { accountHolder: String + allowMessaging: Boolean bankName: String bic: String certificateBasePDF: File @@ -1441,6 +1448,7 @@ input ConferenceUpdateDataInput { input ConferenceUpdateInput { WaitingListEntry: WaitingListEntryUpdateManyWithoutConferenceNestedInput accountHolder: NullableStringFieldUpdateOperationsInput + allowMessaging: BoolFieldUpdateOperationsInput bankName: NullableStringFieldUpdateOperationsInput bic: NullableStringFieldUpdateOperationsInput calendarDays: CalendarDayUpdateManyWithoutConferenceNestedInput @@ -1472,6 +1480,7 @@ input ConferenceUpdateInput { logoDataURL: NullableStringFieldUpdateOperationsInput longTitle: NullableStringFieldUpdateOperationsInput mediaConsentContent: NullableStringFieldUpdateOperationsInput + messageAudits: MessageAuditUpdateManyWithoutConferenceNestedInput nonStateActors: NonStateActorUpdateManyWithoutConferenceNestedInput papers: PaperUpdateManyWithoutConferenceNestedInput paymentTransactions: PaymentTransactionUpdateManyWithoutConferenceNestedInput @@ -1503,6 +1512,7 @@ input ConferenceUpdateInput { input ConferenceUpdateManyMutationInput { accountHolder: NullableStringFieldUpdateOperationsInput + allowMessaging: BoolFieldUpdateOperationsInput bankName: NullableStringFieldUpdateOperationsInput bic: NullableStringFieldUpdateOperationsInput certificateContent: NullableStringFieldUpdateOperationsInput @@ -1554,6 +1564,7 @@ input ConferenceWhereInput { OR: [ConferenceWhereInput!] WaitingListEntry: WaitingListEntryListRelationFilter accountHolder: StringNullableFilter + allowMessaging: BoolFilter bankName: StringNullableFilter bic: StringNullableFilter calendarDays: CalendarDayListRelationFilter @@ -1585,6 +1596,7 @@ input ConferenceWhereInput { logoDataURL: StringNullableFilter longTitle: StringNullableFilter mediaConsentContent: StringNullableFilter + messageAudits: MessageAuditListRelationFilter nonStateActors: NonStateActorListRelationFilter papers: PaperListRelationFilter paymentTransactions: PaymentTransactionListRelationFilter @@ -1620,6 +1632,7 @@ input ConferenceWhereUniqueInput { OR: [ConferenceWhereInput!] WaitingListEntry: WaitingListEntryListRelationFilter accountHolder: StringNullableFilter + allowMessaging: BoolFilter bankName: StringNullableFilter bic: StringNullableFilter calendarDays: CalendarDayListRelationFilter @@ -1651,6 +1664,7 @@ input ConferenceWhereUniqueInput { logoDataURL: StringNullableFilter longTitle: StringNullableFilter mediaConsentContent: StringNullableFilter + messageAudits: MessageAuditListRelationFilter nonStateActors: NonStateActorListRelationFilter papers: PaperListRelationFilter paymentTransactions: PaymentTransactionListRelationFilter @@ -2441,6 +2455,27 @@ input EnumMediaConsentStatusWithAggregatesFilter { notIn: [MediaConsentStatus!] } +input EnumMessageStatusFieldUpdateOperationsInput { + set: MessageStatus +} + +input EnumMessageStatusFilter { + equals: MessageStatus + in: [MessageStatus!] + not: MessageStatus + notIn: [MessageStatus!] +} + +input EnumMessageStatusWithAggregatesFilter { + _count: NestedIntFilter + _max: NestedEnumMessageStatusFilter + _min: NestedEnumMessageStatusFilter + equals: MessageStatus + in: [MessageStatus!] + not: MessageStatus + notIn: [MessageStatus!] +} + input EnumPaperStatusFieldUpdateOperationsInput { set: PaperStatus } @@ -2697,6 +2732,15 @@ type ImportCalendarDayResult { id: String! } +type InboxMessageItem { + body: String! + hasThread: Boolean! + id: String! + senderLabel: String! + sentAt: String! + subject: String! +} + input IntFieldUpdateOperationsInput { decrement: Int divide: Int @@ -2843,6 +2887,208 @@ enum MediaConsentStatus { PARTIALLY_ALLOWED } +type MessageAudit { + body: String! + conference: Conference! + conferenceId: String! + createdAt: DateTime! + id: ID! + messageId: String + recipientUser: User! + recipientUserId: String! + senderUser: User! + senderUserId: String! + status: MessageStatus! + subject: String! + updatedAt: DateTime! +} + +input MessageAuditCreateInput { + body: String! + conferenceId: String! + createdAt: DateTime + id: String + messageId: String + recipientUserId: String! + replies: MessageAuditCreateNestedManyWithoutReplyToMessageInput + replyToMessageId: String + senderUserId: String! + status: MessageStatus + subject: String! + updatedAt: DateTime +} + +input MessageAuditCreateNestedManyWithoutConferenceInput { + connect: [MessageAuditWhereUniqueInput!] +} + +input MessageAuditCreateNestedManyWithoutRecipientUserInput { + connect: [MessageAuditWhereUniqueInput!] +} + +input MessageAuditCreateNestedManyWithoutReplyToMessageInput { + connect: [MessageAuditWhereUniqueInput!] +} + +input MessageAuditCreateNestedManyWithoutSenderUserInput { + connect: [MessageAuditWhereUniqueInput!] +} + +input MessageAuditListRelationFilter { + every: MessageAuditWhereInput + none: MessageAuditWhereInput + some: MessageAuditWhereInput +} + +input MessageAuditNullableScalarRelationFilter { + is: MessageAuditWhereInput + isNot: MessageAuditWhereInput +} + +input MessageAuditOrderByRelationAggregateInput { + _count: SortOrder +} + +input MessageAuditOrderByWithRelationInput { + body: SortOrder + conference: ConferenceOrderByWithRelationInput + conferenceId: SortOrder + createdAt: SortOrder + id: SortOrder + messageId: SortOrder + recipientUser: UserOrderByWithRelationInput + recipientUserId: SortOrder + replies: MessageAuditOrderByRelationAggregateInput + replyToMessage: MessageAuditOrderByWithRelationInput + replyToMessageId: SortOrder + senderUser: UserOrderByWithRelationInput + senderUserId: SortOrder + status: SortOrder + subject: SortOrder + updatedAt: SortOrder +} + +enum MessageAuditScalarFieldEnum { + body + conferenceId + createdAt + id + messageId + recipientUserId + replyToMessageId + senderUserId + status + subject + updatedAt +} + +input MessageAuditUpdateInput { + body: StringFieldUpdateOperationsInput + conferenceId: StringFieldUpdateOperationsInput + createdAt: DateTimeFieldUpdateOperationsInput + id: StringFieldUpdateOperationsInput + messageId: NullableStringFieldUpdateOperationsInput + recipientUserId: StringFieldUpdateOperationsInput + replies: MessageAuditUpdateManyWithoutReplyToMessageNestedInput + replyToMessageId: NullableStringFieldUpdateOperationsInput + senderUserId: StringFieldUpdateOperationsInput + status: EnumMessageStatusFieldUpdateOperationsInput + subject: StringFieldUpdateOperationsInput + updatedAt: DateTimeFieldUpdateOperationsInput +} + +input MessageAuditUpdateManyMutationInput { + body: StringFieldUpdateOperationsInput + createdAt: DateTimeFieldUpdateOperationsInput + id: StringFieldUpdateOperationsInput + messageId: NullableStringFieldUpdateOperationsInput + status: EnumMessageStatusFieldUpdateOperationsInput + subject: StringFieldUpdateOperationsInput + updatedAt: DateTimeFieldUpdateOperationsInput +} + +input MessageAuditUpdateManyWithoutConferenceNestedInput { + connect: [MessageAuditWhereUniqueInput!] + disconnect: [MessageAuditWhereUniqueInput!] + set: [MessageAuditWhereUniqueInput!] +} + +input MessageAuditUpdateManyWithoutRecipientUserNestedInput { + connect: [MessageAuditWhereUniqueInput!] + disconnect: [MessageAuditWhereUniqueInput!] + set: [MessageAuditWhereUniqueInput!] +} + +input MessageAuditUpdateManyWithoutReplyToMessageNestedInput { + connect: [MessageAuditWhereUniqueInput!] + disconnect: [MessageAuditWhereUniqueInput!] + set: [MessageAuditWhereUniqueInput!] +} + +input MessageAuditUpdateManyWithoutSenderUserNestedInput { + connect: [MessageAuditWhereUniqueInput!] + disconnect: [MessageAuditWhereUniqueInput!] + set: [MessageAuditWhereUniqueInput!] +} + +input MessageAuditWhereInput { + AND: [MessageAuditWhereInput!] + NOT: [MessageAuditWhereInput!] + OR: [MessageAuditWhereInput!] + body: StringFilter + conference: ConferenceWhereInput + conferenceId: StringFilter + createdAt: DateTimeFilter + id: StringFilter + messageId: StringNullableFilter + recipientUser: UserWhereInput + recipientUserId: StringFilter + replies: MessageAuditListRelationFilter + replyToMessage: MessageAuditWhereInput + replyToMessageId: StringNullableFilter + senderUser: UserWhereInput + senderUserId: StringFilter + status: EnumMessageStatusFilter + subject: StringFilter + updatedAt: DateTimeFilter +} + +input MessageAuditWhereUniqueInput { + AND: [MessageAuditWhereInput!] + NOT: [MessageAuditWhereInput!] + OR: [MessageAuditWhereInput!] + body: StringFilter + conference: ConferenceWhereInput + conferenceId: StringFilter + createdAt: DateTimeFilter + id: String + messageId: StringNullableFilter + recipientUser: UserWhereInput + recipientUserId: StringFilter + replies: MessageAuditListRelationFilter + replyToMessage: MessageAuditWhereInput + replyToMessageId: StringNullableFilter + senderUser: UserWhereInput + senderUserId: StringFilter + status: EnumMessageStatusFilter + subject: StringFilter + updatedAt: DateTimeFilter +} + +type MessageHistoryItem { + body: String! + recipientLabel: String! + sentAt: String! + status: String! + subject: String! +} + +enum MessageStatus { + BLOCKED + FAILED + SENT +} + type Mutation { assignCommitteesToDelegationMembers(assignments: [updateManyDelegationMemberInputTypeArrayValue!]!): [DelegationMember!]! connectToConferenceSupervisor(conferenceId: ID!, connectionCode: String!, userId: ID): ConferenceSupervisor @@ -2901,6 +3147,7 @@ type Mutation { rotateSupervisorConnectionCode(id: ID!): ConferenceSupervisor! seedNewConference(data: JSONObject!): SeedNewConferenceResult! sendAssignmentData(data: JSONObject!, where: ConferenceWhereUniqueInput!): SetAssignmentDataResult! + sendDelegationMessage(body: String!, conferenceId: String!, recipientId: String!, replyToMessageId: String, subject: String!): String! setAgendaItemReviewHelpStatus(agendaItemId: String!, status: ReviewHelpStatus!): CommitteeAgendaItem! startImpersonation(scope: String, targetUserId: String!): Boolean! stopImpersonation: Boolean! @@ -2932,6 +3179,7 @@ type Mutation { updateOneUsersNewsletterPreferences(email: String!, wantsJoinTeamInformation: Boolean, wantsToReceiveGeneralInformation: Boolean): User updateOneWaitingListEntry(data: WaitingListEntryUpdateInput!, where: WaitingListEntryWhereUniqueInput!): WaitingListEntry updateReviewerSnippet(content: Json!, id: String!, name: String!): ReviewerSnippet! + updateUserMessagingPreference(canReceiveDelegationMail: Boolean!, where: UserWhereUniqueInput!): User upsertSelf: UpsertSelfResult! } @@ -3208,6 +3456,23 @@ input NestedEnumMediaConsentStatusWithAggregatesFilter { notIn: [MediaConsentStatus!] } +input NestedEnumMessageStatusFilter { + equals: MessageStatus + in: [MessageStatus!] + not: MessageStatus + notIn: [MessageStatus!] +} + +input NestedEnumMessageStatusWithAggregatesFilter { + _count: NestedIntFilter + _max: NestedEnumMessageStatusFilter + _min: NestedEnumMessageStatusFilter + equals: MessageStatus + in: [MessageStatus!] + not: MessageStatus + notIn: [MessageStatus!] +} + input NestedEnumPaperStatusFilter { equals: PaperStatus in: [PaperStatus!] @@ -4452,6 +4717,7 @@ type Query { findManyCustomConferenceRoles(cursor: CustomConferenceRoleWhereUniqueInput, distinct: [CustomConferenceRoleScalarFieldEnum!], orderBy: [CustomConferenceRoleOrderByWithRelationInput!], skip: Int, take: Int, where: CustomConferenceRoleWhereInput): [CustomConferenceRole!]! findManyDelegationMembers(cursor: DelegationMemberWhereUniqueInput, distinct: [DelegationMemberScalarFieldEnum!], orderBy: [DelegationMemberOrderByWithRelationInput!], skip: Int, take: Int, where: DelegationMemberWhereInput): [DelegationMember!]! findManyDelegations(cursor: DelegationWhereUniqueInput, distinct: [DelegationScalarFieldEnum!], orderBy: [DelegationOrderByWithRelationInput!], skip: Int, take: Int, where: DelegationWhereInput): [Delegation!]! + findManyMessageAudits(cursor: MessageAuditWhereUniqueInput, distinct: [MessageAuditScalarFieldEnum!], orderBy: [MessageAuditOrderByWithRelationInput!], skip: Int, take: Int, where: MessageAuditWhereInput): [MessageAudit!]! findManyNations(cursor: NationWhereUniqueInput, distinct: [NationScalarFieldEnum!], orderBy: [NationOrderByWithRelationInput!], skip: Int, take: Int, where: NationWhereInput): [Nation!]! findManyNonStateActors(cursor: NonStateActorWhereUniqueInput, distinct: [NonStateActorScalarFieldEnum!], orderBy: [NonStateActorOrderByWithRelationInput!], skip: Int, take: Int, where: NonStateActorWhereInput): [NonStateActor!]! findManyPapers(cursor: PaperWhereUniqueInput, distinct: [PaperScalarFieldEnum!], orderBy: [PaperOrderByWithRelationInput!], skip: Int, take: Int, where: PaperWhereInput): [Paper!]! @@ -4481,6 +4747,7 @@ type Query { findUniqueCustomConferenceRole(where: CustomConferenceRoleWhereUniqueInput!): CustomConferenceRole findUniqueDelegation(where: DelegationWhereUniqueInput!): Delegation findUniqueDelegationMember(where: DelegationMemberWhereUniqueInput!): DelegationMember + findUniqueMessageAudit(where: MessageAuditWhereUniqueInput!): MessageAudit findUniqueNation(where: NationWhereUniqueInput!): Nation findUniqueNonStateActor(where: NonStateActorWhereUniqueInput!): NonStateActor findUniquePaper(where: PaperWhereUniqueInput!): Paper @@ -4499,6 +4766,11 @@ type Query { getCertificateJWT(where: ConferenceParticipantStatusWhereUniqueInput!): CertificateJWT! getCertificateJWTPublicKeyObject: JWK! getConferenceStatistics(conferenceId: ID!, filter: StatsFilter! = ALL): StatisticsResult! + getInboxMessages(conferenceId: String!): [InboxMessageItem!]! + getMessageForReply(conferenceId: String!, messageAuditId: String!): ReplyMessageInfo + getMessageHistory(conferenceId: String!): [MessageHistoryItem!]! + getMessageRecipients(conferenceId: String!): [RecipientGroup!]! + getMessageThread(conferenceId: String!, messageAuditId: String!): [ThreadMessage!]! impersonatableUsers: [User!]! impersonationStatus: ImpersonationStatus! logoutUrl: String! @@ -4517,6 +4789,25 @@ enum QueryMode { insensitive } +type RecipientGroup { + category: String! + fontAwesomeIcon: String + groupId: String! + groupLabel: String! + recipients: [RecipientInfo!]! +} + +type RecipientInfo { + alpha2Code: String + alpha3Code: String + firstName: String + fontAwesomeIcon: String + id: String! + label: String! + lastName: String + roleName: String +} + type RegenerateInvitationResult { message: String newExpiresAt: DateTime @@ -4524,6 +4815,21 @@ type RegenerateInvitationResult { success: Boolean! } +type ReplyMessageInfo { + body: String! + id: String! + senderAlpha2Code: String + senderAlpha3Code: String + senderFirstName: String! + senderFontAwesomeIcon: String + senderLabel: String! + senderLastName: String! + senderRoleName: String + senderUserId: String! + sentAt: String! + subject: String! +} + enum ReviewHelpStatus { HELP_NEEDED NO_HELP_WANTED @@ -6046,6 +6352,16 @@ enum TeamRole { TEAM_COORDINATOR } +type ThreadMessage { + body: String! + id: String! + isCurrentUser: Boolean! + senderLabel: String! + senderUserId: String! + sentAt: String! + subject: String! +} + enum TransactionIsolationLevel { ReadCommitted ReadUncommitted @@ -6120,6 +6436,7 @@ type UpsertSelfResult { type User { apartment: String birthday: DateTime + canReceiveDelegationMail: Boolean! city: String conferenceParticipantStatus: [ConferenceParticipantStatus!]! conferenceParticipationsCount: Int! @@ -6152,6 +6469,7 @@ type User { input UserCreateInput { apartment: String birthday: DateTime + canReceiveDelegationMail: Boolean city: String conferenceParticipantStatus: ConferenceParticipantStatusCreateNestedManyWithoutUserInput conferenceSupervisor: ConferenceSupervisorCreateNestedManyWithoutUserInput @@ -6176,7 +6494,9 @@ input UserCreateInput { phone: String preferred_username: String! pronouns: String + receivedDelegationMessages: MessageAuditCreateNestedManyWithoutRecipientUserInput reviewerSnippets: ReviewerSnippetCreateNestedManyWithoutUserInput + sentDelegationMessages: MessageAuditCreateNestedManyWithoutSenderUserInput singleParticipant: SingleParticipantCreateNestedManyWithoutUserInput street: String surveyAnswers: SurveyAnswerCreateNestedManyWithoutUserInput @@ -6196,6 +6516,7 @@ input UserNullableScalarRelationFilter { input UserOrderByWithRelationInput { apartment: SortOrder birthday: SortOrder + canReceiveDelegationMail: SortOrder city: SortOrder conferenceParticipantStatus: ConferenceParticipantStatusOrderByRelationAggregateInput conferenceSupervisor: ConferenceSupervisorOrderByRelationAggregateInput @@ -6220,7 +6541,9 @@ input UserOrderByWithRelationInput { phone: SortOrder preferred_username: SortOrder pronouns: SortOrder + receivedDelegationMessages: MessageAuditOrderByRelationAggregateInput reviewerSnippets: ReviewerSnippetOrderByRelationAggregateInput + sentDelegationMessages: MessageAuditOrderByRelationAggregateInput singleParticipant: SingleParticipantOrderByRelationAggregateInput street: SortOrder surveyAnswers: SurveyAnswerOrderByRelationAggregateInput @@ -6338,6 +6661,7 @@ input UserReferenceInPaymentTransactionWhereUniqueInput { enum UserScalarFieldEnum { apartment birthday + canReceiveDelegationMail city country createdAt @@ -6368,6 +6692,7 @@ input UserScalarRelationFilter { input UserUpdateDataInput { apartment: String birthday: DateTime! + canReceiveDelegationMail: Boolean city: String! country: String! emergencyContacts: String! @@ -6386,6 +6711,7 @@ input UserUpdateDataInput { input UserUpdateInput { apartment: NullableStringFieldUpdateOperationsInput birthday: NullableDateTimeFieldUpdateOperationsInput + canReceiveDelegationMail: BoolFieldUpdateOperationsInput city: NullableStringFieldUpdateOperationsInput conferenceParticipantStatus: ConferenceParticipantStatusUpdateManyWithoutUserNestedInput conferenceSupervisor: ConferenceSupervisorUpdateManyWithoutUserNestedInput @@ -6410,7 +6736,9 @@ input UserUpdateInput { phone: NullableStringFieldUpdateOperationsInput preferred_username: StringFieldUpdateOperationsInput pronouns: NullableStringFieldUpdateOperationsInput + receivedDelegationMessages: MessageAuditUpdateManyWithoutRecipientUserNestedInput reviewerSnippets: ReviewerSnippetUpdateManyWithoutUserNestedInput + sentDelegationMessages: MessageAuditUpdateManyWithoutSenderUserNestedInput singleParticipant: SingleParticipantUpdateManyWithoutUserNestedInput street: NullableStringFieldUpdateOperationsInput surveyAnswers: SurveyAnswerUpdateManyWithoutUserNestedInput @@ -6425,6 +6753,7 @@ input UserUpdateInput { input UserUpdateManyMutationInput { apartment: NullableStringFieldUpdateOperationsInput birthday: NullableDateTimeFieldUpdateOperationsInput + canReceiveDelegationMail: BoolFieldUpdateOperationsInput city: NullableStringFieldUpdateOperationsInput country: NullableStringFieldUpdateOperationsInput createdAt: DateTimeFieldUpdateOperationsInput @@ -6453,6 +6782,7 @@ input UserWhereInput { OR: [UserWhereInput!] apartment: StringNullableFilter birthday: DateTimeNullableFilter + canReceiveDelegationMail: BoolFilter city: StringNullableFilter conferenceParticipantStatus: ConferenceParticipantStatusListRelationFilter conferenceSupervisor: ConferenceSupervisorListRelationFilter @@ -6477,7 +6807,9 @@ input UserWhereInput { phone: StringNullableFilter preferred_username: StringFilter pronouns: StringNullableFilter + receivedDelegationMessages: MessageAuditListRelationFilter reviewerSnippets: ReviewerSnippetListRelationFilter + sentDelegationMessages: MessageAuditListRelationFilter singleParticipant: SingleParticipantListRelationFilter street: StringNullableFilter surveyAnswers: SurveyAnswerListRelationFilter @@ -6495,6 +6827,7 @@ input UserWhereUniqueInput { OR: [UserWhereInput!] apartment: StringNullableFilter birthday: DateTimeNullableFilter + canReceiveDelegationMail: BoolFilter city: StringNullableFilter conferenceParticipantStatus: ConferenceParticipantStatusListRelationFilter conferenceSupervisor: ConferenceSupervisorListRelationFilter @@ -6519,7 +6852,9 @@ input UserWhereUniqueInput { phone: StringNullableFilter preferred_username: StringFilter pronouns: StringNullableFilter + receivedDelegationMessages: MessageAuditListRelationFilter reviewerSnippets: ReviewerSnippetListRelationFilter + sentDelegationMessages: MessageAuditListRelationFilter singleParticipant: SingleParticipantListRelationFilter street: StringNullableFilter surveyAnswers: SurveyAnswerListRelationFilter diff --git a/src/api/abilities/abilities.ts b/src/api/abilities/abilities.ts index 80713c13..60e0cfe3 100644 --- a/src/api/abilities/abilities.ts +++ b/src/api/abilities/abilities.ts @@ -29,6 +29,7 @@ import { defineAbilitiesForReviewerSnippet } from './entities/reviewerSnippet'; import { defineAbilitiesForCalendarDay } from './entities/calendarDay'; import { defineAbilitiesForCalendarTrack } from './entities/calendarTrack'; import { defineAbilitiesForCalendarEntry } from './entities/calendarEntry'; +import { defineAbilitiesForMessageAudit } from './entities/messageAudit'; import { defineAbilitiesForPlace } from './entities/place'; // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -169,6 +170,7 @@ export const defineAbilitiesForUser = (oidc: OIDC) => { defineAbilitiesForCalendarDay(oidc, builder); defineAbilitiesForCalendarTrack(oidc, builder); defineAbilitiesForCalendarEntry(oidc, builder); + defineAbilitiesForMessageAudit(oidc, builder); defineAbilitiesForPlace(oidc, builder); return builder.build({ diff --git a/src/api/abilities/entities/messageAudit.ts b/src/api/abilities/entities/messageAudit.ts new file mode 100644 index 00000000..1f5d8666 --- /dev/null +++ b/src/api/abilities/entities/messageAudit.ts @@ -0,0 +1,31 @@ +import type { AbilityBuilder } from '@casl/ability'; +import type { AppAbility } from '../abilities'; +import type { OIDC } from '$api/context/oidc'; + +export const defineAbilitiesForMessageAudit = (oidc: OIDC, { can }: AbilityBuilder) => { + if (oidc && oidc.user) { + const user = oidc.user; + + // Users can see messages they sent + can(['list', 'read'], 'MessageAudit', { + senderUser: { id: user.sub } + }); + + // Users can see messages they received + can(['list', 'read'], 'MessageAudit', { + recipientUser: { id: user.sub } + }); + + // Team members with PROJECT_MANAGEMENT or PARTICIPANT_CARE can see audits in their conferences + can(['list', 'read'], 'MessageAudit', { + conference: { + teamMembers: { + some: { + user: { id: user.sub }, + role: { in: ['PROJECT_MANAGEMENT', 'PARTICIPANT_CARE'] } + } + } + } + }); + } +}; diff --git a/src/api/resolvers/api.ts b/src/api/resolvers/api.ts index e543a5ff..bc352400 100644 --- a/src/api/resolvers/api.ts +++ b/src/api/resolvers/api.ts @@ -22,6 +22,7 @@ import './modules/customConferenceRole'; import './modules/delegation'; import './modules/delegationMember'; import './modules/impersonation'; +import './modules/messageAudit'; import './modules/nation'; import './modules/nonStateActor'; import './modules/paymentTransaction'; diff --git a/src/api/resolvers/modules/assignments.ts b/src/api/resolvers/modules/assignments.ts index 2884a463..158e00c1 100644 --- a/src/api/resolvers/modules/assignments.ts +++ b/src/api/resolvers/modules/assignments.ts @@ -5,7 +5,7 @@ import { ProjectDataSchema, type Committee, type Nation -} from '../../../routes/(authenticated)/assignment-assistant/[projectId]/appData.svelte'; +} from '../../../routes/(authenticated)/assignment-assistant/[projectId]/schemas'; import { builder } from '../builder'; import { m } from '$lib/paraglide/messages'; import { makeEntryCode } from '$api/services/entryCodeGenerator'; diff --git a/src/api/resolvers/modules/conference/conference.ts b/src/api/resolvers/modules/conference/conference.ts index ab76a32e..c7c778f4 100644 --- a/src/api/resolvers/modules/conference/conference.ts +++ b/src/api/resolvers/modules/conference/conference.ts @@ -38,6 +38,7 @@ import { ConferenceIsOpenPaperSubmissionFieldObject, ConferenceShowInfoExpandedFieldObject, ConferenceShowCalendarFieldObject, + ConferenceAllowMessagingFieldObject, ConferenceTimezoneFieldObject, deleteOneConferenceMutationObject, findManyConferenceQueryObject, @@ -65,6 +66,7 @@ builder.prismaObject('Conference', { linkToPaperInbox: t.field(ConferenceLinkToPaperInboxFieldObject), isOpenPaperSubmission: t.field(ConferenceIsOpenPaperSubmissionFieldObject), showCalendar: t.field(ConferenceShowCalendarFieldObject), + allowMessaging: t.field(ConferenceAllowMessagingFieldObject), timezone: t.field(ConferenceTimezoneFieldObject), longTitle: t.field(ConferenceLongTitleFieldObject), location: t.field(ConferenceLocationFieldObject), @@ -481,6 +483,9 @@ builder.mutationFields((t) => { showCalendar: t.boolean({ required: false }), + allowMessaging: t.boolean({ + required: false + }), timezone: t.string({ required: false }), @@ -648,6 +653,8 @@ builder.mutationFields((t) => { ? undefined : args.data.isOpenPaperSubmission, showCalendar: args.data.showCalendar === null ? undefined : args.data.showCalendar, + allowMessaging: + args.data.allowMessaging === null ? undefined : args.data.allowMessaging, timezone: args.data.timezone ?? undefined, showInfoExpanded: args.data.showInfoExpanded === null ? undefined : args.data.showInfoExpanded, diff --git a/src/api/resolvers/modules/messageAudit.ts b/src/api/resolvers/modules/messageAudit.ts new file mode 100644 index 00000000..3648bffc --- /dev/null +++ b/src/api/resolvers/modules/messageAudit.ts @@ -0,0 +1,293 @@ +import { builder } from '../builder'; +import { + findManyMessageAuditQueryObject, + findUniqueMessageAuditQueryObject, + MessageAuditIdFieldObject, + MessageAuditCreatedAtFieldObject, + MessageAuditUpdatedAtFieldObject, + MessageAuditSubjectFieldObject, + MessageAuditBodyFieldObject, + MessageAuditSenderUserIdFieldObject, + MessageAuditSenderUserFieldObject, + MessageAuditRecipientUserIdFieldObject, + MessageAuditRecipientUserFieldObject, + MessageAuditConferenceIdFieldObject, + MessageAuditConferenceFieldObject, + MessageAuditMessageIdFieldObject, + MessageAuditStatusFieldObject +} from '$db/generated/graphql/MessageAudit'; +import { GraphQLError } from 'graphql'; +import { + sendDelegationMessage, + getMessageRecipients, + getMessageHistory, + getMessageForReply, + getInboxMessages, + getMessageThread +} from '../../../composers/messagingComposer'; + +export const GQLMessageAudit = builder.prismaObject('MessageAudit', { + fields: (t) => ({ + id: t.field(MessageAuditIdFieldObject), + createdAt: t.field(MessageAuditCreatedAtFieldObject), + updatedAt: t.field(MessageAuditUpdatedAtFieldObject), + subject: t.field(MessageAuditSubjectFieldObject), + body: t.field(MessageAuditBodyFieldObject), + senderUserId: t.field(MessageAuditSenderUserIdFieldObject), + senderUser: t.relation('senderUser', MessageAuditSenderUserFieldObject), + recipientUserId: t.field(MessageAuditRecipientUserIdFieldObject), + recipientUser: t.relation('recipientUser', MessageAuditRecipientUserFieldObject), + conferenceId: t.field(MessageAuditConferenceIdFieldObject), + conference: t.relation('conference', MessageAuditConferenceFieldObject), + messageId: t.field(MessageAuditMessageIdFieldObject), + status: t.field(MessageAuditStatusFieldObject) + }) +}); + +// Simple type for recipient info +const RecipientInfo = builder.simpleObject('RecipientInfo', { + fields: (t) => ({ + id: t.string(), + label: t.string(), + firstName: t.string({ nullable: true }), + lastName: t.string({ nullable: true }), + alpha2Code: t.string({ nullable: true }), + alpha3Code: t.string({ nullable: true }), + fontAwesomeIcon: t.string({ nullable: true }), + roleName: t.string({ nullable: true }) + }) +}); + +// Grouped recipients by committee / NSA / custom role +const RecipientGroup = builder.simpleObject('RecipientGroup', { + fields: (t) => ({ + groupId: t.string(), + groupLabel: t.string(), + category: t.string(), + fontAwesomeIcon: t.string({ nullable: true }), + recipients: t.field({ type: [RecipientInfo] }) + }) +}); + +// Type for history items +const MessageHistoryItem = builder.simpleObject('MessageHistoryItem', { + fields: (t) => ({ + recipientLabel: t.string(), + subject: t.string(), + body: t.string(), + sentAt: t.string(), + status: t.string() + }) +}); + +// Type for reply message info +const ReplyMessageInfo = builder.simpleObject('ReplyMessageInfo', { + fields: (t) => ({ + id: t.string(), + subject: t.string(), + body: t.string(), + senderLabel: t.string(), + senderUserId: t.string(), + senderFirstName: t.string(), + senderLastName: t.string(), + senderAlpha2Code: t.string({ nullable: true }), + senderAlpha3Code: t.string({ nullable: true }), + senderFontAwesomeIcon: t.string({ nullable: true }), + senderRoleName: t.string({ nullable: true }), + sentAt: t.string() + }) +}); + +// Type for inbox items +const InboxMessageItem = builder.simpleObject('InboxMessageItem', { + fields: (t) => ({ + id: t.string(), + senderLabel: t.string(), + subject: t.string(), + body: t.string(), + sentAt: t.string(), + hasThread: t.boolean() + }) +}); + +// Type for thread messages +const ThreadMessage = builder.simpleObject('ThreadMessage', { + fields: (t) => ({ + id: t.string(), + subject: t.string(), + body: t.string(), + sentAt: t.string(), + senderLabel: t.string(), + senderUserId: t.string(), + isCurrentUser: t.boolean() + }) +}); + +builder.queryFields((t) => { + const field = findManyMessageAuditQueryObject(t); + return { + findManyMessageAudits: t.prismaField({ + ...field, + resolve: (query, root, args, ctx, info) => { + args.where = { + ...args.where, + AND: [ctx.permissions.allowDatabaseAccessTo('list').MessageAudit] + }; + + return field.resolve(query, root, args, ctx, info); + } + }) + }; +}); + +builder.queryFields((t) => { + const field = findUniqueMessageAuditQueryObject(t); + return { + findUniqueMessageAudit: t.prismaField({ + ...field, + resolve: (query, root, args, ctx, info) => { + args.where = { + ...args.where, + AND: [ctx.permissions.allowDatabaseAccessTo('read').MessageAudit] + }; + + return field.resolve(query, root, args, ctx, info); + } + }) + }; +}); + +// Custom query to get message recipients for a conference +builder.queryField('getMessageRecipients', (t) => + t.field({ + type: [RecipientGroup], + args: { + conferenceId: t.arg.string({ required: true }) + }, + resolve: async (_root, args, ctx) => { + const user = ctx.permissions.getLoggedInUserOrThrow(); + try { + return await getMessageRecipients(args.conferenceId, user.sub); + } catch (e: unknown) { + console.error(e); + const message = e instanceof Error ? e.message : 'Error fetching recipients'; + throw new GraphQLError(message); + } + } + }) +); + +// Custom query to get message history +builder.queryField('getMessageHistory', (t) => + t.field({ + type: [MessageHistoryItem], + args: { + conferenceId: t.arg.string({ required: true }) + }, + resolve: async (_root, args, ctx) => { + const user = ctx.permissions.getLoggedInUserOrThrow(); + try { + return await getMessageHistory(args.conferenceId, user.sub); + } catch (e: unknown) { + console.error(e); + const message = e instanceof Error ? e.message : 'Error fetching history'; + throw new GraphQLError(message); + } + } + }) +); + +// Query to get a message for reply context +builder.queryField('getMessageForReply', (t) => + t.field({ + type: ReplyMessageInfo, + nullable: true, + args: { + messageAuditId: t.arg.string({ required: true }), + conferenceId: t.arg.string({ required: true }) + }, + resolve: async (_root, args, ctx) => { + const user = ctx.permissions.getLoggedInUserOrThrow(); + try { + return await getMessageForReply(args.messageAuditId, args.conferenceId, user.sub); + } catch (e: unknown) { + console.error(e); + const message = e instanceof Error ? e.message : 'Error fetching reply message'; + throw new GraphQLError(message); + } + } + }) +); + +// Query to get inbox messages (received) +builder.queryField('getInboxMessages', (t) => + t.field({ + type: [InboxMessageItem], + args: { + conferenceId: t.arg.string({ required: true }) + }, + resolve: async (_root, args, ctx) => { + const user = ctx.permissions.getLoggedInUserOrThrow(); + try { + return await getInboxMessages(args.conferenceId, user.sub); + } catch (e: unknown) { + console.error(e); + const message = e instanceof Error ? e.message : 'Error fetching inbox messages'; + throw new GraphQLError(message); + } + } + }) +); + +// Query to get a message thread +builder.queryField('getMessageThread', (t) => + t.field({ + type: [ThreadMessage], + args: { + messageAuditId: t.arg.string({ required: true }), + conferenceId: t.arg.string({ required: true }) + }, + resolve: async (_root, args, ctx) => { + const user = ctx.permissions.getLoggedInUserOrThrow(); + try { + return await getMessageThread(args.messageAuditId, args.conferenceId, user.sub); + } catch (e: unknown) { + console.error(e); + const message = e instanceof Error ? e.message : 'Error fetching message thread'; + throw new GraphQLError(message); + } + } + }) +); + +// Mutation to send a delegation message +builder.mutationField('sendDelegationMessage', (t) => + t.field({ + type: 'String', + args: { + conferenceId: t.arg.string({ required: true }), + recipientId: t.arg.string({ required: true }), + subject: t.arg.string({ required: true }), + body: t.arg.string({ required: true }), + replyToMessageId: t.arg.string({ required: false }) + }, + resolve: async (_root, args, ctx) => { + const user = ctx.permissions.getLoggedInUserOrThrow(); + try { + return await sendDelegationMessage({ + conferenceId: args.conferenceId, + recipientId: args.recipientId, + subject: args.subject, + body: args.body, + origin: ctx.url.origin, + senderId: user.sub, + replyToMessageId: args.replyToMessageId ?? undefined + }); + } catch (e: unknown) { + console.error(e); + const message = e instanceof Error ? e.message : 'Error sending message'; + throw new GraphQLError(message); + } + } + }) +); diff --git a/src/api/resolvers/modules/user.ts b/src/api/resolvers/modules/user.ts index 31e9d18c..94c531a1 100644 --- a/src/api/resolvers/modules/user.ts +++ b/src/api/resolvers/modules/user.ts @@ -80,6 +80,7 @@ export const GQLUser = builder.prismaObject('User', { emergencyContacts: t.field(UserEmergencyContactsFieldObject), wantsToReceiveGeneralInformation: t.field(UserWantsToReceiveGeneralInformationFieldObject), wantsJoinTeamInformation: t.field(UserWantsJoinTeamInformationFieldObject), + canReceiveDelegationMail: t.exposeBoolean('canReceiveDelegationMail', { nullable: false }), globalNotes: t.field(UserGlobalNotesFieldObject), papers: t.relation('papers', { query: (_args, ctx) => ({ @@ -275,7 +276,8 @@ builder.mutationFields((t) => { wantsToReceiveGeneralInformation: t.boolean({ required: false }), - wantsJoinTeamInformation: t.boolean({ required: false }) + wantsJoinTeamInformation: t.boolean({ required: false }), + canReceiveDelegationMail: t.boolean({ required: false }) }) }) }) @@ -336,6 +338,33 @@ builder.mutationFields((t) => { }; }); +builder.mutationFields((t) => { + const field = updateOneUserMutationObject(t); + return { + updateUserMessagingPreference: t.prismaField({ + ...field, + args: { + where: field.args.where, + canReceiveDelegationMail: t.arg.boolean() + }, + resolve: async (query, root, args, ctx) => { + args.where = { + ...args.where, + AND: [ctx.permissions.allowDatabaseAccessTo('update').User] + }; + + return await db.user.update({ + ...query, + where: args.where, + data: { + canReceiveDelegationMail: args.canReceiveDelegationMail + } + }); + } + }) + }; +}); + builder.mutationFields((t) => { const field = updateOneUserMutationObject(t); return { diff --git a/src/api/services/email/delegationMessageTemplates.ts b/src/api/services/email/delegationMessageTemplates.ts new file mode 100644 index 00000000..ef7f8578 --- /dev/null +++ b/src/api/services/email/delegationMessageTemplates.ts @@ -0,0 +1,6 @@ +import { renderEmail, DelegationMessageEmail } from '$lib/emails'; +import type { DelegationMessageEmailProps } from './types'; + +export async function renderDelegationMessageEmail(props: DelegationMessageEmailProps) { + return renderEmail(DelegationMessageEmail, props); +} diff --git a/src/api/services/email/index.ts b/src/api/services/email/index.ts index d9139200..aa28d514 100644 --- a/src/api/services/email/index.ts +++ b/src/api/services/email/index.ts @@ -2,4 +2,10 @@ export { emailService } from './emailService'; export { emailConfig } from './emailConfig'; export { sendNewReviewNotification } from './sendNewReviewNotification'; -export type { EmailOptions, SendEmailResult, NewReviewEmailProps } from './types'; +export { renderDelegationMessageEmail } from './delegationMessageTemplates'; +export type { + EmailOptions, + SendEmailResult, + NewReviewEmailProps, + DelegationMessageEmailProps +} from './types'; diff --git a/src/api/services/email/types.ts b/src/api/services/email/types.ts index 20aefe97..e692e56f 100644 --- a/src/api/services/email/types.ts +++ b/src/api/services/email/types.ts @@ -31,3 +31,22 @@ export interface NewReviewEmailProps { conferenceTitle: string; paperUrl: string; } + +/** + * Props for the DelegationMessageEmail template + */ +export interface DelegationMessageEmailProps { + senderLabel: string; + senderInitials: string; + subject: string; + messageBody: string; + conferenceTitle: string; + replyUrl: string; + threadMessages?: Array<{ + senderLabel: string; + senderInitials: string; + subject: string; + body: string; + sentAt: string; + }>; +} diff --git a/src/composers/messagingComposer.ts b/src/composers/messagingComposer.ts new file mode 100644 index 00000000..04ba3c20 --- /dev/null +++ b/src/composers/messagingComposer.ts @@ -0,0 +1,968 @@ +import { db } from '$db/db'; +import { renderDelegationMessageEmail } from '$api/services/email/delegationMessageTemplates'; +import { emailService } from '$api/services/email/emailService'; +import { getFullTranslatedCountryNameFromISO3Code } from '$lib/services/nationTranslationHelper.svelte'; + +// Helper function to generate delegate label +export function getInitials(firstName: string, lastName: string) { + return `${firstName.charAt(0)}.${lastName.charAt(0)}.`; +} + +export function getDelegateLabel( + user: { given_name: string; family_name: string }, + delegationMember: { + delegation: { + assignedNation: { alpha3Code: string } | null; + assignedNonStateActor: { name: string } | null; + }; + assignedCommittee: { abbreviation: string | null; name: string } | null; + } | null, + singleParticipant: { + assignedRole: { name: string } | null; + } | null +): string { + let label = 'Participant'; + + if (delegationMember) { + if (delegationMember.delegation.assignedNation) { + const alpha3Code = delegationMember.delegation.assignedNation.alpha3Code; + const nationName = getFullTranslatedCountryNameFromISO3Code(alpha3Code); + + label = nationName; + if (delegationMember.assignedCommittee) { + label += ` (${delegationMember.assignedCommittee.abbreviation || delegationMember.assignedCommittee.name})`; + } + } else if (delegationMember.delegation.assignedNonStateActor) { + label = delegationMember.delegation.assignedNonStateActor.name; + if (delegationMember.assignedCommittee) { + label += ` (${delegationMember.assignedCommittee.abbreviation || delegationMember.assignedCommittee.name})`; + } + } + } else if (singleParticipant) { + if (singleParticipant.assignedRole) { + label = singleParticipant.assignedRole.name; + } + } + return label; +} + +export type RecipientInfo = { + id: string; + label: string; + firstName: string | null; + lastName: string | null; + alpha2Code: string | null; + alpha3Code: string | null; + fontAwesomeIcon: string | null; + roleName: string | null; +}; + +export type RecipientGroup = { + groupId: string; + groupLabel: string; + category: 'COMMITTEE' | 'NSA' | 'CUSTOM_ROLE'; + fontAwesomeIcon: string | null; + recipients: RecipientInfo[]; +}; + +export async function getMessageRecipients( + conferenceId: string, + userId: string +): Promise { + // Verify caller is a participant in this conference + const callerDelegationMember = await db.delegationMember.findUnique({ + where: { conferenceId_userId: { conferenceId, userId } } + }); + + if (!callerDelegationMember) { + const callerSingleParticipant = await db.singleParticipant.findUnique({ + where: { conferenceId_userId: { conferenceId, userId } } + }); + + if (!callerSingleParticipant) { + throw new Error('You are not a participant in this conference'); + } + } + + const seenUserIds = new Set(); + + // Query all DelegationMembers in the conference with messaging enabled, excluding current user + const delegationMembers = await db.delegationMember.findMany({ + where: { + conferenceId, + userId: { not: userId }, + user: { canReceiveDelegationMail: true } + }, + include: { + user: { + select: { + id: true, + given_name: true, + family_name: true + } + }, + delegation: { + include: { + assignedNation: true, + assignedNonStateActor: true + } + }, + assignedCommittee: true + } + }); + + // Query all SingleParticipants in the conference with messaging enabled, excluding current user + const singleParticipants = await db.singleParticipant.findMany({ + where: { + conferenceId, + userId: { not: userId }, + user: { canReceiveDelegationMail: true } + }, + include: { + user: { + select: { + id: true, + given_name: true, + family_name: true + } + }, + assignedRole: true + } + }); + + // Group DelegationMembers by committee + const committeeGroups = new Map(); + + // Group DelegationMembers by NSA (those with no assignedCommittee but with assignedNonStateActor) + const nsaGroups = new Map< + string, + { label: string; fontAwesomeIcon: string | null; recipients: RecipientInfo[] } + >(); + + for (const member of delegationMembers) { + if (!member.user || seenUserIds.has(member.user.id)) continue; + seenUserIds.add(member.user.id); + + const userObj = member.user; + const roleLabel = getDelegateLabel(userObj, member, null); + const label = `${roleLabel} - ${userObj.given_name} ${userObj.family_name}`; + + if (member.assignedCommittee) { + const key = member.assignedCommittee.id; + if (!committeeGroups.has(key)) { + const committeeLabel = member.assignedCommittee.abbreviation + ? `${member.assignedCommittee.name} (${member.assignedCommittee.abbreviation})` + : member.assignedCommittee.name; + committeeGroups.set(key, { + label: committeeLabel, + recipients: [] + }); + } + const committeeName = member.assignedCommittee.abbreviation || member.assignedCommittee.name; + committeeGroups.get(key)!.recipients.push({ + id: userObj.id, + label, + firstName: userObj.given_name, + lastName: userObj.family_name, + alpha2Code: member.delegation.assignedNation?.alpha2Code ?? null, + alpha3Code: member.delegation.assignedNation?.alpha3Code ?? null, + fontAwesomeIcon: null, + roleName: committeeName + }); + } else if (member.delegation.assignedNonStateActor) { + const nsa = member.delegation.assignedNonStateActor; + const key = nsa.id; + if (!nsaGroups.has(key)) { + nsaGroups.set(key, { + label: nsa.name, + fontAwesomeIcon: nsa.fontAwesomeIcon, + recipients: [] + }); + } + nsaGroups.get(key)!.recipients.push({ + id: userObj.id, + label, + firstName: userObj.given_name, + lastName: userObj.family_name, + alpha2Code: null, + alpha3Code: null, + fontAwesomeIcon: nsa.fontAwesomeIcon, + roleName: nsa.name + }); + } + } + + // Group SingleParticipants by assignedRole + const roleGroups = new Map< + string, + { label: string; fontAwesomeIcon: string | null; recipients: RecipientInfo[] } + >(); + + for (const participant of singleParticipants) { + if (!participant.user || seenUserIds.has(participant.user.id)) continue; + seenUserIds.add(participant.user.id); + + if (!participant.assignedRole) continue; + + const userObj = participant.user; + const roleLabel = getDelegateLabel(userObj, null, participant); + const label = `${roleLabel} - ${userObj.given_name} ${userObj.family_name}`; + + const key = participant.assignedRole.id; + if (!roleGroups.has(key)) { + roleGroups.set(key, { + label: participant.assignedRole.name, + fontAwesomeIcon: participant.assignedRole.fontAwesomeIcon, + recipients: [] + }); + } + roleGroups.get(key)!.recipients.push({ + id: userObj.id, + label, + firstName: userObj.given_name, + lastName: userObj.family_name, + alpha2Code: null, + alpha3Code: null, + fontAwesomeIcon: participant.assignedRole.fontAwesomeIcon, + roleName: participant.assignedRole.name + }); + } + + // Build result, sorting recipients within each group + const groups: RecipientGroup[] = []; + + for (const [groupId, group] of committeeGroups) { + group.recipients.sort((a, b) => a.label.localeCompare(b.label)); + groups.push({ + groupId, + groupLabel: group.label, + category: 'COMMITTEE', + fontAwesomeIcon: null, + recipients: group.recipients + }); + } + + for (const [groupId, group] of nsaGroups) { + group.recipients.sort((a, b) => a.label.localeCompare(b.label)); + groups.push({ + groupId, + groupLabel: group.label, + category: 'NSA', + fontAwesomeIcon: group.fontAwesomeIcon, + recipients: group.recipients + }); + } + + for (const [groupId, group] of roleGroups) { + group.recipients.sort((a, b) => a.label.localeCompare(b.label)); + groups.push({ + groupId, + groupLabel: group.label, + category: 'CUSTOM_ROLE', + fontAwesomeIcon: group.fontAwesomeIcon, + recipients: group.recipients + }); + } + + // Sort groups by label + groups.sort((a, b) => a.groupLabel.localeCompare(b.groupLabel)); + + return groups; +} + +export type ReplyMessageData = { + id: string; + subject: string; + body: string; + senderLabel: string; + senderUserId: string; + senderFirstName: string; + senderLastName: string; + senderAlpha2Code: string | null; + senderAlpha3Code: string | null; + senderFontAwesomeIcon: string | null; + senderRoleName: string | null; + sentAt: string; +}; + +export async function getMessageForReply( + messageAuditId: string, + conferenceId: string, + userId: string +): Promise { + const audit = await db.messageAudit.findUnique({ + where: { id: messageAuditId }, + select: { + id: true, + subject: true, + body: true, + createdAt: true, + senderUserId: true, + recipientUserId: true, + conferenceId: true, + senderUser: { + select: { + id: true, + given_name: true, + family_name: true + } + } + } + }); + + if (!audit || audit.conferenceId !== conferenceId || audit.recipientUserId !== userId) { + return null; + } + + // Look up sender's delegation/role info for the label + const senderDelegationMember = await db.delegationMember.findUnique({ + where: { + conferenceId_userId: { + conferenceId, + userId: audit.senderUserId + } + }, + include: { + delegation: { + include: { + assignedNation: true, + assignedNonStateActor: true + } + }, + assignedCommittee: true + } + }); + + let senderSingleParticipant = null as { + assignedRole: { name: string; fontAwesomeIcon: string | null } | null; + } | null; + if (!senderDelegationMember) { + senderSingleParticipant = await db.singleParticipant.findUnique({ + where: { + conferenceId_userId: { + conferenceId, + userId: audit.senderUserId + } + }, + include: { + assignedRole: true + } + }); + } + + const senderLabel = getDelegateLabel( + audit.senderUser, + senderDelegationMember, + senderSingleParticipant + ); + + // Derive recipient-info fields for display + const senderAlpha2Code = senderDelegationMember?.delegation.assignedNation?.alpha2Code ?? null; + const senderAlpha3Code = senderDelegationMember?.delegation.assignedNation?.alpha3Code ?? null; + const senderFontAwesomeIcon = + senderDelegationMember?.delegation.assignedNonStateActor?.fontAwesomeIcon ?? + senderSingleParticipant?.assignedRole?.fontAwesomeIcon ?? + null; + const senderRoleName = + senderDelegationMember?.assignedCommittee?.abbreviation ?? + senderDelegationMember?.assignedCommittee?.name ?? + senderDelegationMember?.delegation.assignedNonStateActor?.name ?? + senderSingleParticipant?.assignedRole?.name ?? + null; + + return { + id: audit.id, + subject: audit.subject, + body: audit.body, + senderLabel, + senderUserId: audit.senderUserId, + senderFirstName: audit.senderUser.given_name, + senderLastName: audit.senderUser.family_name, + senderAlpha2Code, + senderAlpha3Code, + senderFontAwesomeIcon, + senderRoleName, + sentAt: audit.createdAt.toISOString() + }; +} + +export async function getMessageHistory(conferenceId: string, userId: string) { + const audits = await db.messageAudit.findMany({ + where: { + senderUserId: userId, + conferenceId: conferenceId + }, + orderBy: { createdAt: 'desc' }, + select: { + subject: true, + body: true, + createdAt: true, + status: true, + recipientUserId: true, + recipientUser: { + select: { + id: true, + given_name: true, + family_name: true + } + } + } + }); + + if (audits.length === 0) { + return []; + } + + const recipientIds = Array.from(new Set(audits.map((audit) => audit.recipientUserId))); + + const delegationMembers = await db.delegationMember.findMany({ + where: { + conferenceId: conferenceId, + userId: { in: recipientIds } + }, + include: { + delegation: { + include: { + assignedNation: true, + assignedNonStateActor: true + } + }, + assignedCommittee: true + } + }); + + const delegationByUserId = new Map(delegationMembers.map((member) => [member.userId, member])); + + const singleParticipants = await db.singleParticipant.findMany({ + where: { + conferenceId: conferenceId, + userId: { in: recipientIds } + }, + include: { + assignedRole: true + } + }); + + const singleByUserId = new Map( + singleParticipants.map((participant) => [participant.userId, participant]) + ); + + const items = audits.map((audit) => { + const recipient = audit.recipientUser; + const delegationMember = delegationByUserId.get(audit.recipientUserId) ?? null; + const singleParticipant = !delegationMember + ? (singleByUserId.get(audit.recipientUserId) ?? null) + : null; + const roleLabel = recipient + ? getDelegateLabel(recipient, delegationMember, singleParticipant) + : 'Participant'; + const recipientLabel = + roleLabel !== 'Participant' && roleLabel + ? roleLabel + : recipient + ? `${recipient.given_name} ${recipient.family_name}` + : 'Participant'; + const status = audit.status.charAt(0) + audit.status.slice(1).toLowerCase(); + + return { + recipientLabel, + subject: audit.subject, + body: audit.body, + sentAt: audit.createdAt.toISOString(), + status + }; + }); + + return items; +} + +export async function getInboxMessages(conferenceId: string, userId: string) { + const audits = await db.messageAudit.findMany({ + where: { + recipientUserId: userId, + conferenceId: conferenceId + }, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + subject: true, + body: true, + createdAt: true, + senderUserId: true, + replyToMessageId: true, + _count: { select: { replies: true } }, + senderUser: { + select: { + id: true, + given_name: true, + family_name: true + } + } + } + }); + + if (audits.length === 0) { + return []; + } + + const senderIds = Array.from(new Set(audits.map((a) => a.senderUserId))); + + const delegationMembers = await db.delegationMember.findMany({ + where: { + conferenceId, + userId: { in: senderIds } + }, + include: { + delegation: { + include: { + assignedNation: true, + assignedNonStateActor: true + } + }, + assignedCommittee: true + } + }); + + const delegationByUserId = new Map(delegationMembers.map((m) => [m.userId, m])); + + const singleParticipants = await db.singleParticipant.findMany({ + where: { + conferenceId, + userId: { in: senderIds } + }, + include: { + assignedRole: true + } + }); + + const singleByUserId = new Map(singleParticipants.map((p) => [p.userId, p])); + + return audits.map((audit) => { + const sender = audit.senderUser; + const delegationMember = delegationByUserId.get(audit.senderUserId) ?? null; + const singleParticipant = !delegationMember + ? (singleByUserId.get(audit.senderUserId) ?? null) + : null; + const senderLabel = sender + ? getDelegateLabel(sender, delegationMember, singleParticipant) + : 'Participant'; + const hasThread = audit.replyToMessageId !== null || audit._count.replies > 0; + + return { + id: audit.id, + senderLabel: + senderLabel !== 'Participant' && senderLabel + ? senderLabel + : sender + ? `${sender.given_name} ${sender.family_name}` + : 'Participant', + subject: audit.subject, + body: audit.body, + sentAt: audit.createdAt.toISOString(), + hasThread + }; + }); +} + +export async function getMessageThread( + messageAuditId: string, + conferenceId: string, + userId: string +) { + // Find the starting message + const startMsg = await db.messageAudit.findUnique({ + where: { id: messageAuditId }, + select: { id: true, replyToMessageId: true, conferenceId: true } + }); + + if (!startMsg || startMsg.conferenceId !== conferenceId) { + return []; + } + + // Walk up to find root message + let rootId = startMsg.id; + let parentId = startMsg.replyToMessageId; + while (parentId) { + const parent = await db.messageAudit.findUnique({ + where: { id: parentId }, + select: { id: true, replyToMessageId: true } + }); + if (!parent) break; + rootId = parent.id; + parentId = parent.replyToMessageId; + } + + // Walk down from root collecting all messages in the chain + const allMessages: Array<{ + id: string; + subject: string; + body: string; + createdAt: Date; + senderUserId: string; + recipientUserId: string; + senderUser: { given_name: string; family_name: string }; + }> = []; + + let currentIds = [rootId]; + while (currentIds.length > 0) { + const messages = await db.messageAudit.findMany({ + where: { id: { in: currentIds } }, + select: { + id: true, + subject: true, + body: true, + createdAt: true, + senderUserId: true, + recipientUserId: true, + senderUser: { + select: { given_name: true, family_name: true } + }, + replies: { + select: { id: true } + } + } + }); + for (const msg of messages) { + allMessages.push(msg); + } + currentIds = messages.flatMap((m) => m.replies.map((r) => r.id)); + } + + // Verify requesting user is part of the thread + const isInThread = allMessages.some( + (m) => m.senderUserId === userId || m.recipientUserId === userId + ); + if (!isInThread) { + return []; + } + + // Sort chronologically + allMessages.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); + + // Batch resolve sender labels + const senderIds = Array.from(new Set(allMessages.map((m) => m.senderUserId))); + + const delegationMembers = await db.delegationMember.findMany({ + where: { conferenceId, userId: { in: senderIds } }, + include: { + delegation: { include: { assignedNation: true, assignedNonStateActor: true } }, + assignedCommittee: true + } + }); + const delegationByUserId = new Map(delegationMembers.map((m) => [m.userId, m])); + + const singleParticipants = await db.singleParticipant.findMany({ + where: { conferenceId, userId: { in: senderIds } }, + include: { assignedRole: true } + }); + const singleByUserId = new Map(singleParticipants.map((p) => [p.userId, p])); + + return allMessages.map((msg) => { + const dm = delegationByUserId.get(msg.senderUserId) ?? null; + const sp = !dm ? (singleByUserId.get(msg.senderUserId) ?? null) : null; + const label = getDelegateLabel(msg.senderUser, dm, sp); + + return { + id: msg.id, + subject: msg.subject, + body: msg.body, + sentAt: msg.createdAt.toISOString(), + senderLabel: label, + senderUserId: msg.senderUserId, + isCurrentUser: msg.senderUserId === userId + }; + }); +} + +export async function sendDelegationMessage({ + conferenceId, + recipientId, + subject, + body, + origin, + senderId, + replyToMessageId +}: { + conferenceId: string; + recipientId: string; + subject: string; + body: string; + origin: string; + senderId: string; + replyToMessageId?: string; +}) { + // Validate inputs + if (!recipientId.trim() || !subject.trim() || !body.trim()) { + throw new Error('Missing required fields'); + } + + if (subject.length > 200) { + throw new Error('Subject must be at most 200 characters'); + } + + if (body.length > 2000) { + throw new Error('Body must be at most 2000 characters'); + } + + if (recipientId === senderId) { + throw new Error('Cannot send message to yourself'); + } + + // Rate limiting: max 10 messages per 10 minutes + const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000); + const recentMessageCount = await db.messageAudit.count({ + where: { + senderUserId: senderId, + createdAt: { gte: tenMinutesAgo } + } + }); + + if (recentMessageCount >= 10) { + throw new Error('Rate limit exceeded. Please wait before sending more messages.'); + } + + // Get sender info + const sender = await db.user.findUnique({ + where: { id: senderId }, + select: { + canReceiveDelegationMail: true, + id: true, + given_name: true, + family_name: true + } + }); + + if (!sender) { + throw new Error('Sender not found'); + } + + if (!sender.canReceiveDelegationMail) { + throw new Error('You must enable messaging in your account settings.'); + } + + // Get recipient info + const recipient = await db.user.findUnique({ + where: { id: recipientId }, + select: { + canReceiveDelegationMail: true, + email: true, + id: true + } + }); + + if (!recipient) { + throw new Error('Recipient not found'); + } + + if (!recipient.canReceiveDelegationMail) { + throw new Error('Recipient has not enabled messaging.'); + } + + // Get conference info + const conference = await db.conference.findUnique({ + where: { id: conferenceId }, + select: { title: true, allowMessaging: true } + }); + + if (!conference) { + throw new Error('Conference not found'); + } + + if (!conference.allowMessaging) { + throw new Error('Messaging is not enabled for this conference.'); + } + + // Verify sender is part of conference + const senderDelegationMember = await db.delegationMember.findUnique({ + where: { + conferenceId_userId: { + conferenceId: conferenceId, + userId: sender.id + } + }, + include: { + delegation: { + include: { + assignedNation: true, + assignedNonStateActor: true + } + }, + assignedCommittee: true + } + }); + + let senderSingleParticipant = null as { + assignedRole: { name: string } | null; + } | null; + if (!senderDelegationMember) { + senderSingleParticipant = await db.singleParticipant.findUnique({ + where: { + conferenceId_userId: { + conferenceId: conferenceId, + userId: sender.id + } + }, + include: { + assignedRole: true + } + }); + } + + if (!senderDelegationMember && !senderSingleParticipant) { + throw new Error('Sender is not part of this conference'); + } + + const senderLabel = getDelegateLabel(sender, senderDelegationMember, senderSingleParticipant); + const senderInitials = + `${sender.given_name.charAt(0)}${sender.family_name.charAt(0)}`.toUpperCase(); + + // Verify recipient is part of conference + const recipientDelegationMember = await db.delegationMember.findUnique({ + where: { + conferenceId_userId: { + conferenceId: conferenceId, + userId: recipient.id + } + } + }); + + let recipientSingleParticipant = null as { + assignedRole: { name: string } | null; + } | null; + if (!recipientDelegationMember) { + recipientSingleParticipant = await db.singleParticipant.findUnique({ + where: { + conferenceId_userId: { + conferenceId: conferenceId, + userId: recipient.id + } + }, + include: { + assignedRole: true + } + }); + } + + if (!recipientDelegationMember && !recipientSingleParticipant) { + throw new Error('Recipient is not part of this conference'); + } + + // Create audit record + const audit = await db.messageAudit.create({ + data: { + subject: subject, + body: body, + senderUserId: sender.id, + recipientUserId: recipient.id, + conferenceId: conferenceId, + status: 'SENT', + replyToMessageId: replyToMessageId ?? null + } + }); + + // Build reply URL from the new audit ID + const replyUrl = `${origin}/dashboard/${conferenceId}/messaging/compose?replyTo=${audit.id}`; + + // Build full thread history for email + const threadMessages: Array<{ + senderLabel: string; + senderInitials: string; + subject: string; + body: string; + sentAt: string; + }> = []; + if (replyToMessageId) { + let currentId: string | null = replyToMessageId; + while (currentId) { + const msg = await db.messageAudit.findUnique({ + where: { id: currentId }, + select: { + subject: true, + body: true, + createdAt: true, + senderUserId: true, + replyToMessageId: true, + senderUser: { + select: { given_name: true, family_name: true } + } + } + }); + if (!msg) break; + const msgSenderDM = await db.delegationMember.findUnique({ + where: { + conferenceId_userId: { conferenceId, userId: msg.senderUserId } + }, + include: { + delegation: { include: { assignedNation: true, assignedNonStateActor: true } }, + assignedCommittee: true + } + }); + let msgSenderSP = null as { assignedRole: { name: string } | null } | null; + if (!msgSenderDM) { + msgSenderSP = await db.singleParticipant.findUnique({ + where: { + conferenceId_userId: { conferenceId, userId: msg.senderUserId } + }, + include: { assignedRole: true } + }); + } + const msgLabel = getDelegateLabel(msg.senderUser, msgSenderDM, msgSenderSP); + const msgInitials = + `${msg.senderUser.given_name.charAt(0)}${msg.senderUser.family_name.charAt(0)}`.toUpperCase(); + threadMessages.push({ + senderLabel: msgLabel, + senderInitials: msgInitials, + subject: msg.subject, + body: msg.body, + sentAt: msg.createdAt.toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) + }); + currentId = msg.replyToMessageId; + } + } + + // Render email + const { html, text } = await renderDelegationMessageEmail({ + senderLabel, + senderInitials, + subject: subject, + messageBody: body, + conferenceTitle: conference.title, + replyUrl, + threadMessages + }); + + // Send email + const result = await emailService.sendEmail({ + to: recipient.email, + subject: `[${conference.title}] Neue Nachricht: ${subject}`, + html, + text + }); + + if (!result.success) { + console.error('Email sending failed', result.error); + + await db.messageAudit.update({ + where: { id: audit.id }, + data: { status: 'FAILED' } + }); + + throw new Error('Failed to send email'); + } + + // Update audit with message ID + if (result.messageId) { + await db.messageAudit.update({ + where: { id: audit.id }, + data: { messageId: result.messageId } + }); + } + + return 'ok'; +} diff --git a/src/lib/components/Messaging/RecipientPickerDrawer.svelte b/src/lib/components/Messaging/RecipientPickerDrawer.svelte new file mode 100644 index 00000000..f13fa3b6 --- /dev/null +++ b/src/lib/components/Messaging/RecipientPickerDrawer.svelte @@ -0,0 +1,274 @@ + + + +{#if selected} +
+
+ {#if selected.alpha2Code} + + {:else if selected.fontAwesomeIcon} + + {/if} +
+
+ {getRecipientDisplayName(selected)} +
+ {#if selected.firstName && selected.lastName} +
+ {formatNames(selected.firstName, selected.lastName)} +
+ {/if} +
+
+ +
+{:else} + +{/if} + + +{#key direction} + + + + + +
+ {#if direction === 'bottom'} +
+
+
+ {/if} +
+ {#if drawerStep === 'recipient'} + + {/if} + + {#if drawerStep === 'group'} + {m.messagingSelectRecipientDrawer()} + {:else if selectedGroup} + {selectedGroup.groupLabel} + {/if} + + +
+
+ + +
+ {#if drawerStep === 'group'} + {#if loadError} +
+ +
+ {:else if groups.length === 0} +
+ +
+ {:else} + + {/if} + {:else if drawerStep === 'recipient' && selectedGroup} + {#if selectedGroup.recipients.length === 0} +
+ +
+ {:else} + + {/if} + {/if} +
+
+
+
+{/key} diff --git a/src/lib/components/Messaging/recipientUtils.ts b/src/lib/components/Messaging/recipientUtils.ts new file mode 100644 index 00000000..f88b9026 --- /dev/null +++ b/src/lib/components/Messaging/recipientUtils.ts @@ -0,0 +1,27 @@ +import { getFullTranslatedCountryNameFromISO3Code } from '$lib/services/nationTranslationHelper.svelte'; + +export type Recipient = { + id: string; + label: string; + firstName: string | null; + lastName: string | null; + alpha2Code: string | null; + alpha3Code: string | null; + fontAwesomeIcon: string | null; + roleName: string | null; +}; + +export type RecipientGroup = { + groupId: string; + groupLabel: string; + category: string; + fontAwesomeIcon: string | null; + recipients: Recipient[]; +}; + +export function getRecipientDisplayName(r: Recipient): string { + if (r.alpha3Code) { + return getFullTranslatedCountryNameFromISO3Code(r.alpha3Code); + } + return r.roleName ?? r.label; +} diff --git a/src/lib/config/dashboardLinks.ts b/src/lib/config/dashboardLinks.ts index b9d80b44..0974a58f 100644 --- a/src/lib/config/dashboardLinks.ts +++ b/src/lib/config/dashboardLinks.ts @@ -26,6 +26,7 @@ export interface DashboardLinkContext { hasNationAssigned?: boolean; membersLackCommittees?: boolean; postalRegistrationComplete?: boolean; + allowMessaging?: boolean; user?: { sub: string; email: string }; } @@ -103,6 +104,16 @@ export const dashboardLinks: DashboardLink[] = [ return undefined; } }, + { + id: 'messaging', + icon: 'envelope', + getTitle: () => m.messagingCenter(), + getDescription: () => m.messagingDescription(), + getHref: (ctx) => `/dashboard/${ctx.conferenceId}/messaging`, + showFor: ['delegation', 'singleParticipant', 'supervisor'], + isVisible: (ctx) => !!ctx.allowMessaging, + isDisabled: () => false + }, { id: 'preparation', icon: 'book-bookmark', diff --git a/src/lib/emails/index.ts b/src/lib/emails/index.ts index 28d71708..6b73eb0a 100644 --- a/src/lib/emails/index.ts +++ b/src/lib/emails/index.ts @@ -1,4 +1,5 @@ export { default as NewReviewEmail } from './templates/NewReviewEmail.svelte'; +export { default as DelegationMessageEmail } from './templates/DelegationMessageEmail.svelte'; export { default as TeamInvitationEmail } from './templates/TeamInvitationEmail.svelte'; export { default as TeamWelcomeEmail } from './templates/TeamWelcomeEmail.svelte'; export { renderEmail } from './render'; diff --git a/src/lib/emails/render.ts b/src/lib/emails/render.ts index fb80b9db..a006a3c3 100644 --- a/src/lib/emails/render.ts +++ b/src/lib/emails/render.ts @@ -1,6 +1,8 @@ import { Renderer, toPlainText } from 'better-svelte-email'; import type { Component } from 'svelte'; +type ExtractProps = C extends Component ? P : never; + /** * Render a Svelte email component to HTML and plain text * @@ -8,9 +10,9 @@ import type { Component } from 'svelte'; * @param props - Props to pass to the component * @returns Object containing rendered HTML and plain text versions */ -export async function renderEmail>( - component: Component, - props: Props +export async function renderEmail( + component: C, + props: ExtractProps ): Promise<{ html: string; text: string }> { const { render } = new Renderer(); const html = await render(component, { props }); diff --git a/src/lib/emails/templates/DelegationMessageEmail.svelte b/src/lib/emails/templates/DelegationMessageEmail.svelte new file mode 100644 index 00000000..1e777420 --- /dev/null +++ b/src/lib/emails/templates/DelegationMessageEmail.svelte @@ -0,0 +1,135 @@ + + + + + {subject} + + + + + + {conferenceTitle} + + + + + + + + + + +
+ {senderInitials} + + + {senderLabel} + +
+ + + + {subject} + + + +
+ {#each messageLines as line} + + {line || '\u00A0'} + + {/each} +
+ + +
+ + Antworten + +
+ + + {#if threadMessages.length > 0} + {#each threadMessages as threadMsg} +
+ + + + + + + +
+ {threadMsg.senderInitials} + + + {threadMsg.senderLabel} · {threadMsg.sentAt} + +
+ + {threadMsg.subject} + + {#each threadMsg.body.split(/\r?\n/) as line} + + {line || '\u00A0'} + + {/each} +
+ {/each} + {/if} + + +
+ + Gesendet ĂĽber {conferenceTitle}. Deine E-Mail-Adresse wurde nicht offengelegt. Bitte + antworte nicht direkt auf diese E-Mail, sondern nutze den Button oben. + +
+ + diff --git a/src/lib/queries/myConferenceparticipationQuery.ts b/src/lib/queries/myConferenceparticipationQuery.ts index 865fce0d..6adbc308 100644 --- a/src/lib/queries/myConferenceparticipationQuery.ts +++ b/src/lib/queries/myConferenceparticipationQuery.ts @@ -4,6 +4,7 @@ export const myConferenceparticipationQuery = graphql(` query MyConferenceparticipationQuery($userId: String!, $conferenceId: String!) { findUniqueUser(where: { id: $userId }) { birthday + canReceiveDelegationMail } findUniqueConferenceParticipantStatus( where: { userId_conferenceId: { userId: $userId, conferenceId: $conferenceId } } @@ -27,6 +28,7 @@ export const myConferenceparticipationQuery = graphql(` linkToPaperInbox isOpenPaperSubmission showCalendar + allowMessaging timezone state startConference diff --git a/src/routes/(authenticated)/Breadcrumbs.svelte b/src/routes/(authenticated)/Breadcrumbs.svelte index 4723126d..5b2fbfb6 100644 --- a/src/routes/(authenticated)/Breadcrumbs.svelte +++ b/src/routes/(authenticated)/Breadcrumbs.svelte @@ -237,9 +237,21 @@ icon: 'eye', translation: m.view() }, + messaging: { + icon: 'envelope', + translation: m.messagingMessaging() + }, 'registration-mode': { icon: 'id-card', translation: m.registrationMode() + }, + compose: { + icon: 'plus', + translation: m.compose() + }, + history: { + icon: 'clock-rotate-left', + translation: m.history() } }; diff --git a/src/routes/(authenticated)/assignment-assistant/[projectId]/schemas.ts b/src/routes/(authenticated)/assignment-assistant/[projectId]/schemas.ts new file mode 100644 index 00000000..887c8f64 --- /dev/null +++ b/src/routes/(authenticated)/assignment-assistant/[projectId]/schemas.ts @@ -0,0 +1,149 @@ +import { z } from 'zod'; + +export const NationSchema = z.object({ + alpha2Code: z.string(), + alpha3Code: z.string() +}); + +export const CommitteeSchema = z.object({ + id: z.string(), + name: z.string(), + abbreviation: z.string(), + numOfSeatsPerDelegation: z.number(), + nations: z.array(NationSchema) +}); + +export const IndividualApplicationOptionSchema = z.object({ + id: z.string(), + name: z.string(), + fontAwesomeIcon: z.string().nullable() +}); + +export const NonStateActorSchema = z.object({ + id: z.string(), + name: z.string(), + abbreviation: z.string(), + fontAwesomeIcon: z.string().nullable(), + seatAmount: z.number() +}); + +export const AppliedForDelegationRoleSchema = z.object({ + id: z.string(), + rank: z.number(), + nation: NationSchema.nullish(), + nonStateActor: NonStateActorSchema.nullish(), + fontAwesomeIcon: z.undefined(), + name: z.undefined() +}); + +export const UserSchema = z.object({ + id: z.string() +}); + +export const SupervisorSchema = z.object({ + id: z.string(), + user: UserSchema +}); + +export const MemberSchema = z.object({ + id: z.string(), + isHeadDelegate: z.boolean(), + user: UserSchema, + supervisors: z.optional(z.array(SupervisorSchema)) +}); + +export const AppliedForSingleRoleSchema = z.object({ + id: z.string(), + fontAwesomeIcon: z.string().nullable(), + name: z.string(), + rank: z.undefined(), + nation: z.undefined(), + nonStateActor: z.undefined() +}); + +export const SightingPropsSchema = z.object({ + evaluation: z.number().nullish(), + flagged: z.boolean().nullish(), + disqualified: z.boolean().nullish(), + note: z.string().nullish() +}); + +export const DelegationAssignmentSchema = z.object({ + assignedNation: NationSchema.nullish() +}); + +export const NSAAssignmentSchema = z.object({ + assignedNSA: NonStateActorSchema.nullish() +}); + +export const SingleAssignmentSchema = z.object({ + assignedRole: IndividualApplicationOptionSchema.nullish() +}); + +export const ConferenceSchema = z.object({ + id: z.string(), + title: z.string(), + startConference: z.coerce.date(), + committees: z.array(CommitteeSchema), + nonStateActors: z.array(NonStateActorSchema), + individualApplicationOptions: z.array(IndividualApplicationOptionSchema) +}); + +export const DelegationSchema = z.object({ + id: z.string(), + appliedForRoles: z.array(AppliedForDelegationRoleSchema), + members: z.array(MemberSchema), + school: z.string().optional(), + supervisors: z.undefined(), + user: z.undefined(), + splittedFrom: z.string().nullish(), + splittedInto: z.array(z.string()).nullish(), + ...SightingPropsSchema.shape, + ...DelegationAssignmentSchema.shape, + ...NSAAssignmentSchema.shape +}); + +export const SingleParticipantSchema = z.object({ + id: z.string(), + user: UserSchema, + appliedForRoles: z.array(AppliedForSingleRoleSchema), + school: z.string().optional(), + supervisors: z.optional(z.array(SupervisorSchema)), + members: z.undefined(), + splittedFrom: z.undefined(), + splittedInto: z.undefined(), + ...SightingPropsSchema.shape, + ...SingleAssignmentSchema.shape +}); + +export const ProjectDataSchema = z.object({ + conference: ConferenceSchema, + delegations: z.array(DelegationSchema), + singleParticipants: z.array(SingleParticipantSchema) +}); + +export const ProjectSchema = z.object({ + id: z.string(), + created: z.string(), + fileName: z.string(), + data: ProjectDataSchema +}); + +export type Nation = z.infer; +export type Committee = z.infer; +export type IndividualApplicationOption = z.infer; +export type NonStateActor = z.infer; +export type AppliedForDelegationRole = z.infer; +export type Delegation = z.infer; +export type AppliedForSingleRole = z.infer; +export type SingleParticipant = z.infer; +export type User = z.infer; +export type Member = z.infer; +export type Supervisor = z.infer; +export type SightingProps = z.infer; +export type DelegationAssignment = z.infer; +export type NSAAssignment = z.infer; +export type SingleAssignment = z.infer; +export type Conference = z.infer; +export type ProjectData = z.infer; +export type Project = z.infer; diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/+page.svelte b/src/routes/(authenticated)/dashboard/[conferenceId]/+page.svelte index 30b45a06..b21ecfa6 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/+page.svelte +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/+page.svelte @@ -13,6 +13,8 @@ import DelegationRegistrationStage from './stages/Delegation/DelegationRegistrationStage.svelte'; import DelegationPreparationStage from './stages/Delegation/DelegationPreparationStage.svelte'; import Supervisor from './stages/Supervisor/Supervisor.svelte'; + import { translateTeamRole } from '$lib/services/enumTranslations'; + import { onMount } from 'svelte'; import TeamMemberDashboard from './stages/TeamMember/TeamMemberDashboard.svelte'; import { configPublic } from '$config/public'; import CalendarSection from '$lib/components/Dashboard/CalendarSection.svelte'; @@ -30,10 +32,37 @@ let status = $derived(conferenceQueryData?.findUniqueConferenceParticipantStatus); let surveyQuestions = $derived(conferenceQueryData?.findManySurveyQuestions); let surveyAnswers = $derived(conferenceQueryData?.findManySurveyAnswers); + let showNewBadge = $state(false); + + const getContentSignature = () => + JSON.stringify({ + conference, + delegationMember, + singleParticipant, + supervisor, + teamMember, + status, + surveyQuestions, + surveyAnswers + }); + + onMount(() => { + if (!conference?.id || !data.user?.sub) return; + const storageKey = `dashboard-content:${conference.id}:${data.user.sub}`; + const signature = getContentSignature(); + const previousSignature = localStorage.getItem(storageKey); + showNewBadge = !!previousSignature && previousSignature !== signature; + localStorage.setItem(storageKey, signature); + });
+ {#if showNewBadge} +
+ new +
+ {/if} {#if conference} + + + import { m } from '$lib/paraglide/messages'; + import { page } from '$app/state'; + import { cache, graphql } from '$houdini'; + import { invalidateAll } from '$app/navigation'; + import DashboardSection from '$lib/components/Dashboard/DashboardSection.svelte'; + import type { PageData } from './$types'; + + const { data }: { data: PageData } = $props(); + + const conferenceId = $derived(page.params.conferenceId); + const basePath = $derived(`/dashboard/${conferenceId}/messaging`); + const canReceiveMail = $derived( + data.conferenceQueryData?.findUniqueUser?.canReceiveDelegationMail ?? false + ); + + let submitting = $state(false); + + const toggleMutation = graphql(` + mutation ToggleMessagingPreference( + $where: UserWhereUniqueInput! + $canReceiveDelegationMail: Boolean! + ) { + updateUserMessagingPreference( + where: $where + canReceiveDelegationMail: $canReceiveDelegationMail + ) { + id + } + } + `); + + async function toggleMessaging() { + submitting = true; + try { + await toggleMutation.mutate({ + where: { id: data.user.sub }, + canReceiveDelegationMail: !canReceiveMail + }); + cache.markStale(); + await invalidateAll(); + } finally { + submitting = false; + } + } + + +
+ +
+ + + + + +
+

{m.messagingCenter()}

+

+ {m.messagingDescription()} +

+ + + {m.messagingNewMessage()} + +
+
+ + + +
+
    +
  • + + {m.messagingAboutRecipientNotice()} +
  • +
  • + + {m.messagingAboutPrivacy()} +
  • +
  • + + {m.messagingAboutThreading()} +
  • +
  • + + {m.messagingAboutGrouped()} +
  • +
  • + + {m.messagingAboutDeliveryLog()} +
  • +
+ +
+
+ + + + + + + + +
    +
  • + + {m.messagingGuidelineRespectful()} +
  • +
  • + + {m.messagingGuidelineDiplomaticCode()} +
  • +
  • + + {m.messagingGuidelineNoSpam()} +
  • +
  • + + {m.messagingGuidelineConcise()} +
  • +
+ + + +
+
+ + diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.server.ts b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.server.ts new file mode 100644 index 00000000..16a47312 --- /dev/null +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.server.ts @@ -0,0 +1,92 @@ +import type { PageServerLoad } from './$types'; +import { error } from '@sveltejs/kit'; +import { graphql } from '$houdini'; + +const getMessageRecipientsQuery = graphql(` + query GetMessageRecipientsQuery($conferenceId: String!) { + getMessageRecipients(conferenceId: $conferenceId) { + groupId + groupLabel + category + fontAwesomeIcon + recipients { + id + label + firstName + lastName + alpha2Code + alpha3Code + fontAwesomeIcon + roleName + } + } + } +`); + +const getMessageForReplyQuery = graphql(` + query GetMessageForReplyQuery($messageAuditId: String!, $conferenceId: String!) { + getMessageForReply(messageAuditId: $messageAuditId, conferenceId: $conferenceId) { + id + subject + body + senderLabel + senderUserId + senderFirstName + senderLastName + senderAlpha2Code + senderAlpha3Code + senderFontAwesomeIcon + senderRoleName + sentAt + } + } +`); + +export const load: PageServerLoad = async (event) => { + const parent = await event.parent(); + const userId = parent.user?.sub; + if (!userId) { + throw error(401, 'Unauthorized'); + } + + const conferenceId = event.params.conferenceId; + if (!conferenceId) { + throw error(400, 'Missing conference id'); + } + + const replyToId = event.url.searchParams.get('replyTo'); + + try { + const result = await getMessageRecipientsQuery.fetch({ + event, + variables: { conferenceId }, + blocking: true + }); + + const recipientGroups = result.data?.getMessageRecipients ?? []; + + if (replyToId) { + const replyResult = await getMessageForReplyQuery.fetch({ + event, + variables: { messageAuditId: replyToId, conferenceId }, + blocking: true + }); + return { + recipientGroups, + replyToMessage: replyResult.data?.getMessageForReply ?? null + }; + } + + return { + recipientGroups, + replyToMessage: null as null + }; + } catch (loadError) { + console.error('Messaging recipients load error:', loadError); + return { + recipientGroups: [], + replyToMessage: null, + recipientLoadError: 'Unable to load recipients' + }; + } +}; diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte new file mode 100644 index 00000000..e7574589 --- /dev/null +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte @@ -0,0 +1,339 @@ + + +
+ +
+

+ {isReplyMode ? m.messagingReplyToMessage() : m.messageComposeMessage()} +

+

+ {#if isReplyMode && selectedRecipientObj} + {m.messagingRespondingTo({ recipientName: recipientDisplayName })} + {:else} + {m.messageSendToParticipants()} + {/if} +

+
+ + {#if !canReceiveMail} + + {/if} + + +
{ + e.preventDefault(); + handleSend(); + }} + > +
+ + + {#if isReplyMode && selectedRecipientObj} + +
+ +
+ {#if selectedRecipientObj.alpha2Code} + + {:else if selectedRecipientObj.fontAwesomeIcon} + + {:else} + + {/if} +
+ {recipientDisplayName} + {#if selectedRecipientObj.firstName && selectedRecipientObj.lastName} + + {selectedRecipientObj.firstName} + {selectedRecipientObj.lastName} + + {/if} +
+
+
+ {:else} + + + {#if !selectedRecipientObj} + + + {m.messagingOnlyEnabledUsers()} + + {/if} + {/if} +
+ + + +
+ + +
+ +
+ + +
+
+ + + {#if replyToMessage} + +
+
+ {replyToMessage.senderLabel} + · + {formattedSentAt} +
+
+ {replyToMessage.subject} +
+
+ {replyToMessage.body} +
+
+
+ {/if} + + +
+ + + {m.messagingSavedInHistory()} + +
+ + + {m.messageCancelButton()} + + +
+
+
+
+
diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/+page.server.ts b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/+page.server.ts new file mode 100644 index 00000000..0bb37ad3 --- /dev/null +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/+page.server.ts @@ -0,0 +1,48 @@ +import type { PageServerLoad } from './$types'; +import { error } from '@sveltejs/kit'; +import { graphql } from '$houdini'; + +const getMessageHistoryQuery = graphql(` + query GetMessageHistoryQuery($conferenceId: String!) { + getMessageHistory(conferenceId: $conferenceId) { + recipientLabel + subject + body + sentAt + status + } + } +`); + +export const load: PageServerLoad = async (event) => { + const parent = await event.parent(); + const userId = parent.user?.sub; + if (!userId) { + throw error(401, 'Unauthorized'); + } + + const conferenceId = event.params.conferenceId; + if (!conferenceId) { + throw error(400, 'Missing conference id'); + } + + try { + const result = await getMessageHistoryQuery.fetch({ + event, + variables: { conferenceId }, + blocking: true + }); + + const items = result.data?.getMessageHistory ?? []; + + return { + items + }; + } catch (historyError) { + console.error('Messaging history load error:', historyError); + return { + items: [], + historyLoadError: 'Unable to load history' + }; + } +}; diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/+page.svelte b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/+page.svelte new file mode 100644 index 00000000..c2a673d4 --- /dev/null +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/+page.svelte @@ -0,0 +1,196 @@ + + +
+ +
+
+

{m.messagingSentHistory()}

+

{m.messageReviewDeliveryStatus()}

+
+ + + {m.messagingNewMessage()} + +
+ + + {#if loadError} + + {/if} + + +
+
+ {#if messages.length === 0} +
+ +

+ {m.messagingNoMessagesYet()} +

+

+ {m.messageNoMessagesSent()} +

+ + + {m.messagingSendFirstMessage()} + +
+ {:else} +
+ + + + + + + + + + + + {#each messages as msg} + + + + + + + + {/each} + +
{m.messageRecipient()}{m.messageSubject()}{m.messagingSent()}{m.messagingStatus()}
{msg.recipientLabel}{msg.subject} + {new Date(msg.sentAt).toLocaleString()} + + {#if msg.status.toLowerCase() === 'delivered' || msg.status.toLowerCase() === 'sent'} + + + {msg.status} + + {:else if msg.status.toLowerCase() === 'pending'} + + + {msg.status} + + {:else if msg.status.toLowerCase() === 'failed'} + + + {msg.status} + + {:else} + + + {msg.status} + + {/if} + + +
+
+ {/if} +
+
+
+ + + + {#if selectedMessage} +
+
+ {m.messageRecipient()} +

{selectedMessage.recipientLabel}

+
+
+ {m.messageSubject()} +

{selectedMessage.subject}

+
+
+
+ {m.messagingSent()} +

{new Date(selectedMessage.sentAt).toLocaleString()}

+
+
+ {m.messagingStatus()} +
+ {#if selectedMessage.status.toLowerCase() === 'delivered' || selectedMessage.status.toLowerCase() === 'sent'} + + + {selectedMessage.status} + + {:else if selectedMessage.status.toLowerCase() === 'pending'} + + + {selectedMessage.status} + + {:else if selectedMessage.status.toLowerCase() === 'failed'} + + + {selectedMessage.status} + + {:else} + + + {selectedMessage.status} + + {/if} +
+
+
+
+
+

{selectedMessage.body}

+
+
+ {/if} + + {#snippet action()} + + {/snippet} +
diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/inbox/+page.server.ts b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/inbox/+page.server.ts new file mode 100644 index 00000000..98c6a0dd --- /dev/null +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/inbox/+page.server.ts @@ -0,0 +1,49 @@ +import type { PageServerLoad } from './$types'; +import { error } from '@sveltejs/kit'; +import { graphql } from '$houdini'; + +const getInboxMessagesQuery = graphql(` + query GetInboxMessagesQuery($conferenceId: String!) { + getInboxMessages(conferenceId: $conferenceId) { + id + senderLabel + subject + body + sentAt + hasThread + } + } +`); + +export const load: PageServerLoad = async (event) => { + const parent = await event.parent(); + const userId = parent.user?.sub; + if (!userId) { + throw error(401, 'Unauthorized'); + } + + const conferenceId = event.params.conferenceId; + if (!conferenceId) { + throw error(400, 'Missing conference id'); + } + + try { + const result = await getInboxMessagesQuery.fetch({ + event, + variables: { conferenceId }, + blocking: true + }); + + const items = result.data?.getInboxMessages ?? []; + + return { + items + }; + } catch (inboxError) { + console.error('Inbox load error:', inboxError); + return { + items: [], + inboxLoadError: 'Unable to load inbox' + }; + } +}; diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/inbox/+page.svelte b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/inbox/+page.svelte new file mode 100644 index 00000000..f5e854f4 --- /dev/null +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/inbox/+page.svelte @@ -0,0 +1,260 @@ + + +
+ +
+
+

{m.messagingInbox()}

+

{m.messagingInboxDescription()}

+
+ + + {m.messagingNewMessage()} + +
+ + + + + + {#if loadError} + + {/if} + + +
+
+ {#if messages.length === 0} +
+ +

+ {m.messagingInboxEmpty()} +

+

+ {m.messagingInboxEmptyDescription()} +

+
+ {:else} +
+ + + + + + + + + + + {#each messages as msg} + + + + + + + {/each} + +
{m.messagingInboxSender()}{m.messageSubject()}{m.messagingInboxReceived()}
{msg.senderLabel}{msg.subject} + {new Date(msg.sentAt).toLocaleString()} + +
+ {#if msg.hasThread} + + + + {/if} + +
+
+
+ {/if} +
+
+
+ + + + {#if selectedMessage} +
+
+ {m.messagingInboxSender()} +

{selectedMessage.senderLabel}

+
+
+ {m.messageSubject()} +

{selectedMessage.subject}

+
+
+ {m.messagingInboxReceived()} +

{new Date(selectedMessage.sentAt).toLocaleString()}

+
+
+
+

{selectedMessage.body}

+
+ + + {#if selectedMessage.hasThread} +
+ + + {#if threadVisible && threadMessages.length > 0} +
+

{m.messagingThreadTitle()}

+ {#each threadMessages as threadMsg} +
+
+
+ + {threadMsg.isCurrentUser ? m.messagingYou() : threadMsg.senderLabel} + + + {new Date(threadMsg.sentAt).toLocaleString()} + +
+

{threadMsg.subject}

+

{threadMsg.body}

+
+
+ {/each} +
+ {/if} + {/if} +
+ {/if} + + {#snippet action()} + {#if selectedMessage} + + + {m.messagingReply()} + + {/if} + + {/snippet} +
diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/utils.ts b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/utils.ts new file mode 100644 index 00000000..53d91622 --- /dev/null +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/utils.ts @@ -0,0 +1,47 @@ +import countries from 'world-countries'; + +export function getInitials(firstName: string, lastName: string) { + return `${firstName.charAt(0)}.${lastName.charAt(0)}.`; +} + +export function getDelegateLabel( + user: { given_name: string; family_name: string }, + delegationMember: { + delegation: { + assignedNation: { alpha3Code: string } | null; + assignedNonStateActor: { name: string } | null; + }; + assignedCommittee: { abbreviation: string | null; name: string } | null; + } | null, + singleParticipant: { + assignedRole: { name: string } | null; + } | null +) { + let label = 'Participant'; + + if (delegationMember) { + if (delegationMember.delegation.assignedNation) { + const nation = countries.find( + (c) => c.cca3 === delegationMember.delegation.assignedNation?.alpha3Code + ); + const nationName = nation + ? nation.name.common + : delegationMember.delegation.assignedNation.alpha3Code; + + label = nationName; + if (delegationMember.assignedCommittee) { + label += ` (${delegationMember.assignedCommittee.abbreviation || delegationMember.assignedCommittee.name})`; + } + } else if (delegationMember.delegation.assignedNonStateActor) { + label = delegationMember.delegation.assignedNonStateActor.name; + const initials = getInitials(user.given_name, user.family_name); + label += ` (${initials})`; + } + } else if (singleParticipant) { + if (singleParticipant.assignedRole) { + const initials = getInitials(user.given_name, user.family_name); + label = `${singleParticipant.assignedRole.name} (${initials})`; + } + } + return label; +} diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/stages/Delegation/DelegationPreparationStage.svelte b/src/routes/(authenticated)/dashboard/[conferenceId]/stages/Delegation/DelegationPreparationStage.svelte index 59a2ae1a..36561e39 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/stages/Delegation/DelegationPreparationStage.svelte +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/stages/Delegation/DelegationPreparationStage.svelte @@ -57,6 +57,7 @@ membersLackCommittees: delegationMember.delegation.members.some( (member) => !member.assignedCommittee ), + allowMessaging: conference.allowMessaging, user }); diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/stages/SingleParticipant/SingleParticipantPreparationStage.svelte b/src/routes/(authenticated)/dashboard/[conferenceId]/stages/SingleParticipant/SingleParticipantPreparationStage.svelte index 314c8d80..9c8dc688 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/stages/SingleParticipant/SingleParticipantPreparationStage.svelte +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/stages/SingleParticipant/SingleParticipantPreparationStage.svelte @@ -33,6 +33,7 @@ linkToPaperInbox: conference.linkToPaperInbox, surveyQuestionCount: surveyQuestions?.length ?? 0, surveyAnswerCount: surveyAnswers?.length ?? 0, + allowMessaging: conference.allowMessaging, user }); diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/stages/Supervisor/Supervisor.svelte b/src/routes/(authenticated)/dashboard/[conferenceId]/stages/Supervisor/Supervisor.svelte index bdbeadd8..c8647d96 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/stages/Supervisor/Supervisor.svelte +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/stages/Supervisor/Supervisor.svelte @@ -296,6 +296,7 @@ hasConferenceInfo: !!conference.info, linkToPreparationGuide: conference.linkToPreparationGuide, isOpenPaperSubmission: conference.isOpenPaperSubmission, + allowMessaging: conference.allowMessaging, user }); diff --git a/src/routes/(authenticated)/management/[conferenceId]/configuration/+page.server.ts b/src/routes/(authenticated)/management/[conferenceId]/configuration/+page.server.ts index 53d0b924..93060526 100644 --- a/src/routes/(authenticated)/management/[conferenceId]/configuration/+page.server.ts +++ b/src/routes/(authenticated)/management/[conferenceId]/configuration/+page.server.ts @@ -30,6 +30,7 @@ const conferenceQuery = graphql(` linkToServicesPage isOpenPaperSubmission showCalendar + allowMessaging timezone info showInfoExpanded diff --git a/src/routes/(authenticated)/management/[conferenceId]/configuration/+page.svelte b/src/routes/(authenticated)/management/[conferenceId]/configuration/+page.svelte index 6565df86..dd45a627 100644 --- a/src/routes/(authenticated)/management/[conferenceId]/configuration/+page.svelte +++ b/src/routes/(authenticated)/management/[conferenceId]/configuration/+page.svelte @@ -631,6 +631,15 @@ /> {m.showCalendar()} +
diff --git a/src/routes/(authenticated)/management/[conferenceId]/configuration/form-schema.ts b/src/routes/(authenticated)/management/[conferenceId]/configuration/form-schema.ts index 94dbadec..43a73ed8 100644 --- a/src/routes/(authenticated)/management/[conferenceId]/configuration/form-schema.ts +++ b/src/routes/(authenticated)/management/[conferenceId]/configuration/form-schema.ts @@ -14,6 +14,7 @@ export const conferenceSettingsFormSchema = z.object({ linkToServicesPage: z.string().nullish(), isOpenPaperSubmission: z.boolean().default(false), showCalendar: z.boolean().default(false), + allowMessaging: z.boolean().default(false), timezone: z.string().min(1).default('Europe/Berlin'), longTitle: z .string() diff --git a/src/routes/(authenticated)/my-account/+page.server.ts b/src/routes/(authenticated)/my-account/+page.server.ts index 9c94e6cb..6d775871 100644 --- a/src/routes/(authenticated)/my-account/+page.server.ts +++ b/src/routes/(authenticated)/my-account/+page.server.ts @@ -26,6 +26,7 @@ const userQuery = graphql(` emergencyContacts wantsToReceiveGeneralInformation wantsJoinTeamInformation + canReceiveDelegationMail } } `); diff --git a/src/routes/(authenticated)/my-account/+page.svelte b/src/routes/(authenticated)/my-account/+page.svelte index ce2cfcd1..9e6647d5 100644 --- a/src/routes/(authenticated)/my-account/+page.svelte +++ b/src/routes/(authenticated)/my-account/+page.svelte @@ -50,7 +50,7 @@
{/if} -
+
+ +
+ + + + +
+ {@html m.allowDelegationMailerDescription()} +
+
@@ -193,6 +207,7 @@ {m.edit()} +

{@html m.deleteAccountGPDR()}

diff --git a/src/routes/(authenticated)/my-account/form-schema.ts b/src/routes/(authenticated)/my-account/form-schema.ts index 846173de..7365fbee 100644 --- a/src/routes/(authenticated)/my-account/form-schema.ts +++ b/src/routes/(authenticated)/my-account/form-schema.ts @@ -68,5 +68,6 @@ export const userFormSchema = z.object({ return res || s; }), wantsToReceiveGeneralInformation: z.boolean().default(false), - wantsJoinTeamInformation: z.boolean().default(false) + wantsJoinTeamInformation: z.boolean().default(false), + canReceiveDelegationMail: z.boolean().default(false).optional() });