From 74300fa78edf6681cdae4ed193d195c28aa107ba Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Tue, 3 Mar 2026 22:29:16 +0100 Subject: [PATCH 01/89] feat: admin user table improvements, double delegations, and participant view - Allow double delegations: change committeeMember/conferenceMember user relations from one-to-one to one-to-many so multiple users can share a seat - Add admin config table with TanStack Table: sortable columns, global faceted search, paginated with ellipsis, edit modal for role and seat assignment - Show OIDC name and committee columns in the admin user table - Increase Rumble defaultLimit from 300 to 1000 to support larger conferences - Add participant view with identity card and committee pages - Add self-add to speakers list functionality - Add i18n strings for new features (en + de) Co-Authored-By: Claude Opus 4.6 --- bun.lock | 3 + messages/de.json | 24 + messages/en.json | 24 + package.json | 1 + schema.graphql | 20 +- src/api/db/relations.ts | 25 +- src/api/handlers/agendaItem.ts | 5 + src/api/handlers/committee.ts | 12 +- src/api/handlers/committeeMember.ts | 5 + src/api/handlers/conference.ts | 10 +- src/api/handlers/conferenceMember.ts | 5 + src/api/handlers/conferenceUser.ts | 67 ++- src/api/handlers/representation.ts | 5 + src/api/handlers/speakerOnList.ts | 232 +++++++++ src/api/handlers/speakersList.ts | 5 + src/api/rumble.ts | 2 +- src/lib/components/CommitteeGrid.svelte | 13 +- src/lib/components/Majorities.svelte | 26 +- src/routes/app/(launcher)/+page.svelte | 12 +- src/routes/app/(launcher)/+page.ts | 4 + .../[committeeId]/(chairs)/+layout.ts | 1 + .../(chairs)/committeeSubscription.ts | 1 + .../[committeeId]/(chairs)/setup/+page.svelte | 34 ++ .../DownloadPresenceData.svelte | 2 +- .../mission-control/config/+page.svelte | 451 ++++++++++++++++-- .../mission-control/config/+page.ts | 54 +++ .../config/EditConferenceUserModal.svelte | 180 +++++++ .../[conferenceId]/participant/+layout.svelte | 7 + .../app/[conferenceId]/participant/+layout.ts | 47 ++ .../[conferenceId]/participant/+page.svelte | 69 +++ .../app/[conferenceId]/participant/+page.ts | 32 ++ .../ParticipantIdentityCard.svelte | 31 ++ .../participant/[committeeId]/+page.svelte | 215 +++++++++ .../participant/[committeeId]/+page.ts | 81 ++++ .../[committeeId]/committeeSubscription.ts | 74 +++ 35 files changed, 1686 insertions(+), 93 deletions(-) create mode 100644 src/routes/app/[conferenceId]/mission-control/config/EditConferenceUserModal.svelte create mode 100644 src/routes/app/[conferenceId]/participant/+layout.svelte create mode 100644 src/routes/app/[conferenceId]/participant/+layout.ts create mode 100644 src/routes/app/[conferenceId]/participant/+page.svelte create mode 100644 src/routes/app/[conferenceId]/participant/+page.ts create mode 100644 src/routes/app/[conferenceId]/participant/ParticipantIdentityCard.svelte create mode 100644 src/routes/app/[conferenceId]/participant/[committeeId]/+page.svelte create mode 100644 src/routes/app/[conferenceId]/participant/[committeeId]/+page.ts create mode 100644 src/routes/app/[conferenceId]/participant/[committeeId]/committeeSubscription.ts diff --git a/bun.lock b/bun.lock index 95f54a59..d4637aee 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "munify-chase", "dependencies": { + "@tanstack/table-core": "^8.21.3", "drizzle-kit": "1.0.0-beta.1-fd5d1e8", "drizzle-orm": "1.0.0-beta.1-fd5d1e8", "pg": "^8.16.3", @@ -425,6 +426,8 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="], + "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], + "@tiptap/core": ["@tiptap/core@2.27.2", "", { "peerDependencies": { "@tiptap/pm": "^2.7.0" } }, "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ=="], "@tiptap/extension-blockquote": ["@tiptap/extension-blockquote@2.27.2", "", { "peerDependencies": { "@tiptap/core": "^2.7.0" } }, "sha512-oIGZgiAeA4tG3YxbTDfrmENL4/CIwGuP3THtHsNhwRqwsl9SfMk58Ucopi2GXTQSdYXpRJ0ahE6nPqB5D6j/Zw=="], diff --git a/messages/de.json b/messages/de.json index 06b97cfe..ba71c3c2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10,6 +10,7 @@ "addCommittee": "Gremium hinzufügen", "addCountriesCount": "{count} Länder hinzufügen", "addCountry": "Land hinzufügen", + "addMeToList": "Auf die Liste setzen", "addMember": "Mitglied hinzufügen", "addNonStateActor": "NA hinzufügen", "addUnActor": "UN-Akteur hinzufügen", @@ -19,7 +20,11 @@ "agendaItemTitle": "Titel des Tagesordnungspunkts", "agendaItems": "Tagesordnungspunkte", "allRightsReservedby": "Alle Rechte vorbehalten von", + "allowSelfAddToSpeakersList": "Selbst auf Redeliste setzen", + "allowSelfAddToSpeakersListDescription": "Delegierten und nichtstaatlichen Akteuren erlauben, sich selbst auf Redelisten zu setzen.", "announceAdoption": "Verabschiedung verkünden", + "assignedCount": "{count} zugewiesen", + "assignment": "Zuweisung", "back": "Zurück", "baseFontSize": "Basis-Schriftgröße", "baseFontSizeDescription": "Hier kann die Basis-Schriftgröße für die Präsentationsansicht festgelegt werden", @@ -75,6 +80,7 @@ "download": "Download", "downloadPresenceData": "Anwesenheitsdaten", "edit": "Bearbeiten", + "editUser": "Benutzer bearbeiten", "email": "E-Mail", "enterAlpha2Code": "Bitte Alpha2Code eingeben", "enterCountryCodes": "Ländercodes eingeben (Alpha-2 oder Alpha-3)", @@ -137,10 +143,12 @@ "layoutSelect": "Layout auswählen", "link": "Link", "listClosed": "Liste geschlossen", + "listClosedCannotAdd": "Die Liste ist geschlossen", "listEmpty": "Keine Rede", "login": "Anmelden", "logout": "Abmelden", "loose_slow_reindeer_build": "Gremienmitglieder", + "majorities": "Mehrheiten", "majoritySettings": "Mehrheitseinstellungen", "majoritySettingsDescriptions": "Die Einstellung der Mehrheitsverhältnisse dient der richtigen Darstellung, ob eine Mehrheit erreicht wurde.", "maroon_bland_ray_renew": "Gremien-Abkürzung", @@ -152,10 +160,12 @@ "minutesFromNow": "Relative Zeit: Springe X Minuten in die Zukunft", "missionControl": "Mission Control", "moderatedInformalCaucus": "Moderierte informelle Sitzung", + "name": "Name", "nextSpeaker": "Nächste Rede", "nextSpeakerDescription": "Möchtest du wirklich die nächste Rede aufrufen? Eventuelle Fragen- und Kurzbemerkungen werden verfallen.", "noAgendaItemSelected": "Kein Tagesordnungspunkt aktiv", "noAgendaItemSelectedDescription": "Um mit Redelisten arbeiten zu können muss zunächst ein Tagesordnungspunkt ausgewählt werden", + "noAssignmentNeeded": "Keine Mitgliedszuweisung für diese Rolle nötig.", "noCommentList": "Keine Liste für Fragen und Kurzbemerkungen", "noCurrentSpeaker": "Keine Rede", "noData": "Keine Daten", @@ -165,12 +175,16 @@ "nonStateActors": "Nichtstaatliche Akteure", "notAuthorized": "Du bist nicht berechtigt, auf diese Seite zuzugreifen", "notPresent": "Nicht anwesend", + "notPresentCannotAdd": "Du musst als anwesend markiert sein", "nothingChanged": "Nichts verändert", "numberedList": "Nummerierte Liste", "off": "Aus", "on": "An", + "onListPosition": "Du bist #{position} auf der Liste", "openPresentation": "Präsentationsansicht öffnen", + "paperSupportThresholdTooltip": "Benötigte Unterstützerstaaten für das Einreichen eines Änderungsantrags", "parsedCountries": "Hinzuzufügende Länder:", + "participantView": "Teilnehmeransicht", "pause": "Pause", "presence": "Anwesenheit", "present": "Anwesend", @@ -185,6 +199,7 @@ "regionalGroup_latinAmericaCaribbean": "Lateinamerika und Karibik", "regionalGroup_westernEuropeOthers": "Westeuropa und Andere", "regionalGroups": "Regionalgruppen", + "removeFromList": "Von der Liste entfernen", "removeMember": "Entfernen", "role": "Rolle", "rollCall": "Anwesenheitsfeststellung", @@ -195,7 +210,10 @@ "rollCollSuccess": "Anwesenheitsfeststellung abgeschlossen", "save": "Speichern", "searchCommitteeMembers": "Gremienmitglieder durchsuchen", + "searchUsers": "Benutzer suchen...", "selectAgendaItem": "Tagesordnungspunkt auswählen...", + "selectCommitteeMember": "Gremienmitglied auswählen...", + "selectConferenceMember": "Konferenzmitglied auswählen...", "selected": "Ausgewählt", "seoDescription": "MUNify CHASE ist das kostenlose Open-Source-Debattenmanagement-Tool für Model United Nations Konferenzen. Redelisten, Abstimmungen und Resolutionen digital verwalten.", "seoTitle": "MUNify CHASE – Debattenmanagement für Model United Nations", @@ -207,6 +225,7 @@ "short_sleek_snake_hint": "Gremium", "showOfHandsVoting": "Abstimmung per Handzeichen", "simpleMajority": "Einfach", + "simpleMajorityTooltip": "Benötigte Stimmen für die einfache Mehrheit", "speaker": "Redner*in", "speakersList": "Redeliste", "speakersListNamePlaceholder": "Neuer Name...", @@ -241,10 +260,13 @@ "toastUpdateError": "{targetName} konnte nicht aktualisiert werden", "toastUpdateLoading": "{targetName} wird aktualisiert...", "toastUpdateSuccess": "{targetName} aktualisiert", + "totalCountriesPresent": "Anzahl anwesender Staaten", "twoThirdsMajority": "Zwei-Drittel", + "twoThirdsMajorityTooltip": "Benötigte Stimmen für 2/3-Mehrheit", "typeOfVoting": "Abstimmungsart", "unActor": "UN-Akteur", "unActors": "UN-Akteure", + "unassigned": "Nicht zugewiesen", "underline": "Unterstrichen", "undo": "Rückgängig", "unknown": "unbekannt", @@ -261,6 +283,8 @@ "voteTitel": "Name der Abstimmung", "voteTitleDescription": "Der Titel der Abstimmung wird allen Teilnehmenden angezeigt und dient der Identifizierung. Wird es leer gelassen, wird als Fallback \"Abstimmung\" verwendet.", "voting": "Abstimmung", + "waitingForAssignment": "Warte auf Zuweisung", + "waitingForAssignmentDescription": "Du wurdest noch keinem Gremium zugewiesen. Bitte warte, bis ein Admin dich zuweist.", "whiteboard": "Whiteboard", "whiteboardIsEmpty": "Das Whiteboard ist momentan leer...", "whiteboardPlaceholder": "Beginne hier zu schreiben...", diff --git a/messages/en.json b/messages/en.json index f37f213a..7687ed0a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10,6 +10,7 @@ "addCommittee": "Add Committee", "addCountriesCount": "Add {count} countries", "addCountry": "Add Country", + "addMeToList": "Add me to list", "addMember": "Add Member", "addNonStateActor": "Add NGO", "addUnActor": "Add UN Actor", @@ -19,7 +20,11 @@ "agendaItemTitle": "Agenda Item Title", "agendaItems": "Agenda Items", "allRightsReservedby": "All rights reserved by", + "allowSelfAddToSpeakersList": "Self-add to Speakers List", + "allowSelfAddToSpeakersListDescription": "Allow delegates and non-state actors to add themselves to speakers lists.", "announceAdoption": "Announce Adoption", + "assignedCount": "{count} assigned", + "assignment": "Assignment", "back": "Back", "baseFontSize": "Base Font Size", "baseFontSizeDescription": "Here you can set the base font size for the presentation view.", @@ -75,6 +80,7 @@ "download": "Download", "downloadPresenceData": "Presence Data", "edit": "Edit", + "editUser": "Edit User", "email": "Email", "enterAlpha2Code": "Please enter Alpha2Code", "enterCountryCodes": "Enter country codes (Alpha-2 or Alpha-3)", @@ -137,10 +143,12 @@ "layoutSelect": "Select Layout", "link": "Hyperlink", "listClosed": "List closed", + "listClosedCannotAdd": "The list is closed", "listEmpty": "No speech", "login": "Register", "logout": "Log out", "loose_slow_reindeer_build": "Committee Members", + "majorities": "Majorities", "majoritySettings": "Majority Settings", "majoritySettingsDescriptions": "Majority settings help visualize whether a motion has passed.", "maroon_bland_ray_renew": "Committee abbreviation", @@ -152,10 +160,12 @@ "minutesFromNow": "Relative time: Jump X minutes into the future", "missionControl": "Mission Control", "moderatedInformalCaucus": "Moderated informal caucus", + "name": "Name", "nextSpeaker": "Next Speech", "nextSpeakerDescription": "Do you really want to call the next speech? All remaining Points of Information will be discarded.", "noAgendaItemSelected": "No agenda item active", "noAgendaItemSelectedDescription": "To work with speakers' lists, you must first select an agenda item.", + "noAssignmentNeeded": "No member assignment needed for this role.", "noCommentList": "No Point of Information List", "noCurrentSpeaker": "No speech", "noData": "No data", @@ -165,12 +175,16 @@ "nonStateActors": "Non-state Actors", "notAuthorized": "You are not authorized to access this page", "notPresent": "Not present", + "notPresentCannotAdd": "You must be marked as present to add yourself", "nothingChanged": "Nothing changed", "numberedList": "Numbered list", "off": "Off", "on": "On", + "onListPosition": "You are #{position} on the list", "openPresentation": "Open Presentation View", + "paperSupportThresholdTooltip": "Supporting states required to submit an amendment", "parsedCountries": "Countries to add:", + "participantView": "Participant View", "pause": "Pause", "presence": "Presence", "present": "Present", @@ -185,6 +199,7 @@ "regionalGroup_latinAmericaCaribbean": "Latin America and the Caribbean", "regionalGroup_westernEuropeOthers": "Western Europe and Others", "regionalGroups": "Regional Groups", + "removeFromList": "Remove from list", "removeMember": "Remove", "role": "Role", "rollCall": "Roll Call", @@ -195,7 +210,10 @@ "rollCollSuccess": "Roll call complete", "save": "Save", "searchCommitteeMembers": "Search committee members", + "searchUsers": "Search users...", "selectAgendaItem": "Select agenda item...", + "selectCommitteeMember": "Select committee member...", + "selectConferenceMember": "Select conference member...", "selected": "Selected", "seoDescription": "MUNify CHASE is the free, open-source debate management tool for Model United Nations conferences. Manage speakers lists, voting, and resolutions digitally.", "seoTitle": "MUNify CHASE – Debate Management for Model United Nations", @@ -207,6 +225,7 @@ "short_sleek_snake_hint": "Committee", "showOfHandsVoting": "Vote by Show of Hands", "simpleMajority": "Simple", + "simpleMajorityTooltip": "Needed notes for simple majority", "speaker": "Speaker", "speakersList": "General Speakers' List", "speakersListNamePlaceholder": "New name...", @@ -241,10 +260,13 @@ "toastUpdateError": "Could not update {targetName}", "toastUpdateLoading": "Updating {targetName}...", "toastUpdateSuccess": "{targetName} updated", + "totalCountriesPresent": "Count of Present Countries", "twoThirdsMajority": "Two-thirds", + "twoThirdsMajorityTooltip": "Needed votes for two-thrids majority", "typeOfVoting": "Type of Vote", "unActor": "UN Actor", "unActors": "UN Actors", + "unassigned": "Unassigned", "underline": "Underlined", "undo": "Undo", "unknown": "unknown", @@ -261,6 +283,8 @@ "voteTitel": "Vote Title", "voteTitleDescription": "The vote title will be visible to all participants and is used for identification. If left empty, \"Vote\" will be used as fallback.", "voting": "Voting", + "waitingForAssignment": "Waiting for Assignment", + "waitingForAssignmentDescription": "You have not been assigned to a committee yet. Please wait for an admin to assign you.", "whiteboard": "Whiteboard", "whiteboardIsEmpty": "The whiteboard is currently empty...", "whiteboardPlaceholder": "Start writing here...", diff --git a/package.json b/package.json index bcbcbd66..69b3569d 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ }, "type": "module", "dependencies": { + "@tanstack/table-core": "^8.21.3", "drizzle-kit": "1.0.0-beta.1-fd5d1e8", "drizzle-orm": "1.0.0-beta.1-fd5d1e8", "pg": "^8.16.3" diff --git a/schema.graphql b/schema.graphql index 9113a95e..62f54a0f 100644 --- a/schema.graphql +++ b/schema.graphql @@ -49,6 +49,7 @@ type Committee { } type CommitteeMember { + committee(where: CommitteeWhereInputArgument): Committee! committeeId: ID! createdAt: DateTime! id: ID! @@ -57,10 +58,11 @@ type CommitteeMember { representation(where: RepresentationWhereInputArgument): Representation! representationId: ID! updatedAt: DateTime - user(where: ConferenceUserWhereInputArgument): ConferenceUser + users(limit: Int, offset: Int, where: ConferenceUserWhereInputArgument): [ConferenceUser!]! } input CommitteeMemberWhereInputArgument { + committee: CommitteeWhereInputArgument committeeId: ID createdAt: DateTime id: ID @@ -69,7 +71,7 @@ input CommitteeMemberWhereInputArgument { representation: RepresentationWhereInputArgument representationId: ID updatedAt: DateTime - user: ConferenceUserWhereInputArgument + users: ConferenceUserWhereInputArgument } enum CommitteeStatusEnum { @@ -132,7 +134,7 @@ type ConferenceMember { representationId: ID! speakerOnList(limit: Int, offset: Int, where: SpeakerOnListWhereInputArgument): [SpeakerOnList!]! updatedAt: DateTime - user(where: ConferenceUserWhereInputArgument): ConferenceUser + users(limit: Int, offset: Int, where: ConferenceUserWhereInputArgument): [ConferenceUser!]! } input ConferenceMemberWhereInputArgument { @@ -144,13 +146,15 @@ input ConferenceMemberWhereInputArgument { representationId: ID speakerOnList: SpeakerOnListWhereInputArgument updatedAt: DateTime - user: ConferenceUserWhereInputArgument + users: ConferenceUserWhereInputArgument } type ConferenceUser { + committeeMember(where: CommitteeMemberWhereInputArgument): CommitteeMember committeeMemberId: ID conference(where: ConferenceWhereInputArgument): Conference! conferenceId: ID! + conferenceMember(where: ConferenceMemberWhereInputArgument): ConferenceMember conferenceMemberId: ID conferenceUserType: ConferenceUserTypeEnum! createdAt: DateTime! @@ -169,9 +173,11 @@ enum ConferenceUserTypeEnum { } input ConferenceUserWhereInputArgument { + committeeMember: CommitteeMemberWhereInputArgument committeeMemberId: ID conference: ConferenceWhereInputArgument conferenceId: ID + conferenceMember: ConferenceMemberWhereInputArgument conferenceMemberId: ID conferenceUserType: ConferenceUserTypeEnum createdAt: DateTime @@ -271,9 +277,11 @@ type Mutation { importDelegatorConference(data: ImportData!): Conference moveSpeakerToPosition(id: ID!, position: Int!): SpeakerOnList removeSpeakerOnList(speakerOnListId: ID!): SpeakersList + selfAddToSpeakersList(speakersListId: ID!): SpeakerOnList + selfRemoveFromSpeakersList(speakersListId: ID!): SpeakersList setPresenceForCommitteeMembers(ids: [ID!]!, present: Boolean!): [CommitteeMember!] - updateCommittee(activeAgendaItemId: ID, id: ID!, lastResolutionAdoptionDate: DateTime, showWhiteboard: Boolean, stateOfDebate: String, status: CommitteeStatusEnum, statusHeadline: String, statusUntil: DateTime, whiteboardContent: String): Committee - updateConferenceUser(conferenceUserType: ConferenceUserTypeEnum!, id: ID!): ConferenceUser + updateCommittee(activeAgendaItemId: ID, allowDelegationsToAddThemselvesToSpeakersList: Boolean, id: ID!, lastResolutionAdoptionDate: DateTime, showWhiteboard: Boolean, stateOfDebate: String, status: CommitteeStatusEnum, statusHeadline: String, statusUntil: DateTime, whiteboardContent: String): Committee + updateConferenceUser(committeeMemberId: ID, conferenceMemberId: ID, conferenceUserType: ConferenceUserTypeEnum!, id: ID!): ConferenceUser updateSpeakerOnList(id: ID!, overwriteName: String): SpeakerOnList updateSpeakersList(id: ID!, isClosed: Boolean, speakingTime: Int, startTimestamp: DateTime, stopTimer: Boolean = false, timeLeft: Int): SpeakersList } diff --git a/src/api/db/relations.ts b/src/api/db/relations.ts index 3bd526b4..26d7e829 100644 --- a/src/api/db/relations.ts +++ b/src/api/db/relations.ts @@ -50,10 +50,14 @@ export const relations = defineRelations(schema, (r) => ({ to: r.representation.id, optional: false }), - user: r.one.conferenceUser({ + committee: r.one.committee({ + from: r.committeeMember.committeeId, + to: r.committee.id, + optional: false + }), + users: r.many.conferenceUser({ from: r.committeeMember.id, - to: r.conferenceUser.committeeMemberId, - optional: true + to: r.conferenceUser.committeeMemberId }), presenceChangedTimestamps: r.many.presenceChangedTimestamp({ from: r.committeeMember.id, @@ -69,6 +73,16 @@ export const relations = defineRelations(schema, (r) => ({ from: r.conferenceUser.conferenceId, to: r.conference.id, optional: false + }), + committeeMember: r.one.committeeMember({ + from: r.conferenceUser.committeeMemberId, + to: r.committeeMember.id, + optional: true + }), + conferenceMember: r.one.conferenceMember({ + from: r.conferenceUser.conferenceMemberId, + to: r.conferenceMember.id, + optional: true }) }, representation: { @@ -99,10 +113,9 @@ export const relations = defineRelations(schema, (r) => ({ from: r.conferenceMember.id, to: r.speakerOnList.conferenceMemberId }), - user: r.one.conferenceUser({ + users: r.many.conferenceUser({ from: r.conferenceMember.id, - to: r.conferenceUser.conferenceMemberId, - optional: true + to: r.conferenceUser.conferenceMemberId }) }, agendaItem: { diff --git a/src/api/handlers/agendaItem.ts b/src/api/handlers/agendaItem.ts index 47588e6c..faac3d7d 100644 --- a/src/api/handlers/agendaItem.ts +++ b/src/api/handlers/agendaItem.ts @@ -43,6 +43,11 @@ abilityBuilder.agendaItem.allow(['read']).when(({ mustBeLoggedIn }) => { } }); +abilityBuilder.agendaItem.allow('read').when(({ mustBeLoggedIn }) => { + mustBeLoggedIn(); + return 'allow'; +}); + schemaBuilder.mutationFields((t) => { return { createAgendaItem: t.drizzleField({ diff --git a/src/api/handlers/committee.ts b/src/api/handlers/committee.ts index d58002b4..9d45fa83 100644 --- a/src/api/handlers/committee.ts +++ b/src/api/handlers/committee.ts @@ -24,6 +24,11 @@ abilityBuilder.committee.allow(['read', 'update']).when(({ mustBeLoggedIn }) => } }); +abilityBuilder.committee.allow('read').when(({ mustBeLoggedIn }) => { + mustBeLoggedIn(); + return 'allow'; +}); + const getTotalPresentCount = async ( parent: InferSelectModel & { members: (InferSelectModel & { @@ -124,7 +129,8 @@ schemaBuilder.mutationFields((t) => { activeAgendaItemId: t.arg.id(), lastResolutionAdoptionDate: t.arg({ type: 'DateTime' - }) + }), + allowDelegationsToAddThemselvesToSpeakersList: t.arg.boolean() }, resolve: async (query, root, args, ctx, info) => { await db @@ -137,7 +143,9 @@ schemaBuilder.mutationFields((t) => { statusUntil: args.statusUntil ?? undefined, stateOfDebate: args.stateOfDebate ?? undefined, activeAgendaItemId: args.activeAgendaItemId ?? undefined, - lastResolutionAdoptionDate: args.lastResolutionAdoptionDate ?? undefined + lastResolutionAdoptionDate: args.lastResolutionAdoptionDate ?? undefined, + allowDelegationsToAddThemselvesToSpeakersList: + args.allowDelegationsToAddThemselvesToSpeakersList ?? undefined }) .where( and( diff --git a/src/api/handlers/committeeMember.ts b/src/api/handlers/committeeMember.ts index aaf92f6b..ee64763b 100644 --- a/src/api/handlers/committeeMember.ts +++ b/src/api/handlers/committeeMember.ts @@ -13,6 +13,11 @@ abilityBuilder.committeeMember.allow(['read', 'update']).when(({ mustBeLoggedIn } }); +abilityBuilder.committeeMember.allow('read').when(({ mustBeLoggedIn }) => { + mustBeLoggedIn(); + return 'allow'; +}); + schemaBuilder.mutationFields((t) => { return { setPresenceForCommitteeMembers: t.drizzleField({ diff --git a/src/api/handlers/conference.ts b/src/api/handlers/conference.ts index ba543472..13e8ca9c 100644 --- a/src/api/handlers/conference.ts +++ b/src/api/handlers/conference.ts @@ -16,11 +16,11 @@ abilityBuilder.conference.allow('read').when(({ mustBeLoggedIn }) => { return 'allow'; } }); -// .when(({ user }) => { -// if (user) { -// return {}; -// } -// }); + +abilityBuilder.conference.allow('read').when(({ mustBeLoggedIn }) => { + mustBeLoggedIn(); + return 'allow'; +}); const ref = object({ table: 'conference', diff --git a/src/api/handlers/conferenceMember.ts b/src/api/handlers/conferenceMember.ts index dec11436..0c45680e 100644 --- a/src/api/handlers/conferenceMember.ts +++ b/src/api/handlers/conferenceMember.ts @@ -11,5 +11,10 @@ abilityBuilder.conferenceMember.allow('read').when(({ mustBeLoggedIn }) => { } }); +abilityBuilder.conferenceMember.allow('read').when(({ mustBeLoggedIn }) => { + mustBeLoggedIn(); + return 'allow'; +}); + export const ConferenceMemberWhereInput = arg; export const ConferenceMemberRef = ref; diff --git a/src/api/handlers/conferenceUser.ts b/src/api/handlers/conferenceUser.ts index aef10446..867ccd30 100644 --- a/src/api/handlers/conferenceUser.ts +++ b/src/api/handlers/conferenceUser.ts @@ -17,20 +17,10 @@ abilityBuilder.conferenceUser.allow('read').when(({ mustBeLoggedIn }) => { } }); -// abilityBuilder.conferenceUser.allow('read').when(({ user }) => { -// if (user) { -// return { -// where: eq(schema.conferenceUser.id, user.sub) -// }; -// } -// }); - -// abilityBuilder.conferenceUser.allow('read').when(({ user }) => { -// // TODO -// if (user) { -// return {}; -// } -// }); +abilityBuilder.conferenceUser.allow('read').when(({ mustBeLoggedIn }) => { + mustBeLoggedIn(); + return 'allow'; +}); /** * Helper to check if the current user is an ADMIN for a specific conference @@ -170,7 +160,9 @@ schemaBuilder.mutationFields((t) => ({ conferenceUserType: t.arg({ type: enum_({ tsName: 'conferenceUserType' }), required: true - }) + }), + committeeMemberId: t.arg({ type: 'ID' }), + conferenceMemberId: t.arg({ type: 'ID' }) }, resolve: async (query, root, args, ctx, info) => { // First get the conference user to find the conferenceId @@ -212,9 +204,52 @@ schemaBuilder.mutationFields((t) => ({ } } + // Validate committeeMemberId belongs to a committee in the same conference + if (args.committeeMemberId) { + const committeeMember = await db.query.committeeMember.findFirst({ + where: { id: args.committeeMemberId }, + with: { committee: true } + }); + if ( + !committeeMember || + committeeMember.committee.conferenceId !== conferenceUser.conferenceId + ) { + throw new GraphQLError('Committee member does not belong to this conference'); + } + } + + // Validate conferenceMemberId belongs to the same conference + if (args.conferenceMemberId) { + const conferenceMember = await db.query.conferenceMember.findFirst({ + where: { id: args.conferenceMemberId } + }); + if (!conferenceMember || conferenceMember.conferenceId !== conferenceUser.conferenceId) { + throw new GraphQLError('Conference member does not belong to this conference'); + } + } + + // Build the update set + const updateSet: Record = { + conferenceUserType: args.conferenceUserType + }; + + // Auto-clear: when role changes away from DELEGATE, clear committeeMemberId + if (args.conferenceUserType !== 'DELEGATE') { + updateSet.committeeMemberId = null; + } else if (args.committeeMemberId !== undefined) { + updateSet.committeeMemberId = args.committeeMemberId; + } + + // Auto-clear: when role changes away from NON_STATE_ACTOR, clear conferenceMemberId + if (args.conferenceUserType !== 'NON_STATE_ACTOR') { + updateSet.conferenceMemberId = null; + } else if (args.conferenceMemberId !== undefined) { + updateSet.conferenceMemberId = args.conferenceMemberId; + } + await db .update(schema.conferenceUser) - .set({ conferenceUserType: args.conferenceUserType }) + .set(updateSet) .where(eq(schema.conferenceUser.id, args.id)); pubsub.updated(args.id); diff --git a/src/api/handlers/representation.ts b/src/api/handlers/representation.ts index ee2ea2f4..6456dfc1 100644 --- a/src/api/handlers/representation.ts +++ b/src/api/handlers/representation.ts @@ -10,3 +10,8 @@ abilityBuilder.representation.allow(['read', 'update']).when(({ mustBeLoggedIn } return 'allow'; } }); + +abilityBuilder.representation.allow('read').when(({ mustBeLoggedIn }) => { + mustBeLoggedIn(); + return 'allow'; +}); diff --git a/src/api/handlers/speakerOnList.ts b/src/api/handlers/speakerOnList.ts index da8e2f8b..52a04e48 100644 --- a/src/api/handlers/speakerOnList.ts +++ b/src/api/handlers/speakerOnList.ts @@ -22,6 +22,11 @@ abilityBuilder.speakerOnList.allow(['read', 'update', 'delete']).when(({ mustBeL } }); +abilityBuilder.speakerOnList.allow('read').when(({ mustBeLoggedIn }) => { + mustBeLoggedIn(); + return 'allow'; +}); + schemaBuilder.mutationFields((t) => { return { updateSpeakerOnList: t.drizzleField({ @@ -194,6 +199,233 @@ schemaBuilder.mutationFields((t) => { .then(assertFindFirstExists); } }), + selfAddToSpeakersList: t.drizzleField({ + type: ref, + args: { + speakersListId: t.arg.id({ required: true }) + }, + resolve: async (query, root, args, ctx, info) => { + const user = ctx.mustBeLoggedIn(); + if (!user.email) { + throw new GraphQLError('User email is required'); + } + + const createdId = await db.transaction(async (tx) => { + // Find the user's conferenceUser record + const conferenceUser = await tx.query.conferenceUser.findFirst({ + where: { userEmail: user.email! }, + with: { + committeeMember: { + with: { committee: true } + }, + conferenceMember: true + } + }); + + if (!conferenceUser) { + throw new GraphQLError('Conference user not found'); + } + + if ( + conferenceUser.conferenceUserType !== 'DELEGATE' && + conferenceUser.conferenceUserType !== 'NON_STATE_ACTOR' + ) { + throw new GraphQLError( + 'Only delegates and non-state actors can self-add to speakers lists' + ); + } + + // Get the speakers list and traverse to committee to check the flag + const speakersList = await tx.query.speakersList.findFirst({ + where: { id: args.speakersListId }, + with: { + agendaItem: { + with: { + committee: true + } + } + } + }); + + if (!speakersList) { + throw new GraphQLError('Speakers list not found'); + } + + if (speakersList.isClosed) { + throw new GraphQLError('Speakers list is closed'); + } + + const committee = (speakersList as any).agendaItem?.committee; + if (!committee) { + throw new GraphQLError('Committee not found for this speakers list'); + } + if (!committee.allowDelegationsToAddThemselvesToSpeakersList) { + throw new GraphQLError( + 'Self-adding to speakers list is not enabled for this committee' + ); + } + + let committeeMemberId: string | null = null; + let conferenceMemberId: string | null = null; + + const confUser = conferenceUser as any; + if (conferenceUser.conferenceUserType === 'DELEGATE') { + if (!confUser.committeeMember) { + throw new GraphQLError('Delegate is not assigned to a committee'); + } + if (!confUser.committeeMember.present) { + throw new GraphQLError('Delegate must be marked as present to add to speakers list'); + } + if (confUser.committeeMember.committeeId !== committee.id) { + throw new GraphQLError('Delegate is not a member of this committee'); + } + committeeMemberId = confUser.committeeMember.id; + } else { + // NON_STATE_ACTOR + if (!confUser.conferenceMember) { + throw new GraphQLError('Non-state actor is not assigned a conference member'); + } + conferenceMemberId = confUser.conferenceMember.id; + } + + // Check not already on list + const existing = await tx.query.speakerOnList.findFirst({ + where: { + speakersListId: args.speakersListId, + ...(committeeMemberId ? { committeeMemberId } : {}), + ...(conferenceMemberId ? { conferenceMemberId } : {}) + } + }); + + if (existing) { + throw new GraphQLError('Already on speakers list'); + } + + // Append at end + const position = ( + await tx + .select({ count: count() }) + .from(table) + .where(eq(table.speakersListId, args.speakersListId)) + .then(assertFirstEntryExists) + ).count; + + const created = await tx + .insert(table) + .values({ + committeeMemberId, + conferenceMemberId, + speakersListId: args.speakersListId, + position + }) + .returning({ id: table.id }) + .then(assertFirstEntryExists); + + return created.id; + }); + + pubsub.created(); + + return db.query.speakerOnList + .findFirst( + query( + ctx.abilities.speakerOnList.filter('read', { + inject: { where: { id: createdId } } + }).query.single + ) + ) + .then(assertFindFirstExists); + } + }), + selfRemoveFromSpeakersList: t.drizzleField({ + type: SpeakersListRef, + args: { + speakersListId: t.arg.id({ required: true }) + }, + resolve: async (query, root, args, ctx, info) => { + const user = ctx.mustBeLoggedIn(); + if (!user.email) { + throw new GraphQLError('User email is required'); + } + + const removed = await db.transaction(async (tx) => { + const conferenceUser = await tx.query.conferenceUser.findFirst({ + where: { userEmail: user.email! }, + with: { + committeeMember: true, + conferenceMember: true + } + }); + + if (!conferenceUser) { + throw new GraphQLError('Conference user not found'); + } + + // Find own speaker entry on this list + let speakerOnList; + if (conferenceUser.committeeMemberId) { + speakerOnList = await tx.query.speakerOnList.findFirst({ + where: { + speakersListId: args.speakersListId, + committeeMemberId: conferenceUser.committeeMemberId + } + }); + } + if (!speakerOnList && conferenceUser.conferenceMemberId) { + speakerOnList = await tx.query.speakerOnList.findFirst({ + where: { + speakersListId: args.speakersListId, + conferenceMemberId: conferenceUser.conferenceMemberId + } + }); + } + + if (!speakerOnList) { + throw new GraphQLError('You are not on this speakers list'); + } + + const deleted = await tx + .delete(table) + .where(eq(table.id, speakerOnList.id)) + .returning() + .then(assertFirstEntryExists); + + // Shift positions down + const aboutToBeShiftedDown = await tx.query.speakerOnList.findMany({ + where: { + speakersListId: deleted.speakersListId, + position: { + gt: deleted.position + } + }, + orderBy: { position: 'asc' } + }); + + for (const speaker of aboutToBeShiftedDown) { + await tx + .update(table) + .set({ + position: sql`${table.position} - 1` + }) + .where(eq(table.id, speaker.id)); + } + + return deleted; + }); + + pubsub.removed(removed.id); + + return db.query.speakersList + .findFirst( + query( + ctx.abilities.speakersList.filter('read', { + inject: { where: { id: removed.speakersListId } } + }).query.single + ) + ) + .then(assertFindFirstExists); + } + }), moveSpeakerToPosition: t.drizzleField({ type: ref, args: { diff --git a/src/api/handlers/speakersList.ts b/src/api/handlers/speakersList.ts index 55040f2b..1d83a0b3 100644 --- a/src/api/handlers/speakersList.ts +++ b/src/api/handlers/speakersList.ts @@ -56,6 +56,11 @@ abilityBuilder.speakersList.allow(['read', 'update', 'delete']).when(({ mustBeLo } }); +abilityBuilder.speakersList.allow('read').when(({ mustBeLoggedIn }) => { + mustBeLoggedIn(); + return 'allow'; +}); + schemaBuilder.mutationFields((t) => { return { updateSpeakersList: t.drizzleField({ diff --git a/src/api/rumble.ts b/src/api/rumble.ts index 39505122..dc9f75d1 100644 --- a/src/api/rumble.ts +++ b/src/api/rumble.ts @@ -13,5 +13,5 @@ export const { abilityBuilder, schemaBuilder, arg, object, query, pubsub, create rumble({ db, context, - defaultLimit: 300 + defaultLimit: 1000 }); diff --git a/src/lib/components/CommitteeGrid.svelte b/src/lib/components/CommitteeGrid.svelte index 46ce6cf3..b916c587 100644 --- a/src/lib/components/CommitteeGrid.svelte +++ b/src/lib/components/CommitteeGrid.svelte @@ -2,14 +2,19 @@ import * as m from '$lib/paraglide/messages.js'; import IconInfoBox from './IconInfoBox.svelte'; import { getCommitteeStatusIcon, getCommitteeStatusText } from '$lib/utils/committeeStatus'; - import { type CommitteeOverviewQuery$result, type MissionControlQuery$result } from '$houdini'; + import { + type CommitteeOverviewQuery$result, + type MissionControlQuery$result, + type ParticipantConferenceQuery$result + } from '$houdini'; import AdoptionConfetti from './AdoptionConfetti.svelte'; interface Props { conference: | MissionControlQuery$result['findFirstConference'] - | CommitteeOverviewQuery$result['findFirstConference']; - environment?: 'SPECTATOR' | 'TEAM'; + | CommitteeOverviewQuery$result['findFirstConference'] + | ParticipantConferenceQuery$result['findFirstConference']; + environment?: 'SPECTATOR' | 'TEAM' | 'PARTICIPANT'; } let { conference, environment = 'SPECTATOR' }: Props = $props(); @@ -17,6 +22,8 @@ const getHref = (committeeId: string) => { if (environment === 'TEAM') { return `/app/${conference.id}/${committeeId}/setup`; + } else if (environment === 'PARTICIPANT') { + return `/app/${conference.id}/participant/${committeeId}`; } else { return `/app/${conference.id}/${committeeId}`; } diff --git a/src/lib/components/Majorities.svelte b/src/lib/components/Majorities.svelte index 8cd3f95d..7990bc69 100644 --- a/src/lib/components/Majorities.svelte +++ b/src/lib/components/Majorities.svelte @@ -1,4 +1,6 @@ @@ -81,7 +87,7 @@ {#each conferenceData as c} {@const conf = c.conference} diff --git a/src/routes/app/(launcher)/+page.ts b/src/routes/app/(launcher)/+page.ts index cad4c342..b435452e 100644 --- a/src/routes/app/(launcher)/+page.ts +++ b/src/routes/app/(launcher)/+page.ts @@ -6,6 +6,10 @@ export const _houdini_load = graphql(` findManyConferenceUser(where: { user: { id: $userId } }) { id conferenceUserType + committeeMemberId + committeeMember { + committeeId + } conference { id title diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/+layout.ts b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/+layout.ts index d92f1fae..e9a406e2 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/+layout.ts +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/+layout.ts @@ -17,6 +17,7 @@ export const _houdini_load = graphql(` paperSupportThreshold whiteboardContent lastResolutionAdoptionDate + allowDelegationsToAddThemselvesToSpeakersList activeAgendaItem { id title diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/committeeSubscription.ts b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/committeeSubscription.ts index e01275b9..e32557cb 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/committeeSubscription.ts +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/committeeSubscription.ts @@ -16,6 +16,7 @@ export const CommitteeSubscription = graphql(` paperSupportThreshold whiteboardContent lastResolutionAdoptionDate + allowDelegationsToAddThemselvesToSpeakersList activeAgendaItem { id title diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/setup/+page.svelte b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/setup/+page.svelte index 1e9fae41..807238a2 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/setup/+page.svelte +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/setup/+page.svelte @@ -15,6 +15,7 @@ import StateOfDebate from '$lib/components/committee/StateOfDebateChanger.svelte'; import AgendaItemChanger from '$lib/components/committee/AgendaItemChanger.svelte'; import PresentationSettings from './PresentationSettings.svelte'; + import Tabs from '$lib/components/Tabs.svelte'; import { CommitteeSubscription } from '../committeeSubscription'; import { ScrollArea } from 'bits-ui'; import StatusWidget from '../StatusWidget.svelte'; @@ -42,6 +43,26 @@ } } `); + + const UpdateSelfAddMutation = graphql(` + mutation UpdateSelfAdd( + $committeeId: ID! + $allowDelegationsToAddThemselvesToSpeakersList: Boolean! + ) { + updateCommittee( + id: $committeeId + allowDelegationsToAddThemselvesToSpeakersList: $allowDelegationsToAddThemselvesToSpeakersList + ) { + id + allowDelegationsToAddThemselvesToSpeakersList + } + } + `); + + const selfAddTabs = [ + { id: true, label: m.on(), faIcon: 'fa-check' }, + { id: false, label: m.off(), faIcon: 'fa-xmark' } + ]; {#if committee} @@ -100,6 +121,19 @@ + +

{m.allowSelfAddToSpeakersListDescription()}

+ { + UpdateSelfAddMutation.mutate({ + committeeId: committee.id, + allowDelegationsToAddThemselvesToSpeakersList: tab + }); + }} + /> +
+ {#each getVisiblePages(table.getState().pagination.pageIndex, table.getPageCount()) as item, i (item === 'ellipsis' ? `ellipsis-${i}` : item)} + {#if item === 'ellipsis'} + + {:else} + + {/if} + {/each} + + + + {/if} +
{m.addMember()} @@ -267,3 +620,13 @@ {/if} + + diff --git a/src/routes/app/[conferenceId]/mission-control/config/+page.ts b/src/routes/app/[conferenceId]/mission-control/config/+page.ts index 40963e3d..41069650 100644 --- a/src/routes/app/[conferenceId]/mission-control/config/+page.ts +++ b/src/routes/app/[conferenceId]/mission-control/config/+page.ts @@ -11,6 +11,60 @@ export const _houdini_load = graphql(` id userEmail conferenceUserType + user { + givenName + familyName + } + committeeMember { + id + representation { + id + name + alpha2Code + alpha3Code + faIcon + } + committee { + id + name + abbreviation + } + } + conferenceMember { + id + representation { + id + name + alpha3Code + type + faIcon + } + } + } + committees { + id + name + abbreviation + members { + id + representation { + id + name + alpha2Code + alpha3Code + faIcon + } + } + } + members { + id + representation { + id + name + alpha3Code + type + faIcon + } } } currentUserRole: findManyConferenceUser( diff --git a/src/routes/app/[conferenceId]/mission-control/config/EditConferenceUserModal.svelte b/src/routes/app/[conferenceId]/mission-control/config/EditConferenceUserModal.svelte new file mode 100644 index 00000000..361a1060 --- /dev/null +++ b/src/routes/app/[conferenceId]/mission-control/config/EditConferenceUserModal.svelte @@ -0,0 +1,180 @@ + + + + {#if user} +

{m.editUser()}

+

{user.userEmail}

+ +
+ + +
+ + {#if selectedRole === 'DELEGATE'} +
+ + +
+ {:else if selectedRole === 'NON_STATE_ACTOR'} +
+ + +
+ {:else} +

{m.noAssignmentNeeded()}

+ {/if} + + + {/if} +
diff --git a/src/routes/app/[conferenceId]/participant/+layout.svelte b/src/routes/app/[conferenceId]/participant/+layout.svelte new file mode 100644 index 00000000..ae9c9d03 --- /dev/null +++ b/src/routes/app/[conferenceId]/participant/+layout.svelte @@ -0,0 +1,7 @@ + + +{@render children()} diff --git a/src/routes/app/[conferenceId]/participant/+layout.ts b/src/routes/app/[conferenceId]/participant/+layout.ts new file mode 100644 index 00000000..0b4f846c --- /dev/null +++ b/src/routes/app/[conferenceId]/participant/+layout.ts @@ -0,0 +1,47 @@ +import { graphql } from '$houdini'; +import type { ParticipantIdentityQueryVariables } from './$houdini'; + +export const _houdini_load = graphql(` + query ParticipantIdentityQuery($conferenceId: ID!, $userId: ID!) { + findManyConferenceUser(where: { conference: { id: $conferenceId }, user: { id: $userId } }) { + id + conferenceUserType + committeeMemberId + conferenceMemberId + committeeMember { + id + present + committeeId + representation { + id + name + alpha2Code + alpha3Code + type + faIcon + } + } + conferenceMember { + id + representation { + id + name + alpha3Code + type + faIcon + } + } + } + } +`); + +export const _ParticipantIdentityQueryVariables: ParticipantIdentityQueryVariables = async ( + event +) => { + const { user } = await event.parent(); + + return { + conferenceId: event.params.conferenceId, + userId: user.sub + }; +}; diff --git a/src/routes/app/[conferenceId]/participant/+page.svelte b/src/routes/app/[conferenceId]/participant/+page.svelte new file mode 100644 index 00000000..10335f5d --- /dev/null +++ b/src/routes/app/[conferenceId]/participant/+page.svelte @@ -0,0 +1,69 @@ + + + + {m.participantView()} - MUNify CHASE + + +{#if role === 'DELEGATE' && !myCommitteeId} + +
+ +

{m.waitingForAssignment()}

+

+ {m.waitingForAssignmentDescription()} +

+ + + {m.back()} + +
+{:else if conference} + + + +
+ +
+ + +{/if} diff --git a/src/routes/app/[conferenceId]/participant/+page.ts b/src/routes/app/[conferenceId]/participant/+page.ts new file mode 100644 index 00000000..62fe7ea9 --- /dev/null +++ b/src/routes/app/[conferenceId]/participant/+page.ts @@ -0,0 +1,32 @@ +import { graphql } from '$houdini'; +import type { ParticipantConferenceQueryVariables } from './$houdini'; + +export const _houdini_load = graphql(` + query ParticipantConferenceQuery($conferenceId: ID!) { + findFirstConference(where: { id: $conferenceId }) { + id + title + committees { + id + name + abbreviation + lastResolutionAdoptionDate + activeAgendaItem { + id + title + } + status + statusHeadline + statusUntil + } + } + } +`); + +export const _ParticipantConferenceQueryVariables: ParticipantConferenceQueryVariables = ( + event +) => { + return { + conferenceId: event.params.conferenceId + }; +}; diff --git a/src/routes/app/[conferenceId]/participant/ParticipantIdentityCard.svelte b/src/routes/app/[conferenceId]/participant/ParticipantIdentityCard.svelte new file mode 100644 index 00000000..dfe8f635 --- /dev/null +++ b/src/routes/app/[conferenceId]/participant/ParticipantIdentityCard.svelte @@ -0,0 +1,31 @@ + + +
+
+ {#if representation} + + {:else} + + {/if} + {displayName} +
+
diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/+page.svelte b/src/routes/app/[conferenceId]/participant/[committeeId]/+page.svelte new file mode 100644 index 00000000..e66ed992 --- /dev/null +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/+page.svelte @@ -0,0 +1,215 @@ + + + + {committee?.name ?? m.committee()} - MUNify CHASE + + +{#if committee} + + + + +
+ +
+ +
+ + +
+
+ + + {#if committee.statusHeadline} +
{committee.statusHeadline}
+ {/if} +
+
+
+
+

{m.majorities()}

+ +
+
+ + + {#if isParticipant || role === 'SPECTATOR'} + {#each [{ list: speakersList, label: m.speakersList(), myPosition: myPositionOnSpeakers }, { list: commentList, label: m.commentList(), myPosition: myPositionOnComments }] as { list, label, myPosition }} + {#if list} +
+
+

{label}

+ + + + + + + {#if canSelfAdd} + {#if myPosition !== null} + +
+ + {m.onListPosition({ position: String(myPosition + 1) })} + + +
+ {:else if list.isClosed} +
+ + {m.listClosedCannotAdd()} +
+ {:else if role === 'DELEGATE' && !myPresent} +
+ + {m.notPresentCannotAdd()} +
+ {:else} + + {/if} + {/if} +
+
+ {/if} + {/each} + {/if} + + + {#if committee.showWhiteboard && committee.whiteboardContent} +
+
+

{m.whiteboard()}

+ +
+
+ {/if} +
+{/if} diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/+page.ts b/src/routes/app/[conferenceId]/participant/[committeeId]/+page.ts new file mode 100644 index 00000000..c7d6d16e --- /dev/null +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/+page.ts @@ -0,0 +1,81 @@ +import { graphql } from '$houdini'; +import type { ParticipantCommitteeQueryVariables } from './$houdini'; + +export const _houdini_load = graphql(` + query ParticipantCommitteeQuery($committeeId: ID!) { + findFirstCommittee(where: { id: $committeeId }) { + id + abbreviation + name + status + statusHeadline + statusUntil + showWhiteboard + whiteboardContent + allowDelegationsToAddThemselvesToSpeakersList + totalPresent + simpleMajority + twoThirdsMajority + paperSupportThreshold + activeAgendaItem { + id + title + speakersList { + id + type + isClosed + speakingTime + startTimestamp + timeLeft + speakers { + id + position + overwriteName + committeeMember { + id + representation { + id + type + name + regionalGroup + alpha2Code + alpha3Code + faIcon + } + present + } + conferenceMember { + id + representation { + id + type + name + regionalGroup + alpha2Code + alpha3Code + faIcon + } + } + } + } + } + members { + id + present + representation { + id + type + name + alpha2Code + faIcon + } + } + } + } +`); + +export const _ParticipantCommitteeQueryVariables: ParticipantCommitteeQueryVariables = (event) => { + return { + committeeId: event.params.committeeId + }; +}; diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/committeeSubscription.ts b/src/routes/app/[conferenceId]/participant/[committeeId]/committeeSubscription.ts new file mode 100644 index 00000000..395d0eb3 --- /dev/null +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/committeeSubscription.ts @@ -0,0 +1,74 @@ +import { graphql } from '$houdini'; + +export const ParticipantCommitteeSubscription = graphql(` + subscription ParticipantCommitteeSubscription($id: ID!) { + findFirstCommittee(where: { id: $id }) { + id + abbreviation + name + status + statusHeadline + statusUntil + showWhiteboard + whiteboardContent + allowDelegationsToAddThemselvesToSpeakersList + totalPresent + simpleMajority + twoThirdsMajority + paperSupportThreshold + activeAgendaItem { + id + title + speakersList { + id + type + isClosed + speakingTime + startTimestamp + timeLeft + speakers { + id + position + overwriteName + committeeMember { + id + representation { + id + type + name + regionalGroup + alpha2Code + alpha3Code + faIcon + } + present + } + conferenceMember { + id + representation { + id + type + name + regionalGroup + alpha2Code + alpha3Code + faIcon + } + } + } + } + } + members { + id + present + representation { + id + type + name + alpha2Code + faIcon + } + } + } + } +`); From 1fd857d1b433a050e62cfd9a46415c27bef5383d Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Tue, 3 Mar 2026 23:41:27 +0100 Subject: [PATCH 02/89] feat: tabbed conference config page with full CRUD for committees, delegations, and NSAs Restructure the config page into 5 tabs (General, Users, Committees, Delegations, Non-State Actors). Add backend mutations for create/update/delete on conferences, committees, representations, committee members, and conference members. Extract existing user management into UsersTab and build new tab components for managing conference settings, committees, delegations (with bulk country-code add and committee seat editing), and NSA/UN actors. Co-Authored-By: Claude Opus 4.6 --- messages/de.json | 10 + messages/en.json | 10 + schema.graphql | 11 +- src/api/handlers/committee.ts | 71 +- src/api/handlers/committeeMember.ts | 72 +- src/api/handlers/conference.ts | 50 +- src/api/handlers/conferenceMember.ts | 67 +- src/api/handlers/conferenceUser.ts | 2 +- src/api/handlers/representation.ts | 105 ++- .../mission-control/config/+page.svelte | 647 ++---------------- .../mission-control/config/+page.ts | 11 + .../config/CommitteesTab.svelte | 233 +++++++ .../config/DelegationsTab.svelte | 188 +++++ .../config/EditDelegationModal.svelte | 174 +++++ .../mission-control/config/GeneralTab.svelte | 123 ++++ .../mission-control/config/NsaTab.svelte | 186 +++++ .../mission-control/config/UsersTab.svelte | 619 +++++++++++++++++ 17 files changed, 1984 insertions(+), 595 deletions(-) create mode 100644 src/routes/app/[conferenceId]/mission-control/config/CommitteesTab.svelte create mode 100644 src/routes/app/[conferenceId]/mission-control/config/DelegationsTab.svelte create mode 100644 src/routes/app/[conferenceId]/mission-control/config/EditDelegationModal.svelte create mode 100644 src/routes/app/[conferenceId]/mission-control/config/GeneralTab.svelte create mode 100644 src/routes/app/[conferenceId]/mission-control/config/NsaTab.svelte create mode 100644 src/routes/app/[conferenceId]/mission-control/config/UsersTab.svelte diff --git a/messages/de.json b/messages/de.json index ba71c3c2..1b9e1498 100644 --- a/messages/de.json +++ b/messages/de.json @@ -13,6 +13,7 @@ "addMeToList": "Auf die Liste setzen", "addMember": "Mitglied hinzufügen", "addNonStateActor": "NA hinzufügen", + "addRepresentation": "Delegation hinzufügen", "addUnActor": "UN-Akteur hinzufügen", "admin": "Admin", "adoptionAnnouncement": "BREAKING: Verabschiedung einer Resolution zum Thema \"{agendaItem}\" im Gremium {committeeName}", @@ -55,6 +56,7 @@ "committeeOverview": "Gremienübersicht", "committeeStatus": "Gremienstatus", "committeeStatusExpired": "{status} abgelaufen!", + "committees": "Gremien", "con": "Dagegen", "conferenceCreated": "Konferenz erstellt!", "conferenceCreationError": "Konferenz konnte nicht erstellt werden", @@ -63,6 +65,8 @@ "conferenceMembers": "Konferenzmitglieder", "conferenceTitle": "Konferenztitel", "configuration": "Konfiguration", + "confirmDeleteCommittee": "Möchtest du dieses Gremium wirklich löschen? Alle zugehörigen Daten gehen verloren.", + "confirmDeleteRepresentation": "Möchtest du diese Delegation wirklich entfernen? Zugehörige Gremienmitgliedschaften werden entfernt.", "confirmRemoveMember": "Möchtest du dieses Mitglied wirklich entfernen?", "countries": "Länder", "countriesRecognized": "{count} Länder erkannt", @@ -76,6 +80,7 @@ "dateCannotBeInPast": "Das Datum darf nicht in der Vergangenheit liegen!", "delegate": "Delegierte*r", "delegations": "Delegationen", + "deleteRepresentation": "Delegation entfernen", "displayRegionalGroups": "Regionalgruppenanzeige", "download": "Download", "downloadPresenceData": "Anwesenheitsdaten", @@ -91,10 +96,13 @@ "fileParseError": "Fehler beim Parsen der Datei", "formalDebate": "Formale Debatte", "forward": "Weiter", + "general": "Allgemein", "gotoSettings": "Zu den Einstellungen", "h1": "Überschrift 1", "h2": "Überschrift 2", "h3": "Überschrift 3", + "hasModeratedCaucus": "Moderierte informelle Sitzung", + "hasModeratedCaucusDescription": "Moderierte informelle Sitzung als Gremien-Status aktivieren.", "home": "Home", "homeAboutText": "CHASE (CHAirSoftwarE) ist eine Webanwendung zur Verwaltung und Durchführung von Debatten in Model United Nations Konferenzen. Sie ist für Vorsitzende und Delegierte gleichermaßen konzipiert. CHASE ermöglicht es Vorsitzenden, Debatten einfach zu verwalten, während Delegierte der Debatte folgen und mit anderen Delegierten auf intuitive und strukturierte Weise zusammenarbeiten können. CHASE ist freie und open source Software.", "homeAboutTitle": "Über CHASE", @@ -189,6 +197,7 @@ "presence": "Anwesenheit", "present": "Anwesend", "presentationMode": "Präsentationsansicht", + "pressWebsite": "Presse-Website", "pro": "Dafür", "publish": "Veröffentlichen", "publishChanges": "Änderungen Veröffentlichen", @@ -279,6 +288,7 @@ "upload": "Hochladen", "url": "URL", "userAlreadyExists": "Benutzer existiert bereits in dieser Konferenz: {email}", + "users": "Benutzer", "version": "Version", "voteTitel": "Name der Abstimmung", "voteTitleDescription": "Der Titel der Abstimmung wird allen Teilnehmenden angezeigt und dient der Identifizierung. Wird es leer gelassen, wird als Fallback \"Abstimmung\" verwendet.", diff --git a/messages/en.json b/messages/en.json index 7687ed0a..a3edcbb8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -13,6 +13,7 @@ "addMeToList": "Add me to list", "addMember": "Add Member", "addNonStateActor": "Add NGO", + "addRepresentation": "Add Delegation", "addUnActor": "Add UN Actor", "admin": "Admin", "adoptionAnnouncement": "BREAKING: Resolution on \"{agendaItem}\" adopted in the committee {committeeName}", @@ -55,6 +56,7 @@ "committeeOverview": "Committee Overview", "committeeStatus": "Committee Status", "committeeStatusExpired": "{status} expired!", + "committees": "Committees", "con": "Against", "conferenceCreated": "Conference created!", "conferenceCreationError": "Could not create conference", @@ -63,6 +65,8 @@ "conferenceMembers": "Conference Members", "conferenceTitle": "Conference Title", "configuration": "Configuration", + "confirmDeleteCommittee": "Are you sure you want to delete this committee? All associated data will be lost.", + "confirmDeleteRepresentation": "Are you sure you want to remove this delegation? Associated committee memberships will be removed.", "confirmRemoveMember": "Are you sure you want to remove this member?", "countries": "Countries", "countriesRecognized": "{count} countries recognized", @@ -76,6 +80,7 @@ "dateCannotBeInPast": "The date must not be in the past!", "delegate": "Delegate", "delegations": "Delegations", + "deleteRepresentation": "Remove Delegation", "displayRegionalGroups": "Display Regional Blocs", "download": "Download", "downloadPresenceData": "Presence Data", @@ -91,10 +96,13 @@ "fileParseError": "Error parsing file", "formalDebate": "Formal debate", "forward": "Next", + "general": "General", "gotoSettings": "Go to settings", "h1": "Heading 1", "h2": "Heading 2", "h3": "Heading 3", + "hasModeratedCaucus": "Moderated Caucus", + "hasModeratedCaucusDescription": "Enable moderated informal caucus as a committee status option.", "home": "Home", "homeAboutText": "CHASE (CHAirSoftwarE) is a web application for managing and conducting debates at Model United Nations conferences. It is designed for both chairs and delegates. CHASE allows chairs to easily manage debates, while delegates can follow the discussion and collaborate with others in an intuitive and structured way. CHASE is free and open-source software.", "homeAboutTitle": "About CHASE", @@ -189,6 +197,7 @@ "presence": "Presence", "present": "Present", "presentationMode": "Presentation View", + "pressWebsite": "Press Website", "pro": "In Favor", "publish": "Publish", "publishChanges": "Publish changes", @@ -279,6 +288,7 @@ "upload": "Upload", "url": "URL", "userAlreadyExists": "User already exists in this conference: {email}", + "users": "Users", "version": "Version", "voteTitel": "Vote Title", "voteTitleDescription": "The vote title will be visible to all participants and is used for identification. If left empty, \"Vote\" will be used as fallback.", diff --git a/schema.graphql b/schema.graphql index 62f54a0f..8041d965 100644 --- a/schema.graphql +++ b/schema.graphql @@ -272,15 +272,24 @@ type Mutation { addSpeakerOnList(committeeMemberId: ID, conferenceMemberId: ID, position: Int, speakersListId: ID!): SpeakerOnList clearSpeakersList(id: ID!): SpeakersList createAgendaItem(committeeId: ID!, title: String!): AgendaItem + createCommittee(abbreviation: String!, conferenceId: ID!, name: String!): Committee + createCommitteeMember(committeeId: ID!, representationId: ID!): CommitteeMember + createConferenceMember(conferenceId: ID!, representationId: ID!): ConferenceMember createConferenceUser(conferenceId: ID!, conferenceUserType: ConferenceUserTypeEnum!, userEmail: String!): ConferenceUser + createRepresentation(alpha2Code: String, alpha3Code: String, conferenceId: ID!, faIcon: String, name: String, type: RepresentationTypeEnum!): Representation + deleteCommittee(id: ID!): Boolean + deleteCommitteeMember(id: ID!): Boolean + deleteConferenceMember(id: ID!): Boolean deleteConferenceUser(id: ID!): Boolean + deleteRepresentation(id: ID!): Boolean importDelegatorConference(data: ImportData!): Conference moveSpeakerToPosition(id: ID!, position: Int!): SpeakerOnList removeSpeakerOnList(speakerOnListId: ID!): SpeakersList selfAddToSpeakersList(speakersListId: ID!): SpeakerOnList selfRemoveFromSpeakersList(speakersListId: ID!): SpeakersList setPresenceForCommitteeMembers(ids: [ID!]!, present: Boolean!): [CommitteeMember!] - updateCommittee(activeAgendaItemId: ID, allowDelegationsToAddThemselvesToSpeakersList: Boolean, id: ID!, lastResolutionAdoptionDate: DateTime, showWhiteboard: Boolean, stateOfDebate: String, status: CommitteeStatusEnum, statusHeadline: String, statusUntil: DateTime, whiteboardContent: String): Committee + updateCommittee(abbreviation: String, activeAgendaItemId: ID, allowDelegationsToAddThemselvesToSpeakersList: Boolean, id: ID!, lastResolutionAdoptionDate: DateTime, name: String, showWhiteboard: Boolean, stateOfDebate: String, status: CommitteeStatusEnum, statusHeadline: String, statusUntil: DateTime, whiteboardContent: String): Committee + updateConference(hasModeratedCaucus: Boolean, id: ID!, pressWebsite: String, title: String): Conference updateConferenceUser(committeeMemberId: ID, conferenceMemberId: ID, conferenceUserType: ConferenceUserTypeEnum!, id: ID!): ConferenceUser updateSpeakerOnList(id: ID!, overwriteName: String): SpeakerOnList updateSpeakersList(id: ID!, isClosed: Boolean, speakingTime: Int, startTimestamp: DateTime, stopTimer: Boolean = false, timeLeft: Int): SpeakersList diff --git a/src/api/handlers/committee.ts b/src/api/handlers/committee.ts index 9d45fa83..86497e83 100644 --- a/src/api/handlers/committee.ts +++ b/src/api/handlers/committee.ts @@ -9,9 +9,11 @@ import { arg as rumbleArg } from '$api/rumble'; import { isWhitelistedEmail } from '$api/services/isDMUNEmail'; -import { assertFirstEntryExists } from '@m1212e/rumble'; +import { assertFindFirstExists, assertFirstEntryExists } from '@m1212e/rumble'; import { and, count, eq, type InferSelectModel } from 'drizzle-orm'; import { calculateMajority } from '$lib/utils/majorities'; +import { assertConferenceAdmin } from './conferenceUser'; +import { GraphQLError } from 'graphql'; const statusEnum = enum_({ tsName: 'committeeStatus' @@ -109,13 +111,72 @@ query({ schemaBuilder.mutationFields((t) => { return { + createCommittee: t.drizzleField({ + type: ref, + args: { + conferenceId: t.arg.id({ required: true }), + name: t.arg.string({ required: true }), + abbreviation: t.arg.string({ required: true }) + }, + resolve: async (query, root, args, ctx, info) => { + await assertConferenceAdmin(ctx, args.conferenceId); + + const result = await db + .insert(schema.committee) + .values({ + conferenceId: args.conferenceId, + name: args.name, + abbreviation: args.abbreviation + }) + .returning() + .then(assertFirstEntryExists); + + pubsub.updated(result.id); + + return db.query.committee + .findFirst( + query( + ctx.abilities.committee.filter('read', { + inject: { + where: { id: result.id } + } + }).query.single + ) + ) + .then(assertFindFirstExists); + } + }), + + deleteCommittee: t.field({ + type: 'Boolean', + args: { + id: t.arg.id({ required: true }) + }, + resolve: async (root, args, ctx, info) => { + const committee = await db.query.committee.findFirst({ + where: { id: args.id } + }); + + if (!committee) { + throw new GraphQLError('Committee not found'); + } + + await assertConferenceAdmin(ctx, committee.conferenceId); + + await db.delete(schema.committee).where(eq(schema.committee.id, args.id)); + + pubsub.removed(args.id); + + return true; + } + }), + updateCommittee: t.drizzleField({ type: ref, args: { id: t.arg.id({ required: true }), - //TODO do we want to allow updates to these defaults? - // e.g. abbreviation and name probably are pretty static... - // name: t.arg.string(), + name: t.arg.string(), + abbreviation: t.arg.string(), whiteboardContent: t.arg.string(), showWhiteboard: t.arg.boolean(), status: t.arg({ @@ -136,6 +197,8 @@ schemaBuilder.mutationFields((t) => { await db .update(schema.committee) .set({ + name: args.name ?? undefined, + abbreviation: args.abbreviation ?? undefined, whiteboardContent: args.whiteboardContent ?? undefined, showWhiteboard: args.showWhiteboard ?? undefined, status: args.status ?? undefined, diff --git a/src/api/handlers/committeeMember.ts b/src/api/handlers/committeeMember.ts index ee64763b..8a864968 100644 --- a/src/api/handlers/committeeMember.ts +++ b/src/api/handlers/committeeMember.ts @@ -1,8 +1,11 @@ import { db, schema } from '$api/db/db'; import { abilityBuilder, schemaBuilder } from '$api/rumble'; -import { and, inArray } from 'drizzle-orm'; +import { and, eq, inArray } from 'drizzle-orm'; import { basics } from './basics'; import { isWhitelistedEmail } from '$api/services/isDMUNEmail'; +import { assertConferenceAdmin } from './conferenceUser'; +import { assertFindFirstExists, assertFirstEntryExists } from '@m1212e/rumble'; +import { GraphQLError } from 'graphql'; const { arg, ref, pubsub, table } = basics('committeeMember'); @@ -20,6 +23,73 @@ abilityBuilder.committeeMember.allow('read').when(({ mustBeLoggedIn }) => { schemaBuilder.mutationFields((t) => { return { + createCommitteeMember: t.drizzleField({ + type: ref, + args: { + committeeId: t.arg.id({ required: true }), + representationId: t.arg.id({ required: true }) + }, + resolve: async (query, root, args, ctx, info) => { + const committee = await db.query.committee.findFirst({ + where: { id: args.committeeId } + }); + + if (!committee) { + throw new GraphQLError('Committee not found'); + } + + await assertConferenceAdmin(ctx, committee.conferenceId); + + const result = await db + .insert(schema.committeeMember) + .values({ + committeeId: args.committeeId, + representationId: args.representationId + }) + .returning() + .then(assertFirstEntryExists); + + pubsub.updated(result.id); + + return db.query.committeeMember + .findFirst( + query( + ctx.abilities.committeeMember.filter('read', { + inject: { + where: { id: result.id } + } + }).query.single + ) + ) + .then(assertFindFirstExists); + } + }), + + deleteCommitteeMember: t.field({ + type: 'Boolean', + args: { + id: t.arg.id({ required: true }) + }, + resolve: async (root, args, ctx, info) => { + const committeeMember = await db.query.committeeMember.findFirst({ + where: { id: args.id }, + with: { committee: true } + }); + + if (!committeeMember) { + throw new GraphQLError('Committee member not found'); + } + + await assertConferenceAdmin(ctx, committeeMember.committee.conferenceId); + + await db.delete(schema.committeeMember).where(eq(schema.committeeMember.id, args.id)); + + pubsub.removed(args.id); + + return true; + } + }), + setPresenceForCommitteeMembers: t.drizzleField({ type: [ref], args: { diff --git a/src/api/handlers/conference.ts b/src/api/handlers/conference.ts index 13e8ca9c..40d8d646 100644 --- a/src/api/handlers/conference.ts +++ b/src/api/handlers/conference.ts @@ -1,15 +1,19 @@ -import { db } from '$api/db/db'; +import { db, schema } from '$api/db/db'; import { abilityBuilder, object, query, + schemaBuilder, pubsub as rumblePubsub, arg as rumbleArg } from '$api/rumble'; import { isWhitelistedEmail } from '$api/services/isDMUNEmail'; import { ConferenceMemberRef, ConferenceMemberWhereInput } from './conferenceMember'; +import { assertConferenceAdmin } from './conferenceUser'; +import { eq } from 'drizzle-orm'; +import { assertFindFirstExists } from '@m1212e/rumble'; -abilityBuilder.conference.allow('read').when(({ mustBeLoggedIn }) => { +abilityBuilder.conference.allow(['read', 'update']).when(({ mustBeLoggedIn }) => { const user = mustBeLoggedIn(); if (user?.email && isWhitelistedEmail(user.email)) { @@ -62,10 +66,48 @@ const ref = object({ }) }); -const pubsub = rumblePubsub({ table: 'committee' }); -const arg = rumbleArg({ table: 'committee' }); +const pubsub = rumblePubsub({ table: 'conference' }); +const arg = rumbleArg({ table: 'conference' }); query({ table: 'conference' }); +schemaBuilder.mutationFields((t) => ({ + updateConference: t.drizzleField({ + type: ref, + args: { + id: t.arg.id({ required: true }), + title: t.arg.string(), + pressWebsite: t.arg.string(), + hasModeratedCaucus: t.arg.boolean() + }, + resolve: async (query, root, args, ctx, info) => { + await assertConferenceAdmin(ctx, args.id); + + await db + .update(schema.conference) + .set({ + title: args.title ?? undefined, + pressWebsite: args.pressWebsite ?? undefined, + hasModeratedCaucus: args.hasModeratedCaucus ?? undefined + }) + .where(eq(schema.conference.id, args.id)); + + pubsub.updated(args.id); + + return db.query.conference + .findFirst( + query( + ctx.abilities.conference.filter('read', { + inject: { + where: { id: args.id } + } + }).query.single + ) + ) + .then(assertFindFirstExists); + } + }) +})); + export const ConferenceRef = ref; diff --git a/src/api/handlers/conferenceMember.ts b/src/api/handlers/conferenceMember.ts index 0c45680e..b92bde5c 100644 --- a/src/api/handlers/conferenceMember.ts +++ b/src/api/handlers/conferenceMember.ts @@ -1,6 +1,11 @@ -import { abilityBuilder } from '$api/rumble'; +import { db, schema } from '$api/db/db'; +import { abilityBuilder, schemaBuilder } from '$api/rumble'; import { isWhitelistedEmail } from '$api/services/isDMUNEmail'; import { basics } from './basics'; +import { assertConferenceAdmin } from './conferenceUser'; +import { assertFindFirstExists, assertFirstEntryExists } from '@m1212e/rumble'; +import { eq } from 'drizzle-orm'; +import { GraphQLError } from 'graphql'; const { arg, ref, pubsub, table } = basics('conferenceMember'); @@ -16,5 +21,65 @@ abilityBuilder.conferenceMember.allow('read').when(({ mustBeLoggedIn }) => { return 'allow'; }); +schemaBuilder.mutationFields((t) => ({ + createConferenceMember: t.drizzleField({ + type: ref, + args: { + conferenceId: t.arg.id({ required: true }), + representationId: t.arg.id({ required: true }) + }, + resolve: async (query, root, args, ctx, info) => { + await assertConferenceAdmin(ctx, args.conferenceId); + + const result = await db + .insert(schema.conferenceMember) + .values({ + conferenceId: args.conferenceId, + representationId: args.representationId + }) + .returning() + .then(assertFirstEntryExists); + + pubsub.updated(result.id); + + return db.query.conferenceMember + .findFirst( + query( + ctx.abilities.conferenceMember.filter('read', { + inject: { + where: { id: result.id } + } + }).query.single + ) + ) + .then(assertFindFirstExists); + } + }), + + deleteConferenceMember: t.field({ + type: 'Boolean', + args: { + id: t.arg.id({ required: true }) + }, + resolve: async (root, args, ctx, info) => { + const conferenceMember = await db.query.conferenceMember.findFirst({ + where: { id: args.id } + }); + + if (!conferenceMember) { + throw new GraphQLError('Conference member not found'); + } + + await assertConferenceAdmin(ctx, conferenceMember.conferenceId); + + await db.delete(schema.conferenceMember).where(eq(schema.conferenceMember.id, args.id)); + + pubsub.removed(args.id); + + return true; + } + }) +})); + export const ConferenceMemberWhereInput = arg; export const ConferenceMemberRef = ref; diff --git a/src/api/handlers/conferenceUser.ts b/src/api/handlers/conferenceUser.ts index 867ccd30..da332cc2 100644 --- a/src/api/handlers/conferenceUser.ts +++ b/src/api/handlers/conferenceUser.ts @@ -26,7 +26,7 @@ abilityBuilder.conferenceUser.allow('read').when(({ mustBeLoggedIn }) => { * Helper to check if the current user is an ADMIN for a specific conference * (either OIDC admin or conference ADMIN role) */ -async function assertConferenceAdmin( +export async function assertConferenceAdmin( ctx: { hasRole: (role: string) => boolean; mustBeLoggedIn: () => { email?: string | null } }, conferenceId: string ) { diff --git a/src/api/handlers/representation.ts b/src/api/handlers/representation.ts index 6456dfc1..4c74f8a7 100644 --- a/src/api/handlers/representation.ts +++ b/src/api/handlers/representation.ts @@ -1,9 +1,18 @@ -import { abilityBuilder } from '$api/rumble'; +import { db, schema } from '$api/db/db'; +import { abilityBuilder, enum_, schemaBuilder } from '$api/rumble'; import { isWhitelistedEmail } from '$api/services/isDMUNEmail'; import { basics } from './basics'; +import { assertConferenceAdmin } from './conferenceUser'; +import { assertFindFirstExists, assertFirstEntryExists } from '@m1212e/rumble'; +import { eq } from 'drizzle-orm'; +import { GraphQLError } from 'graphql'; const { arg, ref, pubsub, table } = basics('representation'); +const representationTypeEnum = enum_({ + tsName: 'representationType' +}); + abilityBuilder.representation.allow(['read', 'update']).when(({ mustBeLoggedIn }) => { const user = mustBeLoggedIn(); if (user?.email && isWhitelistedEmail(user.email)) { @@ -15,3 +24,97 @@ abilityBuilder.representation.allow('read').when(({ mustBeLoggedIn }) => { mustBeLoggedIn(); return 'allow'; }); + +schemaBuilder.mutationFields((t) => ({ + createRepresentation: t.drizzleField({ + type: ref, + args: { + conferenceId: t.arg.id({ required: true }), + type: t.arg({ type: representationTypeEnum, required: true }), + name: t.arg.string(), + alpha2Code: t.arg.string(), + alpha3Code: t.arg.string(), + faIcon: t.arg.string() + }, + resolve: async (query, root, args, ctx, info) => { + await assertConferenceAdmin(ctx, args.conferenceId); + + const result = await db + .insert(schema.representation) + .values({ + conferenceId: args.conferenceId, + type: args.type, + name: args.name ?? undefined, + alpha2Code: args.alpha2Code ?? undefined, + alpha3Code: args.alpha3Code ?? undefined, + faIcon: args.faIcon ?? undefined + }) + .returning() + .then(assertFirstEntryExists); + + // For DELEGATION type, auto-create committee members for all committees + if (args.type === 'DELEGATION') { + const committees = await db.query.committee.findMany({ + where: { conferenceId: args.conferenceId } + }); + + if (committees.length > 0) { + await db.insert(schema.committeeMember).values( + committees.map((c) => ({ + committeeId: c.id, + representationId: result.id + })) + ); + } + } + + pubsub.updated(result.id); + + return db.query.representation + .findFirst( + query( + ctx.abilities.representation.filter('read', { + inject: { + where: { id: result.id } + } + }).query.single + ) + ) + .then(assertFindFirstExists); + } + }), + + deleteRepresentation: t.field({ + type: 'Boolean', + args: { + id: t.arg.id({ required: true }) + }, + resolve: async (root, args, ctx, info) => { + const representation = await db.query.representation.findFirst({ + where: { id: args.id } + }); + + if (!representation) { + throw new GraphQLError('Representation not found'); + } + + await assertConferenceAdmin(ctx, representation.conferenceId); + + // Delete associated committee members first (FK may not cascade) + await db + .delete(schema.committeeMember) + .where(eq(schema.committeeMember.representationId, args.id)); + + // Delete associated conference members + await db + .delete(schema.conferenceMember) + .where(eq(schema.conferenceMember.representationId, args.id)); + + await db.delete(schema.representation).where(eq(schema.representation.id, args.id)); + + pubsub.removed(args.id); + + return true; + } + }) +})); diff --git a/src/routes/app/[conferenceId]/mission-control/config/+page.svelte b/src/routes/app/[conferenceId]/mission-control/config/+page.svelte index a4db967e..e47f8927 100644 --- a/src/routes/app/[conferenceId]/mission-control/config/+page.svelte +++ b/src/routes/app/[conferenceId]/mission-control/config/+page.svelte @@ -2,26 +2,11 @@ import type { PageData } from './$houdini'; import { m } from '$lib/paraglide/messages'; import NavbarBurgerMenu from '$lib/components/NavbarBurgerMenu.svelte'; - import BasicCard from '$lib/components/BasicCard.svelte'; - import { cache, graphql } from '$houdini'; - import { invalidateAll } from '$app/navigation'; - import toast from 'svelte-french-toast'; - import { promiseToastStrings } from '$lib/utils/toast'; - import EditConferenceUserModal from './EditConferenceUserModal.svelte'; - import Flag from '$lib/components/Flag.svelte'; - import { getTranslatedCountryNameFromAlpha3Code } from '$lib/utils/nationTranslationHelper.svelte'; - import { - createTable, - getCoreRowModel, - getSortedRowModel, - getFilteredRowModel, - getPaginationRowModel, - type ColumnDef, - type SortingState, - type PaginationState, - type Updater, - type FilterFn - } from '@tanstack/table-core'; + import GeneralTab from './GeneralTab.svelte'; + import UsersTab from './UsersTab.svelte'; + import CommitteesTab from './CommitteesTab.svelte'; + import DelegationsTab from './DelegationsTab.svelte'; + import NsaTab from './NsaTab.svelte'; let { data }: { data: PageData } = $props(); @@ -29,350 +14,17 @@ let conference = $derived(query ? $query.data?.findFirstConference : undefined); let currentUserRole = $derived($query.data?.currentUserRole?.[0]); let isAdmin = $derived(currentUserRole?.conferenceUserType === 'ADMIN'); - let conferenceUsers = $derived(conference?.users ?? []); let currentUserEmail = $derived(data.user?.email); + let activeTab = $state<'general' | 'users' | 'committees' | 'delegations' | 'nsa'>('general'); + const menubarItems = [ { faIcon: 'fa-rocket-launch', title: m.missionControl(), - href: '..' - } - ]; - - // Form state - let bulkEmails = $state(''); - let newRole = $state<'ADMIN' | 'TEAM' | 'SPECTATOR' | 'DELEGATE' | 'NON_STATE_ACTOR'>('TEAM'); - let isBulkSubmitting = $state(false); - - // Modal state - let editModalOpen = $state(false); - let editingUser = $state<(typeof conferenceUsers)[number] | null>(null); - - // Table state - let sorting = $state([]); - let globalFilter = $state(''); - let pagination = $state({ pageIndex: 0, pageSize: 10 }); - - type ConferenceUserRow = (typeof conferenceUsers)[number]; - - // Compute assignment counts per member (how many users share each seat) - let committeeMemberAssignmentCounts = $derived( - conferenceUsers.reduce((map, u) => { - if (u.committeeMember?.id) - map.set(u.committeeMember.id, (map.get(u.committeeMember.id) ?? 0) + 1); - return map; - }, new Map()) - ); - - let conferenceMemberAssignmentCounts = $derived( - conferenceUsers.reduce((map, u) => { - if (u.conferenceMember?.id) - map.set(u.conferenceMember.id, (map.get(u.conferenceMember.id) ?? 0) + 1); - return map; - }, new Map()) - ); - - const roleBadgeClass: Record = { - ADMIN: 'badge-error', - TEAM: 'badge-warning', - DELEGATE: 'badge-primary', - NON_STATE_ACTOR: 'badge-secondary', - SPECTATOR: 'badge-ghost' - }; - - const roleLabel: Record string> = { - ADMIN: () => m.admin(), - TEAM: () => m.teamMember(), - DELEGATE: () => m.delegate(), - NON_STATE_ACTOR: () => m.nonStateActor(), - SPECTATOR: () => m.spectator() - }; - - function getAssignmentText(user: ConferenceUserRow): string | null { - if (user.conferenceUserType === 'DELEGATE' && user.committeeMember?.representation) { - const rep = user.committeeMember.representation; - return rep.name || getTranslatedCountryNameFromAlpha3Code(rep.alpha3Code); - } - if (user.conferenceUserType === 'NON_STATE_ACTOR' && user.conferenceMember?.representation) { - const rep = user.conferenceMember.representation; - return rep.name || getTranslatedCountryNameFromAlpha3Code(rep.alpha3Code); - } - return null; - } - - function getAssignmentRepresentation(user: ConferenceUserRow) { - if (user.conferenceUserType === 'DELEGATE' && user.committeeMember?.representation) { - return user.committeeMember.representation; - } - if (user.conferenceUserType === 'NON_STATE_ACTOR' && user.conferenceMember?.representation) { - return user.conferenceMember.representation; - } - return undefined; - } - - function isAssignableRole(role: string): boolean { - return role === 'DELEGATE' || role === 'NON_STATE_ACTOR'; - } - - function getCommitteeText(user: ConferenceUserRow): string { - return user.committeeMember?.committee?.abbreviation ?? ''; - } - - function getUserDisplayName(user: ConferenceUserRow): string { - const given = user.user?.givenName ?? ''; - const family = user.user?.familyName ?? ''; - const full = `${given} ${family}`.trim(); - return full || ''; - } - - const globalFilterFn: FilterFn = (row, _columnId, filterValue) => { - const search = (filterValue as string).toLowerCase(); - const user = row.original; - return ( - user.userEmail.toLowerCase().includes(search) || - getUserDisplayName(user).toLowerCase().includes(search) || - (roleLabel[user.conferenceUserType]?.() ?? user.conferenceUserType) - .toLowerCase() - .includes(search) || - (getAssignmentText(user) ?? '').toLowerCase().includes(search) || - getCommitteeText(user).toLowerCase().includes(search) - ); - }; - - // TanStack Table (framework-agnostic core) with Svelte 5 runes - const columns: ColumnDef[] = [ - { - accessorKey: 'userEmail', - header: 'Email' - }, - { - id: 'name', - accessorFn: (row) => getUserDisplayName(row), - header: 'Name' - }, - { - accessorKey: 'conferenceUserType', - header: 'Role' - }, - { - id: 'committee', - accessorFn: (row) => getCommitteeText(row) - }, - { - id: 'assignment', - accessorFn: (row) => getAssignmentText(row) ?? '', - enableSorting: false - }, - { - id: 'actions', - enableSorting: false + href: '.' } ]; - - function handleSortingChange(updater: Updater) { - sorting = typeof updater === 'function' ? updater(sorting) : updater; - } - - function handleGlobalFilterChange(updater: Updater) { - globalFilter = typeof updater === 'function' ? updater(globalFilter) : updater; - pagination = { ...pagination, pageIndex: 0 }; - } - - function handlePaginationChange(updater: Updater) { - pagination = typeof updater === 'function' ? updater(pagination) : updater; - } - - function getVisiblePages(current: number, total: number): (number | 'ellipsis')[] { - if (total <= 7) return Array.from({ length: total }, (_, i) => i); - const pages: (number | 'ellipsis')[] = [0]; - const start = Math.max(1, current - 1); - const end = Math.min(total - 2, current + 1); - if (start > 1) pages.push('ellipsis'); - for (let i = start; i <= end; i++) pages.push(i); - if (end < total - 2) pages.push('ellipsis'); - pages.push(total - 1); - return pages; - } - - // Get default table state (includes columnPinning, columnVisibility, etc.) - const _defaultState = createTable({ - data: [] as ConferenceUserRow[], - columns, - state: {}, - onStateChange: () => {}, - getCoreRowModel: getCoreRowModel(), - renderFallbackValue: null - }).initialState; - - // Reactive table: recreated when data/state changes - let table = $derived( - createTable({ - data: conferenceUsers as ConferenceUserRow[], - columns, - state: { - ..._defaultState, - sorting, - globalFilter, - pagination - }, - onStateChange: () => {}, - onSortingChange: handleSortingChange, - onGlobalFilterChange: handleGlobalFilterChange, - onPaginationChange: handlePaginationChange, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - globalFilterFn, - getFilteredRowModel: getFilteredRowModel(), - getPaginationRowModel: getPaginationRowModel(), - renderFallbackValue: null - }) - ); - - const CreateConferenceUserMutation = graphql(` - mutation CreateConferenceUser( - $conferenceId: ID! - $userEmail: String! - $conferenceUserType: ConferenceUserTypeEnum! - ) { - createConferenceUser( - conferenceId: $conferenceId - userEmail: $userEmail - conferenceUserType: $conferenceUserType - ) { - id - userEmail - conferenceUserType - } - } - `); - - const DeleteConferenceUserMutation = graphql(` - mutation DeleteConferenceUser($id: ID!) { - deleteConferenceUser(id: $id) - } - `); - - const UpdateConferenceUserMutation = graphql(` - mutation UpdateConferenceUser( - $id: ID! - $conferenceUserType: ConferenceUserTypeEnum! - $committeeMemberId: ID - $conferenceMemberId: ID - ) { - updateConferenceUser( - id: $id - conferenceUserType: $conferenceUserType - committeeMemberId: $committeeMemberId - conferenceMemberId: $conferenceMemberId - ) { - id - userEmail - conferenceUserType - committeeMember { - id - representation { - id - name - alpha2Code - faIcon - } - committee { - id - name - abbreviation - } - } - conferenceMember { - id - representation { - id - name - type - faIcon - } - } - } - } - `); - - function isCurrentUser(email: string): boolean { - return currentUserEmail === email; - } - - function isValidEmail(email: string): boolean { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - return emailRegex.test(email); - } - - function parseEmails(input: string): string[] { - return input - .split(/[\n,;]+/) - .map((email) => email.trim().toLowerCase()) - .filter((email) => email.length > 0 && isValidEmail(email)); - } - - async function addBulkMembers() { - if (!conference?.id || !query) return; - - const emails = parseEmails(bulkEmails); - if (emails.length === 0) return; - - isBulkSubmitting = true; - try { - for (const email of emails) { - await toast.promise( - CreateConferenceUserMutation.mutate({ - conferenceId: conference.id, - userEmail: email, - conferenceUserType: newRole - }), - promiseToastStrings(m.member(), 'add') - ); - } - bulkEmails = ''; - } finally { - isBulkSubmitting = false; - cache.markStale(); - await invalidateAll(); - } - } - - async function removeMember(id: string) { - if (!confirm(m.confirmRemoveMember()) || !query) return; - - await toast.promise( - DeleteConferenceUserMutation.mutate({ id }), - promiseToastStrings(m.member(), 'delete') - ); - cache.markStale(); - await invalidateAll(); - } - - function openEditModal(user: ConferenceUserRow) { - editingUser = user; - editModalOpen = true; - } - - async function handleEditSave(saveData: { - conferenceUserType: 'ADMIN' | 'TEAM' | 'DELEGATE' | 'NON_STATE_ACTOR' | 'SPECTATOR'; - committeeMemberId: string | null; - conferenceMemberId: string | null; - }) { - if (!editingUser || !query) return; - - await toast.promise( - UpdateConferenceUserMutation.mutate({ - id: editingUser.id, - conferenceUserType: saveData.conferenceUserType, - committeeMemberId: saveData.committeeMemberId, - conferenceMemberId: saveData.conferenceMemberId - }), - promiseToastStrings(m.member(), 'update') - ); - cache.markStale(); - await invalidateAll(); - } @@ -396,223 +48,64 @@ {:else if conference}

{conference.title}

- - -
- -
- - -
- - - {#each table.getHeaderGroups() as headerGroup (headerGroup.id)} - - {#each headerGroup.headers as header (header.id)} - - {/each} - - {/each} - - - {#if table.getRowModel().rows.length === 0} - - - - {:else} - {#each table.getRowModel().rows as row (row.id)} - {@const user = row.original} - {@const isSelf = isCurrentUser(user.userEmail)} - - - - - - - - - - - - - - - {/each} - {/if} - -
- {#if !header.isPlaceholder} -
- {#if header.id === 'userEmail'} - {m.email()} - {:else if header.id === 'name'} - {m.name()} - {:else if header.id === 'conferenceUserType'} - {m.role()} - {:else if header.id === 'committee'} - {m.committee()} - {:else if header.id === 'assignment'} - {m.assignment()} - {/if} - {#if header.column.getIsSorted() === 'asc'} - - {:else if header.column.getIsSorted() === 'desc'} - - {:else if header.column.getCanSort()} - - {/if} -
- {/if} -
- {m.noMembers()} -
- {user.userEmail} - {#if isSelf} - {m.you()} - {/if} - - {getUserDisplayName(user) || '—'} - - - {roleLabel[user.conferenceUserType]?.() ?? user.conferenceUserType} - - - {#if user.committeeMember?.committee} - {user.committeeMember.committee.abbreviation} - {:else} - - {/if} - - {#if getAssignmentRepresentation(user)} -
- - {getAssignmentText(user)} -
- {:else if isAssignableRole(user.conferenceUserType)} - {m.unassigned()} - {:else} - - {/if} -
-
- - -
-
-
- - - {#if table.getPageCount() > 1} -
- - {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + - 1}–{Math.min( - (table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize, - table.getFilteredRowModel().rows.length - )} / {table.getFilteredRowModel().rows.length} - -
- - {#each getVisiblePages(table.getState().pagination.pageIndex, table.getPageCount()) as item, i (item === 'ellipsis' ? `ellipsis-${i}` : item)} - {#if item === 'ellipsis'} - - {:else} - - {/if} - {/each} - -
-
- {/if} +
+ + + + + +
- -
- {m.addMember()} -
- -
-
- - -
- -
-
-
-
+ {#if activeTab === 'general'} + + {:else if activeTab === 'users'} + + {:else if activeTab === 'committees'} + + {:else if activeTab === 'delegations'} + + {:else if activeTab === 'nsa'} + + {/if} {:else}
@@ -620,13 +113,3 @@ {/if}
- - diff --git a/src/routes/app/[conferenceId]/mission-control/config/+page.ts b/src/routes/app/[conferenceId]/mission-control/config/+page.ts index 41069650..fa7eefc7 100644 --- a/src/routes/app/[conferenceId]/mission-control/config/+page.ts +++ b/src/routes/app/[conferenceId]/mission-control/config/+page.ts @@ -7,6 +7,8 @@ export const _houdini_load = graphql(` findFirstConference(where: { id: $conferenceId }) { id title + pressWebsite + hasModeratedCaucus users { id userEmail @@ -53,6 +55,7 @@ export const _houdini_load = graphql(` alpha2Code alpha3Code faIcon + type } } } @@ -66,6 +69,14 @@ export const _houdini_load = graphql(` faIcon } } + representations { + id + name + alpha2Code + alpha3Code + type + faIcon + } } currentUserRole: findManyConferenceUser( where: { conferenceId: $conferenceId, user: { id: $userId } } diff --git a/src/routes/app/[conferenceId]/mission-control/config/CommitteesTab.svelte b/src/routes/app/[conferenceId]/mission-control/config/CommitteesTab.svelte new file mode 100644 index 00000000..ee6c5718 --- /dev/null +++ b/src/routes/app/[conferenceId]/mission-control/config/CommitteesTab.svelte @@ -0,0 +1,233 @@ + + + +
+ + + + + + + + + + + {#if committees.length === 0} + + + + {:else} + {#each committees as committee (committee.id)} + + {#if editingId === committee.id} + + + + + {:else} + + + + + {/if} + + {/each} + {/if} + +
{m.committeeAbbreviation()}{m.committeeName()}{m.committeeMembers()}
{m.noData()}
+ + + + {committee.members.length} +
+ + +
+
{committee.abbreviation}{committee.name}{committee.members.length} +
+ + +
+
+
+ + +
+ {m.addCommittee()} +
+
+ {m.committeeAbbreviation()} + +
+
+ {m.committeeName()} + +
+ +
+
+
diff --git a/src/routes/app/[conferenceId]/mission-control/config/DelegationsTab.svelte b/src/routes/app/[conferenceId]/mission-control/config/DelegationsTab.svelte new file mode 100644 index 00000000..c19c0a78 --- /dev/null +++ b/src/routes/app/[conferenceId]/mission-control/config/DelegationsTab.svelte @@ -0,0 +1,188 @@ + + + +
+ + + + + + + + + + + + {#if delegations.length === 0} + + + + {:else} + {#each delegations as delegation (delegation.id)} + + + + + + + + {/each} + {/if} + +
{m.name()}Alpha-3{m.committees()}
{m.noData()}
+ + + {delegation.name || getTranslatedCountryNameFromAlpha3Code(delegation.alpha3Code)} + + {delegation.alpha3Code?.toUpperCase() ?? '—'} + + {getCommitteesForDelegation(delegation.id) || '—'} + +
+ + +
+
+
+ +
+ +
+
+ + + + diff --git a/src/routes/app/[conferenceId]/mission-control/config/EditDelegationModal.svelte b/src/routes/app/[conferenceId]/mission-control/config/EditDelegationModal.svelte new file mode 100644 index 00000000..5625a60b --- /dev/null +++ b/src/routes/app/[conferenceId]/mission-control/config/EditDelegationModal.svelte @@ -0,0 +1,174 @@ + + + + {#if delegation} +

{m.edit()}

+ +
+ + + {delegation.name || getTranslatedCountryNameFromAlpha3Code(delegation.alpha3Code)} + +
+ +
+ + {#if committees.length === 0} +

{m.noData()}

+ {:else} +
+ {#each committees as committee (committee.id)} + + {/each} +
+ {/if} +
+ + + {/if} +
diff --git a/src/routes/app/[conferenceId]/mission-control/config/GeneralTab.svelte b/src/routes/app/[conferenceId]/mission-control/config/GeneralTab.svelte new file mode 100644 index 00000000..6f640dd5 --- /dev/null +++ b/src/routes/app/[conferenceId]/mission-control/config/GeneralTab.svelte @@ -0,0 +1,123 @@ + + + +
+
+ + +
+ +
+ + +
+ +
+ +
+ +
+ +
+
+
diff --git a/src/routes/app/[conferenceId]/mission-control/config/NsaTab.svelte b/src/routes/app/[conferenceId]/mission-control/config/NsaTab.svelte new file mode 100644 index 00000000..312dd5ad --- /dev/null +++ b/src/routes/app/[conferenceId]/mission-control/config/NsaTab.svelte @@ -0,0 +1,186 @@ + + + +
+ + + + + + + + + + + {#if nsaActors.length === 0} + + + + {:else} + {#each nsaActors as actor (actor.id)} + + + + + + + {/each} + {/if} + +
{m.icon()}{m.name()}{m.role()}
{m.noData()}
+ + {actor.name ?? '—'} + + {typeLabel[actor.type]?.() ?? actor.type} + + + +
+
+ + +
+ + {m.addNonStateActor()} + +
+
+ {m.name()} + +
+
+ {m.role()} + +
+
+ {m.icon()} + +
+ +
+
+
diff --git a/src/routes/app/[conferenceId]/mission-control/config/UsersTab.svelte b/src/routes/app/[conferenceId]/mission-control/config/UsersTab.svelte new file mode 100644 index 00000000..f2cf42fb --- /dev/null +++ b/src/routes/app/[conferenceId]/mission-control/config/UsersTab.svelte @@ -0,0 +1,619 @@ + + + + +
+ +
+ + +
+ + + {#each table.getHeaderGroups() as headerGroup (headerGroup.id)} + + {#each headerGroup.headers as header (header.id)} + + {/each} + + {/each} + + + {#if table.getRowModel().rows.length === 0} + + + + {:else} + {#each table.getRowModel().rows as row (row.id)} + {@const user = row.original} + {@const isSelf = isCurrentUser(user.userEmail)} + + + + + + + + + + + + + + + {/each} + {/if} + +
+ {#if !header.isPlaceholder} +
+ {#if header.id === 'userEmail'} + {m.email()} + {:else if header.id === 'name'} + {m.name()} + {:else if header.id === 'conferenceUserType'} + {m.role()} + {:else if header.id === 'committee'} + {m.committee()} + {:else if header.id === 'assignment'} + {m.assignment()} + {/if} + {#if header.column.getIsSorted() === 'asc'} + + {:else if header.column.getIsSorted() === 'desc'} + + {:else if header.column.getCanSort()} + + {/if} +
+ {/if} +
+ {m.noMembers()} +
+ {user.userEmail} + {#if isSelf} + {m.you()} + {/if} + + {getUserDisplayName(user) || '—'} + + + {roleLabel[user.conferenceUserType]?.() ?? user.conferenceUserType} + + + {#if user.committeeMember?.committee} + {user.committeeMember.committee.abbreviation} + {:else} + + {/if} + + {#if getAssignmentRepresentation(user)} +
+ + {getAssignmentText(user)} +
+ {:else if isAssignableRole(user.conferenceUserType)} + {m.unassigned()} + {:else} + + {/if} +
+
+ + +
+
+
+ + + {#if table.getPageCount() > 1} +
+ + {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + + 1}–{Math.min( + (table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize, + table.getFilteredRowModel().rows.length + )} / {table.getFilteredRowModel().rows.length} + +
+ + {#each getVisiblePages(table.getState().pagination.pageIndex, table.getPageCount()) as item, i (item === 'ellipsis' ? `ellipsis-${i}` : item)} + {#if item === 'ellipsis'} + + {:else} + + {/if} + {/each} + +
+
+ {/if} + + +
+ {m.addMember()} +
+ +
+
+ + +
+ +
+
+
+
+ + From fc956c1dcfdb65af501be3023650af23d6940b4d Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Tue, 3 Mar 2026 23:50:01 +0100 Subject: [PATCH 03/89] fix: correct speakers list position display and make controls full width Position 0 (currently speaking) now shows "You're up!" badge instead of "#1", and queue numbering starts at the actual position. Badge and remove button are now stacked full width. Co-Authored-By: Claude Opus 4.6 --- messages/de.json | 3 ++- messages/en.json | 3 ++- .../participant/[committeeId]/+page.svelte | 16 +++++++++++----- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/messages/de.json b/messages/de.json index 1b9e1498..24661fe9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -303,5 +303,6 @@ "withoutAbstentions": "Keine Enthaltungen", "yes": "Ja", "you": "Du", - "youCannotEditYourself": "Du kannst deine eigene Rolle nicht bearbeiten" + "youCannotEditYourself": "Du kannst deine eigene Rolle nicht bearbeiten", + "youreUp": "Du bist dran!" } diff --git a/messages/en.json b/messages/en.json index a3edcbb8..c624bc15 100644 --- a/messages/en.json +++ b/messages/en.json @@ -303,5 +303,6 @@ "withoutAbstentions": "No Abstentions", "yes": "Yes", "you": "You", - "youCannotEditYourself": "You cannot edit your own role" + "youCannotEditYourself": "You cannot edit your own role", + "youreUp": "You're up!" } diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/+page.svelte b/src/routes/app/[conferenceId]/participant/[committeeId]/+page.svelte index e66ed992..8025da28 100644 --- a/src/routes/app/[conferenceId]/participant/[committeeId]/+page.svelte +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/+page.svelte @@ -167,12 +167,18 @@ {#if canSelfAdd} {#if myPosition !== null} -
- - {m.onListPosition({ position: String(myPosition + 1) })} - +
+ {#if myPosition === 0} + + {m.youreUp()} + + {:else} + + {m.onListPosition({ position: String(myPosition) })} + + {/if}
From 67f647efad0b4a9bea69643b04804a59cacc61ab Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Wed, 4 Mar 2026 12:28:19 +0100 Subject: [PATCH 06/89] =?UTF-8?q?feat:=20resolution=20editor=20Phase=202?= =?UTF-8?q?=20=E2=80=94=20delegate=20working=20paper=20UI=20with=20clause?= =?UTF-8?q?=20locking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add full delegate-facing resolution editing with collaborative clause-level locking using explicit click-to-lock UX (hover overlay + "Done editing" button). Includes: paper detail page, auto-save, share codes, sponsor management, paper submission, lock subscription, hybrid heartbeat, and collaborative mode that activates only when multiple editors are present. Co-Authored-By: Claude Opus 4.6 --- bun.lock | 192 ++++- docs/plans/resolution-meta-plan.md | 31 +- messages/de.json | 33 + messages/en.json | 33 + package.json | 2 +- schema.graphql | 421 ++++++++++- src/api/db/relations.ts | 20 + src/api/db/reset.ts | 24 +- src/api/db/schema.ts | 27 +- src/api/handlers/amendment.ts | 17 + src/api/handlers/amendmentSponsor.ts | 17 + src/api/handlers/operativeClauseVote.ts | 17 + src/api/handlers/paperClauseLock.ts | 191 +++++ src/api/handlers/paperShareCode.ts | 5 +- src/api/handlers/paperSponsor.ts | 5 +- src/api/handlers/register.ts | 6 + src/api/handlers/resolutionComment.ts | 17 + src/api/handlers/resolutionPaper.ts | 185 ++++- src/api/handlers/resolutionVoteResult.ts | 17 + src/app.css | 1 + src/lib/components/Fieldset.svelte | 24 + src/lib/utils/paperNameGenerator.ts | 107 +++ .../participant/[committeeId]/+layout.svelte | 62 ++ .../participant/[committeeId]/+layout.ts | 24 + .../participant/[committeeId]/+page.svelte | 24 +- .../[committeeId]/papers/+page.svelte | 289 +++++++ .../participant/[committeeId]/papers/+page.ts | 43 ++ .../papers/[paperId]/+page.svelte | 708 ++++++++++++++++++ .../[committeeId]/papers/[paperId]/+page.ts | 54 ++ .../papers/[paperId]/lockSubscription.ts | 22 + .../[paperId]/paperDetailSubscription.ts | 45 ++ .../papers/papersSubscription.ts | 36 + vite.config.ts | 44 ++ 33 files changed, 2691 insertions(+), 52 deletions(-) create mode 100644 src/api/handlers/amendment.ts create mode 100644 src/api/handlers/amendmentSponsor.ts create mode 100644 src/api/handlers/operativeClauseVote.ts create mode 100644 src/api/handlers/paperClauseLock.ts create mode 100644 src/api/handlers/resolutionComment.ts create mode 100644 src/api/handlers/resolutionVoteResult.ts create mode 100644 src/lib/components/Fieldset.svelte create mode 100644 src/lib/utils/paperNameGenerator.ts create mode 100644 src/routes/app/[conferenceId]/participant/[committeeId]/+layout.svelte create mode 100644 src/routes/app/[conferenceId]/participant/[committeeId]/+layout.ts create mode 100644 src/routes/app/[conferenceId]/participant/[committeeId]/papers/+page.svelte create mode 100644 src/routes/app/[conferenceId]/participant/[committeeId]/papers/+page.ts create mode 100644 src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte create mode 100644 src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.ts create mode 100644 src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/lockSubscription.ts create mode 100644 src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/paperDetailSubscription.ts create mode 100644 src/routes/app/[conferenceId]/participant/[committeeId]/papers/papersSubscription.ts diff --git a/bun.lock b/bun.lock index 7421aa8f..e06daa2c 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "munify-chase", "dependencies": { - "@deutschemodelunitednations/munify-resolution-editor": "^0.1.1", + "@deutschemodelunitednations/munify-resolution-editor": "file:../munify-resolution-editor", "@tanstack/table-core": "^8.21.3", "drizzle-kit": "1.0.0-beta.1-fd5d1e8", "drizzle-orm": "1.0.0-beta.1-fd5d1e8", @@ -103,13 +103,15 @@ "@babel/types": ["@babel/types@7.27.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" } }, "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg=="], + "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], + "@clack/core": ["@clack/core@0.3.5", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-5cfhQNH+1VQ2xLQlmzXMqUoiaH0lRBq9/CLW9lTyMbuKLC3+xEK01tHVvyut++mLOn5urSHmkm6I0Lg9MaJSTQ=="], "@clack/prompts": ["@clack/prompts@0.6.3", "", { "dependencies": { "@clack/core": "^0.3.2", "is-unicode-supported": "*", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-AM+kFmAHawpUQv2q9+mcB6jLKxXGjgu/r2EQjEwujgpCdzrST6BJqYw00GRn56/L/Izw5U7ImoLmy00X/r80Pw=="], "@deutschemodelunitednations/corporate-identity": ["@deutschemodelunitednations/corporate-identity@1.1.10", "", { "dependencies": { "@fontsource/outfit": "^5.2.8", "@fontsource/roboto-mono": "^5.2.8", "@fontsource/vollkorn": "^5.2.10", "esbuild": "^0.25.10", "js-yaml": "^4.1.0", "tailwind-shades": "^1.1.2" } }, "sha512-OjjAdEODbg2D+ZWwdzEGjFBclF3hFgP1d/ib6Xz29cd5KzbumKzeKxiDl/xVURcwEovwlyf5Q5qc+kG/iXNNYA=="], - "@deutschemodelunitednations/munify-resolution-editor": ["@deutschemodelunitednations/munify-resolution-editor@0.1.1", "", { "dependencies": { "zod": "^3.24.1" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-zpcvRHA2l7HbOoO/ViRD8iGfCUOKydcwubcNHXwNKZCU/684YCeJvuo81sZPl0/ah3Rtuec9Xm3Vnbde8kh6eA=="], + "@deutschemodelunitednations/munify-resolution-editor": ["@deutschemodelunitednations/munify-resolution-editor@file:../munify-resolution-editor", { "dependencies": { "zod": "^3.24.1" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.8", "@sveltejs/kit": "^2.16.0", "@sveltejs/package": "^2.3.7", "@sveltejs/vite-plugin-svelte": "^5.0.3", "@tailwindcss/vite": "^4.0.0", "@types/node": "^22.10.7", "@vitest/coverage-v8": "^3.0.4", "daisyui": "^5.0.0", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-svelte": "^3.0.0", "globals": "^16.0.0", "lefthook": "^1.6.0", "prettier": "^3.4.2", "prettier-plugin-svelte": "^3.3.3", "publint": "^0.3.2", "svelte": "^5.0.0", "svelte-check": "^4.1.4", "tailwindcss": "^4.0.0", "typescript": "^5.7.3", "typescript-eslint": "^8.20.0", "vite": "^6.0.7", "vitest": "^3.0.4" }, "peerDependencies": { "svelte": "^5.0.0" } }], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], @@ -269,6 +271,8 @@ "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + "@istanbuljs/schema": ["@istanbuljs/schema@0.1.3", "", {}, "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -319,6 +323,8 @@ "@pothos/plugin-smart-subscriptions": ["@pothos/plugin-smart-subscriptions@4.1.2", "", { "peerDependencies": { "@pothos/core": "*", "graphql": "^16.10.0" } }, "sha512-taIVytpY39OL30Tb43e0w9RrjJIzObp1nVe5Zr2fK1cLJM0jgqTdMsdSbFIeA0RS5q13E7CnDNov0/CTswSzIw=="], + "@publint/pack": ["@publint/pack@0.1.4", "", {}, "sha512-HDVTWq3H0uTXiU0eeSQntcVUTPP3GamzeXI41+x7uU9J65JgWQh3qWZHblR1i0npXfFtF+mxBiU2nJH8znxWnQ=="], + "@remirror/core-constants": ["@remirror/core-constants@3.0.0", "", {}, "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg=="], "@repeaterjs/repeater": ["@repeaterjs/repeater@3.0.6", "", {}, "sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA=="], @@ -389,8 +395,12 @@ "@sveltejs/adapter-node": ["@sveltejs/adapter-node@5.4.0", "", { "dependencies": { "@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "rollup": "^4.9.5" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "sha512-NMsrwGVPEn+J73zH83Uhss/hYYZN6zT3u31R3IHAn3MiKC3h8fjmIAhLfTSOeNHr5wPYfjjMg8E+1gyFgyrEcQ=="], + "@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.10", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew=="], + "@sveltejs/kit": ["@sveltejs/kit@2.49.4", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.3.2", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-JFtOqDoU0DI/+QSG8qnq5bKcehVb3tCHhOG4amsSYth5/KgO4EkJvi42xSAiyKmXAAULW1/Zdb6lkgGEgSxdZg=="], + "@sveltejs/package": ["@sveltejs/package@2.5.7", "", { "dependencies": { "chokidar": "^5.0.0", "kleur": "^4.1.5", "sade": "^1.8.1", "semver": "^7.5.4", "svelte2tsx": "~0.7.33" }, "peerDependencies": { "svelte": "^3.44.0 || ^4.0.0 || ^5.0.0-next.1" }, "bin": { "svelte-package": "svelte-package.js" } }, "sha512-qqD9xa9H7TDiGFrF6rz7AirOR8k15qDK/9i4MIE8te4vWsv5GEogPks61rrZcLy+yWph+aI6pIj2MdoK3YI8AQ=="], + "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@5.1.1", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.17", "vitefu": "^1.0.6" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ=="], "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@4.0.1", "", { "dependencies": { "debug": "^4.3.7" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.0", "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw=="], @@ -553,6 +563,8 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + "@vitest/coverage-v8": ["@vitest/coverage-v8@3.2.4", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^1.0.2", "ast-v8-to-istanbul": "^0.3.3", "debug": "^4.4.1", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", "magic-string": "^0.30.17", "magicast": "^0.3.5", "std-env": "^3.9.0", "test-exclude": "^7.0.1", "tinyrainbow": "^2.0.0" }, "peerDependencies": { "@vitest/browser": "3.2.4", "vitest": "3.2.4" }, "optionalPeers": ["@vitest/browser"] }, "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ=="], + "@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="], "@vitest/mocker": ["@vitest/mocker@4.0.18", "", { "dependencies": { "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ=="], @@ -605,6 +617,8 @@ "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], + "ast-v8-to-istanbul": ["ast-v8-to-istanbul@0.3.12", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g=="], + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], "b4a": ["b4a@1.7.3", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q=="], @@ -641,6 +655,8 @@ "busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="], + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -653,6 +669,8 @@ "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], + "cheerio": ["cheerio@1.1.2", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.0.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.12.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg=="], "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], @@ -723,6 +741,10 @@ "dedent": ["dedent@1.5.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg=="], + "dedent-js": ["dedent-js@1.0.1", "", {}, "sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ=="], + + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], @@ -937,6 +959,8 @@ "houdini-svelte": ["houdini-svelte@3.0.0-next.13", "", { "dependencies": { "@kitql/helpers": "^0.8.2", "ast-types": "^0.16.1", "estree-walker": "^3.0.1", "graphql": "^16.10.0", "houdini": "^2.0.0-next.10", "recast": "0.23.8", "rollup": "^4.39.0" }, "peerDependencies": { "@sveltejs/kit": "^2.9.0", "svelte": "^5.0.0", "vite": "^7.0.0" } }, "sha512-jCJjGNdDGuxAld7SxpsX8JJm/Ik4c/+IymORbH9c4bS4WlWgd9Q5IfTn6EUYac1hXj9Uqmi6Coputqr/yGe18w=="], + "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + "htmlparser2": ["htmlparser2@10.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.1", "entities": "^6.0.0" } }, "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g=="], "human-id": ["human-id@4.1.1", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-3gKm/gCSUipeLsRYZbbdA1BD83lBoWUkZ7G9VFrhWPAU76KwYo5KR8V28bpoPm/ygy0x5/GCbpRQdY7VLYCoIg=="], @@ -991,6 +1015,14 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], + + "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], + + "istanbul-lib-source-maps": ["istanbul-lib-source-maps@5.0.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.23", "debug": "^4.1.1", "istanbul-lib-coverage": "^3.0.0" } }, "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A=="], + + "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], + "jackspeak": ["jackspeak@4.1.1", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" } }, "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], @@ -999,6 +1031,8 @@ "js-sha256": ["js-sha256@0.11.0", "", {}, "sha512-6xNlKayMZvds9h1Y1VWc0fQHQ82BxTXizWPEtEeGvmOUYpBRy4gbWroHLpzowe6xiQhHpelCQiE7HEdznyBL9Q=="], + "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], @@ -1091,12 +1125,18 @@ "loud-rejection": ["loud-rejection@2.2.0", "", { "dependencies": { "currently-unhandled": "^0.4.1", "signal-exit": "^3.0.2" } }, "sha512-S0FayMXku80toa5sZ6Ro4C+s+EtFDCsyJNG/AzFMfX3AxD5Si4dZsgzm/kKnbOxHl5Cv8jBlno8+3XYIh2pNjQ=="], + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], + "lowlight": ["lowlight@2.9.0", "", { "dependencies": { "@types/hast": "^2.0.0", "fault": "^2.0.0", "highlight.js": "~11.8.0" } }, "sha512-OpcaUTCLmHuVuBcyNckKfH5B0oA4JUavb/M/8n9iAvanJYNQkrVm4pvyX0SUaqkBG4dnWHKt7p50B3ngAG2Rfw=="], "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="], + "magicast": ["magicast@0.3.5", "", { "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ=="], + + "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], + "many-keys-map": ["many-keys-map@2.0.1", "", {}, "sha512-DHnZAD4phTbZ+qnJdjoNEVU1NecYoSdbOOoVmTDH46AuxDkEVh3MxTVpXq10GtcTC6mndN9dkv1rNfpjRcLnOw=="], "markdown-it": ["markdown-it@14.1.0", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg=="], @@ -1179,6 +1219,8 @@ "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "parse-package-name": ["parse-package-name@1.0.0", "", {}, "sha512-kBeTUtcj+SkyfaW4+KBe0HtsloBJ/mKTPoxpVdA57GZiPerREsUWJOhVj9anXweFiJkm5y8FG1sxFZkZ0SN6wg=="], @@ -1203,6 +1245,8 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + "periscopic": ["periscopic@3.1.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^3.0.0", "is-reference": "^3.0.0" } }, "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw=="], "pg": ["pg@8.16.3", "", { "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", "pg-protocol": "^1.10.3", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.2.7" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw=="], @@ -1293,6 +1337,8 @@ "prosemirror-view": ["prosemirror-view@1.39.2", "", { "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0" } }, "sha512-BmOkml0QWNob165gyUxXi5K5CVUgVPpqMEAAml/qzgKn9boLUWVPzQ6LtzXw8Cn1GtRQX4ELumPxqtLTDaAKtg=="], + "publint": ["publint@0.3.18", "", { "dependencies": { "@publint/pack": "^0.1.4", "package-manager-detector": "^1.6.0", "picocolors": "^1.1.1", "sade": "^1.8.1" }, "bin": { "publint": "src/cli.js" } }, "sha512-JRJFeBTrfx4qLwEuGFPk+haJOJN97KnPuK01yj+4k/Wj5BgoOK5uNsivporiqBjk2JDaslg7qJOhGRnpltGeog=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], @@ -1347,6 +1393,8 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "scule": ["scule@1.3.0", "", {}, "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g=="], + "semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], "set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="], @@ -1405,6 +1453,8 @@ "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], + "style-to-object": ["style-to-object@1.0.8", "", { "dependencies": { "inline-style-parser": "0.2.4" } }, "sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g=="], "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], @@ -1433,6 +1483,8 @@ "svelte-writable-derived": ["svelte-writable-derived@3.1.1", "", { "peerDependencies": { "svelte": "^3.2.1 || ^4.0.0-next.1 || ^5.0.0-next.94" } }, "sha512-w4LR6/bYZEuCs7SGr+M54oipk/UQKtiMadyOhW0PTwAtJ/Ai12QS77sLngEcfBx2q4H8ZBQucc9ktSA5sUGZWw=="], + "svelte2tsx": ["svelte2tsx@0.7.51", "", { "dependencies": { "dedent-js": "^1.0.1", "scule": "^1.3.0" }, "peerDependencies": { "svelte": "^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0", "typescript": "^4.9.4 || ^5.0.0" } }, "sha512-YbVMQi5LtQkVGOMdATTY8v3SMtkNjzYtrVDGaN3Bv+0LQ47tGXu/Oc8ryTkcYuEJWTZFJ8G2+2I8ORcQVGt9Ag=="], + "symlink-or-copy": ["symlink-or-copy@1.3.1", "", {}, "sha512-0K91MEXFpBUaywiwSSkmKjnGcasG/rVBXFLJz5DrgGabpYD6N+3yZrfD6uUIfpuTu65DZLHi7N8CizHc07BPZA=="], "tabbable": ["tabbable@6.2.0", "", {}, "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="], @@ -1445,6 +1497,8 @@ "teex": ["teex@1.0.1", "", { "dependencies": { "streamx": "^2.12.5" } }, "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg=="], + "test-exclude": ["test-exclude@7.0.2", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^10.4.1", "minimatch": "^10.2.2" } }, "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw=="], + "text-decoder": ["text-decoder@1.2.3", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA=="], "through2": ["through2@2.0.5", "", { "dependencies": { "readable-stream": "~2.3.6", "xtend": "~4.0.1" } }, "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ=="], @@ -1457,8 +1511,12 @@ "tinyglobby": ["tinyglobby@0.2.12", "", { "dependencies": { "fdir": "^6.4.3", "picomatch": "^4.0.2" } }, "sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww=="], + "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], + "tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="], + "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], + "tippy.js": ["tippy.js@6.3.7", "", { "dependencies": { "@popperjs/core": "^2.9.0" } }, "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -1517,6 +1575,8 @@ "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], + "vitefu": ["vitefu@1.0.6", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" }, "optionalPeers": ["vite"] }, "sha512-+Rex1GlappUyNN6UfwbVZne/9cYC4+R2XDk9xkNXBKMw6HQagdX9PgZ8V2v1WUSK1wfBLp7qbI1+XSNIlB1xmA=="], "vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="], @@ -1569,6 +1629,10 @@ "@deutschemodelunitednations/corporate-identity/js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + "@deutschemodelunitednations/munify-resolution-editor/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], @@ -1639,6 +1703,10 @@ "@rollup/pluginutils/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], + "@sveltejs/package/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + + "@sveltejs/package/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "@sveltejs/vite-plugin-svelte/magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="], "@sveltejs/vite-plugin-svelte-inspector/debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], @@ -1671,6 +1739,10 @@ "@typescript-eslint/utils/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + "@vitest/coverage-v8/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "@vitest/coverage-v8/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], + "@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], "@vitest/mocker/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -1683,6 +1755,10 @@ "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "ast-v8-to-istanbul/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "ast-v8-to-istanbul/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "broccoli-output-wrapper/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], @@ -1741,12 +1817,16 @@ "is-reference/@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="], + "istanbul-lib-report/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "json-schema-to-typescript/js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], "json-schema-to-typescript/prettier": ["prettier@3.5.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw=="], "lowlight/highlight.js": ["highlight.js@11.8.0", "", {}, "sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg=="], + "make-dir/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], @@ -1769,10 +1849,16 @@ "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], + "svelte-eslint-parser/postcss-selector-parser": ["postcss-selector-parser@7.1.0", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA=="], "svelte-fast-marquee/svelte": ["svelte@4.2.19", "", { "dependencies": { "@ampproject/remapping": "^2.2.1", "@jridgewell/sourcemap-codec": "^1.4.15", "@jridgewell/trace-mapping": "^0.3.18", "@types/estree": "^1.0.1", "acorn": "^8.9.0", "aria-query": "^5.3.0", "axobject-query": "^4.0.0", "code-red": "^1.0.3", "css-tree": "^2.3.1", "estree-walker": "^3.0.3", "is-reference": "^3.0.1", "locate-character": "^3.0.0", "magic-string": "^0.30.4", "periscopic": "^3.1.0" } }, "sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw=="], + "test-exclude/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + + "test-exclude/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + "tinyglobby/fdir": ["fdir@6.4.3", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw=="], "tinyglobby/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], @@ -1789,6 +1875,34 @@ "vitest/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "@deutschemodelunitednations/munify-resolution-editor/vite/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/@vitest/runner": ["@vitest/runner@3.2.4", "", { "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/@vitest/snapshot": ["@vitest/snapshot@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], @@ -1897,6 +2011,8 @@ "@rollup/plugin-commonjs/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + "@sveltejs/package/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + "@sveltejs/vite-plugin-svelte/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], @@ -1905,6 +2021,8 @@ "@vitest/mocker/estree-walker/@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="], + "ast-v8-to-istanbul/estree-walker/@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="], + "broccoli-output-wrapper/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], "broccoli-output-wrapper/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], @@ -2045,6 +2163,14 @@ "svelte-fast-marquee/svelte/magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="], + "test-exclude/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + + "test-exclude/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "test-exclude/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "test-exclude/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="], @@ -2149,6 +2275,10 @@ "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], + "@deutschemodelunitednations/munify-resolution-editor/vitest/@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/vite/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], + "@formatjs/cli-lib/@formatjs/icu-messageformat-parser/@formatjs/ecma402-abstract/@formatjs/fast-memoize": ["@formatjs/fast-memoize@2.2.3", "", { "dependencies": { "tslib": "2" } }, "sha512-3jeJ+HyOfu8osl3GNSL4vVHUuWFXR03Iz9jjgI7RwjG6ysu/Ymdr0JRCPHfF5yGbTE6JCrd63EpvX1/WybYRbA=="], "@formatjs/cli-lib/@formatjs/icu-messageformat-parser/@formatjs/ecma402-abstract/@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.5.8", "", { "dependencies": { "tslib": "2" } }, "sha512-I+WDNWWJFZie+jkfkiK5Mp4hEDyRSEvmyfYadflOno/mmKJKcB17fEpEH0oJu/OWhhCJ8kJBDz2YMd/6cDl7Mg=="], @@ -2191,6 +2321,64 @@ "quick-temp/rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "test-exclude/glob/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], + + "test-exclude/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/@vitest/mocker/estree-walker/@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="], + + "@deutschemodelunitednations/munify-resolution-editor/vitest/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], + "houdini-svelte/houdini/glob/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], "houdini-svelte/houdini/graphql-yoga/@envelop/core/@envelop/types": ["@envelop/types@4.0.1", "", { "dependencies": { "tslib": "^2.5.0" } }, "sha512-ULo27/doEsP7uUhm2iTnElx13qTO6I5FKvmLoX41cpfuw8x6e0NUFknoqhEsLzAbgz8xVS5mjwcxGCXh4lDYzg=="], diff --git a/docs/plans/resolution-meta-plan.md b/docs/plans/resolution-meta-plan.md index f04564cb..4ed5540d 100644 --- a/docs/plans/resolution-meta-plan.md +++ b/docs/plans/resolution-meta-plan.md @@ -87,7 +87,7 @@ Result: | `current_operative_index` | `smallint` nullable | Which operative paragraph is active (0-indexed). Amendments locked for index < this | | `support_re_evaluation_open` | `boolean` default false | Whether delegations can currently change DR support | -### New Tables (10) +### New Tables (11) **`resolution_paper`** — Single entity for the entire lifecycle | Column | Type | Notes | @@ -139,6 +139,17 @@ Result: | `created_at`, `updated_at` | `timestamp` | | | | | UNIQUE(paper_id, conference_user_id) | +**`paper_clause_lock`** — Pessimistic per-clause editing locks (collaborative mode) +| Column | Type | Notes | +|--------|------|-------| +| `id` | `text` (nanoid) | PK | +| `paper_id` | `text` FK→resolutionPaper | | +| `clause_id` | `text` | Clause `id` from JSON | +| `conference_user_id` | `text` FK→conferenceUser | Lock holder | +| `expires_at` | `timestamp` | TTL (60s from acquire/refresh) | +| `created_at`, `updated_at` | `timestamp` | | +| | | UNIQUE(paper_id, clause_id) | + **`resolution_comment`** — Comments on draft resolutions (paragraph + document level) | Column | Type | Notes | |--------|------|-------| @@ -349,14 +360,16 @@ commentPanel?: Snippet<[{ resolution: Resolution; activeClauseId?: string }]>; - ~~Create handlers: `resolutionPaper`, `paperSponsor`, `paperShareCode`, `paperEditor`, `paperContentSnapshot`~~ - ~~Register handlers, add i18n messages~~ -### Phase 2: Delegate Working Paper UI +### Phase 2: Delegate Working Paper UI ✅ -- Papers overview page + paper detail/editor page -- `ResolutionEditor` integration with `onResolutionChange` (debounced 500ms) -- Share code creation, copying, redemption -- Sponsor list + "Sponsor" flow -- "Submit to Chair" button -- Navigation from participant committee page +- ~~Papers overview page + paper detail/editor page~~ +- ~~`ResolutionEditor` integration with `onResolutionChange` (debounced 500ms)~~ +- ~~Share code creation, copying, redemption~~ +- ~~Sponsor list + "Sponsor" flow~~ +- ~~"Submit to Chair" button~~ +- ~~Navigation from participant committee page~~ +- ~~Clause-level locking: `paper_clause_lock` table, acquire/release/heartbeat mutations, subscription, lock-aware content merge~~ +- ~~Click-to-lock UX: hover overlay ("Start editing"), inline "Done editing" button, `collaborativeMode` gate~~ ### Phase 3: Chair Resolutions Tab + DR Promotion @@ -410,6 +423,8 @@ commentPanel?: Snippet<[{ resolution: Resolution; activeClauseId?: string }]>; **Real-time**: Rumble pubsub → Houdini subscriptions. `onResolutionChange` (debounced 500ms) → mutation → pubsub. Last-writer-wins acceptable for MUN context. +**Clause-level locking**: Explicit click-to-lock UX (not focus/blur). Delegates hover an unlocked clause to see a "Start editing" overlay, click to acquire a server-side lock, and click "Done editing" to release. Locks are per-clause rows in `paper_clause_lock` with a 60s TTL. A hybrid heartbeat (30s interval, only fires when idle >25s) keeps locks alive during active editing — saves already refresh locks implicitly via `updatePaperContent`. Lock state is pushed via GraphQL subscription; optimistic IDs bridge the gap. `collaborativeMode` gates all lock UI: solo editing (no share codes used, working paper status) shows no overlays or lock buttons. The `beforeunload` handler and navigation cleanup release all held locks via `sendBeacon`. + **Amendment application**: When accepted (by consensus or vote), server mutates JSON + creates snapshot. DELETE removes clause, ADD inserts, ALTER_TEXT replaces blocks, ALTER_POSITION moves. Record keeps status for history. **Paragraph debate tracking**: Committee-level `currentOperativeIndex` field. Server-side enforcement: `createAmendment` rejects if `targetOperativeIndex < currentOperativeIndex`. Chair advances via `setCurrentOperativeIndex`. diff --git a/messages/de.json b/messages/de.json index bbe00360..61b285fa 100644 --- a/messages/de.json +++ b/messages/de.json @@ -41,13 +41,18 @@ "chairControls": "Vorsitz-Steuerung", "changeSpeakersName": "Name ändern", "changeSpeakersTime": "Redezeit ändern", + "changesSaved": "Gespeichert", + "clauseLockedBy": "Wird bearbeitet von {country}", "clearFormatting": "Formatierung löschen", "clearList": "Liste zurücksetzen", "clearListDescription": "Bist du dir sicher, dass du die gesamte Liste zurücksetzen möchtest?", "close": "Schließen", "closeList": "Liste schließen", "code": "Code", + "codeCopied": "Code kopiert!", + "codeRedeemed": "Code erfolgreich eingelöst", "codesUnrecognized": "{count} Codes nicht erkannt", + "collaborativeEditingInfo": "Andere Delegierte bearbeiten diese Resolution. Fahre über eine Klausel und klicke \"Bearbeitung starten\" um zu beginnen. Sperren laufen nach 1 Minute Inaktivität automatisch ab.", "comingSoon": "bald verfügbar", "commentList": "Fragen und Kurzbemerkungen", "committee": "Gremium", @@ -70,8 +75,11 @@ "conferenceTitle": "Konferenztitel", "configuration": "Konfiguration", "confirmDeleteCommittee": "Möchtest du dieses Gremium wirklich löschen? Alle zugehörigen Daten gehen verloren.", + "confirmDeletePaper": "Dieses Arbeitspapier wirklich löschen? Es wird für alle Beteiligten ausgeblendet.", "confirmDeleteRepresentation": "Möchtest du diese Delegation wirklich entfernen? Zugehörige Gremienmitgliedschaften werden entfernt.", "confirmRemoveMember": "Möchtest du dieses Mitglied wirklich entfernen?", + "confirmSubmitPaper": "Möchtest du dieses Papier wirklich an den Vorsitz einreichen? Du kannst es danach nicht mehr bearbeiten.", + "copyCode": "Kopieren", "countries": "Länder", "countriesRecognized": "{count} Länder erkannt", "countryCodesHelp": "Unterstützt Alpha-2 (DE, US) und Alpha-3 (DEU, USA) Codes. Trenne mit Leerzeichen, Kommas, Semikolons oder neuen Zeilen.", @@ -80,11 +88,16 @@ "countryNsaOrCustomRole": "Land, NA oder spezielle Rolle", "create": "Erstellen", "createConference": "Konferenz erstellen", + "createPaper": "Papier erstellen", "createResolutionPaper": "Arbeitspapier erstellen", + "createShareCodeEdit": "Bearbeitungs-Code erstellen", + "createShareCodeSponsor": "Unterstützer-Code erstellen", "customName": "Benutzerdefinierter Name...", "dateCannotBeInPast": "Das Datum darf nicht in der Vergangenheit liegen!", "delegate": "Delegierte*r", "delegations": "Delegationen", + "deleteCode": "Code löschen", + "deletePaper": "Papier löschen", "deleteRepresentation": "Delegation entfernen", "displayRegionalGroups": "Regionalgruppenanzeige", "documentNumber": "Dokumentennummer", @@ -94,10 +107,12 @@ "draftResolutions": "Resolutionsentwürfe", "edit": "Bearbeiten", "editAccess": "Bearbeitungszugriff", + "editPaper": "Papier bearbeiten", "editUser": "Benutzer bearbeiten", "editors": "Bearbeiter", "email": "E-Mail", "enterAlpha2Code": "Bitte Alpha2Code eingeben", + "enterCode": "Code eingeben", "enterCountryCodes": "Ländercodes eingeben (Alpha-2 oder Alpha-3)", "errorUpdatingStateOfDebate": "Fehler beim Speichern des Debattenstatus", "errorUpdatingStatus": "Status konnte nicht gesetzt werden", @@ -165,6 +180,7 @@ "listClosed": "Liste geschlossen", "listClosedCannotAdd": "Die Liste ist geschlossen", "listEmpty": "Keine Rede", + "lockAcquireFailed": "Dieser Absatz wird gerade von {country} bearbeitet. Bitte versuche es gleich erneut.", "login": "Anmelden", "logout": "Abmelden", "loose_slow_reindeer_build": "Gremienmitglieder", @@ -182,16 +198,20 @@ "minutesFromNow": "Relative Zeit: Springe X Minuten in die Zukunft", "missionControl": "Mission Control", "moderatedInformalCaucus": "Moderierte informelle Sitzung", + "myPapers": "Meine Papiere", "name": "Name", "nextSpeaker": "Nächste Rede", "nextSpeakerDescription": "Möchtest du wirklich die nächste Rede aufrufen? Eventuelle Fragen- und Kurzbemerkungen werden verfallen.", + "noActiveAgendaItem": "Kein aktiver Tagesordnungspunkt. Es kann derzeit kein Papier erstellt werden.", "noAgendaItemSelected": "Kein Tagesordnungspunkt aktiv", "noAgendaItemSelectedDescription": "Um mit Redelisten arbeiten zu können muss zunächst ein Tagesordnungspunkt ausgewählt werden", "noAssignmentNeeded": "Keine Mitgliedszuweisung für diese Rolle nötig.", "noCommentList": "Keine Liste für Fragen und Kurzbemerkungen", "noCurrentSpeaker": "Keine Rede", "noData": "Keine Daten", + "noDraftResolutionsYet": "Noch keine Resolutionsentwürfe.", "noMembers": "Noch keine Mitglieder", + "noPapersYet": "Noch keine Papiere. Erstelle eines oder gib einen Freigabecode ein.", "noResults": "Keine Ergebnisse", "nonStateActor": "Nichtstaatlicher Akteur", "nonStateActors": "Nichtstaatliche Akteure", @@ -205,7 +225,12 @@ "onListPosition": "Du bist #{position} auf der Liste", "openPresentation": "Präsentationsansicht öffnen", "operativeClause": "Operativklausel", + "paperCreated": "Papier erstellt", + "paperDeleted": "Papier gelöscht", + "paperSubmitted": "Papier beim Vorsitz eingereicht", "paperSupportThresholdTooltip": "Benötigte Unterstützerstaaten für das Einreichen eines Änderungsantrags", + "paperTitle": "Papiertitel", + "papers": "Papiere", "parsedCountries": "Hinzuzufügende Länder:", "participantView": "Teilnehmeransicht", "pause": "Pause", @@ -229,6 +254,7 @@ "rejected": "Abgelehnt", "removeFromList": "Von der Liste entfernen", "removeMember": "Entfernen", + "removeSponsor": "Unterstützung zurückziehen", "resolutionPaper": "Resolutionspapier", "resolutionPapers": "Resolutionspapiere", "resolutions": "Resolutionen", @@ -240,6 +266,8 @@ "rollCollError": "Gremienmitglied nicht gefunden", "rollCollSuccess": "Anwesenheitsfeststellung abgeschlossen", "save": "Speichern", + "saveError": "Speichern fehlgeschlagen", + "savingChanges": "Speichern...", "searchCommitteeMembers": "Gremienmitglieder durchsuchen", "searchUsers": "Benutzer suchen...", "selectAgendaItem": "Tagesordnungspunkt auswählen...", @@ -267,6 +295,8 @@ "speakersListOvertime": "Redezeit abgelaufen!", "spectator": "Zuschauer*in", "sponsor": "Unterstützer", + "sponsorCount": "{count} Unterstützer", + "sponsorPaper": "Unterstützen", "sponsors": "Unterstützer", "startVote": "Abstimmung starten", "stateOfDebate": "Debattenstand", @@ -277,6 +307,7 @@ "submitPaper": "Papier einreichen", "submitStateOfDebate": "Debattenstatus speichern", "submitStatus": "Status setzen", + "submitToChair": "An Vorsitz einreichen", "submitted": "Eingereicht", "supportReEvaluation": "Unterstützungsneuprüfung", "suspension": "Vertagung", @@ -311,6 +342,7 @@ "unknown": "unbekannt", "unrecognizedCodes": "Nicht erkannte Codes:", "until": "bis {time} Uhr", + "untitledPaper": "Unbenanntes Papier", "updatedStateOfDebate": "Debattenstatus gespeichert", "updatingStateOfDebate": "Debattenstatus wird gespeichert...", "updatingStatus": "Status wird gesetzt...", @@ -320,6 +352,7 @@ "userAlreadyExists": "Benutzer existiert bereits in dieser Konferenz: {email}", "users": "Benutzer", "version": "Version", + "viewPaper": "Papier ansehen", "voteOutcome": "Abstimmungsergebnis", "voteResult": "Abstimmungsergebnis", "voteTitel": "Name der Abstimmung", diff --git a/messages/en.json b/messages/en.json index ef4338a1..acfd9212 100644 --- a/messages/en.json +++ b/messages/en.json @@ -41,13 +41,18 @@ "chairControls": "Chair Controls", "changeSpeakersName": "Change Name", "changeSpeakersTime": "Change Speaking Time", + "changesSaved": "Saved", + "clauseLockedBy": "Being edited by {country}", "clearFormatting": "Delete formatting", "clearList": "Reset List", "clearListDescription": "Are you sure you want to reset the entire list?", "close": "Close", "closeList": "Close List", "code": "Code", + "codeCopied": "Code copied!", + "codeRedeemed": "Code redeemed successfully", "codesUnrecognized": "{count} codes unrecognized", + "collaborativeEditingInfo": "Other delegates are editing this resolution. Hover a clause and click \"Start editing\" to begin. Locks expire automatically after 1 minute of inactivity.", "comingSoon": "coming soon", "commentList": "Point of Information", "committee": "Committee", @@ -70,8 +75,11 @@ "conferenceTitle": "Conference Title", "configuration": "Configuration", "confirmDeleteCommittee": "Are you sure you want to delete this committee? All associated data will be lost.", + "confirmDeletePaper": "Are you sure you want to delete this working paper? It will be hidden for all participants.", "confirmDeleteRepresentation": "Are you sure you want to remove this delegation? Associated committee memberships will be removed.", "confirmRemoveMember": "Are you sure you want to remove this member?", + "confirmSubmitPaper": "Are you sure you want to submit this paper to the chair? You will no longer be able to edit it.", + "copyCode": "Copy", "countries": "Countries", "countriesRecognized": "{count} countries recognized", "countryCodesHelp": "Supports Alpha-2 (DE, US) and Alpha-3 (DEU, USA) codes. Separate with spaces, commas, semicolons, or new lines.", @@ -80,11 +88,16 @@ "countryNsaOrCustomRole": "Country, NSA, or special role", "create": "Create", "createConference": "Create Conference", + "createPaper": "Create Paper", "createResolutionPaper": "Create Working Paper", + "createShareCodeEdit": "Create Edit Code", + "createShareCodeSponsor": "Create Sponsor Code", "customName": "Custom name...", "dateCannotBeInPast": "The date must not be in the past!", "delegate": "Delegate", "delegations": "Delegations", + "deleteCode": "Delete Code", + "deletePaper": "Delete Paper", "deleteRepresentation": "Remove Delegation", "displayRegionalGroups": "Display Regional Blocs", "documentNumber": "Document Number", @@ -94,10 +107,12 @@ "draftResolutions": "Draft Resolutions", "edit": "Edit", "editAccess": "Edit Access", + "editPaper": "Edit Paper", "editUser": "Edit User", "editors": "Editors", "email": "Email", "enterAlpha2Code": "Please enter Alpha2Code", + "enterCode": "Enter Code", "enterCountryCodes": "Enter country codes (Alpha-2 or Alpha-3)", "errorUpdatingStateOfDebate": "Error saving debate status", "errorUpdatingStatus": "Status could not be set", @@ -165,6 +180,7 @@ "listClosed": "List closed", "listClosedCannotAdd": "The list is closed", "listEmpty": "No speech", + "lockAcquireFailed": "This clause is currently being edited by {country}. Please try again shortly.", "login": "Register", "logout": "Log out", "loose_slow_reindeer_build": "Committee Members", @@ -182,16 +198,20 @@ "minutesFromNow": "Relative time: Jump X minutes into the future", "missionControl": "Mission Control", "moderatedInformalCaucus": "Moderated informal caucus", + "myPapers": "My Papers", "name": "Name", "nextSpeaker": "Next Speech", "nextSpeakerDescription": "Do you really want to call the next speech? All remaining Points of Information will be discarded.", + "noActiveAgendaItem": "No active agenda item. A paper cannot be created right now.", "noAgendaItemSelected": "No agenda item active", "noAgendaItemSelectedDescription": "To work with speakers' lists, you must first select an agenda item.", "noAssignmentNeeded": "No member assignment needed for this role.", "noCommentList": "No Point of Information List", "noCurrentSpeaker": "No speech", "noData": "No data", + "noDraftResolutionsYet": "No draft resolutions yet.", "noMembers": "No members yet", + "noPapersYet": "No papers yet. Create one or enter a share code.", "noResults": "No results", "nonStateActor": "Non-state Actor", "nonStateActors": "Non-state Actors", @@ -205,7 +225,12 @@ "onListPosition": "You are #{position} on the list", "openPresentation": "Open Presentation View", "operativeClause": "Operative Clause", + "paperCreated": "Paper created", + "paperDeleted": "Paper deleted", + "paperSubmitted": "Paper submitted to chair", "paperSupportThresholdTooltip": "Supporting states required to submit an amendment", + "paperTitle": "Paper Title", + "papers": "Papers", "parsedCountries": "Countries to add:", "participantView": "Participant View", "pause": "Pause", @@ -229,6 +254,7 @@ "rejected": "Rejected", "removeFromList": "Remove from list", "removeMember": "Remove", + "removeSponsor": "Remove Sponsorship", "resolutionPaper": "Resolution Paper", "resolutionPapers": "Resolution Papers", "resolutions": "Resolutions", @@ -240,6 +266,8 @@ "rollCollError": "Committee member not found", "rollCollSuccess": "Roll call complete", "save": "Save", + "saveError": "Save failed", + "savingChanges": "Saving...", "searchCommitteeMembers": "Search committee members", "searchUsers": "Search users...", "selectAgendaItem": "Select agenda item...", @@ -267,6 +295,8 @@ "speakersListOvertime": "Speaking time over!", "spectator": "Spectator", "sponsor": "Sponsor", + "sponsorCount": "{count} sponsors", + "sponsorPaper": "Sponsor", "sponsors": "Sponsors", "startVote": "Start Vote", "stateOfDebate": "State of Debate", @@ -277,6 +307,7 @@ "submitPaper": "Submit Paper", "submitStateOfDebate": "Save debate status", "submitStatus": "Set status", + "submitToChair": "Submit to Chair", "submitted": "Submitted", "supportReEvaluation": "Support Re-evaluation", "suspension": "Suspension", @@ -311,6 +342,7 @@ "unknown": "unknown", "unrecognizedCodes": "Unrecognized codes:", "until": "until {time}", + "untitledPaper": "Untitled Paper", "updatedStateOfDebate": "Debate status saved", "updatingStateOfDebate": "Saving debate status...", "updatingStatus": "Status is being set...", @@ -320,6 +352,7 @@ "userAlreadyExists": "User already exists in this conference: {email}", "users": "Users", "version": "Version", + "viewPaper": "View Paper", "voteOutcome": "Vote Outcome", "voteResult": "Vote Result", "voteTitel": "Vote Title", diff --git a/package.json b/package.json index 305a68e1..07025a52 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,7 @@ }, "type": "module", "dependencies": { - "@deutschemodelunitednations/munify-resolution-editor": "^0.1.1", + "@deutschemodelunitednations/munify-resolution-editor": "file:../munify-resolution-editor", "@tanstack/table-core": "^8.21.3", "drizzle-kit": "1.0.0-beta.1-fd5d1e8", "drizzle-orm": "1.0.0-beta.1-fd5d1e8", diff --git a/schema.graphql b/schema.graphql index 6fdf0f03..49717199 100644 --- a/schema.graphql +++ b/schema.graphql @@ -4,6 +4,7 @@ type AgendaItem { createdAt: DateTime! id: ID! isActive: Boolean + resolutionPapers(limit: Int, offset: Int, where: ResolutionPaperWhereInputArgument): [ResolutionPaper!]! speakersList(limit: Int, offset: Int, where: SpeakersListWhereInputArgument): [SpeakersList!]! title: String! updatedAt: DateTime @@ -14,15 +15,92 @@ input AgendaItemWhereInputArgument { committeeId: ID createdAt: DateTime id: ID + resolutionPapers: ResolutionPaperWhereInputArgument speakersList: SpeakersListWhereInputArgument title: String updatedAt: DateTime } +type Amendment { + createdAt: DateTime! + id: ID! + newContent: JSON + paper(where: ResolutionPaperWhereInputArgument): ResolutionPaper! + paperId: ID! + proposer(where: CommitteeMemberWhereInputArgument): CommitteeMember! + proposerCommitteeMemberId: ID! + sponsors(limit: Int, offset: Int, where: AmendmentSponsorWhereInputArgument): [AmendmentSponsor!]! + status: AmendmentStatusEnum! + targetClauseId: ID + targetOperativeIndex: Int + targetPosition: Int + type: AmendmentTypeEnum! + updatedAt: DateTime +} + +type AmendmentSponsor { + amendment(where: AmendmentWhereInputArgument): Amendment! + amendmentId: ID! + committeeMember(where: CommitteeMemberWhereInputArgument): CommitteeMember! + committeeMemberId: ID! + createdAt: DateTime! + id: ID! + updatedAt: DateTime +} + +input AmendmentSponsorWhereInputArgument { + amendment: AmendmentWhereInputArgument + amendmentId: ID + committeeMember: CommitteeMemberWhereInputArgument + committeeMemberId: ID + createdAt: DateTime + id: ID + updatedAt: DateTime +} + +enum AmendmentStatusEnum { + ACCEPTED + CONSENSUS_ADOPTED + PENDING + REJECTED + SUBMITTED + WITHDRAWN +} + +enum AmendmentTypeEnum { + ADD + ALTER_POSITION + ALTER_TEXT + DELETE +} + +input AmendmentWhereInputArgument { + createdAt: DateTime + id: ID + newContent: JSON + paper: ResolutionPaperWhereInputArgument + paperId: ID + proposer: CommitteeMemberWhereInputArgument + proposerCommitteeMemberId: ID + sponsors: AmendmentSponsorWhereInputArgument + status: AmendmentStatusEnum + targetClauseId: ID + targetOperativeIndex: Int + targetPosition: Int + type: AmendmentTypeEnum + updatedAt: DateTime +} + +enum CommentVisibilityEnum { + PUBLIC + TEAM_ONLY +} + type Committee { abbreviation: String! activeAgendaItem(where: AgendaItemWhereInputArgument): AgendaItem activeAgendaItemId: ID + activeDraftResolution(where: ResolutionPaperWhereInputArgument): ResolutionPaper activeDraftResolutionId: ID agendaItems(limit: Int, offset: Int, where: AgendaItemWhereInputArgument): [AgendaItem!]! allowDelegationsToAddThemselvesToSpeakersList: Boolean! @@ -39,6 +117,7 @@ type Committee { members(limit: Int, offset: Int, where: CommitteeMemberWhereInputArgument): [CommitteeMember!]! name: String! paperSupportThreshold: Int + resolutionPapers(limit: Int, offset: Int, where: ResolutionPaperWhereInputArgument): [ResolutionPaper!]! showWhiteboard: Boolean! simpleMajority: Int stateOfDebate: String @@ -53,12 +132,16 @@ type Committee { } type CommitteeMember { + amendmentSponsors(limit: Int, offset: Int, where: AmendmentSponsorWhereInputArgument): [AmendmentSponsor!]! committee(where: CommitteeWhereInputArgument): Committee! committeeId: ID! createdAt: DateTime! + createdPapers(limit: Int, offset: Int, where: ResolutionPaperWhereInputArgument): [ResolutionPaper!]! id: ID! + paperSponsors(limit: Int, offset: Int, where: PaperSponsorWhereInputArgument): [PaperSponsor!]! presenceChangedTimestamps(limit: Int, offset: Int, where: PresenceChangedTimestampWhereInputArgument): [PresenceChangedTimestamp!]! present: Boolean! + proposedAmendments(limit: Int, offset: Int, where: AmendmentWhereInputArgument): [Amendment!]! representation(where: RepresentationWhereInputArgument): Representation! representationId: ID! updatedAt: DateTime @@ -66,12 +149,16 @@ type CommitteeMember { } input CommitteeMemberWhereInputArgument { + amendmentSponsors: AmendmentSponsorWhereInputArgument committee: CommitteeWhereInputArgument committeeId: ID createdAt: DateTime + createdPapers: ResolutionPaperWhereInputArgument id: ID + paperSponsors: PaperSponsorWhereInputArgument presenceChangedTimestamps: PresenceChangedTimestampWhereInputArgument present: Boolean + proposedAmendments: AmendmentWhereInputArgument representation: RepresentationWhereInputArgument representationId: ID updatedAt: DateTime @@ -90,6 +177,7 @@ input CommitteeWhereInputArgument { abbreviation: String activeAgendaItem: AgendaItemWhereInputArgument activeAgendaItemId: ID + activeDraftResolution: ResolutionPaperWhereInputArgument activeDraftResolutionId: ID agendaItems: AgendaItemWhereInputArgument allowDelegationsToAddThemselvesToSpeakersList: Boolean @@ -105,6 +193,7 @@ input CommitteeWhereInputArgument { maxDraftResolutions: Int members: CommitteeMemberWhereInputArgument name: String + resolutionPapers: ResolutionPaperWhereInputArgument showWhiteboard: Boolean stateOfDebate: String status: CommitteeStatusEnum @@ -158,6 +247,8 @@ input ConferenceMemberWhereInputArgument { } type ConferenceUser { + clauseLocks(limit: Int, offset: Int, where: PaperClauseLockWhereInputArgument): [PaperClauseLock!]! + comments(limit: Int, offset: Int, where: ResolutionCommentWhereInputArgument): [ResolutionComment!]! committeeMember(where: CommitteeMemberWhereInputArgument): CommitteeMember committeeMemberId: ID conference(where: ConferenceWhereInputArgument): Conference! @@ -167,6 +258,7 @@ type ConferenceUser { conferenceUserType: ConferenceUserTypeEnum! createdAt: DateTime! id: ID! + paperEditors(limit: Int, offset: Int, where: PaperEditorWhereInputArgument): [PaperEditor!]! updatedAt: DateTime user(where: UserWhereInputArgument): User userEmail: String! @@ -181,6 +273,8 @@ enum ConferenceUserTypeEnum { } input ConferenceUserWhereInputArgument { + clauseLocks: PaperClauseLockWhereInputArgument + comments: ResolutionCommentWhereInputArgument committeeMember: CommitteeMemberWhereInputArgument committeeMemberId: ID conference: ConferenceWhereInputArgument @@ -190,6 +284,7 @@ input ConferenceUserWhereInputArgument { conferenceUserType: ConferenceUserTypeEnum createdAt: DateTime id: ID + paperEditors: PaperEditorWhereInputArgument updatedAt: DateTime user: UserWhereInputArgument userEmail: String @@ -277,7 +372,9 @@ The `JSON` scalar type represents JSON values as specified by [ECMA-404](http:// scalar JSON type Mutation { + acquireClauseLock(clauseId: String!, paperId: ID!): PaperClauseLock addSpeakerOnList(committeeMemberId: ID, conferenceMemberId: ID, position: Int, speakersListId: ID!): SpeakerOnList + addSponsor(committeeMemberId: ID!, paperId: ID!): PaperSponsor clearSpeakersList(id: ID!): SpeakersList createAgendaItem(committeeId: ID!, title: String!): AgendaItem createCommittee(abbreviation: String!, conferenceId: ID!, name: String!): Committee @@ -285,24 +382,176 @@ type Mutation { createConferenceMember(conferenceId: ID!, representationId: ID!): ConferenceMember createConferenceUser(conferenceId: ID!, conferenceUserType: ConferenceUserTypeEnum!, userEmail: String!): ConferenceUser createRepresentation(alpha2Code: String, alpha3Code: String, conferenceId: ID!, faIcon: String, name: String, type: RepresentationTypeEnum!): Representation + createResolutionPaper(agendaItemId: ID!, committeeId: ID!, title: String): ResolutionPaper + createShareCode(paperId: ID!, permission: ShareCodePermissionEnum!): PaperShareCode deleteCommittee(id: ID!): Boolean deleteCommitteeMember(id: ID!): Boolean deleteConferenceMember(id: ID!): Boolean deleteConferenceUser(id: ID!): Boolean deleteRepresentation(id: ID!): Boolean + deleteShareCode(shareCodeId: ID!): Boolean importDelegatorConference(data: ImportData!): Conference moveSpeakerToPosition(id: ID!, position: Int!): SpeakerOnList + promoteToDraftResolution(paperId: ID!): ResolutionPaper + recordVoteResult(outcome: VoteOutcomeEnum!, paperId: ID!, votesAbstain: Int, votesAgainst: Int!, votesFor: Int!): ResolutionPaper + redeemShareCode(code: String!): ShareCodeRedemptionResult + releaseAllMyLocks(paperId: ID!): Boolean + releaseClauseLock(clauseId: String!, paperId: ID!): Boolean + removeEditor(conferenceUserId: ID!, paperId: ID!): Boolean removeSpeakerOnList(speakerOnListId: ID!): SpeakersList + removeSponsor(committeeMemberId: ID!, paperId: ID!): Boolean selfAddToSpeakersList(speakersListId: ID!): SpeakerOnList selfRemoveFromSpeakersList(speakersListId: ID!): SpeakersList setPresenceForCommitteeMembers(ids: [ID!]!, present: Boolean!): [CommitteeMember!] - updateCommittee(abbreviation: String, activeAgendaItemId: ID, allowDelegationsToAddThemselvesToSpeakersList: Boolean, id: ID!, lastResolutionAdoptionDate: DateTime, name: String, showWhiteboard: Boolean, stateOfDebate: String, status: CommitteeStatusEnum, statusHeadline: String, statusUntil: DateTime, whiteboardContent: String): Committee + softDeletePaper(paperId: ID!): Boolean + submitPaper(paperId: ID!): ResolutionPaper + updateCommittee(abbreviation: String, activeAgendaItemId: ID, activeDraftResolutionId: ID, allowDelegationsToAddThemselvesToSpeakersList: Boolean, currentOperativeIndex: Int, id: ID!, lastResolutionAdoptionDate: DateTime, maxDraftResolutions: Int, name: String, showWhiteboard: Boolean, stateOfDebate: String, status: CommitteeStatusEnum, statusHeadline: String, statusUntil: DateTime, supportReEvaluationOpen: Boolean, whiteboardContent: String): Committee updateConference(hasModeratedCaucus: Boolean, id: ID!, pressWebsite: String, title: String): Conference updateConferenceUser(committeeMemberId: ID, conferenceMemberId: ID, conferenceUserType: ConferenceUserTypeEnum!, id: ID!): ConferenceUser + updatePaperContent(content: JSON!, paperId: ID!): ResolutionPaper + updatePaperTitle(paperId: ID!, title: String!): ResolutionPaper updateSpeakerOnList(id: ID!, overwriteName: String): SpeakerOnList updateSpeakersList(id: ID!, isClosed: Boolean, speakingTime: Int, startTimestamp: DateTime, stopTimer: Boolean = false, timeLeft: Int): SpeakersList } +type OperativeClauseVote { + clauseId: ID! + createdAt: DateTime! + id: ID! + outcome: VoteOutcomeEnum! + paper(where: ResolutionPaperWhereInputArgument): ResolutionPaper! + paperId: ID! + updatedAt: DateTime + votesAbstain: Int! + votesAgainst: Int! + votesFor: Int! +} + +input OperativeClauseVoteWhereInputArgument { + clauseId: ID + createdAt: DateTime + id: ID + outcome: VoteOutcomeEnum + paper: ResolutionPaperWhereInputArgument + paperId: ID + updatedAt: DateTime + votesAbstain: Int + votesAgainst: Int + votesFor: Int +} + +type PaperClauseLock { + acquiredAt: DateTime! + clauseId: ID! + conferenceUser(where: ConferenceUserWhereInputArgument): ConferenceUser! + conferenceUserId: ID! + createdAt: DateTime! + id: ID! + paper(where: ResolutionPaperWhereInputArgument): ResolutionPaper! + paperId: ID! + updatedAt: DateTime +} + +input PaperClauseLockWhereInputArgument { + acquiredAt: DateTime + clauseId: ID + conferenceUser: ConferenceUserWhereInputArgument + conferenceUserId: ID + createdAt: DateTime + id: ID + paper: ResolutionPaperWhereInputArgument + paperId: ID + updatedAt: DateTime +} + +type PaperContentSnapshot { + content: JSON + createdAt: DateTime! + id: ID! + paper(where: ResolutionPaperWhereInputArgument): ResolutionPaper! + paperId: ID! + trigger: String + updatedAt: DateTime +} + +input PaperContentSnapshotWhereInputArgument { + content: JSON + createdAt: DateTime + id: ID + paper: ResolutionPaperWhereInputArgument + paperId: ID + trigger: String + updatedAt: DateTime +} + +type PaperEditor { + conferenceUser(where: ConferenceUserWhereInputArgument): ConferenceUser! + conferenceUserId: ID! + createdAt: DateTime! + id: ID! + paper(where: ResolutionPaperWhereInputArgument): ResolutionPaper! + paperId: ID! + updatedAt: DateTime +} + +input PaperEditorWhereInputArgument { + conferenceUser: ConferenceUserWhereInputArgument + conferenceUserId: ID + createdAt: DateTime + id: ID + paper: ResolutionPaperWhereInputArgument + paperId: ID + updatedAt: DateTime +} + +type PaperShareCode { + code: String! + createdAt: DateTime! + id: ID! + paper(where: ResolutionPaperWhereInputArgument): ResolutionPaper! + paperId: ID! + permission: ShareCodePermissionEnum! + updatedAt: DateTime +} + +input PaperShareCodeWhereInputArgument { + code: String + createdAt: DateTime + id: ID + paper: ResolutionPaperWhereInputArgument + paperId: ID + permission: ShareCodePermissionEnum + updatedAt: DateTime +} + +type PaperSponsor { + committeeMember(where: CommitteeMemberWhereInputArgument): CommitteeMember! + committeeMemberId: ID! + createdAt: DateTime! + id: ID! + paper(where: ResolutionPaperWhereInputArgument): ResolutionPaper! + paperId: ID! + updatedAt: DateTime +} + +input PaperSponsorWhereInputArgument { + committeeMember: CommitteeMemberWhereInputArgument + committeeMemberId: ID + createdAt: DateTime + id: ID + paper: ResolutionPaperWhereInputArgument + paperId: ID + updatedAt: DateTime +} + +enum PaperStatusEnum { + AMENDMENT_PHASE + DRAFT_RESOLUTION + FINAL + SUBMITTED + WORKING_PAPER +} + type PresenceChangedTimestamp { committeeMember(where: CommitteeMemberWhereInputArgument): CommitteeMember committeeMemberId: ID! @@ -325,24 +574,46 @@ input PresenceChangedTimestampWhereInputArgument { type Query { findFirstAgendaItem(where: AgendaItemWhereInputArgument): AgendaItem! + findFirstAmendment(where: AmendmentWhereInputArgument): Amendment! + findFirstAmendmentSponsor(where: AmendmentSponsorWhereInputArgument): AmendmentSponsor! findFirstCommittee(where: CommitteeWhereInputArgument): Committee! findFirstCommitteeMember(where: CommitteeMemberWhereInputArgument): CommitteeMember! findFirstConference(where: ConferenceWhereInputArgument): Conference! findFirstConferenceMember(where: ConferenceMemberWhereInputArgument): ConferenceMember! findFirstConferenceUser(where: ConferenceUserWhereInputArgument): ConferenceUser! + findFirstOperativeClauseVote(where: OperativeClauseVoteWhereInputArgument): OperativeClauseVote! + findFirstPaperClauseLock(where: PaperClauseLockWhereInputArgument): PaperClauseLock! + findFirstPaperContentSnapshot(where: PaperContentSnapshotWhereInputArgument): PaperContentSnapshot! + findFirstPaperEditor(where: PaperEditorWhereInputArgument): PaperEditor! + findFirstPaperShareCode(where: PaperShareCodeWhereInputArgument): PaperShareCode! + findFirstPaperSponsor(where: PaperSponsorWhereInputArgument): PaperSponsor! findFirstPresenceChangedTimestamp(where: PresenceChangedTimestampWhereInputArgument): PresenceChangedTimestamp! findFirstRepresentation(where: RepresentationWhereInputArgument): Representation! + findFirstResolutionComment(where: ResolutionCommentWhereInputArgument): ResolutionComment! + findFirstResolutionPaper(where: ResolutionPaperWhereInputArgument): ResolutionPaper! + findFirstResolutionVoteResult(where: ResolutionVoteResultWhereInputArgument): ResolutionVoteResult! findFirstSpeakerOnList(where: SpeakerOnListWhereInputArgument): SpeakerOnList! findFirstSpeakersList(where: SpeakersListWhereInputArgument): SpeakersList! findFirstUser(where: UserWhereInputArgument): User! findManyAgendaItem(limit: Int, offset: Int, where: AgendaItemWhereInputArgument): [AgendaItem!]! + findManyAmendment(limit: Int, offset: Int, where: AmendmentWhereInputArgument): [Amendment!]! + findManyAmendmentSponsor(limit: Int, offset: Int, where: AmendmentSponsorWhereInputArgument): [AmendmentSponsor!]! findManyCommittee(limit: Int, offset: Int, where: CommitteeWhereInputArgument): [Committee!]! findManyCommitteeMember(limit: Int, offset: Int, where: CommitteeMemberWhereInputArgument): [CommitteeMember!]! findManyConference(limit: Int, offset: Int, where: ConferenceWhereInputArgument): [Conference!]! findManyConferenceMember(limit: Int, offset: Int, where: ConferenceMemberWhereInputArgument): [ConferenceMember!]! findManyConferenceUser(limit: Int, offset: Int, where: ConferenceUserWhereInputArgument): [ConferenceUser!]! + findManyOperativeClauseVote(limit: Int, offset: Int, where: OperativeClauseVoteWhereInputArgument): [OperativeClauseVote!]! + findManyPaperClauseLock(limit: Int, offset: Int, where: PaperClauseLockWhereInputArgument): [PaperClauseLock!]! + findManyPaperContentSnapshot(limit: Int, offset: Int, where: PaperContentSnapshotWhereInputArgument): [PaperContentSnapshot!]! + findManyPaperEditor(limit: Int, offset: Int, where: PaperEditorWhereInputArgument): [PaperEditor!]! + findManyPaperShareCode(limit: Int, offset: Int, where: PaperShareCodeWhereInputArgument): [PaperShareCode!]! + findManyPaperSponsor(limit: Int, offset: Int, where: PaperSponsorWhereInputArgument): [PaperSponsor!]! findManyPresenceChangedTimestamp(limit: Int, offset: Int, where: PresenceChangedTimestampWhereInputArgument): [PresenceChangedTimestamp!]! findManyRepresentation(limit: Int, offset: Int, where: RepresentationWhereInputArgument): [Representation!]! + findManyResolutionComment(limit: Int, offset: Int, where: ResolutionCommentWhereInputArgument): [ResolutionComment!]! + findManyResolutionPaper(limit: Int, offset: Int, where: ResolutionPaperWhereInputArgument): [ResolutionPaper!]! + findManyResolutionVoteResult(limit: Int, offset: Int, where: ResolutionVoteResultWhereInputArgument): [ResolutionVoteResult!]! findManySpeakerOnList(limit: Int, offset: Int, where: SpeakerOnListWhereInputArgument): [SpeakerOnList!]! findManySpeakersList(limit: Int, offset: Int, where: SpeakersListWhereInputArgument): [SpeakersList!]! findManyUser(limit: Int, offset: Int, where: UserWhereInputArgument): [User!]! @@ -395,6 +666,126 @@ input RepresentationWhereInputArgument { updatedAt: DateTime } +type ResolutionComment { + author(where: ConferenceUserWhereInputArgument): ConferenceUser! + authorConferenceUserId: ID! + clauseId: ID + content: String! + createdAt: DateTime! + id: ID! + paper(where: ResolutionPaperWhereInputArgument): ResolutionPaper! + paperId: ID! + parentComment(where: ResolutionCommentWhereInputArgument): ResolutionComment + parentCommentId: ID + replies(limit: Int, offset: Int, where: ResolutionCommentWhereInputArgument): [ResolutionComment!]! + updatedAt: DateTime + visibility: CommentVisibilityEnum! +} + +input ResolutionCommentWhereInputArgument { + author: ConferenceUserWhereInputArgument + authorConferenceUserId: ID + clauseId: ID + content: String + createdAt: DateTime + id: ID + paper: ResolutionPaperWhereInputArgument + paperId: ID + parentComment: ResolutionCommentWhereInputArgument + parentCommentId: ID + replies: ResolutionCommentWhereInputArgument + updatedAt: DateTime + visibility: CommentVisibilityEnum +} + +type ResolutionPaper { + agendaItem(where: AgendaItemWhereInputArgument): AgendaItem! + agendaItemId: ID! + amendments(limit: Int, offset: Int, where: AmendmentWhereInputArgument): [Amendment!]! + clauseLocks(limit: Int, offset: Int, where: PaperClauseLockWhereInputArgument): [PaperClauseLock!]! + comments(limit: Int, offset: Int, where: ResolutionCommentWhereInputArgument): [ResolutionComment!]! + committee(where: CommitteeWhereInputArgument): Committee! + committeeId: ID! + content: JSON + createdAt: DateTime! + creator(where: CommitteeMemberWhereInputArgument): CommitteeMember! + creatorCommitteeMemberId: ID! + deletedAt: DateTime + documentNumber: String + editors(limit: Int, offset: Int, where: PaperEditorWhereInputArgument): [PaperEditor!]! + id: ID! + operativeClauseVotes(limit: Int, offset: Int, where: OperativeClauseVoteWhereInputArgument): [OperativeClauseVote!]! + sequenceNumber: Int + shareCodes(limit: Int, offset: Int, where: PaperShareCodeWhereInputArgument): [PaperShareCode!]! + snapshots(limit: Int, offset: Int, where: PaperContentSnapshotWhereInputArgument): [PaperContentSnapshot!]! + sponsors(limit: Int, offset: Int, where: PaperSponsorWhereInputArgument): [PaperSponsor!]! + status: PaperStatusEnum! + title: String + updatedAt: DateTime + voteResult(where: ResolutionVoteResultWhereInputArgument): ResolutionVoteResult +} + +input ResolutionPaperWhereInputArgument { + agendaItem: AgendaItemWhereInputArgument + agendaItemId: ID + amendments: AmendmentWhereInputArgument + clauseLocks: PaperClauseLockWhereInputArgument + comments: ResolutionCommentWhereInputArgument + committee: CommitteeWhereInputArgument + committeeId: ID + content: JSON + createdAt: DateTime + creator: CommitteeMemberWhereInputArgument + creatorCommitteeMemberId: ID + deletedAt: DateTime + documentNumber: String + editors: PaperEditorWhereInputArgument + id: ID + operativeClauseVotes: OperativeClauseVoteWhereInputArgument + sequenceNumber: Int + shareCodes: PaperShareCodeWhereInputArgument + snapshots: PaperContentSnapshotWhereInputArgument + sponsors: PaperSponsorWhereInputArgument + status: PaperStatusEnum + title: String + updatedAt: DateTime + voteResult: ResolutionVoteResultWhereInputArgument +} + +type ResolutionVoteResult { + createdAt: DateTime! + id: ID! + outcome: VoteOutcomeEnum! + paper(where: ResolutionPaperWhereInputArgument): ResolutionPaper! + paperId: ID! + updatedAt: DateTime + votesAbstain: Int! + votesAgainst: Int! + votesFor: Int! +} + +input ResolutionVoteResultWhereInputArgument { + createdAt: DateTime + id: ID + outcome: VoteOutcomeEnum + paper: ResolutionPaperWhereInputArgument + paperId: ID + updatedAt: DateTime + votesAbstain: Int + votesAgainst: Int + votesFor: Int +} + +enum ShareCodePermissionEnum { + EDIT + SPONSOR +} + +type ShareCodeRedemptionResult { + paperId: ID + permission: String +} + type SpeakerOnList { committeeMember(where: CommitteeMemberWhereInputArgument): CommitteeMember committeeMemberId: ID @@ -458,24 +849,46 @@ input SpeakersListWhereInputArgument { type Subscription { findFirstAgendaItem(where: AgendaItemWhereInputArgument): AgendaItem! + findFirstAmendment(where: AmendmentWhereInputArgument): Amendment! + findFirstAmendmentSponsor(where: AmendmentSponsorWhereInputArgument): AmendmentSponsor! findFirstCommittee(where: CommitteeWhereInputArgument): Committee! findFirstCommitteeMember(where: CommitteeMemberWhereInputArgument): CommitteeMember! findFirstConference(where: ConferenceWhereInputArgument): Conference! findFirstConferenceMember(where: ConferenceMemberWhereInputArgument): ConferenceMember! findFirstConferenceUser(where: ConferenceUserWhereInputArgument): ConferenceUser! + findFirstOperativeClauseVote(where: OperativeClauseVoteWhereInputArgument): OperativeClauseVote! + findFirstPaperClauseLock(where: PaperClauseLockWhereInputArgument): PaperClauseLock! + findFirstPaperContentSnapshot(where: PaperContentSnapshotWhereInputArgument): PaperContentSnapshot! + findFirstPaperEditor(where: PaperEditorWhereInputArgument): PaperEditor! + findFirstPaperShareCode(where: PaperShareCodeWhereInputArgument): PaperShareCode! + findFirstPaperSponsor(where: PaperSponsorWhereInputArgument): PaperSponsor! findFirstPresenceChangedTimestamp(where: PresenceChangedTimestampWhereInputArgument): PresenceChangedTimestamp! findFirstRepresentation(where: RepresentationWhereInputArgument): Representation! + findFirstResolutionComment(where: ResolutionCommentWhereInputArgument): ResolutionComment! + findFirstResolutionPaper(where: ResolutionPaperWhereInputArgument): ResolutionPaper! + findFirstResolutionVoteResult(where: ResolutionVoteResultWhereInputArgument): ResolutionVoteResult! findFirstSpeakerOnList(where: SpeakerOnListWhereInputArgument): SpeakerOnList! findFirstSpeakersList(where: SpeakersListWhereInputArgument): SpeakersList! findFirstUser(where: UserWhereInputArgument): User! findManyAgendaItem(limit: Int, offset: Int, where: AgendaItemWhereInputArgument): [AgendaItem!]! + findManyAmendment(limit: Int, offset: Int, where: AmendmentWhereInputArgument): [Amendment!]! + findManyAmendmentSponsor(limit: Int, offset: Int, where: AmendmentSponsorWhereInputArgument): [AmendmentSponsor!]! findManyCommittee(limit: Int, offset: Int, where: CommitteeWhereInputArgument): [Committee!]! findManyCommitteeMember(limit: Int, offset: Int, where: CommitteeMemberWhereInputArgument): [CommitteeMember!]! findManyConference(limit: Int, offset: Int, where: ConferenceWhereInputArgument): [Conference!]! findManyConferenceMember(limit: Int, offset: Int, where: ConferenceMemberWhereInputArgument): [ConferenceMember!]! findManyConferenceUser(limit: Int, offset: Int, where: ConferenceUserWhereInputArgument): [ConferenceUser!]! + findManyOperativeClauseVote(limit: Int, offset: Int, where: OperativeClauseVoteWhereInputArgument): [OperativeClauseVote!]! + findManyPaperClauseLock(limit: Int, offset: Int, where: PaperClauseLockWhereInputArgument): [PaperClauseLock!]! + findManyPaperContentSnapshot(limit: Int, offset: Int, where: PaperContentSnapshotWhereInputArgument): [PaperContentSnapshot!]! + findManyPaperEditor(limit: Int, offset: Int, where: PaperEditorWhereInputArgument): [PaperEditor!]! + findManyPaperShareCode(limit: Int, offset: Int, where: PaperShareCodeWhereInputArgument): [PaperShareCode!]! + findManyPaperSponsor(limit: Int, offset: Int, where: PaperSponsorWhereInputArgument): [PaperSponsor!]! findManyPresenceChangedTimestamp(limit: Int, offset: Int, where: PresenceChangedTimestampWhereInputArgument): [PresenceChangedTimestamp!]! findManyRepresentation(limit: Int, offset: Int, where: RepresentationWhereInputArgument): [Representation!]! + findManyResolutionComment(limit: Int, offset: Int, where: ResolutionCommentWhereInputArgument): [ResolutionComment!]! + findManyResolutionPaper(limit: Int, offset: Int, where: ResolutionPaperWhereInputArgument): [ResolutionPaper!]! + findManyResolutionVoteResult(limit: Int, offset: Int, where: ResolutionVoteResultWhereInputArgument): [ResolutionVoteResult!]! findManySpeakerOnList(limit: Int, offset: Int, where: SpeakerOnListWhereInputArgument): [SpeakerOnList!]! findManySpeakersList(limit: Int, offset: Int, where: SpeakersListWhereInputArgument): [SpeakersList!]! findManyUser(limit: Int, offset: Int, where: UserWhereInputArgument): [User!]! @@ -503,4 +916,10 @@ input UserWhereInputArgument { locale: String preferredUsername: String updatedAt: DateTime +} + +enum VoteOutcomeEnum { + ADOPTED + REJECTED + SENT_BACK } \ No newline at end of file diff --git a/src/api/db/relations.ts b/src/api/db/relations.ts index 7151ebbe..214d855e 100644 --- a/src/api/db/relations.ts +++ b/src/api/db/relations.ts @@ -115,6 +115,10 @@ export const relations = defineRelations(schema, (r) => ({ comments: r.many.resolutionComment({ from: r.conferenceUser.id, to: r.resolutionComment.authorConferenceUserId + }), + clauseLocks: r.many.paperClauseLock({ + from: r.conferenceUser.id, + to: r.paperClauseLock.conferenceUserId }) }, representation: { @@ -265,6 +269,10 @@ export const relations = defineRelations(schema, (r) => ({ voteResult: r.one.resolutionVoteResult({ from: r.resolutionPaper.id, to: r.resolutionVoteResult.paperId + }), + clauseLocks: r.many.paperClauseLock({ + from: r.resolutionPaper.id, + to: r.paperClauseLock.paperId }) }, paperContentSnapshot: { @@ -366,5 +374,17 @@ export const relations = defineRelations(schema, (r) => ({ to: r.resolutionPaper.id, optional: false }) + }, + paperClauseLock: { + paper: r.one.resolutionPaper({ + from: r.paperClauseLock.paperId, + to: r.resolutionPaper.id, + optional: false + }), + conferenceUser: r.one.conferenceUser({ + from: r.paperClauseLock.conferenceUserId, + to: r.conferenceUser.id, + optional: false + }) } })); diff --git a/src/api/db/reset.ts b/src/api/db/reset.ts index 612bcc72..9a82fb10 100644 --- a/src/api/db/reset.ts +++ b/src/api/db/reset.ts @@ -1,12 +1,28 @@ +import { sql } from 'drizzle-orm'; import { drizzle } from 'drizzle-orm/node-postgres'; -import * as schema from './schema'; -import { reset } from 'drizzle-seed'; const db = drizzle(process.env.DATABASE_URL!, { - schema: schema, casing: 'snake_case' }); console.info('Resetting database...'); -await reset(db, schema); +await db.execute(sql` + DO $$ DECLARE + r RECORD; + BEGIN + FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP + EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE'; + END LOOP; + END $$; +`); +// Also drop custom enum types +await db.execute(sql` + DO $$ DECLARE + r RECORD; + BEGIN + FOR r IN (SELECT typname FROM pg_type t JOIN pg_namespace n ON t.typnamespace = n.oid WHERE n.nspname = 'public' AND t.typtype = 'e') LOOP + EXECUTE 'DROP TYPE IF EXISTS public.' || quote_ident(r.typname) || ' CASCADE'; + END LOOP; + END $$; +`); console.info('Resetting database done.'); diff --git a/src/api/db/schema.ts b/src/api/db/schema.ts index 38ff83c9..d7682c1c 100644 --- a/src/api/db/schema.ts +++ b/src/api/db/schema.ts @@ -8,7 +8,7 @@ import { boolean, smallint, integer, - jsonb, + json, type AnyPgColumn } from 'drizzle-orm/pg-core'; @@ -277,10 +277,11 @@ export const resolutionPaper = pgTable('resolution_paper', { .notNull() .references(() => committeeMember.id, { onDelete: 'cascade' }), status: paperStatus().notNull().default('WORKING_PAPER'), - content: jsonb(), + content: json(), title: text(), documentNumber: text(), - sequenceNumber: smallint() + sequenceNumber: smallint(), + deletedAt: timestamp() }); export const paperContentSnapshot = pgTable('paper_content_snapshot', { @@ -288,7 +289,7 @@ export const paperContentSnapshot = pgTable('paper_content_snapshot', { paperId: text() .notNull() .references(() => resolutionPaper.id, { onDelete: 'cascade' }), - content: jsonb(), + content: json(), trigger: text() }); @@ -357,7 +358,7 @@ export const amendment = pgTable('amendment', { status: amendmentStatus().notNull().default('PENDING'), targetClauseId: text(), targetOperativeIndex: smallint(), - newContent: jsonb(), + newContent: json(), targetPosition: smallint() }); @@ -387,6 +388,22 @@ export const operativeClauseVote = pgTable('operative_clause_vote', { votesAbstain: integer().notNull().default(0) }); +export const paperClauseLock = pgTable( + 'paper_clause_lock', + { + ...defaultIdAndTimestamps, + paperId: text() + .notNull() + .references(() => resolutionPaper.id, { onDelete: 'cascade' }), + clauseId: text().notNull(), + conferenceUserId: text() + .notNull() + .references(() => conferenceUser.id, { onDelete: 'cascade' }), + acquiredAt: timestamp({ mode: 'date' }).defaultNow().notNull() + }, + (t) => [unique().on(t.paperId, t.clauseId)] +); + export const resolutionVoteResult = pgTable('resolution_vote_result', { ...defaultIdAndTimestamps, paperId: text() diff --git a/src/api/handlers/amendment.ts b/src/api/handlers/amendment.ts new file mode 100644 index 00000000..96894cd2 --- /dev/null +++ b/src/api/handlers/amendment.ts @@ -0,0 +1,17 @@ +import { abilityBuilder } from '$api/rumble'; +import { basics } from './basics'; +import { isWhitelistedEmail } from '$api/services/isDMUNEmail'; + +const { arg, ref, pubsub, table } = basics('amendment'); + +abilityBuilder.amendment.allow('read').when(({ mustBeLoggedIn }) => { + const user = mustBeLoggedIn(); + if (user?.email && isWhitelistedEmail(user.email)) { + return 'allow'; + } +}); + +abilityBuilder.amendment.allow('read').when(({ mustBeLoggedIn }) => { + mustBeLoggedIn(); + return 'allow'; +}); diff --git a/src/api/handlers/amendmentSponsor.ts b/src/api/handlers/amendmentSponsor.ts new file mode 100644 index 00000000..70fc8374 --- /dev/null +++ b/src/api/handlers/amendmentSponsor.ts @@ -0,0 +1,17 @@ +import { abilityBuilder } from '$api/rumble'; +import { basics } from './basics'; +import { isWhitelistedEmail } from '$api/services/isDMUNEmail'; + +const { arg, ref, pubsub, table } = basics('amendmentSponsor'); + +abilityBuilder.amendmentSponsor.allow('read').when(({ mustBeLoggedIn }) => { + const user = mustBeLoggedIn(); + if (user?.email && isWhitelistedEmail(user.email)) { + return 'allow'; + } +}); + +abilityBuilder.amendmentSponsor.allow('read').when(({ mustBeLoggedIn }) => { + mustBeLoggedIn(); + return 'allow'; +}); diff --git a/src/api/handlers/operativeClauseVote.ts b/src/api/handlers/operativeClauseVote.ts new file mode 100644 index 00000000..010a251b --- /dev/null +++ b/src/api/handlers/operativeClauseVote.ts @@ -0,0 +1,17 @@ +import { abilityBuilder } from '$api/rumble'; +import { basics } from './basics'; +import { isWhitelistedEmail } from '$api/services/isDMUNEmail'; + +const { arg, ref, pubsub, table } = basics('operativeClauseVote'); + +abilityBuilder.operativeClauseVote.allow('read').when(({ mustBeLoggedIn }) => { + const user = mustBeLoggedIn(); + if (user?.email && isWhitelistedEmail(user.email)) { + return 'allow'; + } +}); + +abilityBuilder.operativeClauseVote.allow('read').when(({ mustBeLoggedIn }) => { + mustBeLoggedIn(); + return 'allow'; +}); diff --git a/src/api/handlers/paperClauseLock.ts b/src/api/handlers/paperClauseLock.ts new file mode 100644 index 00000000..c5ec80d7 --- /dev/null +++ b/src/api/handlers/paperClauseLock.ts @@ -0,0 +1,191 @@ +import { db, schema } from '$api/db/db'; +import { abilityBuilder, schemaBuilder } from '$api/rumble'; +import { and, eq, lt } from 'drizzle-orm'; +import { basics } from './basics'; +import { assertFindFirstExists, assertFirstEntryExists } from '@m1212e/rumble'; +import { GraphQLError } from 'graphql'; + +const { ref, pubsub } = basics('paperClauseLock'); + +const LOCK_EXPIRY_MS = 60_000; // 60 seconds + +abilityBuilder.paperClauseLock.allow('read').when(({ mustBeLoggedIn }) => { + mustBeLoggedIn(); + return 'allow'; +}); + +schemaBuilder.mutationFields((t) => ({ + acquireClauseLock: t.drizzleField({ + type: ref, + args: { + paperId: t.arg.id({ required: true }), + clauseId: t.arg.string({ required: true }) + }, + resolve: async (query, root, args, ctx) => { + const user = ctx.mustBeLoggedIn(); + + const conferenceUser = await db.query.conferenceUser + .findFirst({ + where: { + user: { id: user.sub } + } + }) + .then(assertFindFirstExists); + + // Clean expired locks for this paper + const expiryThreshold = new Date(Date.now() - LOCK_EXPIRY_MS); + const expiredLocks = await db + .delete(schema.paperClauseLock) + .where( + and( + eq(schema.paperClauseLock.paperId, args.paperId), + lt(schema.paperClauseLock.acquiredAt, expiryThreshold) + ) + ) + .returning(); + + for (const expired of expiredLocks) { + pubsub.removed(expired.id); + } + + // Check existing lock for this (paperId, clauseId) + const existingLock = await db.query.paperClauseLock.findFirst({ + where: { + paperId: args.paperId, + clauseId: args.clauseId + } + }); + + if (existingLock) { + if (existingLock.conferenceUserId === conferenceUser.id) { + // Refresh own lock + await db + .update(schema.paperClauseLock) + .set({ acquiredAt: new Date() }) + .where(eq(schema.paperClauseLock.id, existingLock.id)); + + pubsub.updated(existingLock.id); + + return db.query.paperClauseLock + .findFirst( + query( + ctx.abilities.paperClauseLock.filter('read', { + inject: { where: { id: existingLock.id } } + }).query.single + ) + ) + .then(assertFindFirstExists); + } else { + throw new GraphQLError('Clause is locked by another user'); + } + } + + // Insert new lock — unique constraint handles the race condition + try { + const result = await db + .insert(schema.paperClauseLock) + .values({ + paperId: args.paperId, + clauseId: args.clauseId, + conferenceUserId: conferenceUser.id + }) + .returning() + .then(assertFirstEntryExists); + + // Use created() not updated(id) — other clients' findMany subscriptions + // don't know this ID yet, so updated(id) wouldn't reach them + pubsub.created(); + return db.query.paperClauseLock + .findFirst( + query( + ctx.abilities.paperClauseLock.filter('read', { + inject: { where: { id: result.id } } + }).query.single + ) + ) + .then(assertFindFirstExists); + } catch (e: unknown) { + // Unique constraint violation — another user won the race + if ( + e instanceof Error && + (e.message.includes('unique') || + e.message.includes('duplicate') || + e.message.includes('UNIQUE')) + ) { + throw new GraphQLError('Clause is locked by another user'); + } + throw e; + } + } + }), + + releaseClauseLock: t.field({ + type: 'Boolean', + args: { + paperId: t.arg.id({ required: true }), + clauseId: t.arg.string({ required: true }) + }, + resolve: async (root, args, ctx) => { + const user = ctx.mustBeLoggedIn(); + + const conferenceUser = await db.query.conferenceUser + .findFirst({ + where: { + user: { id: user.sub } + } + }) + .then(assertFindFirstExists); + + const deleted = await db + .delete(schema.paperClauseLock) + .where( + and( + eq(schema.paperClauseLock.paperId, args.paperId), + eq(schema.paperClauseLock.clauseId, args.clauseId), + eq(schema.paperClauseLock.conferenceUserId, conferenceUser.id) + ) + ) + .returning(); + + for (const lock of deleted) { + pubsub.removed(lock.id); + } + + return true; + } + }), + + releaseAllMyLocks: t.field({ + type: 'Boolean', + args: { + paperId: t.arg.id({ required: true }) + }, + resolve: async (root, args, ctx) => { + const user = ctx.mustBeLoggedIn(); + + const conferenceUser = await db.query.conferenceUser + .findFirst({ + where: { + user: { id: user.sub } + } + }) + .then(assertFindFirstExists); + + const deleted = await db + .delete(schema.paperClauseLock) + .where( + and( + eq(schema.paperClauseLock.paperId, args.paperId), + eq(schema.paperClauseLock.conferenceUserId, conferenceUser.id) + ) + ) + .returning(); + + for (const lock of deleted) { + pubsub.removed(lock.id); + } + + return true; + } + }) +})); diff --git a/src/api/handlers/paperShareCode.ts b/src/api/handlers/paperShareCode.ts index d63bd4fa..12f2aa0d 100644 --- a/src/api/handlers/paperShareCode.ts +++ b/src/api/handlers/paperShareCode.ts @@ -1,5 +1,5 @@ import { db, schema } from '$api/db/db'; -import { abilityBuilder, enum_, schemaBuilder } from '$api/rumble'; +import { abilityBuilder, enum_, schemaBuilder, pubsub as rumblePubsub } from '$api/rumble'; import { eq } from 'drizzle-orm'; import { basics } from './basics'; import { isWhitelistedEmail } from '$api/services/isDMUNEmail'; @@ -10,6 +10,7 @@ import { customAlphabet } from 'nanoid'; const generateShareCode = customAlphabet('ABCDEFGHJKLMNPQRSTUVWXYZ23456789', 6); const { arg, ref, pubsub, table } = basics('paperShareCode'); +const paperPubsub = rumblePubsub({ table: 'resolutionPaper' }); const shareCodePermissionEnum = enum_({ tsName: 'shareCodePermission' }); @@ -71,6 +72,7 @@ schemaBuilder.mutationFields((t) => ({ .then(assertFirstEntryExists); pubsub.updated(result.id); + paperPubsub.updated(args.paperId); return db.query.paperShareCode .findFirst( @@ -114,6 +116,7 @@ schemaBuilder.mutationFields((t) => ({ await db.delete(schema.paperShareCode).where(eq(schema.paperShareCode.id, args.shareCodeId)); pubsub.removed(args.shareCodeId); + paperPubsub.updated(shareCode.paperId); return true; } diff --git a/src/api/handlers/paperSponsor.ts b/src/api/handlers/paperSponsor.ts index 7d5b8f35..13bf1348 100644 --- a/src/api/handlers/paperSponsor.ts +++ b/src/api/handlers/paperSponsor.ts @@ -1,5 +1,5 @@ import { db, schema } from '$api/db/db'; -import { abilityBuilder, schemaBuilder } from '$api/rumble'; +import { abilityBuilder, schemaBuilder, pubsub as rumblePubsub } from '$api/rumble'; import { and, eq } from 'drizzle-orm'; import { basics } from './basics'; import { isWhitelistedEmail } from '$api/services/isDMUNEmail'; @@ -7,6 +7,7 @@ import { assertFindFirstExists, assertFirstEntryExists } from '@m1212e/rumble'; import { GraphQLError } from 'graphql'; const { arg, ref, pubsub, table } = basics('paperSponsor'); +const paperPubsub = rumblePubsub({ table: 'resolutionPaper' }); abilityBuilder.paperSponsor.allow(['read', 'update']).when(({ mustBeLoggedIn }) => { const user = mustBeLoggedIn(); @@ -50,6 +51,7 @@ schemaBuilder.mutationFields((t) => ({ .then(assertFirstEntryExists); pubsub.updated(result.id); + paperPubsub.updated(args.paperId); return db.query.paperSponsor .findFirst( @@ -117,6 +119,7 @@ schemaBuilder.mutationFields((t) => ({ ); pubsub.removed(sponsor.id); + paperPubsub.updated(args.paperId); return true; } diff --git a/src/api/handlers/register.ts b/src/api/handlers/register.ts index 46b9b242..e001908c 100644 --- a/src/api/handlers/register.ts +++ b/src/api/handlers/register.ts @@ -16,3 +16,9 @@ import './paperSponsor'; import './paperShareCode'; import './paperEditor'; import './paperContentSnapshot'; +import './resolutionComment'; +import './amendment'; +import './amendmentSponsor'; +import './operativeClauseVote'; +import './resolutionVoteResult'; +import './paperClauseLock'; diff --git a/src/api/handlers/resolutionComment.ts b/src/api/handlers/resolutionComment.ts new file mode 100644 index 00000000..2f21c823 --- /dev/null +++ b/src/api/handlers/resolutionComment.ts @@ -0,0 +1,17 @@ +import { abilityBuilder } from '$api/rumble'; +import { basics } from './basics'; +import { isWhitelistedEmail } from '$api/services/isDMUNEmail'; + +const { arg, ref, pubsub, table } = basics('resolutionComment'); + +abilityBuilder.resolutionComment.allow('read').when(({ mustBeLoggedIn }) => { + const user = mustBeLoggedIn(); + if (user?.email && isWhitelistedEmail(user.email)) { + return 'allow'; + } +}); + +abilityBuilder.resolutionComment.allow('read').when(({ mustBeLoggedIn }) => { + mustBeLoggedIn(); + return 'allow'; +}); diff --git a/src/api/handlers/resolutionPaper.ts b/src/api/handlers/resolutionPaper.ts index 375bbb32..5d1f4aac 100644 --- a/src/api/handlers/resolutionPaper.ts +++ b/src/api/handlers/resolutionPaper.ts @@ -1,6 +1,6 @@ import { db, schema } from '$api/db/db'; import { abilityBuilder, enum_, schemaBuilder } from '$api/rumble'; -import { and, eq, count as drizzleCount } from 'drizzle-orm'; +import { and, eq, isNull, count as drizzleCount } from 'drizzle-orm'; import { basics } from './basics'; import { isWhitelistedEmail } from '$api/services/isDMUNEmail'; import { assertFindFirstExists, assertFirstEntryExists } from '@m1212e/rumble'; @@ -18,13 +18,13 @@ const paperStatusEnum = enum_({ tsName: 'paperStatus' }); abilityBuilder.resolutionPaper.allow(['read', 'update']).when(({ mustBeLoggedIn }) => { const user = mustBeLoggedIn(); if (user?.email && isWhitelistedEmail(user.email)) { - return 'allow'; + return { where: { deletedAt: { isNull: true } } }; } }); abilityBuilder.resolutionPaper.allow('read').when(({ mustBeLoggedIn }) => { mustBeLoggedIn(); - return 'allow'; + return { where: { deletedAt: { isNull: true } } }; }); /** @@ -204,9 +204,141 @@ schemaBuilder.mutationFields((t) => ({ throw new GraphQLError('Paper cannot be edited in its current status'); } + // Resolve sender's conferenceUserId for lock-aware merge + const senderConferenceUser = await db.query.conferenceUser.findFirst({ + where: { user: { id: user.sub } } + }); + + let contentToWrite = parsed.data; + + if (senderConferenceUser) { + // Fetch active (non-expired) locks held by OTHER users + const expiryThreshold = new Date(Date.now() - 60_000); + const otherLocks = await db.query.paperClauseLock.findMany({ + where: { + paperId: args.paperId, + conferenceUserId: { ne: senderConferenceUser.id }, + acquiredAt: { gte: expiryThreshold } + } + }); + + if (otherLocks.length > 0 && paper.content) { + const othersLockedClauseIds = new Set(otherLocks.map((l) => l.clauseId)); + const currentContent = ResolutionSchema.safeParse(paper.content); + + if (currentContent.success) { + const dbContent = currentContent.data; + const incoming = parsed.data; + + // Build map of DB clauses by ID + const dbPreambleMap = new Map(dbContent.preamble.map((c) => [c.id, c])); + const dbOperativeMap = new Map(dbContent.operative.map((c) => [c.id, c])); + + // Merge preamble: for locked clauses, keep DB version + const mergedPreamble = incoming.preamble.map((clause) => { + if (othersLockedClauseIds.has(clause.id) && dbPreambleMap.has(clause.id)) { + return dbPreambleMap.get(clause.id)!; + } + return clause; + }); + + // Append preamble clauses locked by others that sender deleted + for (const [id, clause] of dbPreambleMap) { + if (othersLockedClauseIds.has(id) && !incoming.preamble.some((c) => c.id === id)) { + mergedPreamble.push(clause); + } + } + + // Merge operative: for locked clauses, keep DB version + const mergedOperative = incoming.operative.map((clause) => { + if (othersLockedClauseIds.has(clause.id) && dbOperativeMap.has(clause.id)) { + return dbOperativeMap.get(clause.id)!; + } + return clause; + }); + + // Append operative clauses locked by others that sender deleted + for (const [id, clause] of dbOperativeMap) { + if (othersLockedClauseIds.has(id) && !incoming.operative.some((c) => c.id === id)) { + mergedOperative.push(clause); + } + } + + contentToWrite = { + committeeName: incoming.committeeName, + preamble: mergedPreamble, + operative: mergedOperative + }; + } + } + + // Refresh sender's locks (keep alive during active editing) + await db + .update(schema.paperClauseLock) + .set({ acquiredAt: new Date() }) + .where( + and( + eq(schema.paperClauseLock.paperId, args.paperId), + eq(schema.paperClauseLock.conferenceUserId, senderConferenceUser.id) + ) + ); + } + await db .update(schema.resolutionPaper) - .set({ content: parsed.data }) + .set({ content: contentToWrite }) + .where(eq(schema.resolutionPaper.id, args.paperId)); + + pubsub.updated(args.paperId); + + return db.query.resolutionPaper + .findFirst( + query( + ctx.abilities.resolutionPaper.filter('read', { + inject: { + where: { id: args.paperId } + } + }).query.single + ) + ) + .then(assertFindFirstExists); + } + }), + + updatePaperTitle: t.drizzleField({ + type: ref, + args: { + paperId: t.arg.id({ required: true }), + title: t.arg.string({ required: true }) + }, + resolve: async (query, root, args, ctx, info) => { + const user = ctx.mustBeLoggedIn(); + + const paper = await db.query.resolutionPaper + .findFirst({ + where: { id: args.paperId } + }) + .then(assertFindFirstExists); + + if (paper.status !== 'WORKING_PAPER') { + throw new GraphQLError('Title can only be changed for working papers'); + } + + // Must be creator or editor + const isEditor = await db.query.paperEditor.findFirst({ + where: { + paperId: args.paperId, + conferenceUser: { user: { id: user.sub } } + } + }); + + if (!isEditor && !ctx.hasRole('admin')) { + throw new GraphQLError('You do not have permission to edit this paper'); + } + + await db + .update(schema.resolutionPaper) + .set({ title: args.title }) .where(eq(schema.resolutionPaper.id, args.paperId)); pubsub.updated(args.paperId); @@ -314,7 +446,8 @@ schemaBuilder.mutationFields((t) => ({ .where( and( eq(schema.resolutionPaper.committeeId, paper.committeeId), - eq(schema.resolutionPaper.status, 'DRAFT_RESOLUTION') + eq(schema.resolutionPaper.status, 'DRAFT_RESOLUTION'), + isNull(schema.resolutionPaper.deletedAt) ) ) .then(assertFirstEntryExists); @@ -339,7 +472,8 @@ schemaBuilder.mutationFields((t) => ({ .where( and( eq(schema.resolutionPaper.agendaItemId, paper.agendaItemId), - eq(schema.resolutionPaper.status, 'DRAFT_RESOLUTION') + eq(schema.resolutionPaper.status, 'DRAFT_RESOLUTION'), + isNull(schema.resolutionPaper.deletedAt) ) ) .then(assertFirstEntryExists); @@ -428,5 +562,44 @@ schemaBuilder.mutationFields((t) => ({ ) .then(assertFindFirstExists); } + }), + + softDeletePaper: t.field({ + type: 'Boolean', + args: { + paperId: t.arg.id({ required: true }) + }, + resolve: async (root, args, ctx) => { + const user = ctx.mustBeLoggedIn(); + + const paper = await db.query.resolutionPaper + .findFirst({ + where: { id: args.paperId } + }) + .then(assertFindFirstExists); + + if (paper.status !== 'WORKING_PAPER') { + throw new GraphQLError('Only working papers can be deleted'); + } + + // Only creator can delete + await db.query.conferenceUser + .findFirst({ + where: { + user: { id: user.sub }, + committeeMemberId: paper.creatorCommitteeMemberId + } + }) + .then(assertFindFirstExists); + + await db + .update(schema.resolutionPaper) + .set({ deletedAt: new Date() }) + .where(eq(schema.resolutionPaper.id, args.paperId)); + + pubsub.updated(args.paperId); + + return true; + } }) })); diff --git a/src/api/handlers/resolutionVoteResult.ts b/src/api/handlers/resolutionVoteResult.ts new file mode 100644 index 00000000..82eaab0e --- /dev/null +++ b/src/api/handlers/resolutionVoteResult.ts @@ -0,0 +1,17 @@ +import { abilityBuilder } from '$api/rumble'; +import { basics } from './basics'; +import { isWhitelistedEmail } from '$api/services/isDMUNEmail'; + +const { arg, ref, pubsub, table } = basics('resolutionVoteResult'); + +abilityBuilder.resolutionVoteResult.allow('read').when(({ mustBeLoggedIn }) => { + const user = mustBeLoggedIn(); + if (user?.email && isWhitelistedEmail(user.email)) { + return 'allow'; + } +}); + +abilityBuilder.resolutionVoteResult.allow('read').when(({ mustBeLoggedIn }) => { + mustBeLoggedIn(); + return 'allow'; +}); diff --git a/src/app.css b/src/app.css index 4715a3ec..293b5513 100644 --- a/src/app.css +++ b/src/app.css @@ -2,6 +2,7 @@ @import '@deutschemodelunitednations/corporate-identity/css/shades/dmun'; @import 'tailwindcss'; +@import '@deutschemodelunitednations/munify-resolution-editor/tailwind.css'; @plugin '@tailwindcss/typography'; @plugin "daisyui"; diff --git a/src/lib/components/Fieldset.svelte b/src/lib/components/Fieldset.svelte new file mode 100644 index 00000000..e2b3c007 --- /dev/null +++ b/src/lib/components/Fieldset.svelte @@ -0,0 +1,24 @@ + + +
+ {#if legend} + + {#if faIcon} + + {/if} + {legend} + + {/if} + {@render children()} +
diff --git a/src/lib/utils/paperNameGenerator.ts b/src/lib/utils/paperNameGenerator.ts new file mode 100644 index 00000000..a0cc2613 --- /dev/null +++ b/src/lib/utils/paperNameGenerator.ts @@ -0,0 +1,107 @@ +import { getLocale } from '$lib/paraglide/runtime'; + +const adverbs = { + en: [ + 'Very', + 'Super', + 'Ultra', + 'Quite', + 'Totally', + 'Absolutely', + 'Fairly', + 'Really', + 'Extremely', + 'Incredibly', + 'Remarkably', + 'Exceptionally', + 'Tremendously', + 'Hugely', + 'Fantastically' + ], + de: [ + 'Sehr', + 'Super', + 'Ultra', + 'Ziemlich', + 'Total', + 'Absolut', + 'Recht', + 'Wirklich', + 'Extrem', + 'Unglaublich', + 'Bemerkenswert', + 'Außergewöhnlich', + 'Enorm', + 'Riesig', + 'Fantastisch' + ] +}; + +const adjectives = { + en: [ + 'Happy', + 'Calm', + 'Excited', + 'Energetic', + 'Hopeful', + 'Content', + 'Curious', + 'Motivated', + 'Cheerful', + 'Determined', + 'Confident', + 'Magnificent', + 'Grand', + 'Majestic', + 'Splendid', + 'Glorious', + 'Noble', + 'Dignified', + 'Optimistic' + ], + de: [ + 'Fröhlicher', + 'Ruhiger', + 'Begeisterter', + 'Energischer', + 'Hoffnungsvoller', + 'Zufriedener', + 'Neugieriger', + 'Motivierter', + 'Heiterer', + 'Entschlossener', + 'Selbstbewusster', + 'Großartiger', + 'Grandioser', + 'Majestätischer', + 'Prächtiger', + 'Glorreicher', + 'Edler', + 'Würdevoller', + 'Optimistischer' + ] +}; + +const unSecretaryGenerals = [ + 'Trygve Lie', + 'Dag Hammarskjöld', + 'U Thant', + 'Kurt Waldheim', + 'Javier Pérez de Cuéllar', + 'Boutros Boutros-Ghali', + 'Kofi Annan', + 'Ban Ki-moon', + 'António Guterres' +]; + +function pick(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)]; +} + +export function generatePaperName(): string { + const locale = getLocale() as 'en' | 'de'; + const adverbList = adverbs[locale] ?? adverbs.en; + const adjectiveList = adjectives[locale] ?? adjectives.en; + + return `${pick(adverbList)} ${pick(adjectiveList)} ${pick(unSecretaryGenerals)}`; +} diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/+layout.svelte b/src/routes/app/[conferenceId]/participant/[committeeId]/+layout.svelte new file mode 100644 index 00000000..2996da12 --- /dev/null +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/+layout.svelte @@ -0,0 +1,62 @@ + + +{#if committee} + + + + +
+ {@render children()} +
+ + + +{/if} diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/+layout.ts b/src/routes/app/[conferenceId]/participant/[committeeId]/+layout.ts new file mode 100644 index 00000000..b74cfdb3 --- /dev/null +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/+layout.ts @@ -0,0 +1,24 @@ +import { graphql } from '$houdini'; +import type { ParticipantCommitteeLayoutQueryVariables } from './$houdini'; + +export const _houdini_load = graphql(` + query ParticipantCommitteeLayoutQuery($committeeId: ID!) { + findFirstCommittee(where: { id: $committeeId }) { + id + abbreviation + name + activeAgendaItem { + id + title + } + } + } +`); + +export const _ParticipantCommitteeLayoutQueryVariables: ParticipantCommitteeLayoutQueryVariables = ( + event +) => { + return { + committeeId: event.params.committeeId + }; +}; diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/+page.svelte b/src/routes/app/[conferenceId]/participant/[committeeId]/+page.svelte index 8025da28..27babb45 100644 --- a/src/routes/app/[conferenceId]/participant/[committeeId]/+page.svelte +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/+page.svelte @@ -4,7 +4,7 @@ import type { PageData } from './$houdini'; import { onMount } from 'svelte'; import { ParticipantCommitteeSubscription } from './committeeSubscription'; - import ThemeSwitcher from '$lib/components/ThemeSwitcher.svelte'; + // ThemeSwitcher moved to +layout.svelte import IconInfoBox from '$lib/components/IconInfoBox.svelte'; import { getCommitteeStatusIcon, getCommitteeStatusText } from '$lib/utils/committeeStatus'; import CurrentSpeaker from '$lib/components/speakersList/CurrentSpeaker.svelte'; @@ -35,9 +35,6 @@ conferenceUser?.conferenceMember?.representation ); - // Whether the "back" button should show (NSA/Visitor can go back to overview) - let showBack = $derived(role !== 'DELEGATE'); - onMount(() => { ParticipantCommitteeSubscription.listen({ id: page.params.committeeId! }); }); @@ -98,25 +95,6 @@
{#if committee} - - -
diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/+page.svelte b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/+page.svelte new file mode 100644 index 00000000..91e5e284 --- /dev/null +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/+page.svelte @@ -0,0 +1,289 @@ + + + + {m.papers()} - MUNify CHASE + + +
+ +
+

{m.myPapers()}

+ + +
+ {#if isDelegate} + {#if activeAgendaItem} + + {:else} +
+ +
+ {/if} + {/if} + +
+ e.key === 'Enter' && handleRedeemCode()} + /> + +
+
+ + + {#if myPapers.length === 0} +
+ {m.noPapersYet()} +
+ {:else} + + {/if} +
+ + +
+

{m.draftResolutions()}

+ + {#if draftResolutions.length === 0} +
+ {m.noDraftResolutionsYet()} +
+ {:else} + + {/if} +
+
diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/+page.ts b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/+page.ts new file mode 100644 index 00000000..4fbe3a21 --- /dev/null +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/+page.ts @@ -0,0 +1,43 @@ +import { graphql } from '$houdini'; +import type { ParticipantPapersQueryVariables } from './$houdini'; + +export const _houdini_load = graphql(` + query ParticipantPapersQuery($committeeId: ID!) { + findManyResolutionPaper(where: { committeeId: $committeeId }) { + id + title + status + updatedAt + creatorCommitteeMemberId + documentNumber + creator { + id + representation { + name + alpha3Code + alpha2Code + faIcon + } + } + sponsors { + committeeMemberId + committeeMember { + representation { + name + alpha2Code + faIcon + } + } + } + editors { + conferenceUserId + } + } + } +`); + +export const _ParticipantPapersQueryVariables: ParticipantPapersQueryVariables = (event) => { + return { + committeeId: event.params.committeeId + }; +}; diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte new file mode 100644 index 00000000..1f69060d --- /dev/null +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte @@ -0,0 +1,708 @@ + + + + {paper?.title || m.untitledPaper()} - MUNify CHASE + + +{#if paper} +
+ +
+ + + {m.back()} + +
+ {#if saveStatus === 'saving'} + + {m.savingChanges()} + + {:else if saveStatus === 'saved'} + + {m.changesSaved()} + + {:else if saveStatus === 'error'} + + {m.saveError()} + + {/if} + {#if canDelete} + + {/if} +
+
+ + +
+ +
+
+ {#if paper.documentNumber} + {paper.documentNumber} + {/if} + {paper.title || m.untitledPaper()} + + {paper.status === 'WORKING_PAPER' + ? m.workingPaper() + : paper.status === 'SUBMITTED' + ? m.submitted() + : paper.status === 'DRAFT_RESOLUTION' + ? m.draftResolution() + : m.finalResolution()} + +
+
+
+ + {#if canEdit} +
+ +
+ {/if} + + +
+
+ {#each paper.sponsors as sponsor} +
+ +
+ {/each} +
+ {#if canSponsor && myCommitteeMemberId} + + {/if} +
+ + + {#if canManageShareCodes} +
+ {#if paper.shareCodes.length > 0} +
+ {#each paper.shareCodes as shareCode} +
+ {shareCode.code} + + {shareCode.permission === 'EDIT' ? m.editAccess() : m.sponsor()} + + + +
+ {/each} +
+ {/if} +
+ + +
+
+ {/if} +
+
+ + + {#if canEdit && collaborativeMode && hasOtherLocks} +
+ + {m.collaborativeEditingInfo()} +
+ {/if} + + +
+ {#if resolution} + {@const collab = canEdit && collaborativeMode} + + {#snippet preambleAnnotations({ clause })} + {@const lock = locksByClauseId.get(clause.id)} + {#if lock} +
+
+ {#if lock.conferenceUser?.committeeMember?.representation} + + {/if} + +
+
+ {/if} + {/snippet} + {#snippet clauseAnnotations({ clause })} + {@const lock = locksByClauseId.get(clause.id)} + {#if lock} +
+
+ {#if lock.conferenceUser?.committeeMember?.representation} + + {/if} + +
+
+ {/if} + {/snippet} +
+ {/if} +
+ + + {#if canSubmit} +
+ +
+ {/if} +
+ + + +
+

{m.submitToChair()}

+

{m.confirmSubmitPaper()}

+
+ + +
+
+
+ + + +
+

{m.deletePaper()}

+

{m.confirmDeletePaper()}

+
+ + +
+
+
+{/if} diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.ts b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.ts new file mode 100644 index 00000000..1dd58399 --- /dev/null +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.ts @@ -0,0 +1,54 @@ +import { graphql } from '$houdini'; +import type { ParticipantPaperDetailQueryVariables } from './$houdini'; + +export const _houdini_load = graphql(` + query ParticipantPaperDetailQuery($paperId: ID!) { + findFirstResolutionPaper(where: { id: $paperId }) { + id + title + status + content + documentNumber + creatorCommitteeMemberId + updatedAt + creator { + id + representation { + name + alpha3Code + alpha2Code + faIcon + } + } + sponsors { + id + committeeMemberId + committeeMember { + representation { + name + alpha3Code + alpha2Code + faIcon + } + } + } + shareCodes { + id + code + permission + } + editors { + id + conferenceUserId + } + } + } +`); + +export const _ParticipantPaperDetailQueryVariables: ParticipantPaperDetailQueryVariables = ( + event +) => { + return { + paperId: event.params.paperId + }; +}; diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/lockSubscription.ts b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/lockSubscription.ts new file mode 100644 index 00000000..67c761da --- /dev/null +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/lockSubscription.ts @@ -0,0 +1,22 @@ +import { graphql } from '$houdini'; + +export const PaperClauseLocksSubscription = graphql(` + subscription PaperClauseLocksSubscription($paperId: ID!) { + findManyPaperClauseLock(where: { paperId: $paperId }) { + id + clauseId + conferenceUserId + acquiredAt + conferenceUser { + committeeMember { + representation { + name + alpha3Code + alpha2Code + faIcon + } + } + } + } + } +`); diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/paperDetailSubscription.ts b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/paperDetailSubscription.ts new file mode 100644 index 00000000..45f358fa --- /dev/null +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/paperDetailSubscription.ts @@ -0,0 +1,45 @@ +import { graphql } from '$houdini'; + +export const ParticipantPaperDetailSubscription = graphql(` + subscription ParticipantPaperDetailSubscription($paperId: ID!) { + findFirstResolutionPaper(where: { id: $paperId }) { + id + title + status + content + documentNumber + creatorCommitteeMemberId + updatedAt + creator { + id + representation { + name + alpha3Code + alpha2Code + faIcon + } + } + sponsors { + id + committeeMemberId + committeeMember { + representation { + name + alpha3Code + alpha2Code + faIcon + } + } + } + shareCodes { + id + code + permission + } + editors { + id + conferenceUserId + } + } + } +`); diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/papersSubscription.ts b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/papersSubscription.ts new file mode 100644 index 00000000..7a4c00e1 --- /dev/null +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/papersSubscription.ts @@ -0,0 +1,36 @@ +import { graphql } from '$houdini'; + +export const ParticipantPapersSubscription = graphql(` + subscription ParticipantPapersSubscription($committeeId: ID!) { + findManyResolutionPaper(where: { committeeId: $committeeId }) { + id + title + status + updatedAt + creatorCommitteeMemberId + documentNumber + creator { + id + representation { + name + alpha3Code + alpha2Code + faIcon + } + } + sponsors { + committeeMemberId + committeeMember { + representation { + name + alpha2Code + faIcon + } + } + } + editors { + conferenceUserId + } + } + } +`); diff --git a/vite.config.ts b/vite.config.ts index 0a931684..331e7923 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,8 +4,52 @@ import tailwindcss from '@tailwindcss/vite'; import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; +function devAutoRestart() { + const RACE_CONDITION_PATTERNS = [ + 'has not been implemented', // Pothos ObjectRef race condition + 'Class extends value undefined is not a constructor or null' // Houdini store race condition + ]; + + return { + name: 'dev-auto-restart', + configureServer(server) { + let restarting = false; + + const triggerRestart = (label) => { + if (restarting) return; + restarting = true; + console.warn(`\n⚠️ ${label}, restarting dev server...\n`); + server.restart(); + }; + + const isRaceCondition = (message) => + RACE_CONDITION_PATTERNS.some((pattern) => message?.includes(pattern)); + + const onUnhandledRejection = (reason) => { + if (reason instanceof Error && isRaceCondition(reason.message)) { + triggerRestart('Race condition detected'); + } + }; + + process.on('unhandledRejection', onUnhandledRejection); + server.httpServer?.on('close', () => { + process.off('unhandledRejection', onUnhandledRejection); + }); + + const originalSsrFixStacktrace = server.ssrFixStacktrace; + server.ssrFixStacktrace = function (e) { + originalSsrFixStacktrace.call(this, e); + if (isRaceCondition(e?.message)) { + triggerRestart('SSR race condition detected'); + } + }; + } + }; +} + export default defineConfig({ plugins: [ + devAutoRestart(), tailwindcss(), paraglideVitePlugin({ project: './project.inlang', From 859f57122eb7ce0a8b2f9abc7cce4b90082a5caf Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Wed, 4 Mar 2026 16:00:39 +0100 Subject: [PATCH 07/89] =?UTF-8?q?feat:=20resolution=20editor=20Phase=203?= =?UTF-8?q?=20=E2=80=94=20chair=20resolutions=20tab,=20DR=20promotion,=20c?= =?UTF-8?q?oncurrent=20editing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convert ChairNavbar from top tabs to bottom dock with 5 items (incl. Resolutions) - Add resolutions page with submitted papers queue, DR list, and placeholder sections - Promote-to-DR mutation with confirmation modal and document number assignment - DR detail page with always-editable ResolutionEditor and clause locking - Clause-level locking for concurrent chair/Sekretariat editing (heartbeat, lock badges) - Title hidden post-promotion; document number is sole DR identifier (chair + participant) - Add resolution fields to committee query/subscription - Add 13 i18n keys (en + de) Co-Authored-By: Claude Opus 4.6 --- docs/plans/resolution-meta-plan.md | 15 +- messages/de.json | 13 + messages/en.json | 13 + .../[committeeId]/(chairs)/+layout.svelte | 4 +- .../[committeeId]/(chairs)/+layout.ts | 4 + .../[committeeId]/(chairs)/ChairNavbar.svelte | 66 +-- .../(chairs)/committeeSubscription.ts | 4 + .../(chairs)/resolutions/+page.svelte | 310 ++++++++++++ .../(chairs)/resolutions/+page.ts | 49 ++ .../resolutions/[paperId]/+page.svelte | 443 ++++++++++++++++++ .../(chairs)/resolutions/[paperId]/+page.ts | 56 +++ .../[paperId]/chairLockSubscription.ts | 22 + .../[paperId]/chairPaperDetailSubscription.ts | 40 ++ .../chairResolutionPapersSubscription.ts | 40 ++ .../[committeeId]/papers/+page.svelte | 17 +- .../papers/[paperId]/+page.svelte | 7 +- 16 files changed, 1051 insertions(+), 52 deletions(-) create mode 100644 src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.svelte create mode 100644 src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.ts create mode 100644 src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte create mode 100644 src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.ts create mode 100644 src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/chairLockSubscription.ts create mode 100644 src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/chairPaperDetailSubscription.ts create mode 100644 src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/chairResolutionPapersSubscription.ts diff --git a/docs/plans/resolution-meta-plan.md b/docs/plans/resolution-meta-plan.md index 4ed5540d..ab571a2a 100644 --- a/docs/plans/resolution-meta-plan.md +++ b/docs/plans/resolution-meta-plan.md @@ -371,13 +371,14 @@ commentPanel?: Snippet<[{ resolution: Resolution; activeClauseId?: string }]>; - ~~Clause-level locking: `paper_clause_lock` table, acquire/release/heartbeat mutations, subscription, lock-aware content merge~~ - ~~Click-to-lock UX: hover overlay ("Start editing"), inline "Done editing" button, `collaborativeMode` gate~~ -### Phase 3: Chair Resolutions Tab + DR Promotion - -- New 5th tab in `ChairNavbar` (alt+5, `fa-scroll`) -- Submitted papers queue (ranked by sponsor count, suggests top N) -- "Promote to Draft Resolution" with auto-numbering -- DR editor for chair + Sekretariat (parallel access) -- Content snapshot on promotion +### Phase 3: Chair Resolutions Tab + DR Promotion ✅ + +- ~~New 5th tab in `ChairNavbar` (alt+5, `fa-scroll`) — converted to bottom dock~~ +- ~~Submitted papers queue (ranked by sponsor count, suggests top N)~~ +- ~~"Promote to Draft Resolution" with auto-numbering~~ +- ~~DR editor for chair + Sekretariat (parallel access, clause locking)~~ +- ~~Content snapshot on promotion~~ +- ~~Title hidden post-promotion (document number is sole identifier for DRs)~~ ### Phase 4: Support Re-evaluation + DR Ordering diff --git a/messages/de.json b/messages/de.json index 61b285fa..f903666b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5,6 +5,7 @@ "absent": "Abwesend", "absoluteMajority": "Absolut", "abstain": "Enthaltung", + "activeDraftResolution": "Aktiver Resolutionsentwurf", "addAgendaItem": "Punkt hinzufügen", "addAll": "Alle hinzufügen", "addCommittee": "Gremium hinzufügen", @@ -31,6 +32,7 @@ "assignedCount": "{count} zugewiesen", "assignment": "Zuweisung", "back": "Zurück", + "backToResolutions": "Zurück zu Resolutionen", "baseFontSize": "Basis-Schriftgröße", "baseFontSizeDescription": "Hier kann die Basis-Schriftgröße für die Präsentationsansicht festgelegt werden", "blockquote": "Zitat", @@ -94,6 +96,8 @@ "createShareCodeSponsor": "Unterstützer-Code erstellen", "customName": "Benutzerdefinierter Name...", "dateCannotBeInPast": "Das Datum darf nicht in der Vergangenheit liegen!", + "debateControls": "Debattensteuerung", + "debateControlsPlaceholder": "Steuerung des Debattenverlaufs wird in einem zukünftigen Update verfügbar sein.", "delegate": "Delegierte*r", "delegations": "Delegationen", "deleteCode": "Code löschen", @@ -209,10 +213,12 @@ "noCommentList": "Keine Liste für Fragen und Kurzbemerkungen", "noCurrentSpeaker": "Keine Rede", "noData": "Keine Daten", + "noDraftResolution": "Kein Resolutionsentwurf als aktiv gesetzt.", "noDraftResolutionsYet": "Noch keine Resolutionsentwürfe.", "noMembers": "Noch keine Mitglieder", "noPapersYet": "Noch keine Papiere. Erstelle eines oder gib einen Freigabecode ein.", "noResults": "Keine Ergebnisse", + "noSubmittedPapers": "Noch keine eingereichten Papiere.", "nonStateActor": "Nichtstaatlicher Akteur", "nonStateActors": "Nichtstaatliche Akteure", "notAuthorized": "Du bist nicht berechtigt, auf diese Seite zuzugreifen", @@ -227,6 +233,7 @@ "operativeClause": "Operativklausel", "paperCreated": "Papier erstellt", "paperDeleted": "Papier gelöscht", + "paperPromoted": "Papier zum Resolutionsentwurf befördert", "paperSubmitted": "Papier beim Vorsitz eingereicht", "paperSupportThresholdTooltip": "Benötigte Unterstützerstaaten für das Einreichen eines Änderungsantrags", "paperTitle": "Papiertitel", @@ -240,7 +247,9 @@ "presentationMode": "Präsentationsansicht", "pressWebsite": "Presse-Website", "pro": "Dafür", + "promote": "Befördern", "promoteToDraftResolution": "Zum Resolutionsentwurf befördern", + "promoteToDraftResolutionConfirm": "Dieses Papier zum Resolutionsentwurf befördern? Es wird eine Dokumentennummer zugewiesen.", "publish": "Veröffentlichen", "publishChanges": "Änderungen Veröffentlichen", "redeemShareCode": "Freigabecode einlösen", @@ -309,6 +318,8 @@ "submitStatus": "Status setzen", "submitToChair": "An Vorsitz einreichen", "submitted": "Eingereicht", + "submittedPapers": "Eingereichte Papiere", + "submittedPapersDescription": "Von Delegierten eingereichte Papiere, sortiert nach Unterstützerzahl", "supportReEvaluation": "Unterstützungsneuprüfung", "suspension": "Vertagung", "teamMember": "Teammitglied", @@ -330,6 +341,7 @@ "toastUpdateError": "{targetName} konnte nicht aktualisiert werden", "toastUpdateLoading": "{targetName} wird aktualisiert...", "toastUpdateSuccess": "{targetName} aktualisiert", + "topCandidate": "Top-Kandidat", "totalCountriesPresent": "Anzahl anwesender Staaten", "twoThirdsMajority": "Zwei-Drittel", "twoThirdsMajorityTooltip": "Benötigte Stimmen für 2/3-Mehrheit", @@ -361,6 +373,7 @@ "votesAgainst": "Gegenstimmen", "votesFor": "Dafürstimmen", "voting": "Abstimmung", + "votingControlsPlaceholder": "Abstimmungssteuerung wird in einem zukünftigen Update verfügbar sein.", "waitingForAssignment": "Warte auf Zuweisung", "waitingForAssignmentDescription": "Du wurdest noch keinem Gremium zugewiesen. Bitte warte, bis ein Admin dich zuweist.", "whiteboard": "Whiteboard", diff --git a/messages/en.json b/messages/en.json index acfd9212..70dcb8c5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5,6 +5,7 @@ "absent": "Absent", "absoluteMajority": "Absolute", "abstain": "Abstain", + "activeDraftResolution": "Active Draft Resolution", "addAgendaItem": "Add Item", "addAll": "Add All", "addCommittee": "Add Committee", @@ -31,6 +32,7 @@ "assignedCount": "{count} assigned", "assignment": "Assignment", "back": "Back", + "backToResolutions": "Back to Resolutions", "baseFontSize": "Base Font Size", "baseFontSizeDescription": "Here you can set the base font size for the presentation view.", "blockquote": "Quote", @@ -94,6 +96,8 @@ "createShareCodeSponsor": "Create Sponsor Code", "customName": "Custom name...", "dateCannotBeInPast": "The date must not be in the past!", + "debateControls": "Debate Controls", + "debateControlsPlaceholder": "Debate progression controls will be available in a future update.", "delegate": "Delegate", "delegations": "Delegations", "deleteCode": "Delete Code", @@ -209,10 +213,12 @@ "noCommentList": "No Point of Information List", "noCurrentSpeaker": "No speech", "noData": "No data", + "noDraftResolution": "No draft resolution set as active.", "noDraftResolutionsYet": "No draft resolutions yet.", "noMembers": "No members yet", "noPapersYet": "No papers yet. Create one or enter a share code.", "noResults": "No results", + "noSubmittedPapers": "No submitted papers yet.", "nonStateActor": "Non-state Actor", "nonStateActors": "Non-state Actors", "notAuthorized": "You are not authorized to access this page", @@ -227,6 +233,7 @@ "operativeClause": "Operative Clause", "paperCreated": "Paper created", "paperDeleted": "Paper deleted", + "paperPromoted": "Paper promoted to Draft Resolution", "paperSubmitted": "Paper submitted to chair", "paperSupportThresholdTooltip": "Supporting states required to submit an amendment", "paperTitle": "Paper Title", @@ -240,7 +247,9 @@ "presentationMode": "Presentation View", "pressWebsite": "Press Website", "pro": "In Favor", + "promote": "Promote", "promoteToDraftResolution": "Promote to Draft Resolution", + "promoteToDraftResolutionConfirm": "Promote this paper to a Draft Resolution? This will assign it a document number.", "publish": "Publish", "publishChanges": "Publish changes", "redeemShareCode": "Redeem Share Code", @@ -309,6 +318,8 @@ "submitStatus": "Set status", "submitToChair": "Submit to Chair", "submitted": "Submitted", + "submittedPapers": "Submitted Papers", + "submittedPapersDescription": "Papers submitted by delegates, ranked by sponsor count", "supportReEvaluation": "Support Re-evaluation", "suspension": "Suspension", "teamMember": "Team Member", @@ -330,6 +341,7 @@ "toastUpdateError": "Could not update {targetName}", "toastUpdateLoading": "Updating {targetName}...", "toastUpdateSuccess": "{targetName} updated", + "topCandidate": "Top Candidate", "totalCountriesPresent": "Count of Present Countries", "twoThirdsMajority": "Two-thirds", "twoThirdsMajorityTooltip": "Needed votes for two-thrids majority", @@ -361,6 +373,7 @@ "votesAgainst": "Votes Against", "votesFor": "Votes For", "voting": "Voting", + "votingControlsPlaceholder": "Voting controls will be available in a future update.", "waitingForAssignment": "Waiting for Assignment", "waitingForAssignmentDescription": "You have not been assigned to a committee yet. Please wait for an admin to assign you.", "whiteboard": "Whiteboard", diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/+layout.svelte b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/+layout.svelte index cee877aa..e14bb51d 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/+layout.svelte +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/+layout.svelte @@ -94,7 +94,9 @@ -{@render children()} +
+ {@render children()} +
m.setup(), href: './setup', key: 'setup' }, + { icon: 'fa-users', label: () => m.presence(), href: './presence', key: 'presence' }, { - faIcon: 'fa-gears', - label: m.setup(), - href: './setup', - shortcut: 'alt+1', - active: page.route.id?.endsWith('setup') - }, - { - faIcon: 'fa-users', - label: m.presence(), - href: './presence', - shortcut: 'alt+2', - active: page.route.id?.endsWith('presence') - }, - { - faIcon: 'fa-podium', - label: m.speakersList(), + icon: 'fa-podium', + label: () => m.speakersList(), href: './speakers-list', - shortcut: 'alt+3', - active: page.route.id?.endsWith('speakers-list') + key: 'speakers-list' }, - { - faIcon: 'fa-box-ballot', - label: m.voting(), - href: './voting', - shortcut: 'alt+4', - active: page.route.id?.endsWith('voting') - } - ]); + { icon: 'fa-box-ballot', label: () => m.voting(), href: './voting', key: 'voting' }, + { icon: 'fa-scroll', label: () => m.resolutions(), href: './resolutions', key: 'resolutions' } + ]; + + function isActive(key: string) { + return page.route.id?.includes(key) ?? false; + } $effect(() => { - hotkeys('alt+1, alt+2, alt+3, alt+4', (event, handler) => { + hotkeys('alt+1, alt+2, alt+3, alt+4, alt+5', (event, handler) => { event.preventDefault(); switch (handler.key) { case 'alt+1': @@ -62,20 +46,28 @@ case 'alt+4': goto('./voting'); break; + case 'alt+5': + goto('./resolutions'); + break; } }); }); + + + +
+ {#each dockItems as item (item.key)} + + + {item.label()} + + {/each} +
diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/committeeSubscription.ts b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/committeeSubscription.ts index e32557cb..29f342c9 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/committeeSubscription.ts +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/committeeSubscription.ts @@ -14,6 +14,10 @@ export const CommitteeSubscription = graphql(` simpleMajority twoThirdsMajority paperSupportThreshold + maxDraftResolutions + activeDraftResolutionId + supportReEvaluationOpen + currentOperativeIndex whiteboardContent lastResolutionAdoptionDate allowDelegationsToAddThemselvesToSpeakersList diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.svelte b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.svelte new file mode 100644 index 00000000..e5ab4621 --- /dev/null +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.svelte @@ -0,0 +1,310 @@ + + + + {m.resolutions()} - MUNify CHASE + + +{#if committee} +
+
+ +
+ + + + + + +
+ + +
+ + +

{m.submittedPapersDescription()}

+ + {#if submittedPapers.length === 0} +
+ {m.noSubmittedPapers()} +
+ {:else} +
+ {#each submittedPapers as paper, i (paper.id)} +
+
+
+
+ {#if i < availableSlots} + {m.topCandidate()} + {/if} +

+ {paper.title || m.untitledPaper()} +

+
+
+ {#if paper.creator?.representation} + + + {paper.creator.representation.name} + + {/if} + + + {m.sponsorCount({ + count: String(paper.sponsors.length) + })} + + {timeAgo(paper.updatedAt)} +
+
+
+ + {m.viewPaper()} + + +
+
+
+ {/each} +
+ {/if} +
+ + + + {#if draftResolutions.length === 0} +
+ {m.noDraftResolutionsYet()} +
+ {:else} + + {/if} +
+ + + +
+ {#if committee.activeDraftResolutionId} + {@const activeDr = draftResolutions.find( + (p) => p.id === committee.activeDraftResolutionId + )} + {#if activeDr} +

+ {activeDr.documentNumber ?? m.draftResolution()} +

+ {/if} + {:else} +

{m.noDraftResolution()}

+ {/if} +

{m.debateControlsPlaceholder()}

+
+
+ + + +
+

{m.votingControlsPlaceholder()}

+
+
+
+
+
+ + + +
+

{m.promoteToDraftResolution()}

+

{m.promoteToDraftResolutionConfirm()}

+

{promotePaperTitle}

+
+ + +
+
+
+{/if} diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.ts b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.ts new file mode 100644 index 00000000..967d74ea --- /dev/null +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.ts @@ -0,0 +1,49 @@ +import { graphql } from '$houdini'; +import type { ChairResolutionPapersQueryVariables } from './$houdini'; + +export const _houdini_load = graphql(` + query ChairResolutionPapersQuery($committeeId: ID!) { + findManyResolutionPaper(where: { committeeId: $committeeId }) { + id + title + status + documentNumber + sequenceNumber + updatedAt + creatorCommitteeMemberId + agendaItem { + id + title + } + creator { + id + representation { + name + alpha2Code + alpha3Code + faIcon + } + } + sponsors { + id + committeeMemberId + committeeMember { + representation { + name + alpha2Code + alpha3Code + faIcon + } + } + } + } + } +`); + +export const _ChairResolutionPapersQueryVariables: ChairResolutionPapersQueryVariables = ( + event +) => { + return { + committeeId: event.params.committeeId + }; +}; diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte new file mode 100644 index 00000000..1ba463e7 --- /dev/null +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte @@ -0,0 +1,443 @@ + + + + {paper?.documentNumber ?? m.draftResolution()} - MUNify CHASE + + +{#if paper} +
+ +
+ + + {m.backToResolutions()} + +
+ {#if saveStatus === 'saving'} + + {m.savingChanges()} + + {:else if saveStatus === 'saved'} + + {m.changesSaved()} + + {:else if saveStatus === 'error'} + + {m.saveError()} + + {/if} +
+
+ + +
+ +
+
+ + {paper.documentNumber ?? m.draftResolution()} + + + {getStatusText(paper.status)} + +
+
+
+ + {#if paper.agendaItem} +
+ {m.agendaItem()}: + {paper.agendaItem.title} +
+ {/if} + + + {#if paper.creator?.representation} +
+ Creator: + + {paper.creator.representation.name ?? + getTranslatedCountryNameFromAlpha3Code(paper.creator.representation.alpha3Code)} +
+ {/if} + + +
+
+ {#each paper.sponsors as sponsor (sponsor.id)} +
+ +
+ {/each} +
+

+ {m.sponsorCount({ count: String(paper.sponsors.length) })} +

+
+
+
+ + + {#if hasOtherLocks} +
+ + {m.collaborativeEditingInfo()} +
+ {/if} + + +
+ {#if resolution} + + {#snippet preambleAnnotations({ clause })} + {@const lock = locksByClauseId.get(clause.id)} + {#if lock} +
+
+ {#if lock.conferenceUser?.committeeMember?.representation} + + {/if} + +
+
+ {/if} + {/snippet} + {#snippet clauseAnnotations({ clause })} + {@const lock = locksByClauseId.get(clause.id)} + {#if lock} +
+
+ {#if lock.conferenceUser?.committeeMember?.representation} + + {/if} + +
+
+ {/if} + {/snippet} +
+ {/if} +
+
+{/if} diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.ts b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.ts new file mode 100644 index 00000000..e011e60b --- /dev/null +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.ts @@ -0,0 +1,56 @@ +import { graphql } from '$houdini'; +import type { ChairPaperDetailQueryVariables } from './$houdini'; + +export const _houdini_load = graphql(` + query ChairPaperDetailQuery($paperId: ID!, $conferenceId: ID!, $userId: ID!) { + findFirstResolutionPaper(where: { id: $paperId }) { + id + title + status + content + documentNumber + sequenceNumber + updatedAt + agendaItem { + id + title + } + creator { + id + representation { + name + alpha2Code + alpha3Code + faIcon + } + } + sponsors { + id + committeeMemberId + committeeMember { + representation { + name + alpha2Code + alpha3Code + faIcon + } + } + } + } + currentUser: findManyConferenceUser( + where: { conferenceId: $conferenceId, user: { id: $userId } } + limit: 1 + ) { + id + } + } +`); + +export const _ChairPaperDetailQueryVariables: ChairPaperDetailQueryVariables = async (event) => { + const { user } = await event.parent(); + return { + paperId: event.params.paperId, + conferenceId: event.params.conferenceId, + userId: user.sub + }; +}; diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/chairLockSubscription.ts b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/chairLockSubscription.ts new file mode 100644 index 00000000..840c111b --- /dev/null +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/chairLockSubscription.ts @@ -0,0 +1,22 @@ +import { graphql } from '$houdini'; + +export const ChairPaperClauseLocksSubscription = graphql(` + subscription ChairPaperClauseLocksSubscription($paperId: ID!) { + findManyPaperClauseLock(where: { paperId: $paperId }) { + id + clauseId + conferenceUserId + acquiredAt + conferenceUser { + committeeMember { + representation { + name + alpha3Code + alpha2Code + faIcon + } + } + } + } + } +`); diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/chairPaperDetailSubscription.ts b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/chairPaperDetailSubscription.ts new file mode 100644 index 00000000..3a12d89f --- /dev/null +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/chairPaperDetailSubscription.ts @@ -0,0 +1,40 @@ +import { graphql } from '$houdini'; + +export const ChairPaperDetailSubscription = graphql(` + subscription ChairPaperDetailSubscription($paperId: ID!) { + findFirstResolutionPaper(where: { id: $paperId }) { + id + title + status + content + documentNumber + sequenceNumber + updatedAt + agendaItem { + id + title + } + creator { + id + representation { + name + alpha2Code + alpha3Code + faIcon + } + } + sponsors { + id + committeeMemberId + committeeMember { + representation { + name + alpha2Code + alpha3Code + faIcon + } + } + } + } + } +`); diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/chairResolutionPapersSubscription.ts b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/chairResolutionPapersSubscription.ts new file mode 100644 index 00000000..2b9714f6 --- /dev/null +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/chairResolutionPapersSubscription.ts @@ -0,0 +1,40 @@ +import { graphql } from '$houdini'; + +export const ChairResolutionPapersSubscription = graphql(` + subscription ChairResolutionPapersSubscription($committeeId: ID!) { + findManyResolutionPaper(where: { committeeId: $committeeId }) { + id + title + status + documentNumber + sequenceNumber + updatedAt + creatorCommitteeMemberId + agendaItem { + id + title + } + creator { + id + representation { + name + alpha2Code + alpha3Code + faIcon + } + } + sponsors { + id + committeeMemberId + committeeMember { + representation { + name + alpha2Code + alpha3Code + faIcon + } + } + } + } + } +`); diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/+page.svelte b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/+page.svelte index 91e5e284..fe70d0a6 100644 --- a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/+page.svelte +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/+page.svelte @@ -220,7 +220,11 @@

- {paper.title || m.untitledPaper()} + {#if paper.documentNumber} + {paper.documentNumber} + {:else} + {paper.title || m.untitledPaper()} + {/if}

-
- {#if paper.documentNumber} - {paper.documentNumber} - {/if} -

- {paper.title || m.untitledPaper()} -

-
+

+ {paper.documentNumber ?? m.draftResolution()} +

diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte index 1f69060d..d5afe021 100644 --- a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte @@ -424,7 +424,7 @@ - {paper?.title || m.untitledPaper()} - MUNify CHASE + {paper?.documentNumber ?? paper?.title ?? m.untitledPaper()} - MUNify CHASE {#if paper} @@ -466,9 +466,10 @@
{#if paper.documentNumber} - {paper.documentNumber} + {paper.documentNumber} + {:else} + {paper.title || m.untitledPaper()} {/if} - {paper.title || m.untitledPaper()} { allowDelegationsToAddThemselvesToSpeakersList: t.arg.boolean(), maxDraftResolutions: t.arg.int(), activeDraftResolutionId: t.arg.id(), + clearActiveDraftResolution: t.arg.boolean(), currentOperativeIndex: t.arg.int(), supportReEvaluationOpen: t.arg.boolean() }, resolve: async (query, root, args, ctx, info) => { + // Validate activeDraftResolutionId if provided + if (args.activeDraftResolutionId) { + const paper = await db.query.resolutionPaper.findFirst({ + where: { id: args.activeDraftResolutionId } + }); + + if (!paper) { + throw new GraphQLError('Paper not found'); + } + if (paper.committeeId !== args.id) { + throw new GraphQLError('Paper does not belong to this committee'); + } + if (paper.status !== 'DRAFT_RESOLUTION' && paper.status !== 'AMENDMENT_PHASE') { + throw new GraphQLError('Only draft resolutions can be set as active'); + } + } + + // Auto-close re-evaluation when setting an active DR + const supportReEvaluationOpen = args.activeDraftResolutionId + ? false + : (args.supportReEvaluationOpen ?? undefined); + await db .update(schema.committee) .set({ @@ -214,9 +237,11 @@ schemaBuilder.mutationFields((t) => { allowDelegationsToAddThemselvesToSpeakersList: args.allowDelegationsToAddThemselvesToSpeakersList ?? undefined, maxDraftResolutions: args.maxDraftResolutions ?? undefined, - activeDraftResolutionId: args.activeDraftResolutionId ?? undefined, + activeDraftResolutionId: args.clearActiveDraftResolution + ? null + : (args.activeDraftResolutionId ?? undefined), currentOperativeIndex: args.currentOperativeIndex ?? undefined, - supportReEvaluationOpen: args.supportReEvaluationOpen ?? undefined + supportReEvaluationOpen }) .where( and( diff --git a/src/api/handlers/paperSponsor.ts b/src/api/handlers/paperSponsor.ts index 13bf1348..bddc00c6 100644 --- a/src/api/handlers/paperSponsor.ts +++ b/src/api/handlers/paperSponsor.ts @@ -41,6 +41,25 @@ schemaBuilder.mutationFields((t) => ({ }) .then(assertFindFirstExists); + // Check re-evaluation gate for DR+ papers + const paper = await db.query.resolutionPaper + .findFirst({ where: { id: args.paperId } }) + .then(assertFindFirstExists); + + if (paper.status === 'FINAL') { + throw new GraphQLError('Cannot sponsor a finalized paper'); + } + + if (paper.status === 'DRAFT_RESOLUTION' || paper.status === 'AMENDMENT_PHASE') { + const committee = await db.query.committee + .findFirst({ where: { id: paper.committeeId } }) + .then(assertFindFirstExists); + + if (!committee.supportReEvaluationOpen) { + throw new GraphQLError('Support re-evaluation is not currently open'); + } + } + const result = await db .insert(schema.paperSponsor) .values({ @@ -85,6 +104,21 @@ schemaBuilder.mutationFields((t) => ({ }) .then(assertFindFirstExists); + // Check re-evaluation gate for DR+ papers + const paper = await db.query.resolutionPaper + .findFirst({ where: { id: args.paperId } }) + .then(assertFindFirstExists); + + if (paper.status === 'DRAFT_RESOLUTION' || paper.status === 'AMENDMENT_PHASE') { + const committee = await db.query.committee + .findFirst({ where: { id: paper.committeeId } }) + .then(assertFindFirstExists); + + if (!committee.supportReEvaluationOpen) { + throw new GraphQLError('Support re-evaluation is not currently open'); + } + } + // Must be self (removing own sponsorship) or paper creator const conferenceUser = await db.query.conferenceUser.findFirst({ where: { diff --git a/src/api/handlers/resolutionPaper.ts b/src/api/handlers/resolutionPaper.ts index 5d1f4aac..9ee85d2f 100644 --- a/src/api/handlers/resolutionPaper.ts +++ b/src/api/handlers/resolutionPaper.ts @@ -1,5 +1,5 @@ import { db, schema } from '$api/db/db'; -import { abilityBuilder, enum_, schemaBuilder } from '$api/rumble'; +import { abilityBuilder, enum_, schemaBuilder, pubsub as rumblePubsub } from '$api/rumble'; import { and, eq, isNull, count as drizzleCount } from 'drizzle-orm'; import { basics } from './basics'; import { isWhitelistedEmail } from '$api/services/isDMUNEmail'; @@ -12,6 +12,7 @@ import { } from '@deutschemodelunitednations/munify-resolution-editor/schema'; const { arg, ref, pubsub, table } = basics('resolutionPaper'); +const committeePubsub = rumblePubsub({ table: 'committee' }); const paperStatusEnum = enum_({ tsName: 'paperStatus' }); @@ -548,6 +549,20 @@ schemaBuilder.mutationFields((t) => ({ .where(eq(schema.resolutionPaper.id, args.paperId)); }); + // Clear activeDraftResolutionId if this was the active DR + const committee = await db.query.committee + .findFirst({ where: { id: paper.committeeId } }) + .then(assertFindFirstExists); + + if (committee.activeDraftResolutionId === args.paperId) { + await db + .update(schema.committee) + .set({ activeDraftResolutionId: null }) + .where(eq(schema.committee.id, paper.committeeId)); + + committeePubsub.updated(paper.committeeId); + } + pubsub.updated(args.paperId); return db.query.resolutionPaper diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.svelte b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.svelte index e5ab4621..92e61a6d 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.svelte +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.svelte @@ -34,14 +34,20 @@ .sort((a, b) => b.sponsors.length - a.sponsors.length) ); - // Draft resolutions (DR, AMENDMENT_PHASE, FINAL), sorted by sequenceNumber + // Draft resolutions (DR, AMENDMENT_PHASE, FINAL) + // During re-evaluation: sorted by sponsor count (descending) to show ranking + // Otherwise: sorted by sequenceNumber let draftResolutions = $derived( papers .filter( (p) => p.status === 'DRAFT_RESOLUTION' || p.status === 'AMENDMENT_PHASE' || p.status === 'FINAL' ) - .sort((a, b) => (a.sequenceNumber ?? 0) - (b.sequenceNumber ?? 0)) + .sort((a, b) => + committee?.supportReEvaluationOpen + ? b.sponsors.length - a.sponsors.length + : (a.sequenceNumber ?? 0) - (b.sequenceNumber ?? 0) + ) ); let existingDrCount = $derived(draftResolutions.length); @@ -65,6 +71,27 @@ } `); + // Update committee mutation (for activeDR + re-evaluation) + const UpdateCommitteeMutation = graphql(` + mutation UpdateCommitteeResolutionsMutation( + $id: ID! + $activeDraftResolutionId: ID + $clearActiveDraftResolution: Boolean + $supportReEvaluationOpen: Boolean + ) { + updateCommittee( + id: $id + activeDraftResolutionId: $activeDraftResolutionId + clearActiveDraftResolution: $clearActiveDraftResolution + supportReEvaluationOpen: $supportReEvaluationOpen + ) { + id + activeDraftResolutionId + supportReEvaluationOpen + } + } + `); + let showPromoteModal = $state(false); let promotePaperId = $state(null); let promotePaperTitle = $state(''); @@ -86,6 +113,39 @@ } } + async function setActiveDr(paperId: string) { + try { + await UpdateCommitteeMutation.mutate({ + id: page.params.committeeId!, + activeDraftResolutionId: paperId + }); + } catch { + toast.error(m.saveError()); + } + } + + async function clearActiveDr() { + try { + await UpdateCommitteeMutation.mutate({ + id: page.params.committeeId!, + clearActiveDraftResolution: true + }); + } catch { + toast.error(m.saveError()); + } + } + + async function toggleReEvaluation(open: boolean) { + try { + await UpdateCommitteeMutation.mutate({ + id: page.params.committeeId!, + supportReEvaluationOpen: open + }); + } catch { + toast.error(m.saveError()); + } + } + function getStatusBadgeClass(status: string) { switch (status) { case 'DRAFT_RESOLUTION': @@ -227,12 +287,17 @@ {:else}
{#each draftResolutions as paper (paper.id)} - - +
{/each}
{/if} - + -
- {#if committee.activeDraftResolutionId} - {@const activeDr = draftResolutions.find( - (p) => p.id === committee.activeDraftResolutionId - )} - {#if activeDr} -

- {activeDr.documentNumber ?? m.draftResolution()} -

- {/if} - {:else} -

{m.noDraftResolution()}

- {/if} -

{m.debateControlsPlaceholder()}

+
+ +
+
+
+

{m.supportReEvaluation()}

+

+ {#if committee.supportReEvaluationOpen} + {m.supportReEvaluationOpen()} + {:else} + {m.supportReEvaluationClosed()} + {/if} +

+
+ toggleReEvaluation(!committee.supportReEvaluationOpen)} + /> +
+
diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/+layout.ts b/src/routes/app/[conferenceId]/participant/[committeeId]/+layout.ts index b74cfdb3..bea1bc93 100644 --- a/src/routes/app/[conferenceId]/participant/[committeeId]/+layout.ts +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/+layout.ts @@ -7,6 +7,8 @@ export const _houdini_load = graphql(` id abbreviation name + supportReEvaluationOpen + activeDraftResolutionId activeAgendaItem { id title diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/committeeSubscription.ts b/src/routes/app/[conferenceId]/participant/[committeeId]/committeeSubscription.ts index 395d0eb3..0519178c 100644 --- a/src/routes/app/[conferenceId]/participant/[committeeId]/committeeSubscription.ts +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/committeeSubscription.ts @@ -12,6 +12,8 @@ export const ParticipantCommitteeSubscription = graphql(` showWhiteboard whiteboardContent allowDelegationsToAddThemselvesToSpeakersList + supportReEvaluationOpen + activeDraftResolutionId totalPresent simpleMajority twoThirdsMajority diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/+page.svelte b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/+page.svelte index fe70d0a6..7c17d7a1 100644 --- a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/+page.svelte +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/+page.svelte @@ -6,7 +6,9 @@ import { graphql } from '$houdini'; import { onMount } from 'svelte'; import { ParticipantPapersSubscription } from './papersSubscription'; + import { ParticipantCommitteeSubscription } from '../committeeSubscription'; import { generatePaperName } from '$lib/utils/paperNameGenerator'; + import Flag from '$lib/components/Flag.svelte'; import toast from 'svelte-french-toast'; let { data }: { data: PageData } = $props(); @@ -14,7 +16,10 @@ let query = $derived(data?.ParticipantPapersQuery); let identityQuery = $derived(data?.ParticipantIdentityQuery); let layoutQuery = $derived(data?.ParticipantCommitteeLayoutQuery); - let committee = $derived($layoutQuery.data?.findFirstCommittee); + let committee = $derived( + $ParticipantCommitteeSubscription.data?.findFirstCommittee ?? + $layoutQuery.data?.findFirstCommittee + ); let papers = $derived( $ParticipantPapersSubscription.data?.findManyResolutionPaper ?? @@ -52,6 +57,7 @@ ParticipantPapersSubscription.listen({ committeeId: page.params.committeeId! }); + ParticipantCommitteeSubscription.listen({ id: page.params.committeeId! }); }); // Create paper mutation @@ -92,6 +98,34 @@ } `); + // Sponsor mutations for re-evaluation support toggle + const AddSponsorMutation = graphql(` + mutation AddSponsorListMutation($paperId: ID!, $committeeMemberId: ID!) { + addSponsor(paperId: $paperId, committeeMemberId: $committeeMemberId) { + id + } + } + `); + + const RemoveSponsorMutation = graphql(` + mutation RemoveSponsorListMutation($paperId: ID!, $committeeMemberId: ID!) { + removeSponsor(paperId: $paperId, committeeMemberId: $committeeMemberId) + } + `); + + async function toggleSupport(paperId: string, currentlySupporting: boolean) { + if (!myCommitteeMemberId) return; + try { + if (currentlySupporting) { + await RemoveSponsorMutation.mutate({ paperId, committeeMemberId: myCommitteeMemberId }); + } else { + await AddSponsorMutation.mutate({ paperId, committeeMemberId: myCommitteeMemberId }); + } + } catch { + toast.error(m.saveError()); + } + } + let shareCodeInput = $state(''); async function handleRedeemCode() { @@ -147,9 +181,9 @@ } } - function timeAgo(dateStr: string | null | undefined) { + function timeAgo(dateStr: string | Date | null | undefined) { if (!dateStr) return ''; - const date = new Date(dateStr); + const date = dateStr instanceof Date ? dateStr : new Date(dateStr); const now = new Date(); const diff = now.getTime() - date.getTime(); const minutes = Math.floor(diff / 60000); @@ -248,7 +282,12 @@
-

{m.draftResolutions()}

+
+

{m.draftResolutions()}

+ {#if committee?.supportReEvaluationOpen} + {m.supportReEvaluation()} + {/if} +
{#if draftResolutions.length === 0}
@@ -257,16 +296,31 @@ {:else}
{#each draftResolutions as paper} - s.committeeMemberId === myCommitteeMemberId + )} + {@const isActiveDr = paper.id === committee?.activeDraftResolutionId} +
-
+
-

- {paper.documentNumber ?? m.draftResolution()} -

+
+

+ {paper.documentNumber ?? m.draftResolution()} +

+ {#if isActiveDr} + + {m.activeDraftResolution()} + + {/if} +
@@ -276,11 +330,38 @@
- {m.sponsorCount({ count: String(paper.sponsors.length) })} + {m.supporterCount({ count: String(paper.sponsors.length) })}
-
-
+ + {#if paper.sponsors.length > 0} +
+ {#each paper.sponsors as sponsor} + {#if sponsor.committeeMember?.representation} + + {/if} + {/each} +
+ {/if} + + + {#if committee?.supportReEvaluationOpen && isDelegate && paper.status !== 'FINAL'} +
+ +
+ {/if} +
{/each}
{/if} diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte index d5afe021..d503df72 100644 --- a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte @@ -7,6 +7,7 @@ import { onMount } from 'svelte'; import { ParticipantPaperDetailSubscription } from './paperDetailSubscription'; import { PaperClauseLocksSubscription } from './lockSubscription'; + import { ParticipantCommitteeSubscription } from '../../committeeSubscription'; import { ResolutionEditor, migrateResolution, @@ -24,7 +25,10 @@ let query = $derived(data?.ParticipantPaperDetailQuery); let identityQuery = $derived(data?.ParticipantIdentityQuery); let layoutQuery = $derived(data?.ParticipantCommitteeLayoutQuery); - let committee = $derived($layoutQuery.data?.findFirstCommittee); + let committee = $derived( + $ParticipantCommitteeSubscription.data?.findFirstCommittee ?? + $layoutQuery.data?.findFirstCommittee + ); let conferenceUser = $derived($identityQuery.data?.findManyConferenceUser?.[0]); let role = $derived(conferenceUser?.conferenceUserType); @@ -53,6 +57,14 @@ let canDelete = $derived(isCreator && paper?.status === 'WORKING_PAPER'); + // DR support: delegate can toggle support during re-evaluation + let isDrStatus = $derived( + paper?.status === 'DRAFT_RESOLUTION' || paper?.status === 'AMENDMENT_PHASE' + ); + let canToggleDrSupport = $derived( + isDelegate && isDrStatus && committee?.supportReEvaluationOpen === true + ); + // Collaborative mode: only enable lock UI when paper has other editors or is beyond working paper let collaborativeMode = $derived( (paper?.editors?.length ?? 0) > 0 || paper?.status !== 'WORKING_PAPER' @@ -77,6 +89,7 @@ onMount(() => { ParticipantPaperDetailSubscription.listen({ paperId: page.params.paperId! }); + ParticipantCommitteeSubscription.listen({ id: page.params.committeeId! }); if (collaborativeMode) { PaperClauseLocksSubscription.listen({ paperId: page.params.paperId! }); } @@ -519,13 +532,36 @@
{/each}
- {#if canSponsor && myCommitteeMemberId} +

+ {m.supporterCount({ count: String(paper.sponsors.length) })} +

+ {#if canSponsor && myCommitteeMemberId && !isDrStatus} + + {:else if canToggleDrSupport && myCommitteeMemberId} + +
+ {m.supportReEvaluation()} + +
{/if}
From 4deb76d4ea27f1f5ff88b2e6c51773ed5f26a835 Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Wed, 4 Mar 2026 23:10:49 +0100 Subject: [PATCH 09/89] =?UTF-8?q?feat:=20resolution=20editor=20Phase=205?= =?UTF-8?q?=20=E2=80=94=20comment=20system=20for=20chairs=20and=20particip?= =?UTF-8?q?ants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add team-internal comment system on draft resolutions with clause-level and document-level threading, visibility control (TEAM_ONLY default, PUBLIC opt-in), read-only view for participants, and real-time updates via GraphQL subscriptions. Co-Authored-By: Claude Opus 4.6 --- messages/de.json | 16 + messages/en.json | 16 + schema.graphql | 3 + src/api/handlers/resolutionComment.ts | 237 ++++++++++- src/lib/components/CommentSection.svelte | 373 ++++++++++++++++++ .../resolutions/[paperId]/+page.svelte | 172 ++++++++ .../[paperId]/chairCommentsSubscription.ts | 27 ++ .../papers/[paperId]/+page.svelte | 176 +++++++++ .../papers/[paperId]/commentsSubscription.ts | 27 ++ 9 files changed, 1045 insertions(+), 2 deletions(-) create mode 100644 src/lib/components/CommentSection.svelte create mode 100644 src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/chairCommentsSubscription.ts create mode 100644 src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/commentsSubscription.ts diff --git a/messages/de.json b/messages/de.json index 2dbc44ed..ea7a6e03 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8,6 +8,7 @@ "activeDraftResolution": "In Behandlung", "addAgendaItem": "Punkt hinzufügen", "addAll": "Alle hinzufügen", + "addComment": "Kommentar hinzufügen", "addCommittee": "Gremium hinzufügen", "addCountriesCount": "{count} Länder hinzufügen", "addCountry": "Land hinzufügen", @@ -44,6 +45,7 @@ "changeSpeakersName": "Name ändern", "changeSpeakersTime": "Redezeit ändern", "changesSaved": "Gespeichert", + "clauseComments": "auf Klauseln", "clauseLockedBy": "Wird bearbeitet von {country}", "clearActiveDr": "Aktiv aufheben", "clearFormatting": "Formatierung löschen", @@ -58,7 +60,13 @@ "codesUnrecognized": "{count} Codes nicht erkannt", "collaborativeEditingInfo": "Andere Delegierte bearbeiten diese Resolution. Fahre über eine Klausel und klicke \"Bearbeitung starten\" um zu beginnen. Sperren laufen nach 1 Minute Inaktivität automatisch ab.", "comingSoon": "bald verfügbar", + "commentDeleted": "Kommentar gelöscht", "commentList": "Fragen und Kurzbemerkungen", + "commentPlaceholder": "Kommentar schreiben...", + "commentPosted": "Kommentar gesendet", + "commentUpdated": "Kommentar aktualisiert", + "comments": "Kommentare", + "commentsOnClause": "{count} Kommentar(e)", "committee": "Gremium", "committeeAbbreviation": "Gremien-Abkürzung", "committeeDoesNotExist": "Das Gremium existiert nicht.", @@ -102,16 +110,20 @@ "delegate": "Delegierte*r", "delegations": "Delegationen", "deleteCode": "Code löschen", + "deleteComment": "Löschen", "deletePaper": "Papier löschen", "deleteRepresentation": "Delegation entfernen", "displayRegionalGroups": "Regionalgruppenanzeige", + "documentLevelComments": "Dokumentkommentare", "documentNumber": "Dokumentennummer", + "documentWide": "dokumentweit", "download": "Download", "downloadPresenceData": "Anwesenheitsdaten", "draftResolution": "Resolutionsentwurf", "draftResolutions": "Resolutionsentwürfe", "edit": "Bearbeiten", "editAccess": "Bearbeitungszugriff", + "editComment": "Bearbeiten", "editPaper": "Papier bearbeiten", "editUser": "Benutzer bearbeiten", "editors": "Bearbeiter", @@ -213,6 +225,7 @@ "noAgendaItemSelectedDescription": "Um mit Redelisten arbeiten zu können muss zunächst ein Tagesordnungspunkt ausgewählt werden", "noAssignmentNeeded": "Keine Mitgliedszuweisung für diese Rolle nötig.", "noCommentList": "Keine Liste für Fragen und Kurzbemerkungen", + "noComments": "Noch keine Kommentare", "noCurrentSpeaker": "Keine Rede", "noData": "Keine Daten", "noDraftResolution": "Kein Resolutionsentwurf als aktiv gesetzt.", @@ -253,6 +266,7 @@ "promote": "Befördern", "promoteToDraftResolution": "Zum Resolutionsentwurf befördern", "promoteToDraftResolutionConfirm": "Dieses Papier zum Resolutionsentwurf befördern? Es wird eine Dokumentennummer zugewiesen.", + "publicComment": "Öffentlich", "publish": "Veröffentlichen", "publishChanges": "Änderungen Veröffentlichen", "redeemShareCode": "Freigabecode einlösen", @@ -267,6 +281,7 @@ "removeFromList": "Von der Liste entfernen", "removeMember": "Entfernen", "removeSponsor": "Unterstützung zurückziehen", + "replyToComment": "Antworten", "resolutionPaper": "Resolutionspapier", "resolutionPapers": "Resolutionspapiere", "resolutions": "Resolutionen", @@ -332,6 +347,7 @@ "supporterCount": "{count} Unterstützer", "suspension": "Vertagung", "teamMember": "Teammitglied", + "teamOnly": "Nur Team", "theme": "Theme", "timeOver": "Redezeit ist abgelaufen!", "timer": "Zeit", diff --git a/messages/en.json b/messages/en.json index 09a4cb24..5a73bab8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8,6 +8,7 @@ "activeDraftResolution": "In Progress", "addAgendaItem": "Add Item", "addAll": "Add All", + "addComment": "Add Comment", "addCommittee": "Add Committee", "addCountriesCount": "Add {count} countries", "addCountry": "Add Country", @@ -44,6 +45,7 @@ "changeSpeakersName": "Change Name", "changeSpeakersTime": "Change Speaking Time", "changesSaved": "Saved", + "clauseComments": "on clauses", "clauseLockedBy": "Being edited by {country}", "clearActiveDr": "Clear Active", "clearFormatting": "Delete formatting", @@ -58,7 +60,13 @@ "codesUnrecognized": "{count} codes unrecognized", "collaborativeEditingInfo": "Other delegates are editing this resolution. Hover a clause and click \"Start editing\" to begin. Locks expire automatically after 1 minute of inactivity.", "comingSoon": "coming soon", + "commentDeleted": "Comment deleted", "commentList": "Point of Information", + "commentPlaceholder": "Write a comment...", + "commentPosted": "Comment posted", + "commentUpdated": "Comment updated", + "comments": "Comments", + "commentsOnClause": "{count} comment(s)", "committee": "Committee", "committeeAbbreviation": "Committee Abbreviation", "committeeDoesNotExist": "The committee does not exist.", @@ -102,16 +110,20 @@ "delegate": "Delegate", "delegations": "Delegations", "deleteCode": "Delete Code", + "deleteComment": "Delete", "deletePaper": "Delete Paper", "deleteRepresentation": "Remove Delegation", "displayRegionalGroups": "Display Regional Blocs", + "documentLevelComments": "Document Comments", "documentNumber": "Document Number", + "documentWide": "document-wide", "download": "Download", "downloadPresenceData": "Presence Data", "draftResolution": "Draft Resolution", "draftResolutions": "Draft Resolutions", "edit": "Edit", "editAccess": "Edit Access", + "editComment": "Edit", "editPaper": "Edit Paper", "editUser": "Edit User", "editors": "Editors", @@ -213,6 +225,7 @@ "noAgendaItemSelectedDescription": "To work with speakers' lists, you must first select an agenda item.", "noAssignmentNeeded": "No member assignment needed for this role.", "noCommentList": "No Point of Information List", + "noComments": "No comments yet", "noCurrentSpeaker": "No speech", "noData": "No data", "noDraftResolution": "No draft resolution set as active.", @@ -253,6 +266,7 @@ "promote": "Promote", "promoteToDraftResolution": "Promote to Draft Resolution", "promoteToDraftResolutionConfirm": "Promote this paper to a Draft Resolution? This will assign it a document number.", + "publicComment": "Public", "publish": "Publish", "publishChanges": "Publish changes", "redeemShareCode": "Redeem Share Code", @@ -267,6 +281,7 @@ "removeFromList": "Remove from list", "removeMember": "Remove", "removeSponsor": "Remove Sponsorship", + "replyToComment": "Reply", "resolutionPaper": "Resolution Paper", "resolutionPapers": "Resolution Papers", "resolutions": "Resolutions", @@ -332,6 +347,7 @@ "supporterCount": "{count} supporters", "suspension": "Suspension", "teamMember": "Team Member", + "teamOnly": "Team Only", "theme": "Theme", "timeOver": "Speaking time is up!", "timer": "Timer", diff --git a/schema.graphql b/schema.graphql index bfb2c95e..44c9dfdc 100644 --- a/schema.graphql +++ b/schema.graphql @@ -377,6 +377,7 @@ type Mutation { addSponsor(committeeMemberId: ID!, paperId: ID!): PaperSponsor clearSpeakersList(id: ID!): SpeakersList createAgendaItem(committeeId: ID!, title: String!): AgendaItem + createComment(clauseId: String, content: String!, paperId: ID!, parentCommentId: ID, visibility: CommentVisibilityEnum): ResolutionComment createCommittee(abbreviation: String!, conferenceId: ID!, name: String!): Committee createCommitteeMember(committeeId: ID!, representationId: ID!): CommitteeMember createConferenceMember(conferenceId: ID!, representationId: ID!): ConferenceMember @@ -384,6 +385,7 @@ type Mutation { createRepresentation(alpha2Code: String, alpha3Code: String, conferenceId: ID!, faIcon: String, name: String, type: RepresentationTypeEnum!): Representation createResolutionPaper(agendaItemId: ID!, committeeId: ID!, title: String): ResolutionPaper createShareCode(paperId: ID!, permission: ShareCodePermissionEnum!): PaperShareCode + deleteComment(commentId: ID!): Boolean deleteCommittee(id: ID!): Boolean deleteCommitteeMember(id: ID!): Boolean deleteConferenceMember(id: ID!): Boolean @@ -405,6 +407,7 @@ type Mutation { setPresenceForCommitteeMembers(ids: [ID!]!, present: Boolean!): [CommitteeMember!] softDeletePaper(paperId: ID!): Boolean submitPaper(paperId: ID!): ResolutionPaper + updateComment(commentId: ID!, content: String!): ResolutionComment updateCommittee(abbreviation: String, activeAgendaItemId: ID, activeDraftResolutionId: ID, allowDelegationsToAddThemselvesToSpeakersList: Boolean, clearActiveDraftResolution: Boolean, currentOperativeIndex: Int, id: ID!, lastResolutionAdoptionDate: DateTime, maxDraftResolutions: Int, name: String, showWhiteboard: Boolean, stateOfDebate: String, status: CommitteeStatusEnum, statusHeadline: String, statusUntil: DateTime, supportReEvaluationOpen: Boolean, whiteboardContent: String): Committee updateConference(hasModeratedCaucus: Boolean, id: ID!, pressWebsite: String, title: String): Conference updateConferenceUser(committeeMemberId: ID, conferenceMemberId: ID, conferenceUserType: ConferenceUserTypeEnum!, id: ID!): ConferenceUser diff --git a/src/api/handlers/resolutionComment.ts b/src/api/handlers/resolutionComment.ts index 2f21c823..a16c798f 100644 --- a/src/api/handlers/resolutionComment.ts +++ b/src/api/handlers/resolutionComment.ts @@ -1,9 +1,47 @@ -import { abilityBuilder } from '$api/rumble'; +import { db, schema } from '$api/db/db'; +import { abilityBuilder, enum_, schemaBuilder, pubsub as rumblePubsub } from '$api/rumble'; +import { eq } from 'drizzle-orm'; import { basics } from './basics'; import { isWhitelistedEmail } from '$api/services/isDMUNEmail'; +import { assertFindFirstExists, assertFirstEntryExists } from '@m1212e/rumble'; +import { GraphQLError } from 'graphql'; const { arg, ref, pubsub, table } = basics('resolutionComment'); +const paperPubsub = rumblePubsub({ table: 'resolutionPaper' }); +const commentVisibilityEnum = enum_({ tsName: 'commentVisibility' }); + +// Helper: check if user is TEAM/ADMIN for the conference owning a given paper +async function isChairOrAdmin( + ctx: { + hasRole: (role: string) => boolean; + mustBeLoggedIn: () => { sub?: string; email?: string | null }; + }, + committeeId: string +): Promise { + if (ctx.hasRole('admin')) return true; + + const user = ctx.mustBeLoggedIn(); + if (user.email && isWhitelistedEmail(user.email)) return true; + + const cuRecord = await db.query.conferenceUser.findFirst({ + where: { + conference: { + committees: { id: committeeId } + }, + user: { id: user.sub }, + conferenceUserType: { in: ['ADMIN', 'TEAM'] } + } + }); + + return !!cuRecord; +} + +// ────────────────────────────────────────────────── +// Access control +// ────────────────────────────────────────────────── + +// TEAM / ADMIN / Whitelisted → can see ALL comments (including TEAM_ONLY) abilityBuilder.resolutionComment.allow('read').when(({ mustBeLoggedIn }) => { const user = mustBeLoggedIn(); if (user?.email && isWhitelistedEmail(user.email)) { @@ -11,7 +49,202 @@ abilityBuilder.resolutionComment.allow('read').when(({ mustBeLoggedIn }) => { } }); +// Regular logged-in users → only see PUBLIC comments abilityBuilder.resolutionComment.allow('read').when(({ mustBeLoggedIn }) => { mustBeLoggedIn(); - return 'allow'; + return { where: { visibility: 'PUBLIC' } }; }); + +// ────────────────────────────────────────────────── +// Mutations +// ────────────────────────────────────────────────── + +schemaBuilder.mutationFields((t) => ({ + createComment: t.drizzleField({ + type: ref, + args: { + paperId: t.arg.id({ required: true }), + content: t.arg.string({ required: true }), + clauseId: t.arg.string(), + visibility: t.arg({ type: commentVisibilityEnum }), + parentCommentId: t.arg.id() + }, + resolve: async (query, root, args, ctx, info) => { + const user = ctx.mustBeLoggedIn(); + + // Resolve conference user + const conferenceUser = await db.query.conferenceUser + .findFirst({ + where: { user: { id: user.sub } } + }) + .then(assertFindFirstExists); + + // Fetch paper and validate status + const paper = await db.query.resolutionPaper + .findFirst({ + where: { id: args.paperId } + }) + .then(assertFindFirstExists); + + const allowedStatuses = ['SUBMITTED', 'DRAFT_RESOLUTION', 'AMENDMENT_PHASE', 'FINAL']; + if (!allowedStatuses.includes(paper.status)) { + throw new GraphQLError( + 'Comments are only allowed on submitted papers and draft resolutions' + ); + } + + // Visibility check: only chairs/admins can post TEAM_ONLY + const visibility = args.visibility ?? 'PUBLIC'; + if (visibility === 'TEAM_ONLY') { + const isChair = await isChairOrAdmin(ctx, paper.committeeId); + if (!isChair) { + throw new GraphQLError('Only chairs and team members can post team-only comments'); + } + } + + // Validate parentCommentId if provided + if (args.parentCommentId) { + const parent = await db.query.resolutionComment + .findFirst({ + where: { id: args.parentCommentId } + }) + .then(assertFindFirstExists); + + if (parent.paperId !== args.paperId) { + throw new GraphQLError('Parent comment must belong to the same paper'); + } + + // Only 1 level of threading: parent must be top-level + if (parent.parentCommentId) { + throw new GraphQLError('Replies can only be one level deep'); + } + + // If clauseId is set, parent must target the same clause + if (args.clauseId && parent.clauseId && parent.clauseId !== args.clauseId) { + throw new GraphQLError('Reply must target the same clause as the parent comment'); + } + } + + const result = await db + .insert(schema.resolutionComment) + .values({ + paperId: args.paperId, + clauseId: args.clauseId ?? null, + authorConferenceUserId: conferenceUser.id, + content: args.content, + visibility, + parentCommentId: args.parentCommentId ?? null + }) + .returning() + .then(assertFirstEntryExists); + + pubsub.created(); + paperPubsub.updated(args.paperId); + + return db.query.resolutionComment + .findFirst( + query( + ctx.abilities.resolutionComment.filter('read', { + inject: { + where: { id: result.id } + } + }).query.single + ) + ) + .then(assertFindFirstExists); + } + }), + + updateComment: t.drizzleField({ + type: ref, + args: { + commentId: t.arg.id({ required: true }), + content: t.arg.string({ required: true }) + }, + resolve: async (query, root, args, ctx, info) => { + const user = ctx.mustBeLoggedIn(); + + const comment = await db.query.resolutionComment + .findFirst({ + where: { id: args.commentId } + }) + .then(assertFindFirstExists); + + // Must be the author + const conferenceUser = await db.query.conferenceUser + .findFirst({ + where: { user: { id: user.sub } } + }) + .then(assertFindFirstExists); + + if (comment.authorConferenceUserId !== conferenceUser.id) { + throw new GraphQLError('Only the author can edit a comment'); + } + + await db + .update(schema.resolutionComment) + .set({ content: args.content }) + .where(eq(schema.resolutionComment.id, args.commentId)); + + pubsub.updated(args.commentId); + paperPubsub.updated(comment.paperId); + + return db.query.resolutionComment + .findFirst( + query( + ctx.abilities.resolutionComment.filter('read', { + inject: { + where: { id: args.commentId } + } + }).query.single + ) + ) + .then(assertFindFirstExists); + } + }), + + deleteComment: t.field({ + type: 'Boolean', + args: { + commentId: t.arg.id({ required: true }) + }, + resolve: async (root, args, ctx) => { + const user = ctx.mustBeLoggedIn(); + + const comment = await db.query.resolutionComment + .findFirst({ + where: { id: args.commentId } + }) + .then(assertFindFirstExists); + + const conferenceUser = await db.query.conferenceUser + .findFirst({ + where: { user: { id: user.sub } } + }) + .then(assertFindFirstExists); + + // Author can delete own, or chairs/admins can delete any + const isAuthor = comment.authorConferenceUserId === conferenceUser.id; + if (!isAuthor) { + const paper = await db.query.resolutionPaper + .findFirst({ where: { id: comment.paperId } }) + .then(assertFindFirstExists); + + const isChair = await isChairOrAdmin(ctx, paper.committeeId); + if (!isChair) { + throw new GraphQLError('Only the author or chairs can delete comments'); + } + } + + // Cascade deletes replies via DB constraint + await db + .delete(schema.resolutionComment) + .where(eq(schema.resolutionComment.id, args.commentId)); + + pubsub.removed(args.commentId); + paperPubsub.updated(comment.paperId); + + return true; + } + }) +})); diff --git a/src/lib/components/CommentSection.svelte b/src/lib/components/CommentSection.svelte new file mode 100644 index 00000000..103609b9 --- /dev/null +++ b/src/lib/components/CommentSection.svelte @@ -0,0 +1,373 @@ + + +{#if !readonly || commentCount > 0} +
+ + + + {#if expanded} +
+ {#if topLevelComments.length === 0} +

{m.noComments()}

+ {/if} + + {#each topLevelComments as comment (comment.id)} +
+ +
+ {#if comment.author.committeeMember?.representation} + + {/if} + {getAuthorName(comment)} + {formatTime(comment.createdAt)} + {#if comment.visibility === 'PUBLIC'} + {m.publicComment()} + {/if} +
+ + + {#if editingId === comment.id} +
+ +
+ + +
+
+ {:else} +

{comment.content}

+ {/if} + + + {#if !readonly && editingId !== comment.id} +
+ + {#if isAuthor(comment)} + + + {/if} +
+ {/if} + + + {#if getReplies(comment.id).length > 0} +
+ {#each getReplies(comment.id) as reply (reply.id)} +
+
+ {#if reply.author.committeeMember?.representation} + + {/if} + {getAuthorName(reply)} + {formatTime(reply.createdAt)} + {#if reply.visibility === 'PUBLIC'} + {m.publicComment()} + {/if} +
+ + {#if editingId === reply.id} +
+ +
+ + +
+
+ {:else} +

{reply.content}

+ {/if} + + {#if !readonly && editingId !== reply.id && isAuthor(reply)} +
+ + +
+ {/if} +
+ {/each} +
+ {/if} + + + {#if !readonly && replyingTo === comment.id} +
+ +
+ {#if canPostTeamOnly} + + {/if} + +
+
+ {/if} +
+ {/each} + + + {#if !readonly} +
+ +
+ {#if canPostTeamOnly} + + {/if} + +
+
+ {/if} +
+ {/if} +
+{/if} diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte index 1ba463e7..d4d5ebfa 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte @@ -7,6 +7,7 @@ import { CommitteeSubscription } from '../../committeeSubscription'; import { ChairPaperDetailSubscription } from './chairPaperDetailSubscription'; import { ChairPaperClauseLocksSubscription } from './chairLockSubscription'; + import { ChairPaperCommentsSubscription } from './chairCommentsSubscription'; import { ResolutionEditor, migrateResolution, @@ -14,6 +15,7 @@ } from '@deutschemodelunitednations/munify-resolution-editor'; import Flag from '$lib/components/Flag.svelte'; import Fieldset from '$lib/components/Fieldset.svelte'; + import CommentSection from '$lib/components/CommentSection.svelte'; import { getTranslatedCountryNameFromAlpha3Code } from '$lib/utils/nationTranslationHelper.svelte'; import toast from 'svelte-french-toast'; import { fly, fade } from 'svelte/transition'; @@ -58,6 +60,7 @@ onMount(() => { ChairPaperDetailSubscription.listen({ paperId: page.params.paperId! }); ChairPaperClauseLocksSubscription.listen({ paperId: page.params.paperId! }); + ChairPaperCommentsSubscription.listen({ paperId: page.params.paperId! }); // Hybrid heartbeat — only fires when idle with held locks const heartbeatInterval = setInterval(() => { @@ -267,6 +270,99 @@ } } + // ===================================================== + // Comments + // ===================================================== + + let allComments = $derived($ChairPaperCommentsSubscription.data?.findManyResolutionComment ?? []); + + // Group comments by clauseId for inline display + let commentsByClauseId = $derived.by(() => { + const map = new Map(); + for (const comment of allComments) { + const key = comment.clauseId; + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(comment); + } + return map; + }); + + // Comment counts per clause (for badge annotations) + let commentCountByClauseId = $derived.by(() => { + const map = new Map(); + for (const comment of allComments) { + if (comment.clauseId) { + map.set(comment.clauseId, (map.get(comment.clauseId) ?? 0) + 1); + } + } + return map; + }); + + // Comment statistics + let documentCommentCount = $derived(allComments.filter((c) => !c.clauseId).length); + let clauseCommentCount = $derived(allComments.filter((c) => c.clauseId).length); + + // Comment mutations + const CreateCommentMutation = graphql(` + mutation ChairCreateCommentMutation( + $paperId: ID! + $content: String! + $clauseId: String + $visibility: CommentVisibilityEnum + $parentCommentId: ID + ) { + createComment( + paperId: $paperId + content: $content + clauseId: $clauseId + visibility: $visibility + parentCommentId: $parentCommentId + ) { + id + } + } + `); + + const UpdateCommentMutation = graphql(` + mutation ChairUpdateCommentMutation($commentId: ID!, $content: String!) { + updateComment(commentId: $commentId, content: $content) { + id + } + } + `); + + const DeleteCommentMutation = graphql(` + mutation ChairDeleteCommentMutation($commentId: ID!) { + deleteComment(commentId: $commentId) + } + `); + + async function onCreateComment( + content: string, + visibility: string, + parentCommentId?: string, + clauseId?: string | null + ) { + await CreateCommentMutation.mutate({ + paperId: page.params.paperId!, + content, + clauseId: clauseId ?? null, + visibility: visibility as 'PUBLIC' | 'TEAM_ONLY', + parentCommentId: parentCommentId ?? null + }); + toast.success(m.commentPosted()); + } + + async function onUpdateComment(commentId: string, content: string) { + await UpdateCommentMutation.mutate({ commentId, content }); + toast.success(m.commentUpdated()); + } + + async function onDeleteComment(commentId: string) { + await DeleteCommentMutation.mutate({ commentId }); + toast.success(m.commentDeleted()); + } + // Collapsible metadata let metadataOpen = $state(false); @@ -362,6 +458,27 @@ {/if} + + {#if allComments.length > 0} +
+
+ + {allComments.length} + {m.comments()} +
+ | +
+ {documentCommentCount} + {m.documentWide()} +
+ | +
+ {clauseCommentCount} + {m.clauseComments()} +
+
+ {/if} +
{#if resolution} @@ -378,6 +495,7 @@ > {#snippet preambleAnnotations({ clause })} {@const lock = locksByClauseId.get(clause.id)} + {@const commentCount = commentCountByClauseId.get(clause.id) ?? 0} {#if lock}
{/if} + {#if commentCount > 0} +
+ + {commentCount} +
+ {/if} {/snippet} {#snippet clauseAnnotations({ clause })} {@const lock = locksByClauseId.get(clause.id)} + {@const commentCount = commentCountByClauseId.get(clause.id) ?? 0} {#if lock}
{/if} + {#if commentCount > 0} +
+ + {commentCount} +
+ {/if} + {/snippet} + {#snippet preambleClauseToolbar({ clause })} + + onCreateComment(content, visibility, parentCommentId, clause.id)} + {onUpdateComment} + {onDeleteComment} + /> + {/snippet} + {#snippet clauseToolbar({ clause })} + + onCreateComment(content, visibility, parentCommentId, clause.id)} + {onUpdateComment} + {onDeleteComment} + /> {/snippet} {/if}
+ + +
+ + onCreateComment(content, visibility, parentCommentId, null)} + {onUpdateComment} + {onDeleteComment} + /> +
{/if} diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/chairCommentsSubscription.ts b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/chairCommentsSubscription.ts new file mode 100644 index 00000000..838dbb15 --- /dev/null +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/chairCommentsSubscription.ts @@ -0,0 +1,27 @@ +import { graphql } from '$houdini'; + +export const ChairPaperCommentsSubscription = graphql(` + subscription ChairPaperCommentsSubscription($paperId: ID!) { + findManyResolutionComment(where: { paperId: $paperId }) { + id + clauseId + content + visibility + parentCommentId + createdAt + updatedAt + author { + id + committeeMember { + representation { + name + alpha2Code + alpha3Code + faIcon + } + } + conferenceUserType + } + } + } +`); diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte index d503df72..54fc1b5d 100644 --- a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte @@ -8,6 +8,7 @@ import { ParticipantPaperDetailSubscription } from './paperDetailSubscription'; import { PaperClauseLocksSubscription } from './lockSubscription'; import { ParticipantCommitteeSubscription } from '../../committeeSubscription'; + import { ParticipantPaperCommentsSubscription } from './commentsSubscription'; import { ResolutionEditor, migrateResolution, @@ -16,6 +17,7 @@ import Modal from '$lib/components/Modal.svelte'; import Fieldset from '$lib/components/Fieldset.svelte'; import Flag from '$lib/components/Flag.svelte'; + import CommentSection from '$lib/components/CommentSection.svelte'; import { getTranslatedCountryNameFromAlpha3Code } from '$lib/utils/nationTranslationHelper.svelte'; import toast from 'svelte-french-toast'; import { fly, fade } from 'svelte/transition'; @@ -70,6 +72,9 @@ (paper?.editors?.length ?? 0) > 0 || paper?.status !== 'WORKING_PAPER' ); + // Comments: show on SUBMITTED+ papers only (not working papers) + let showComments = $derived(paper?.status !== 'WORKING_PAPER'); + // Resolution content — initialize from paper data let resolution = $state(null); let hasPendingSave = $state(false); @@ -93,6 +98,9 @@ if (collaborativeMode) { PaperClauseLocksSubscription.listen({ paperId: page.params.paperId! }); } + if (showComments) { + ParticipantPaperCommentsSubscription.listen({ paperId: page.params.paperId! }); + } // Hybrid heartbeat — only fires when idle with held locks const heartbeatInterval = setInterval(() => { @@ -130,6 +138,13 @@ }; }); + // Start comments subscription when status changes to submitted+ + $effect(() => { + if (showComments) { + ParticipantPaperCommentsSubscription.listen({ paperId: page.params.paperId! }); + } + }); + // ===================================================== // Clause-level locking // ===================================================== @@ -432,6 +447,85 @@ toast.success(m.codeCopied()); } + // ===================================================== + // Comments + // ===================================================== + + let allComments = $derived( + $ParticipantPaperCommentsSubscription.data?.findManyResolutionComment ?? [] + ); + + let commentsByClauseId = $derived.by(() => { + const map = new Map(); + for (const comment of allComments) { + const key = comment.clauseId; + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(comment); + } + return map; + }); + + // Comment mutations + const CreateCommentMutation = graphql(` + mutation ParticipantCreateCommentMutation( + $paperId: ID! + $content: String! + $clauseId: String + $visibility: CommentVisibilityEnum + $parentCommentId: ID + ) { + createComment( + paperId: $paperId + content: $content + clauseId: $clauseId + visibility: $visibility + parentCommentId: $parentCommentId + ) { + id + } + } + `); + + const UpdateCommentMutation = graphql(` + mutation ParticipantUpdateCommentMutation($commentId: ID!, $content: String!) { + updateComment(commentId: $commentId, content: $content) { + id + } + } + `); + + const DeleteCommentMutation = graphql(` + mutation ParticipantDeleteCommentMutation($commentId: ID!) { + deleteComment(commentId: $commentId) + } + `); + + async function onCreateComment( + content: string, + visibility: string, + parentCommentId?: string, + clauseId?: string | null + ) { + await CreateCommentMutation.mutate({ + paperId: page.params.paperId!, + content, + clauseId: clauseId ?? null, + visibility: visibility as 'PUBLIC' | 'TEAM_ONLY', + parentCommentId: parentCommentId ?? null + }); + toast.success(m.commentPosted()); + } + + async function onUpdateComment(commentId: string, content: string) { + await UpdateCommentMutation.mutate({ commentId, content }); + toast.success(m.commentUpdated()); + } + + async function onDeleteComment(commentId: string) { + await DeleteCommentMutation.mutate({ commentId }); + toast.success(m.commentDeleted()); + } + // Collapsible metadata let metadataOpen = $state(true); @@ -696,10 +790,92 @@ {/if} {/snippet} + {#snippet preambleClauseToolbar({ clause })} + {#if showComments} + + onCreateComment(content, visibility, parentCommentId, clause.id)} + {onUpdateComment} + {onDeleteComment} + /> + {/if} + {/snippet} + {#snippet clauseToolbar({ clause })} + {#if showComments} + + onCreateComment(content, visibility, parentCommentId, clause.id)} + {onUpdateComment} + {onDeleteComment} + /> + {/if} + {/snippet} + {#snippet afterPreambleClause({ clause })} + {#if showComments && !canEdit} + + onCreateComment(content, visibility, parentCommentId, clause.id)} + {onUpdateComment} + {onDeleteComment} + /> + {/if} + {/snippet} + {#snippet afterOperativeClause({ clause, index })} + {#if showComments && !canEdit} + + onCreateComment(content, visibility, parentCommentId, clause.id)} + {onUpdateComment} + {onDeleteComment} + /> + {/if} + {/snippet} {/if} + + {#if showComments && (commentsByClauseId.get(null)?.length ?? 0) > 0} +
+ + onCreateComment(content, visibility, parentCommentId, null)} + {onUpdateComment} + {onDeleteComment} + /> +
+ {/if} + {#if canSubmit}
diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/commentsSubscription.ts b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/commentsSubscription.ts new file mode 100644 index 00000000..ac192823 --- /dev/null +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/commentsSubscription.ts @@ -0,0 +1,27 @@ +import { graphql } from '$houdini'; + +export const ParticipantPaperCommentsSubscription = graphql(` + subscription ParticipantPaperCommentsSubscription($paperId: ID!) { + findManyResolutionComment(where: { paperId: $paperId }) { + id + clauseId + content + visibility + parentCommentId + createdAt + updatedAt + author { + id + committeeMember { + representation { + name + alpha2Code + alpha3Code + faIcon + } + } + conferenceUserType + } + } + } +`); From 4314825a6bc02a0a4e1142d3857cd45a8741022b Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Wed, 4 Mar 2026 23:24:50 +0100 Subject: [PATCH 10/89] feat: chair sponsor management with add/remove UI and sorted display Allow chairs (TEAM/ADMIN) to add or remove any committee member as a sponsor, bypassing delegate role checks, status gates, and re-evaluation gates. Adds a searchable modal for adding sponsors and hover-X buttons for removal. Sponsors are now sorted alphabetically by name in both chair and participant views. Co-Authored-By: Claude Opus 4.6 --- messages/de.json | 13 +- messages/en.json | 5 + src/api/handlers/paperSponsor.ts | 105 +++++++++------- .../resolutions/[paperId]/+page.svelte | 118 +++++++++++++++++- .../papers/[paperId]/+page.svelte | 15 ++- 5 files changed, 201 insertions(+), 55 deletions(-) diff --git a/messages/de.json b/messages/de.json index ea7a6e03..8a543608 100644 --- a/messages/de.json +++ b/messages/de.json @@ -16,6 +16,7 @@ "addMember": "Mitglied hinzufügen", "addNonStateActor": "NA hinzufügen", "addRepresentation": "Delegation hinzufügen", + "addSponsor": "Sponsor hinzufügen", "addUnActor": "UN-Akteur hinzufügen", "admin": "Admin", "adopted": "Angenommen", @@ -296,6 +297,7 @@ "saveError": "Speichern fehlgeschlagen", "savingChanges": "Speichern...", "searchCommitteeMembers": "Gremienmitglieder durchsuchen", + "searchMembers": "Mitglieder suchen...", "searchUsers": "Benutzer suchen...", "selectAgendaItem": "Tagesordnungspunkt auswählen...", "selectCommitteeMember": "Gremienmitglied auswählen...", @@ -322,10 +324,12 @@ "speakersListNotFound": "Redeliste nicht gefunden", "speakersListOvertime": "Redezeit abgelaufen!", "spectator": "Zuschauer*in", - "sponsor": "Unterstützer", - "sponsorCount": "{count} Unterstützer", + "sponsor": "Unterstützerstaaten", + "sponsorAdded": "Sponsor hinzugefügt", + "sponsorCount": "{count} Unterstützerstaaten", "sponsorPaper": "Unterstützen", - "sponsors": "Unterstützer", + "sponsorRemoved": "Sponsor entfernt", + "sponsors": "Unterstützerstaaten", "startVote": "Abstimmung starten", "stateOfDebate": "Debattenstand", "statusUpdated": "Status wurde gesetzt", @@ -339,12 +343,13 @@ "submitted": "Eingereicht", "submittedPapers": "Eingereichte Papiere", "submittedPapersDescription": "Von Delegierten eingereichte Papiere, sortiert nach Unterstützerzahl", + "submittingNation": "Einreichender Staat", "supportDraftResolution": "Unterstützen", "supportReEvaluation": "Änderung der Unterstützung", "supportReEvaluationClosed": "Änderung der Unterstützung nicht erlaubt", "supportReEvaluationNotOpen": "Änderung der Unterstützung ist derzeit nicht erlaubt", "supportReEvaluationOpen": "Änderung der Unterstützung erlaubt", - "supporterCount": "{count} Unterstützer", + "supporterCount": "{count} Unterstützerstaaten", "suspension": "Vertagung", "teamMember": "Teammitglied", "teamOnly": "Nur Team", diff --git a/messages/en.json b/messages/en.json index 5a73bab8..11c32f2a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -16,6 +16,7 @@ "addMember": "Add Member", "addNonStateActor": "Add NGO", "addRepresentation": "Add Delegation", + "addSponsor": "Add Sponsor", "addUnActor": "Add UN Actor", "admin": "Admin", "adopted": "Adopted", @@ -296,6 +297,7 @@ "saveError": "Save failed", "savingChanges": "Saving...", "searchCommitteeMembers": "Search committee members", + "searchMembers": "Search members...", "searchUsers": "Search users...", "selectAgendaItem": "Select agenda item...", "selectCommitteeMember": "Select committee member...", @@ -323,8 +325,10 @@ "speakersListOvertime": "Speaking time over!", "spectator": "Spectator", "sponsor": "Sponsor", + "sponsorAdded": "Sponsor added", "sponsorCount": "{count} sponsors", "sponsorPaper": "Sponsor", + "sponsorRemoved": "Sponsor removed", "sponsors": "Sponsors", "startVote": "Start Vote", "stateOfDebate": "State of Debate", @@ -339,6 +343,7 @@ "submitted": "Submitted", "submittedPapers": "Submitted Papers", "submittedPapersDescription": "Papers submitted by delegates, ranked by sponsor count", + "submittingNation": "Submitting Nation", "supportDraftResolution": "Support", "supportReEvaluation": "Support Re-evaluation", "supportReEvaluationClosed": "Re-evaluation is closed", diff --git a/src/api/handlers/paperSponsor.ts b/src/api/handlers/paperSponsor.ts index bddc00c6..09c6b3a6 100644 --- a/src/api/handlers/paperSponsor.ts +++ b/src/api/handlers/paperSponsor.ts @@ -5,6 +5,7 @@ import { basics } from './basics'; import { isWhitelistedEmail } from '$api/services/isDMUNEmail'; import { assertFindFirstExists, assertFirstEntryExists } from '@m1212e/rumble'; import { GraphQLError } from 'graphql'; +import { assertCommitteeChairOrAdmin } from './resolutionPaper'; const { arg, ref, pubsub, table } = basics('paperSponsor'); const paperPubsub = rumblePubsub({ table: 'resolutionPaper' }); @@ -31,32 +32,40 @@ schemaBuilder.mutationFields((t) => ({ resolve: async (query, root, args, ctx, info) => { const user = ctx.mustBeLoggedIn(); - // Must be a DELEGATE - await db.query.conferenceUser - .findFirst({ - where: { - user: { id: user.sub }, - conferenceUserType: 'DELEGATE' - } - }) - .then(assertFindFirstExists); - - // Check re-evaluation gate for DR+ papers const paper = await db.query.resolutionPaper .findFirst({ where: { id: args.paperId } }) .then(assertFindFirstExists); - if (paper.status === 'FINAL') { - throw new GraphQLError('Cannot sponsor a finalized paper'); - } + // Try chair/admin path first (bypasses all gates) + let isChair = false; + try { + await assertCommitteeChairOrAdmin(ctx, paper.committeeId); + isChair = true; + } catch {} - if (paper.status === 'DRAFT_RESOLUTION' || paper.status === 'AMENDMENT_PHASE') { - const committee = await db.query.committee - .findFirst({ where: { id: paper.committeeId } }) + if (!isChair) { + // Must be a DELEGATE + await db.query.conferenceUser + .findFirst({ + where: { + user: { id: user.sub }, + conferenceUserType: 'DELEGATE' + } + }) .then(assertFindFirstExists); - if (!committee.supportReEvaluationOpen) { - throw new GraphQLError('Support re-evaluation is not currently open'); + if (paper.status === 'FINAL') { + throw new GraphQLError('Cannot sponsor a finalized paper'); + } + + if (paper.status === 'DRAFT_RESOLUTION' || paper.status === 'AMENDMENT_PHASE') { + const committee = await db.query.committee + .findFirst({ where: { id: paper.committeeId } }) + .then(assertFindFirstExists); + + if (!committee.supportReEvaluationOpen) { + throw new GraphQLError('Support re-evaluation is not currently open'); + } } } @@ -104,42 +113,44 @@ schemaBuilder.mutationFields((t) => ({ }) .then(assertFindFirstExists); - // Check re-evaluation gate for DR+ papers const paper = await db.query.resolutionPaper .findFirst({ where: { id: args.paperId } }) .then(assertFindFirstExists); - if (paper.status === 'DRAFT_RESOLUTION' || paper.status === 'AMENDMENT_PHASE') { - const committee = await db.query.committee - .findFirst({ where: { id: paper.committeeId } }) - .then(assertFindFirstExists); - - if (!committee.supportReEvaluationOpen) { - throw new GraphQLError('Support re-evaluation is not currently open'); - } - } - - // Must be self (removing own sponsorship) or paper creator - const conferenceUser = await db.query.conferenceUser.findFirst({ - where: { - user: { id: user.sub } + // Try chair/admin path first (bypasses all gates) + let isChair = false; + try { + await assertCommitteeChairOrAdmin(ctx, paper.committeeId); + isChair = true; + } catch {} + + if (!isChair) { + if (paper.status === 'DRAFT_RESOLUTION' || paper.status === 'AMENDMENT_PHASE') { + const committee = await db.query.committee + .findFirst({ where: { id: paper.committeeId } }) + .then(assertFindFirstExists); + + if (!committee.supportReEvaluationOpen) { + throw new GraphQLError('Support re-evaluation is not currently open'); + } } - }); - const isSelf = conferenceUser?.committeeMemberId === args.committeeMemberId; + // Must be self (removing own sponsorship) or paper creator + const conferenceUser = await db.query.conferenceUser.findFirst({ + where: { + user: { id: user.sub } + } + }); - if (!isSelf) { - const paper = await db.query.resolutionPaper - .findFirst({ - where: { id: args.paperId } - }) - .then(assertFindFirstExists); + const isSelf = conferenceUser?.committeeMemberId === args.committeeMemberId; - const isCreator = conferenceUser?.committeeMemberId === paper.creatorCommitteeMemberId; - if (!isCreator) { - throw new GraphQLError( - 'Only the sponsor themselves or the paper creator can remove a sponsor' - ); + if (!isSelf) { + const isCreator = conferenceUser?.committeeMemberId === paper.creatorCommitteeMemberId; + if (!isCreator) { + throw new GraphQLError( + 'Only the sponsor themselves or the paper creator can remove a sponsor' + ); + } } } diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte index d4d5ebfa..a95efc57 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte @@ -15,6 +15,7 @@ } from '@deutschemodelunitednations/munify-resolution-editor'; import Flag from '$lib/components/Flag.svelte'; import Fieldset from '$lib/components/Fieldset.svelte'; + import Modal from '$lib/components/Modal.svelte'; import CommentSection from '$lib/components/CommentSection.svelte'; import { getTranslatedCountryNameFromAlpha3Code } from '$lib/utils/nationTranslationHelper.svelte'; import toast from 'svelte-french-toast'; @@ -363,6 +364,70 @@ toast.success(m.commentDeleted()); } + // ===================================================== + // Sponsor management + // ===================================================== + + const AddSponsorMutation = graphql(` + mutation ChairAddSponsorMutation($paperId: ID!, $committeeMemberId: ID!) { + addSponsor(paperId: $paperId, committeeMemberId: $committeeMemberId) { + id + } + } + `); + + const RemoveSponsorMutation = graphql(` + mutation ChairRemoveSponsorMutation($paperId: ID!, $committeeMemberId: ID!) { + removeSponsor(paperId: $paperId, committeeMemberId: $committeeMemberId) + } + `); + + let showAddSponsorModal = $state(false); + let sponsorSearchQuery = $state(''); + + let availableMembers = $derived( + (committee?.members ?? []).filter( + (member) => !paper?.sponsors.some((s) => s.committeeMemberId === member.id) + ) + ); + + function getRepresentationName( + rep: { name?: string | null; alpha3Code?: string | null } | null | undefined + ) { + return rep?.name ?? getTranslatedCountryNameFromAlpha3Code(rep?.alpha3Code) ?? ''; + } + + let sortedSponsors = $derived( + [...(paper?.sponsors ?? [])].sort((a, b) => + getRepresentationName(a.committeeMember?.representation).localeCompare( + getRepresentationName(b.committeeMember?.representation) + ) + ) + ); + + let filteredAvailableMembers = $derived( + (sponsorSearchQuery + ? availableMembers.filter((member) => + getRepresentationName(member.representation) + .toLowerCase() + .includes(sponsorSearchQuery.toLowerCase()) + ) + : availableMembers + ).sort((a, b) => + getRepresentationName(a.representation).localeCompare(getRepresentationName(b.representation)) + ) + ); + + async function handleAddSponsor(committeeMemberId: string) { + await AddSponsorMutation.mutate({ paperId: page.params.paperId!, committeeMemberId }); + toast.success(m.sponsorAdded()); + } + + async function handleRemoveSponsor(committeeMemberId: string) { + await RemoveSponsorMutation.mutate({ paperId: page.params.paperId!, committeeMemberId }); + toast.success(m.sponsorRemoved()); + } + // Collapsible metadata let metadataOpen = $state(false); @@ -421,7 +486,7 @@ {#if paper.creator?.representation}
- Creator: + {m.submittingNation()}: {paper.creator.representation.name ?? getTranslatedCountryNameFromAlpha3Code(paper.creator.representation.alpha3Code)} @@ -431,17 +496,32 @@
- {#each paper.sponsors as sponsor (sponsor.id)} + {#each sortedSponsors as sponsor (sponsor.id)}
+
{/each} +

{m.sponsorCount({ count: String(paper.sponsors.length) })} @@ -612,4 +692,36 @@ />

+ + + +
+

{m.addSponsor()}

+ +
+ +
+ {#each filteredAvailableMembers as member (member.id)} + + {/each} + {#if filteredAvailableMembers.length === 0} +

{m.noResults()}

+ {/if} +
+
{/if} diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte index 54fc1b5d..3eee95cf 100644 --- a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte @@ -56,6 +56,19 @@ let isSponsor = $derived( paper?.sponsors.some((s) => s.committeeMemberId === myCommitteeMemberId) ); + let sortedSponsors = $derived( + [...(paper?.sponsors ?? [])].sort((a, b) => { + const nameA = + a.committeeMember?.representation?.name ?? + getTranslatedCountryNameFromAlpha3Code(a.committeeMember?.representation?.alpha3Code) ?? + ''; + const nameB = + b.committeeMember?.representation?.name ?? + getTranslatedCountryNameFromAlpha3Code(b.committeeMember?.representation?.alpha3Code) ?? + ''; + return nameA.localeCompare(nameB); + }) + ); let canDelete = $derived(isCreator && paper?.status === 'WORKING_PAPER'); @@ -614,7 +627,7 @@
- {#each paper.sponsors as sponsor} + {#each sortedSponsors as sponsor}
Date: Wed, 4 Mar 2026 23:26:43 +0100 Subject: [PATCH 11/89] =?UTF-8?q?docs:=20update=20meta-plan=20=E2=80=94=20?= =?UTF-8?q?mark=20Phases=204=20and=205=20as=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- docs/plans/resolution-meta-plan.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/plans/resolution-meta-plan.md b/docs/plans/resolution-meta-plan.md index ab571a2a..6d5ab11f 100644 --- a/docs/plans/resolution-meta-plan.md +++ b/docs/plans/resolution-meta-plan.md @@ -380,19 +380,20 @@ commentPanel?: Snippet<[{ resolution: Resolution; activeClauseId?: string }]>; - ~~Content snapshot on promotion~~ - ~~Title hidden post-promotion (document number is sole identifier for DRs)~~ -### Phase 4: Support Re-evaluation + DR Ordering +### Phase 4: Support Re-evaluation + DR Ordering ✅ -- "Open/Close Re-evaluation" chair controls -- Delegate UI: add/remove support on DRs (multi-support) -- Dynamic sponsor count display + DR ranking -- "Set Active DR" for debate progression +- ~~"Open/Close Re-evaluation" chair controls~~ +- ~~Delegate UI: add/remove support on DRs (multi-support)~~ +- ~~Dynamic sponsor count display + DR ranking~~ +- ~~"Set Active DR" for debate progression~~ +- ~~Chair sponsor management: add/remove any member as sponsor (bypasses all gates), searchable modal, sorted display~~ -### Phase 5: Comment System +### Phase 5: Comment System ✅ -- `resolutionComment` handler -- `CommentPanel.svelte` (document + clause level, threading, visibility) -- **Editor library**: preamble extension points -- Integration in chair + participant DR views +- ~~`resolutionComment` handler (create, update, delete, threading, PUBLIC/TEAM_ONLY visibility)~~ +- ~~`CommentSection.svelte` (document + clause level, threading, visibility)~~ +- ~~**Editor library**: preamble extension points (preambleAnnotations, preambleClauseToolbar)~~ +- ~~Integration in chair + participant DR views with real-time subscriptions~~ ### Phase 6: Amendment System From 2d33d6f2295101dc4580448de1841e98d5cedb89 Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Thu, 5 Mar 2026 00:05:15 +0100 Subject: [PATCH 12/89] =?UTF-8?q?feat:=20resolution=20editor=20Phase=206b?= =?UTF-8?q?=20=E2=80=94=20amendment=20system=20backend=20mutations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the full amendment system backend (GO §17): - 6 amendment mutations: create, submit, adoptByConsensus, accept, reject, withdraw - 2 sponsor mutations: addAmendmentSponsor, removeAmendmentSponsor - Auto-transition to AMENDMENT_PHASE when chair sets currentOperativeIndex - Amendment application logic (DELETE, ADD, ALTER_TEXT, ALTER_POSITION) with content snapshots - Sponsor threshold validation (10% of present delegations) - Clause-already-passed guard for DELETE/ALTER_TEXT amendments Co-Authored-By: Claude Opus 4.6 --- schema.graphql | 8 + src/api/handlers/amendment.ts | 553 ++++++++++++++++++++++++++- src/api/handlers/amendmentSponsor.ts | 146 ++++++- src/api/handlers/committee.ts | 33 ++ 4 files changed, 738 insertions(+), 2 deletions(-) diff --git a/schema.graphql b/schema.graphql index 44c9dfdc..4370c233 100644 --- a/schema.graphql +++ b/schema.graphql @@ -372,11 +372,15 @@ The `JSON` scalar type represents JSON values as specified by [ECMA-404](http:// scalar JSON type Mutation { + acceptAmendment(amendmentId: ID!): Amendment acquireClauseLock(clauseId: String!, paperId: ID!): PaperClauseLock + addAmendmentSponsor(amendmentId: ID!, committeeMemberId: ID!): AmendmentSponsor addSpeakerOnList(committeeMemberId: ID, conferenceMemberId: ID, position: Int, speakersListId: ID!): SpeakerOnList addSponsor(committeeMemberId: ID!, paperId: ID!): PaperSponsor + adoptByConsensus(amendmentId: ID!): Amendment clearSpeakersList(id: ID!): SpeakersList createAgendaItem(committeeId: ID!, title: String!): AgendaItem + createAmendment(newContent: JSON, paperId: ID!, targetClauseId: String, targetOperativeIndex: Int, targetPosition: Int, type: AmendmentTypeEnum!): Amendment createComment(clauseId: String, content: String!, paperId: ID!, parentCommentId: ID, visibility: CommentVisibilityEnum): ResolutionComment createCommittee(abbreviation: String!, conferenceId: ID!, name: String!): Committee createCommitteeMember(committeeId: ID!, representationId: ID!): CommitteeMember @@ -397,8 +401,10 @@ type Mutation { promoteToDraftResolution(paperId: ID!): ResolutionPaper recordVoteResult(outcome: VoteOutcomeEnum!, paperId: ID!, votesAbstain: Int, votesAgainst: Int!, votesFor: Int!): ResolutionPaper redeemShareCode(code: String!): ShareCodeRedemptionResult + rejectAmendment(amendmentId: ID!): Amendment releaseAllMyLocks(paperId: ID!): Boolean releaseClauseLock(clauseId: String!, paperId: ID!): Boolean + removeAmendmentSponsor(amendmentId: ID!, committeeMemberId: ID!): Boolean removeEditor(conferenceUserId: ID!, paperId: ID!): Boolean removeSpeakerOnList(speakerOnListId: ID!): SpeakersList removeSponsor(committeeMemberId: ID!, paperId: ID!): Boolean @@ -406,6 +412,7 @@ type Mutation { selfRemoveFromSpeakersList(speakersListId: ID!): SpeakersList setPresenceForCommitteeMembers(ids: [ID!]!, present: Boolean!): [CommitteeMember!] softDeletePaper(paperId: ID!): Boolean + submitAmendment(amendmentId: ID!): Amendment submitPaper(paperId: ID!): ResolutionPaper updateComment(commentId: ID!, content: String!): ResolutionComment updateCommittee(abbreviation: String, activeAgendaItemId: ID, activeDraftResolutionId: ID, allowDelegationsToAddThemselvesToSpeakersList: Boolean, clearActiveDraftResolution: Boolean, currentOperativeIndex: Int, id: ID!, lastResolutionAdoptionDate: DateTime, maxDraftResolutions: Int, name: String, showWhiteboard: Boolean, stateOfDebate: String, status: CommitteeStatusEnum, statusHeadline: String, statusUntil: DateTime, supportReEvaluationOpen: Boolean, whiteboardContent: String): Committee @@ -415,6 +422,7 @@ type Mutation { updatePaperTitle(paperId: ID!, title: String!): ResolutionPaper updateSpeakerOnList(id: ID!, overwriteName: String): SpeakerOnList updateSpeakersList(id: ID!, isClosed: Boolean, speakingTime: Int, startTimestamp: DateTime, stopTimer: Boolean = false, timeLeft: Int): SpeakersList + withdrawAmendment(amendmentId: ID!): Amendment } type OperativeClauseVote { diff --git a/src/api/handlers/amendment.ts b/src/api/handlers/amendment.ts index 96894cd2..d0a7f6d5 100644 --- a/src/api/handlers/amendment.ts +++ b/src/api/handlers/amendment.ts @@ -1,8 +1,21 @@ -import { abilityBuilder } from '$api/rumble'; +import { db, schema } from '$api/db/db'; +import { abilityBuilder, enum_, schemaBuilder, pubsub as rumblePubsub } from '$api/rumble'; import { basics } from './basics'; import { isWhitelistedEmail } from '$api/services/isDMUNEmail'; +import { assertFindFirstExists, assertFirstEntryExists } from '@m1212e/rumble'; +import { and, eq, count as drizzleCount } from 'drizzle-orm'; +import { GraphQLError } from 'graphql'; +import { assertCommitteeChairOrAdmin } from './resolutionPaper'; +import { + ResolutionSchema, + OperativeClauseSchema +} from '@deutschemodelunitednations/munify-resolution-editor/schema'; const { arg, ref, pubsub, table } = basics('amendment'); +const paperPubsub = rumblePubsub({ table: 'resolutionPaper' }); + +const amendmentTypeEnum = enum_({ tsName: 'amendmentType' }); +const amendmentStatusEnum = enum_({ tsName: 'amendmentStatus' }); abilityBuilder.amendment.allow('read').when(({ mustBeLoggedIn }) => { const user = mustBeLoggedIn(); @@ -15,3 +28,541 @@ abilityBuilder.amendment.allow('read').when(({ mustBeLoggedIn }) => { mustBeLoggedIn(); return 'allow'; }); + +// ============================================================================= +// HELPERS +// ============================================================================= + +async function getPresentDelegationCount(committeeId: string): Promise { + const result = await db + .select({ count: drizzleCount() }) + .from(schema.committeeMember) + .innerJoin( + schema.representation, + eq(schema.committeeMember.representationId, schema.representation.id) + ) + .where( + and( + eq(schema.committeeMember.committeeId, committeeId), + eq(schema.committeeMember.present, true), + eq(schema.representation.type, 'DELEGATION') + ) + ) + .then(assertFirstEntryExists); + return result.count; +} + +function validateAmendmentArgs( + type: string, + args: { + targetClauseId?: string | null; + targetOperativeIndex?: number | null; + targetPosition?: number | null; + newContent?: unknown; + } +) { + switch (type) { + case 'DELETE': + if (args.targetClauseId === undefined || args.targetClauseId === null) { + throw new GraphQLError('DELETE amendments require targetClauseId'); + } + if (args.targetOperativeIndex === undefined || args.targetOperativeIndex === null) { + throw new GraphQLError('DELETE amendments require targetOperativeIndex'); + } + break; + case 'ADD': + if (args.targetPosition === undefined || args.targetPosition === null) { + throw new GraphQLError('ADD amendments require targetPosition'); + } + if (!args.newContent) { + throw new GraphQLError('ADD amendments require newContent'); + } + break; + case 'ALTER_TEXT': + if (args.targetClauseId === undefined || args.targetClauseId === null) { + throw new GraphQLError('ALTER_TEXT amendments require targetClauseId'); + } + if (args.targetOperativeIndex === undefined || args.targetOperativeIndex === null) { + throw new GraphQLError('ALTER_TEXT amendments require targetOperativeIndex'); + } + if (!args.newContent) { + throw new GraphQLError('ALTER_TEXT amendments require newContent'); + } + break; + case 'ALTER_POSITION': + if (args.targetOperativeIndex === undefined || args.targetOperativeIndex === null) { + throw new GraphQLError('ALTER_POSITION amendments require targetOperativeIndex (source)'); + } + if (args.targetPosition === undefined || args.targetPosition === null) { + throw new GraphQLError('ALTER_POSITION amendments require targetPosition (destination)'); + } + break; + } +} + +type Resolution = { committeeName: string; preamble: unknown[]; operative: unknown[] }; + +async function applyAmendmentToResolution( + tx: Parameters[0]>[0], + amendment: typeof schema.amendment.$inferSelect, + paper: typeof schema.resolutionPaper.$inferSelect +) { + const parsed = ResolutionSchema.safeParse(paper.content); + if (!parsed.success) { + throw new GraphQLError('Paper content is invalid'); + } + const resolution = parsed.data; + + // Snapshot before applying + await tx.insert(schema.paperContentSnapshot).values({ + paperId: paper.id, + content: paper.content, + trigger: `AMENDMENT_${amendment.type}` + }); + + switch (amendment.type) { + case 'DELETE': { + const idx = amendment.targetOperativeIndex!; + if (idx < 0 || idx >= resolution.operative.length) { + throw new GraphQLError('Target operative index out of range'); + } + if (resolution.operative[idx].id !== amendment.targetClauseId) { + throw new GraphQLError('Clause ID mismatch at target index'); + } + resolution.operative.splice(idx, 1); + break; + } + case 'ADD': { + const parsedClause = OperativeClauseSchema.safeParse(amendment.newContent); + if (!parsedClause.success) { + throw new GraphQLError('Invalid newContent for ADD amendment'); + } + const insertAfter = amendment.targetPosition!; + resolution.operative.splice(insertAfter + 1, 0, parsedClause.data); + break; + } + case 'ALTER_TEXT': { + const idx = amendment.targetOperativeIndex!; + if (idx < 0 || idx >= resolution.operative.length) { + throw new GraphQLError('Target operative index out of range'); + } + const parsedClause = OperativeClauseSchema.safeParse(amendment.newContent); + if (!parsedClause.success) { + throw new GraphQLError('Invalid newContent for ALTER_TEXT amendment'); + } + // Keep original clause ID, replace blocks + resolution.operative[idx] = { + ...parsedClause.data, + id: resolution.operative[idx].id + }; + break; + } + case 'ALTER_POSITION': { + const sourceIdx = amendment.targetOperativeIndex!; + const destIdx = amendment.targetPosition!; + if (sourceIdx < 0 || sourceIdx >= resolution.operative.length) { + throw new GraphQLError('Source operative index out of range'); + } + if (destIdx < 0 || destIdx > resolution.operative.length) { + throw new GraphQLError('Destination index out of range'); + } + const [clause] = resolution.operative.splice(sourceIdx, 1); + // After removing from source, the target index might shift + const adjustedDest = destIdx > sourceIdx ? destIdx - 1 : destIdx; + resolution.operative.splice(adjustedDest, 0, clause); + break; + } + } + + await tx + .update(schema.resolutionPaper) + .set({ content: resolution }) + .where(eq(schema.resolutionPaper.id, paper.id)); +} + +// ============================================================================= +// MUTATIONS +// ============================================================================= + +schemaBuilder.mutationFields((t) => ({ + createAmendment: t.drizzleField({ + type: ref, + args: { + paperId: t.arg.id({ required: true }), + type: t.arg({ type: amendmentTypeEnum, required: true }), + targetClauseId: t.arg.string(), + targetOperativeIndex: t.arg.int(), + targetPosition: t.arg.int(), + newContent: t.arg({ type: 'JSON' }) + }, + resolve: async (query, root, args, ctx, info) => { + const user = ctx.mustBeLoggedIn(); + + // Find delegate's committee member + const paper = await db.query.resolutionPaper + .findFirst({ where: { id: args.paperId } }) + .then(assertFindFirstExists); + + if (paper.status !== 'AMENDMENT_PHASE') { + throw new GraphQLError('Paper must be in AMENDMENT_PHASE'); + } + + // Verify this is the active DR + const committee = await db.query.committee + .findFirst({ where: { id: paper.committeeId } }) + .then(assertFindFirstExists); + + if (committee.activeDraftResolutionId !== paper.id) { + throw new GraphQLError('Paper must be the active draft resolution'); + } + + // Find the delegate's conference user + committee member + const conferenceUser = await db.query.conferenceUser + .findFirst({ + where: { + user: { id: user.sub }, + conferenceUserType: 'DELEGATE', + committeeMember: { + committeeId: paper.committeeId + } + } + }) + .then(assertFindFirstExists); + + if (!conferenceUser.committeeMemberId) { + throw new GraphQLError('You must be assigned to a committee member'); + } + + // Validate type-specific args + validateAmendmentArgs(args.type, args); + + // For DELETE and ALTER_TEXT, validate targetOperativeIndex >= currentOperativeIndex + if ( + (args.type === 'DELETE' || args.type === 'ALTER_TEXT') && + committee.currentOperativeIndex !== null && + args.targetOperativeIndex !== undefined && + args.targetOperativeIndex !== null && + args.targetOperativeIndex < committee.currentOperativeIndex + ) { + throw new GraphQLError('Cannot amend a clause that has already been passed'); + } + + // Validate clauseId exists if provided + if ( + args.targetClauseId && + args.targetOperativeIndex !== undefined && + args.targetOperativeIndex !== null + ) { + const parsed = ResolutionSchema.safeParse(paper.content); + if (parsed.success) { + const clause = parsed.data.operative[args.targetOperativeIndex]; + if (!clause || clause.id !== args.targetClauseId) { + throw new GraphQLError('Clause ID does not match at the given index'); + } + } + } + + // Validate newContent if provided + if (args.newContent) { + const parsedContent = OperativeClauseSchema.safeParse(args.newContent); + if (!parsedContent.success) { + throw new GraphQLError('Invalid newContent: ' + parsedContent.error.message); + } + } + + // Create amendment + const result = await db + .insert(schema.amendment) + .values({ + paperId: args.paperId, + proposerCommitteeMemberId: conferenceUser.committeeMemberId, + type: args.type, + status: 'PENDING', + targetClauseId: args.targetClauseId ?? undefined, + targetOperativeIndex: args.targetOperativeIndex ?? undefined, + newContent: args.newContent ?? undefined, + targetPosition: args.targetPosition ?? undefined + }) + .returning() + .then(assertFirstEntryExists); + + // Auto-add proposer as first sponsor + await db.insert(schema.amendmentSponsor).values({ + amendmentId: result.id, + committeeMemberId: conferenceUser.committeeMemberId + }); + + pubsub.updated(result.id); + paperPubsub.updated(args.paperId); + + return db.query.amendment + .findFirst( + query( + ctx.abilities.amendment.filter('read', { + inject: { where: { id: result.id } } + }).query.single + ) + ) + .then(assertFindFirstExists); + } + }), + + submitAmendment: t.drizzleField({ + type: ref, + args: { + amendmentId: t.arg.id({ required: true }) + }, + resolve: async (query, root, args, ctx, info) => { + const user = ctx.mustBeLoggedIn(); + + const amendment = await db.query.amendment + .findFirst({ where: { id: args.amendmentId } }) + .then(assertFindFirstExists); + + if (amendment.status !== 'PENDING') { + throw new GraphQLError('Only PENDING amendments can be submitted'); + } + + // Only proposer can submit + const conferenceUser = await db.query.conferenceUser + .findFirst({ + where: { + user: { id: user.sub }, + committeeMemberId: amendment.proposerCommitteeMemberId + } + }) + .then(assertFindFirstExists); + + // Check sponsor threshold + const paper = await db.query.resolutionPaper + .findFirst({ where: { id: amendment.paperId } }) + .then(assertFindFirstExists); + + // Verify paragraph not passed + const committee = await db.query.committee + .findFirst({ where: { id: paper.committeeId } }) + .then(assertFindFirstExists); + + if ( + (amendment.type === 'DELETE' || amendment.type === 'ALTER_TEXT') && + committee.currentOperativeIndex !== null && + amendment.targetOperativeIndex !== null && + amendment.targetOperativeIndex < committee.currentOperativeIndex + ) { + throw new GraphQLError('Cannot submit amendment for a clause that has already been passed'); + } + + const presentCount = await getPresentDelegationCount(paper.committeeId); + const threshold = Math.ceil(presentCount * 0.1); + + const sponsorResult = await db + .select({ count: drizzleCount() }) + .from(schema.amendmentSponsor) + .where(eq(schema.amendmentSponsor.amendmentId, args.amendmentId)) + .then(assertFirstEntryExists); + + if (sponsorResult.count < threshold) { + throw new GraphQLError(`Not enough sponsors: ${sponsorResult.count}/${threshold} required`); + } + + await db + .update(schema.amendment) + .set({ status: 'SUBMITTED' }) + .where(eq(schema.amendment.id, args.amendmentId)); + + pubsub.updated(args.amendmentId); + paperPubsub.updated(amendment.paperId); + + return db.query.amendment + .findFirst( + query( + ctx.abilities.amendment.filter('read', { + inject: { where: { id: args.amendmentId } } + }).query.single + ) + ) + .then(assertFindFirstExists); + } + }), + + adoptByConsensus: t.drizzleField({ + type: ref, + args: { + amendmentId: t.arg.id({ required: true }) + }, + resolve: async (query, root, args, ctx, info) => { + const amendment = await db.query.amendment + .findFirst({ where: { id: args.amendmentId } }) + .then(assertFindFirstExists); + + if (amendment.status !== 'SUBMITTED') { + throw new GraphQLError('Only SUBMITTED amendments can be adopted by consensus'); + } + + const paper = await db.query.resolutionPaper + .findFirst({ where: { id: amendment.paperId } }) + .then(assertFindFirstExists); + + await assertCommitteeChairOrAdmin(ctx, paper.committeeId); + + await db.transaction(async (tx) => { + await tx + .update(schema.amendment) + .set({ status: 'CONSENSUS_ADOPTED' }) + .where(eq(schema.amendment.id, args.amendmentId)); + + await applyAmendmentToResolution(tx, amendment, paper); + }); + + pubsub.updated(args.amendmentId); + paperPubsub.updated(amendment.paperId); + + return db.query.amendment + .findFirst( + query( + ctx.abilities.amendment.filter('read', { + inject: { where: { id: args.amendmentId } } + }).query.single + ) + ) + .then(assertFindFirstExists); + } + }), + + acceptAmendment: t.drizzleField({ + type: ref, + args: { + amendmentId: t.arg.id({ required: true }) + }, + resolve: async (query, root, args, ctx, info) => { + const amendment = await db.query.amendment + .findFirst({ where: { id: args.amendmentId } }) + .then(assertFindFirstExists); + + if (amendment.status !== 'SUBMITTED') { + throw new GraphQLError('Only SUBMITTED amendments can be accepted'); + } + + const paper = await db.query.resolutionPaper + .findFirst({ where: { id: amendment.paperId } }) + .then(assertFindFirstExists); + + await assertCommitteeChairOrAdmin(ctx, paper.committeeId); + + await db.transaction(async (tx) => { + await tx + .update(schema.amendment) + .set({ status: 'ACCEPTED' }) + .where(eq(schema.amendment.id, args.amendmentId)); + + await applyAmendmentToResolution(tx, amendment, paper); + }); + + pubsub.updated(args.amendmentId); + paperPubsub.updated(amendment.paperId); + + return db.query.amendment + .findFirst( + query( + ctx.abilities.amendment.filter('read', { + inject: { where: { id: args.amendmentId } } + }).query.single + ) + ) + .then(assertFindFirstExists); + } + }), + + rejectAmendment: t.drizzleField({ + type: ref, + args: { + amendmentId: t.arg.id({ required: true }) + }, + resolve: async (query, root, args, ctx, info) => { + const amendment = await db.query.amendment + .findFirst({ where: { id: args.amendmentId } }) + .then(assertFindFirstExists); + + if (amendment.status !== 'SUBMITTED') { + throw new GraphQLError('Only SUBMITTED amendments can be rejected'); + } + + const paper = await db.query.resolutionPaper + .findFirst({ where: { id: amendment.paperId } }) + .then(assertFindFirstExists); + + await assertCommitteeChairOrAdmin(ctx, paper.committeeId); + + await db + .update(schema.amendment) + .set({ status: 'REJECTED' }) + .where(eq(schema.amendment.id, args.amendmentId)); + + pubsub.updated(args.amendmentId); + paperPubsub.updated(amendment.paperId); + + return db.query.amendment + .findFirst( + query( + ctx.abilities.amendment.filter('read', { + inject: { where: { id: args.amendmentId } } + }).query.single + ) + ) + .then(assertFindFirstExists); + } + }), + + withdrawAmendment: t.drizzleField({ + type: ref, + args: { + amendmentId: t.arg.id({ required: true }) + }, + resolve: async (query, root, args, ctx, info) => { + const user = ctx.mustBeLoggedIn(); + + const amendment = await db.query.amendment + .findFirst({ where: { id: args.amendmentId } }) + .then(assertFindFirstExists); + + if (amendment.status !== 'PENDING' && amendment.status !== 'SUBMITTED') { + throw new GraphQLError('Only PENDING or SUBMITTED amendments can be withdrawn'); + } + + const paper = await db.query.resolutionPaper + .findFirst({ where: { id: amendment.paperId } }) + .then(assertFindFirstExists); + + // Either proposer or chair can withdraw + const isProposer = await db.query.conferenceUser.findFirst({ + where: { + user: { id: user.sub }, + committeeMemberId: amendment.proposerCommitteeMemberId + } + }); + + if (!isProposer) { + // Must be chair/admin + await assertCommitteeChairOrAdmin(ctx, paper.committeeId); + } + + await db + .update(schema.amendment) + .set({ status: 'WITHDRAWN' }) + .where(eq(schema.amendment.id, args.amendmentId)); + + pubsub.updated(args.amendmentId); + paperPubsub.updated(amendment.paperId); + + return db.query.amendment + .findFirst( + query( + ctx.abilities.amendment.filter('read', { + inject: { where: { id: args.amendmentId } } + }).query.single + ) + ) + .then(assertFindFirstExists); + } + }) +})); diff --git a/src/api/handlers/amendmentSponsor.ts b/src/api/handlers/amendmentSponsor.ts index 70fc8374..1dc746ed 100644 --- a/src/api/handlers/amendmentSponsor.ts +++ b/src/api/handlers/amendmentSponsor.ts @@ -1,8 +1,14 @@ -import { abilityBuilder } from '$api/rumble'; +import { db, schema } from '$api/db/db'; +import { abilityBuilder, schemaBuilder, pubsub as rumblePubsub } from '$api/rumble'; import { basics } from './basics'; import { isWhitelistedEmail } from '$api/services/isDMUNEmail'; +import { assertFindFirstExists, assertFirstEntryExists } from '@m1212e/rumble'; +import { and, eq } from 'drizzle-orm'; +import { GraphQLError } from 'graphql'; +import { assertCommitteeChairOrAdmin } from './resolutionPaper'; const { arg, ref, pubsub, table } = basics('amendmentSponsor'); +const amendmentPubsub = rumblePubsub({ table: 'amendment' }); abilityBuilder.amendmentSponsor.allow('read').when(({ mustBeLoggedIn }) => { const user = mustBeLoggedIn(); @@ -15,3 +21,141 @@ abilityBuilder.amendmentSponsor.allow('read').when(({ mustBeLoggedIn }) => { mustBeLoggedIn(); return 'allow'; }); + +schemaBuilder.mutationFields((t) => ({ + addAmendmentSponsor: t.drizzleField({ + type: ref, + args: { + amendmentId: t.arg.id({ required: true }), + committeeMemberId: t.arg.id({ required: true }) + }, + resolve: async (query, root, args, ctx, info) => { + const user = ctx.mustBeLoggedIn(); + + const amendment = await db.query.amendment + .findFirst({ where: { id: args.amendmentId } }) + .then(assertFindFirstExists); + + if (amendment.status !== 'PENDING') { + throw new GraphQLError('Can only add sponsors to PENDING amendments'); + } + + const paper = await db.query.resolutionPaper + .findFirst({ where: { id: amendment.paperId } }) + .then(assertFindFirstExists); + + // Either the delegate adding themselves, or chair adding anyone + const isOwnMembership = await db.query.conferenceUser.findFirst({ + where: { + user: { id: user.sub }, + committeeMemberId: args.committeeMemberId + } + }); + + if (!isOwnMembership) { + // Must be chair/admin + await assertCommitteeChairOrAdmin(ctx, paper.committeeId); + } + + // Check not already sponsor + const existing = await db.query.amendmentSponsor.findFirst({ + where: { + amendmentId: args.amendmentId, + committeeMemberId: args.committeeMemberId + } + }); + + if (existing) { + throw new GraphQLError('Already a sponsor of this amendment'); + } + + const result = await db + .insert(schema.amendmentSponsor) + .values({ + amendmentId: args.amendmentId, + committeeMemberId: args.committeeMemberId + }) + .returning() + .then(assertFirstEntryExists); + + pubsub.updated(result.id); + amendmentPubsub.updated(args.amendmentId); + + return db.query.amendmentSponsor + .findFirst( + query( + ctx.abilities.amendmentSponsor.filter('read', { + inject: { where: { id: result.id } } + }).query.single + ) + ) + .then(assertFindFirstExists); + } + }), + + removeAmendmentSponsor: t.field({ + type: 'Boolean', + args: { + amendmentId: t.arg.id({ required: true }), + committeeMemberId: t.arg.id({ required: true }) + }, + resolve: async (root, args, ctx) => { + const user = ctx.mustBeLoggedIn(); + + const amendment = await db.query.amendment + .findFirst({ where: { id: args.amendmentId } }) + .then(assertFindFirstExists); + + if (amendment.status !== 'PENDING') { + throw new GraphQLError('Can only remove sponsors from PENDING amendments'); + } + + // Cannot remove the proposer + if (args.committeeMemberId === amendment.proposerCommitteeMemberId) { + throw new GraphQLError('Cannot remove the proposer from sponsors'); + } + + const paper = await db.query.resolutionPaper + .findFirst({ where: { id: amendment.paperId } }) + .then(assertFindFirstExists); + + // Either removing own sponsorship, or chair removing anyone + const isOwnMembership = await db.query.conferenceUser.findFirst({ + where: { + user: { id: user.sub }, + committeeMemberId: args.committeeMemberId + } + }); + + if (!isOwnMembership) { + // Must be chair/admin + await assertCommitteeChairOrAdmin(ctx, paper.committeeId); + } + + const existing = await db.query.amendmentSponsor.findFirst({ + where: { + amendmentId: args.amendmentId, + committeeMemberId: args.committeeMemberId + } + }); + + if (!existing) { + throw new GraphQLError('Not a sponsor of this amendment'); + } + + await db + .delete(schema.amendmentSponsor) + .where( + and( + eq(schema.amendmentSponsor.amendmentId, args.amendmentId), + eq(schema.amendmentSponsor.committeeMemberId, args.committeeMemberId) + ) + ); + + pubsub.updated(existing.id); + amendmentPubsub.updated(args.amendmentId); + + return true; + } + }) +})); diff --git a/src/api/handlers/committee.ts b/src/api/handlers/committee.ts index e3a11d72..300a049f 100644 --- a/src/api/handlers/committee.ts +++ b/src/api/handlers/committee.ts @@ -15,6 +15,8 @@ import { calculateMajority } from '$lib/utils/majorities'; import { assertConferenceAdmin } from './conferenceUser'; import { GraphQLError } from 'graphql'; +const paperPubsub = rumblePubsub({ table: 'resolutionPaper' }); + const statusEnum = enum_({ tsName: 'committeeStatus' }); @@ -250,6 +252,37 @@ schemaBuilder.mutationFields((t) => { ) ); + // Auto-transition active DR to AMENDMENT_PHASE when currentOperativeIndex is set + if (args.currentOperativeIndex !== undefined && args.currentOperativeIndex !== null) { + const committee = await db.query.committee.findFirst({ + where: { id: args.id } + }); + + const activeDrId = args.activeDraftResolutionId ?? committee?.activeDraftResolutionId; + + if (activeDrId) { + const activeDr = await db.query.resolutionPaper.findFirst({ + where: { id: activeDrId } + }); + + if (activeDr && activeDr.status === 'DRAFT_RESOLUTION') { + await db + .update(schema.resolutionPaper) + .set({ status: 'AMENDMENT_PHASE' }) + .where(eq(schema.resolutionPaper.id, activeDrId)); + + // Create snapshot + await db.insert(schema.paperContentSnapshot).values({ + paperId: activeDrId, + content: activeDr.content, + trigger: 'AMENDMENT_PHASE' + }); + + paperPubsub.updated(activeDrId); + } + } + } + if (args.activeAgendaItemId) { await db.insert(schema.committeeTopicChangedTimestamp).values({ committeeId: args.id, From 748872549845dc3847cf71f55703cb7408f2bd1c Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Thu, 5 Mar 2026 20:49:04 +0100 Subject: [PATCH 13/89] =?UTF-8?q?WIP:=20resolution=20editor=20Phase=206c?= =?UTF-8?q?=20=E2=80=94=20amendment=20UI,=20lifecycle=20controls,=20and=20?= =?UTF-8?q?OIDC=20auth=20redirect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Amendment queue UI with GO-ordered display - Per-paragraph debate controls and start amendment phase button - Consensus check, accept/reject amendment actions - Fix: re-auth on expired OIDC tokens — mustBeLoggedIn() now throws GraphQLError (passes through Yoga unmasked), Houdini ClientPlugin detects "Must be logged in" errors and triggers page reload → OIDC hook redirects to login - Update meta-plan: mark Phase 6a-6c progress Co-Authored-By: Claude Opus 4.6 --- docs/plans/resolution-meta-plan.md | 8 +- messages/de.json | 39 ++ messages/en.json | 39 ++ src/api/context.ts | 3 +- src/client.ts | 17 +- .../(chairs)/resolutions/+page.svelte | 69 ++- .../resolutions/[paperId]/+page.svelte | 355 ++++++++++- .../[paperId]/chairAmendmentsSubscription.ts | 39 ++ .../[committeeId]/committeeSubscription.ts | 1 + .../papers/[paperId]/+page.svelte | 580 +++++++++++++++++- .../[paperId]/amendmentsSubscription.ts | 39 ++ vite.config.ts | 5 +- 12 files changed, 1182 insertions(+), 12 deletions(-) create mode 100644 src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/chairAmendmentsSubscription.ts create mode 100644 src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/amendmentsSubscription.ts diff --git a/docs/plans/resolution-meta-plan.md b/docs/plans/resolution-meta-plan.md index 6d5ab11f..74f244c5 100644 --- a/docs/plans/resolution-meta-plan.md +++ b/docs/plans/resolution-meta-plan.md @@ -395,11 +395,11 @@ commentPanel?: Snippet<[{ resolution: Resolution; activeClauseId?: string }]>; - ~~**Editor library**: preamble extension points (preambleAnnotations, preambleClauseToolbar)~~ - ~~Integration in chair + participant DR views with real-time subscriptions~~ -### Phase 6: Amendment System +### Phase 6: Amendment System (in progress) -**6a: Editor library** — amendment overlay props, `rejectedClauseIds`, between-clauses snippet, rendering -**6b: Backend** — amendment + sponsor handlers, threshold enforcement, consensus check flow, paragraph index locking, amendment application to JSON + snapshot -**6c: Chair UI** — per-paragraph debate controls, amendment queue (GO-ordered), consensus check button, accept/reject actions +**6a: Editor library** ✅ — amendment overlay props, `rejectedClauseIds`, between-clauses snippet, rendering +**6b: Backend** ✅ — amendment + sponsor handlers, threshold enforcement, consensus check flow, paragraph index locking, amendment application to JSON + snapshot +**6c: Chair UI** ✅ (WIP) — per-paragraph debate controls, amendment queue (GO-ordered), consensus check button, accept/reject actions, start amendment phase button **6d: Delegate UI** — amendment creation form (4 types), sponsor flow ### Phase 7: Voting (Paragraphs + Final) diff --git a/messages/de.json b/messages/de.json index 8a543608..b78dc4ce 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8,6 +8,7 @@ "activeDraftResolution": "In Behandlung", "addAgendaItem": "Punkt hinzufügen", "addAll": "Alle hinzufügen", + "addClause": "Klausel hinzufügen", "addComment": "Kommentar hinzufügen", "addCommittee": "Gremium hinzufügen", "addCountriesCount": "{count} Länder hinzufügen", @@ -19,16 +20,35 @@ "addSponsor": "Sponsor hinzufügen", "addUnActor": "UN-Akteur hinzufügen", "admin": "Admin", + "adoptByConsensus": "Per Konsens annehmen", "adopted": "Angenommen", "adoptionAnnouncement": "BREAKING: Verabschiedung einer Resolution zum Thema \"{agendaItem}\" im Gremium {committeeName}", + "advanceToNextParagraph": "Nächster Absatz", "agendaItem": "Tagesordnungspunkt", "agendaItemTitle": "Titel des Tagesordnungspunkts", "agendaItems": "Tagesordnungspunkte", "allRightsReservedby": "Alle Rechte vorbehalten von", "allowSelfAddToSpeakersList": "Selbst auf Redeliste setzen", "allowSelfAddToSpeakersListDescription": "Delegierten und nichtstaatlichen Akteuren erlauben, sich selbst auf Redelisten zu setzen.", + "alterPosition": "Klausel verschieben", + "alterText": "Text ändern", "amendment": "Änderungsantrag", + "amendmentAccepted": "Angenommen", + "amendmentAcceptedToast": "Änderungsantrag angenommen", + "amendmentAdopted": "Änderungsantrag angenommen", + "amendmentConsensusAdopted": "Per Konsens angenommen", + "amendmentCreated": "Änderungsantrag gestellt", + "amendmentPending": "Ausstehend", "amendmentPhase": "Änderungsantragsphase", + "amendmentPhaseActive": "Änderungsantragsphase aktiv", + "amendmentPhaseStarted": "Änderungsantragsphase gestartet", + "amendmentQueue": "Änderungsanträge", + "amendmentRejected": "Abgelehnt", + "amendmentRejectedToast": "Änderungsantrag abgelehnt", + "amendmentSubmitted": "Eingereicht", + "amendmentSubmittedToast": "Änderungsantrag eingereicht", + "amendmentWithdrawn": "Zurückgezogen", + "amendmentWithdrawnToast": "Änderungsantrag zurückgezogen", "amendments": "Änderungsanträge", "announceAdoption": "Verabschiedung verkünden", "assignedCount": "{count} zugewiesen", @@ -87,10 +107,13 @@ "conferenceMembers": "Konferenzmitglieder", "conferenceTitle": "Konferenztitel", "configuration": "Konfiguration", + "confirmAdoptByConsensus": "Diesen Änderungsantrag per Konsens annehmen? Die Änderung wird sofort angewendet.", "confirmDeleteCommittee": "Möchtest du dieses Gremium wirklich löschen? Alle zugehörigen Daten gehen verloren.", "confirmDeletePaper": "Dieses Arbeitspapier wirklich löschen? Es wird für alle Beteiligten ausgeblendet.", "confirmDeleteRepresentation": "Möchtest du diese Delegation wirklich entfernen? Zugehörige Gremienmitgliedschaften werden entfernt.", + "confirmRejectAmendment": "Diesen Änderungsantrag ablehnen?", "confirmRemoveMember": "Möchtest du dieses Mitglied wirklich entfernen?", + "confirmStartAmendmentPhase": "Änderungsantragsphase für diesen Resolutionsentwurf starten? Delegierte können dann absatzweise Änderungsanträge stellen.", "confirmSubmitPaper": "Möchtest du dieses Papier wirklich an den Vorsitz einreichen? Du kannst es danach nicht mehr bearbeiten.", "copyCode": "Kopieren", "countries": "Länder", @@ -105,11 +128,13 @@ "createResolutionPaper": "Arbeitspapier erstellen", "createShareCodeEdit": "Bearbeitungs-Code erstellen", "createShareCodeSponsor": "Unterstützer-Code erstellen", + "currentParagraph": "Aktueller Absatz", "customName": "Benutzerdefinierter Name...", "dateCannotBeInPast": "Das Datum darf nicht in der Vergangenheit liegen!", "debateControls": "Debattensteuerung", "delegate": "Delegierte*r", "delegations": "Delegationen", + "deleteClause": "Klausel streichen", "deleteCode": "Code löschen", "deleteComment": "Löschen", "deletePaper": "Papier löschen", @@ -141,6 +166,7 @@ "formalDebate": "Formale Debatte", "forward": "Weiter", "general": "Allgemein", + "goToAmendments": "Zu Änderungsanträgen", "gotoSettings": "Zu den Einstellungen", "h1": "Überschrift 1", "h2": "Überschrift 2", @@ -216,6 +242,7 @@ "minutesFromNow": "Relative Zeit: Springe X Minuten in die Zukunft", "missionControl": "Mission Control", "moderatedInformalCaucus": "Moderierte informelle Sitzung", + "myAmendments": "Meine Änderungsanträge", "myPapers": "Meine Papiere", "name": "Name", "nextSpeaker": "Nächste Rede", @@ -224,6 +251,7 @@ "noActiveDr": "Kein aktiver Resolutionsentwurf", "noAgendaItemSelected": "Kein Tagesordnungspunkt aktiv", "noAgendaItemSelectedDescription": "Um mit Redelisten arbeiten zu können muss zunächst ein Tagesordnungspunkt ausgewählt werden", + "noAmendments": "Noch keine Änderungsanträge", "noAssignmentNeeded": "Keine Mitgliedszuweisung für diese Rolle nötig.", "noCommentList": "Keine Liste für Fragen und Kurzbemerkungen", "noComments": "Noch keine Kommentare", @@ -267,6 +295,8 @@ "promote": "Befördern", "promoteToDraftResolution": "Zum Resolutionsentwurf befördern", "promoteToDraftResolutionConfirm": "Dieses Papier zum Resolutionsentwurf befördern? Es wird eine Dokumentennummer zugewiesen.", + "proposeAmendment": "Änderungsantrag stellen", + "proposedBy": "Vorgeschlagen von {name}", "publicComment": "Öffentlich", "publish": "Veröffentlichen", "publishChanges": "Änderungen Veröffentlichen", @@ -300,6 +330,7 @@ "searchMembers": "Mitglieder suchen...", "searchUsers": "Benutzer suchen...", "selectAgendaItem": "Tagesordnungspunkt auswählen...", + "selectAmendmentType": "Antragstyp wählen", "selectCommitteeMember": "Gremienmitglied auswählen...", "selectConferenceMember": "Konferenzmitglied auswählen...", "selected": "Ausgewählt", @@ -326,15 +357,19 @@ "spectator": "Zuschauer*in", "sponsor": "Unterstützerstaaten", "sponsorAdded": "Sponsor hinzugefügt", + "sponsorAmendment": "Unterstützen", "sponsorCount": "{count} Unterstützerstaaten", "sponsorPaper": "Unterstützen", "sponsorRemoved": "Sponsor entfernt", + "sponsorThreshold": "{current}/{needed} Unterstützer ({percent}% benötigt)", "sponsors": "Unterstützerstaaten", + "startAmendmentPhase": "Änderungsantragsphase starten", "startVote": "Abstimmung starten", "stateOfDebate": "Debattenstand", "statusUpdated": "Status wurde gesetzt", "strikethrough": "Durchgestrichen", "submit": "Absenden", + "submitAmendment": "Änderungsantrag einreichen", "submitImg": "Bild einfügen", "submitPaper": "Papier einreichen", "submitStateOfDebate": "Debattenstatus speichern", @@ -351,9 +386,11 @@ "supportReEvaluationOpen": "Änderung der Unterstützung erlaubt", "supporterCount": "{count} Unterstützerstaaten", "suspension": "Vertagung", + "targetPosition": "Zielposition", "teamMember": "Teammitglied", "teamOnly": "Nur Team", "theme": "Theme", + "thresholdNotMet": "Unterstützerschwelle nicht erreicht", "timeOver": "Redezeit ist abgelaufen!", "timer": "Zeit", "toastAddError": "{ targetName } konnte nicht hinzugefügt werden", @@ -411,6 +448,8 @@ "whiteboardPlaceholder": "Beginne hier zu schreiben...", "whiteboardUpdated": "Whiteboard veröffentlicht", "withAbstentions": "Enthaltungen", + "withdrawAmendment": "Zurückziehen", + "withdrawSponsorship": "Unterstützung zurückziehen", "withdrawSupport": "Unterstützung zurückziehen", "withoutAbstentions": "Keine Enthaltungen", "workingPaper": "Arbeitspapier", diff --git a/messages/en.json b/messages/en.json index 11c32f2a..aacf7c76 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8,6 +8,7 @@ "activeDraftResolution": "In Progress", "addAgendaItem": "Add Item", "addAll": "Add All", + "addClause": "Add Clause", "addComment": "Add Comment", "addCommittee": "Add Committee", "addCountriesCount": "Add {count} countries", @@ -19,16 +20,35 @@ "addSponsor": "Add Sponsor", "addUnActor": "Add UN Actor", "admin": "Admin", + "adoptByConsensus": "Adopt by Consensus", "adopted": "Adopted", "adoptionAnnouncement": "BREAKING: Resolution on \"{agendaItem}\" adopted in the committee {committeeName}", + "advanceToNextParagraph": "Advance to Next Paragraph", "agendaItem": "Agenda item", "agendaItemTitle": "Agenda Item Title", "agendaItems": "Agenda Items", "allRightsReservedby": "All rights reserved by", "allowSelfAddToSpeakersList": "Self-add to Speakers List", "allowSelfAddToSpeakersListDescription": "Allow delegates and non-state actors to add themselves to speakers lists.", + "alterPosition": "Move Clause", + "alterText": "Alter Text", "amendment": "Amendment", + "amendmentAccepted": "Accepted", + "amendmentAcceptedToast": "Amendment accepted", + "amendmentAdopted": "Amendment adopted", + "amendmentConsensusAdopted": "Adopted by Consensus", + "amendmentCreated": "Amendment proposed", + "amendmentPending": "Pending", "amendmentPhase": "Amendment Phase", + "amendmentPhaseActive": "Amendment phase active", + "amendmentPhaseStarted": "Amendment phase started", + "amendmentQueue": "Amendment Queue", + "amendmentRejected": "Rejected", + "amendmentRejectedToast": "Amendment rejected", + "amendmentSubmitted": "Submitted", + "amendmentSubmittedToast": "Amendment submitted", + "amendmentWithdrawn": "Withdrawn", + "amendmentWithdrawnToast": "Amendment withdrawn", "amendments": "Amendments", "announceAdoption": "Announce Adoption", "assignedCount": "{count} assigned", @@ -87,10 +107,13 @@ "conferenceMembers": "Conference Members", "conferenceTitle": "Conference Title", "configuration": "Configuration", + "confirmAdoptByConsensus": "Adopt this amendment by consensus? This will immediately apply the change.", "confirmDeleteCommittee": "Are you sure you want to delete this committee? All associated data will be lost.", "confirmDeletePaper": "Are you sure you want to delete this working paper? It will be hidden for all participants.", "confirmDeleteRepresentation": "Are you sure you want to remove this delegation? Associated committee memberships will be removed.", + "confirmRejectAmendment": "Reject this amendment?", "confirmRemoveMember": "Are you sure you want to remove this member?", + "confirmStartAmendmentPhase": "Start the amendment phase for this draft resolution? Delegates will be able to propose amendments paragraph by paragraph.", "confirmSubmitPaper": "Are you sure you want to submit this paper to the chair? You will no longer be able to edit it.", "copyCode": "Copy", "countries": "Countries", @@ -105,11 +128,13 @@ "createResolutionPaper": "Create Working Paper", "createShareCodeEdit": "Create Edit Code", "createShareCodeSponsor": "Create Sponsor Code", + "currentParagraph": "Current Paragraph", "customName": "Custom name...", "dateCannotBeInPast": "The date must not be in the past!", "debateControls": "Debate Controls", "delegate": "Delegate", "delegations": "Delegations", + "deleteClause": "Delete Clause", "deleteCode": "Delete Code", "deleteComment": "Delete", "deletePaper": "Delete Paper", @@ -141,6 +166,7 @@ "formalDebate": "Formal debate", "forward": "Next", "general": "General", + "goToAmendments": "Go to amendments", "gotoSettings": "Go to settings", "h1": "Heading 1", "h2": "Heading 2", @@ -216,6 +242,7 @@ "minutesFromNow": "Relative time: Jump X minutes into the future", "missionControl": "Mission Control", "moderatedInformalCaucus": "Moderated informal caucus", + "myAmendments": "My Amendments", "myPapers": "My Papers", "name": "Name", "nextSpeaker": "Next Speech", @@ -224,6 +251,7 @@ "noActiveDr": "No active draft resolution", "noAgendaItemSelected": "No agenda item active", "noAgendaItemSelectedDescription": "To work with speakers' lists, you must first select an agenda item.", + "noAmendments": "No amendments yet", "noAssignmentNeeded": "No member assignment needed for this role.", "noCommentList": "No Point of Information List", "noComments": "No comments yet", @@ -267,6 +295,8 @@ "promote": "Promote", "promoteToDraftResolution": "Promote to Draft Resolution", "promoteToDraftResolutionConfirm": "Promote this paper to a Draft Resolution? This will assign it a document number.", + "proposeAmendment": "Propose Amendment", + "proposedBy": "Proposed by {name}", "publicComment": "Public", "publish": "Publish", "publishChanges": "Publish changes", @@ -300,6 +330,7 @@ "searchMembers": "Search members...", "searchUsers": "Search users...", "selectAgendaItem": "Select agenda item...", + "selectAmendmentType": "Select amendment type", "selectCommitteeMember": "Select committee member...", "selectConferenceMember": "Select conference member...", "selected": "Selected", @@ -326,15 +357,19 @@ "spectator": "Spectator", "sponsor": "Sponsor", "sponsorAdded": "Sponsor added", + "sponsorAmendment": "Sponsor", "sponsorCount": "{count} sponsors", "sponsorPaper": "Sponsor", "sponsorRemoved": "Sponsor removed", + "sponsorThreshold": "{current}/{needed} sponsors ({percent}% needed)", "sponsors": "Sponsors", + "startAmendmentPhase": "Start Amendment Phase", "startVote": "Start Vote", "stateOfDebate": "State of Debate", "statusUpdated": "Status has been set", "strikethrough": "Strikethrough", "submit": "Submit", + "submitAmendment": "Submit Amendment", "submitImg": "Insert image", "submitPaper": "Submit Paper", "submitStateOfDebate": "Save debate status", @@ -351,9 +386,11 @@ "supportReEvaluationOpen": "Re-evaluation is open — delegates can now change their support", "supporterCount": "{count} supporters", "suspension": "Suspension", + "targetPosition": "Target Position", "teamMember": "Team Member", "teamOnly": "Team Only", "theme": "Theme", + "thresholdNotMet": "Sponsor threshold not met", "timeOver": "Speaking time is up!", "timer": "Timer", "toastAddError": "Could not add { targetName }", @@ -411,6 +448,8 @@ "whiteboardPlaceholder": "Start writing here...", "whiteboardUpdated": "Whiteboard published", "withAbstentions": "With Abstentions", + "withdrawAmendment": "Withdraw", + "withdrawSponsorship": "Withdraw Sponsorship", "withdrawSupport": "Withdraw Support", "withoutAbstentions": "No Abstentions", "workingPaper": "Working Paper", diff --git a/src/api/context.ts b/src/api/context.ts index 35f67148..43f8b0fc 100644 --- a/src/api/context.ts +++ b/src/api/context.ts @@ -1,5 +1,6 @@ import { configPrivate } from '$config/private'; import type { RequestEvent } from '@sveltejs/kit'; +import { GraphQLError } from 'graphql'; export const oidcRoles = ['admin', 'member', 'service_user'] as const; @@ -20,7 +21,7 @@ export async function context(req: RequestEvent) { ...req.locals, mustBeLoggedIn: () => { if (!req.locals.oidc?.user) { - throw new Error('Must be logged in'); + throw new GraphQLError('Must be logged in'); } return req.locals.oidc.user; diff --git a/src/client.ts b/src/client.ts index 8621441e..30cb5091 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,13 +1,26 @@ -import { HoudiniClient } from '$houdini'; +import { HoudiniClient, type ClientPlugin } from '$houdini'; import toast from 'svelte-french-toast'; import { error } from '@sveltejs/kit'; import { subscription } from '$houdini/plugins'; import { createClient } from 'graphql-sse'; +let redirecting = false; + +const authRedirect: ClientPlugin = () => ({ + end(ctx, { resolve, value }) { + if (!redirecting && value.errors?.some((e) => e.message === 'Must be logged in')) { + console.warn('[auth] Session expired, redirecting to login...'); + redirecting = true; + window.location.reload(); + } + resolve(ctx); + } +}); + const url = '/api/graphql'; export default new HoudiniClient({ url, - plugins: [subscription(() => createClient({ url }))], + plugins: [authRedirect, subscription(() => createClient({ url }))], throwOnError: { operations: ['mutation', 'subscription'], error: (errors, ctx) => { diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.svelte b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.svelte index 92e61a6d..ce4ba388 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.svelte +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.svelte @@ -71,30 +71,41 @@ } `); - // Update committee mutation (for activeDR + re-evaluation) + // Update committee mutation (for activeDR + re-evaluation + amendment phase) const UpdateCommitteeMutation = graphql(` mutation UpdateCommitteeResolutionsMutation( $id: ID! $activeDraftResolutionId: ID $clearActiveDraftResolution: Boolean $supportReEvaluationOpen: Boolean + $currentOperativeIndex: Int ) { updateCommittee( id: $id activeDraftResolutionId: $activeDraftResolutionId clearActiveDraftResolution: $clearActiveDraftResolution supportReEvaluationOpen: $supportReEvaluationOpen + currentOperativeIndex: $currentOperativeIndex ) { id activeDraftResolutionId supportReEvaluationOpen + currentOperativeIndex } } `); + // Amendment phase derived state + let activeDr = $derived( + draftResolutions.find((p) => p.id === committee?.activeDraftResolutionId) + ); + let canStartAmendmentPhase = $derived(activeDr && activeDr.status === 'DRAFT_RESOLUTION'); + let isInAmendmentPhase = $derived(activeDr && activeDr.status === 'AMENDMENT_PHASE'); + let showPromoteModal = $state(false); let promotePaperId = $state(null); let promotePaperTitle = $state(''); + let showStartAmendmentPhaseModal = $state(false); function openPromoteModal(paperId: string, title: string | null) { promotePaperId = paperId; @@ -146,6 +157,19 @@ } } + async function startAmendmentPhase() { + try { + await UpdateCommitteeMutation.mutate({ + id: page.params.committeeId!, + currentOperativeIndex: 0 + }); + showStartAmendmentPhaseModal = false; + toast.success(m.amendmentPhaseStarted()); + } catch { + toast.error(m.saveError()); + } + } + function getStatusBadgeClass(status: string) { switch (status) { case 'DRAFT_RESOLUTION': @@ -372,6 +396,33 @@ />
+ + {#if canStartAmendmentPhase} +
+
+
+ {activeDr!.documentNumber} — {m.draftResolution()} +
+ +
+ {:else if isInAmendmentPhase} +
+
+
+ {m.amendmentPhaseActive()} + OP {(committee.currentOperativeIndex ?? 0) + 1} +
+ + {m.goToAmendments()} → + +
+ {/if}
@@ -401,4 +452,20 @@ + + + +
+

{m.startAmendmentPhase()}

+

{m.confirmStartAmendmentPhase()}

+
+ + +
+
+
{/if} diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte index a95efc57..0bdb2bcb 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte @@ -8,10 +8,14 @@ import { ChairPaperDetailSubscription } from './chairPaperDetailSubscription'; import { ChairPaperClauseLocksSubscription } from './chairLockSubscription'; import { ChairPaperCommentsSubscription } from './chairCommentsSubscription'; + import { ChairAmendmentsSubscription } from './chairAmendmentsSubscription'; import { ResolutionEditor, migrateResolution, - type Resolution + calculateAmendmentDiffSize, + type Resolution, + type AmendmentOverlay, + type OperativeClause } from '@deutschemodelunitednations/munify-resolution-editor'; import Flag from '$lib/components/Flag.svelte'; import Fieldset from '$lib/components/Fieldset.svelte'; @@ -62,6 +66,7 @@ ChairPaperDetailSubscription.listen({ paperId: page.params.paperId! }); ChairPaperClauseLocksSubscription.listen({ paperId: page.params.paperId! }); ChairPaperCommentsSubscription.listen({ paperId: page.params.paperId! }); + ChairAmendmentsSubscription.listen({ paperId: page.params.paperId! }); // Hybrid heartbeat — only fires when idle with held locks const heartbeatInterval = setInterval(() => { @@ -430,6 +435,208 @@ // Collapsible metadata let metadataOpen = $state(false); + + // ===================================================== + // Amendments (Phase 6c) + // ===================================================== + + let allAmendments = $derived($ChairAmendmentsSubscription.data?.findManyAmendment ?? []); + + let submittedAmendments = $derived(allAmendments.filter((a) => a.status === 'SUBMITTED')); + + let currentOpIndex = $derived(committee?.currentOperativeIndex ?? 0); + + let operativeClauses = $derived((resolution?.operative ?? []) as OperativeClause[]); + + // GO-ordered: current paragraph first → DELETE > ALTER_TEXT (diff size desc) > ADD > ALTER_POSITION → then by createdAt + let sortedSubmittedAmendments = $derived.by(() => { + const typeOrder: Record = { + DELETE: 0, + ALTER_TEXT: 1, + ADD: 2, + ALTER_POSITION: 3 + }; + return [...submittedAmendments].sort((a, b) => { + // Current paragraph first + const aIsCurrent = (a.targetOperativeIndex ?? -1) === currentOpIndex; + const bIsCurrent = (b.targetOperativeIndex ?? -1) === currentOpIndex; + if (aIsCurrent && !bIsCurrent) return -1; + if (!aIsCurrent && bIsCurrent) return 1; + + // Then by type + const aType = typeOrder[a.type] ?? 99; + const bType = typeOrder[b.type] ?? 99; + if (aType !== bType) return aType - bType; + + // For ALTER_TEXT, sort by diff size descending + if (a.type === 'ALTER_TEXT' && b.type === 'ALTER_TEXT') { + const aClause = operativeClauses[a.targetOperativeIndex ?? 0]; + const bClause = operativeClauses[b.targetOperativeIndex ?? 0]; + if (aClause && bClause && a.newContent && b.newContent) { + const aDiff = calculateAmendmentDiffSize(aClause, a.newContent as OperativeClause); + const bDiff = calculateAmendmentDiffSize(bClause, b.newContent as OperativeClause); + if (aDiff !== bDiff) return bDiff - aDiff; + } + } + + // Then by createdAt + return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + }); + }); + + // Transform server amendments → AmendmentOverlay[] for editor rendering + let amendmentOverlays = $derived.by(() => { + const visible = allAmendments.filter( + (a) => a.status === 'SUBMITTED' || a.status === 'CONSENSUS_ADOPTED' || a.status === 'ACCEPTED' + ); + return visible.map( + (a) => + ({ + id: a.id, + type: a.type, + status: a.status, + targetClauseId: a.targetClauseId ?? undefined, + targetOperativeIndex: a.targetOperativeIndex ?? undefined, + targetPosition: a.targetPosition ?? undefined, + newContent: a.newContent as OperativeClause | undefined, + proposerName: + a.proposer?.representation?.name ?? + getTranslatedCountryNameFromAlpha3Code(a.proposer?.representation?.alpha3Code), + sponsorCount: a.sponsors?.length ?? 0, + isOwnAmendment: false + }) satisfies AmendmentOverlay + ); + }); + + // Amendment mutations + const AdoptByConsensusMutation = graphql(` + mutation ChairAdoptByConsensusMutation($amendmentId: ID!) { + adoptByConsensus(amendmentId: $amendmentId) { + id + status + } + } + `); + + const AcceptAmendmentMutation = graphql(` + mutation ChairAcceptAmendmentMutation($amendmentId: ID!) { + acceptAmendment(amendmentId: $amendmentId) { + id + status + } + } + `); + + const RejectAmendmentMutation = graphql(` + mutation ChairRejectAmendmentMutation($amendmentId: ID!) { + rejectAmendment(amendmentId: $amendmentId) { + id + status + } + } + `); + + const WithdrawAmendmentMutation = graphql(` + mutation ChairWithdrawAmendmentMutation($amendmentId: ID!) { + withdrawAmendment(amendmentId: $amendmentId) { + id + status + } + } + `); + + const UpdateCommitteeMutation = graphql(` + mutation ChairAdvanceParagraphMutation($id: ID!, $currentOperativeIndex: Int) { + updateCommittee(id: $id, currentOperativeIndex: $currentOperativeIndex) { + id + currentOperativeIndex + } + } + `); + + let showAdoptConfirmModal = $state(false); + let showRejectConfirmModal = $state(false); + let confirmAmendmentId = $state(null); + + async function handleAdoptByConsensus(amendmentId: string) { + try { + await AdoptByConsensusMutation.mutate({ amendmentId }); + toast.success(m.amendmentAdopted()); + showAdoptConfirmModal = false; + confirmAmendmentId = null; + } catch { + toast.error(m.saveError()); + } + } + + async function handleRejectAmendment(amendmentId: string) { + try { + await RejectAmendmentMutation.mutate({ amendmentId }); + toast.success(m.amendmentRejectedToast()); + showRejectConfirmModal = false; + confirmAmendmentId = null; + } catch { + toast.error(m.saveError()); + } + } + + async function handleWithdrawAmendment(amendmentId: string) { + try { + await WithdrawAmendmentMutation.mutate({ amendmentId }); + toast.success(m.amendmentWithdrawnToast()); + } catch { + toast.error(m.saveError()); + } + } + + async function handleAdvanceParagraph() { + if (!committee) return; + try { + await UpdateCommitteeMutation.mutate({ + id: committee.id, + currentOperativeIndex: currentOpIndex + 1 + }); + } catch { + toast.error(m.saveError()); + } + } + + function handleAmendmentClick(amendmentId: string) { + const el = document.getElementById(`amendment-${amendmentId}`); + el?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + el?.classList.add('ring-2', 'ring-primary'); + setTimeout(() => el?.classList.remove('ring-2', 'ring-primary'), 2000); + } + + function getAmendmentTypeBadgeClass(type: string) { + switch (type) { + case 'DELETE': + return 'badge-error'; + case 'ADD': + return 'badge-success'; + case 'ALTER_TEXT': + return 'badge-warning'; + case 'ALTER_POSITION': + return 'badge-info'; + default: + return 'badge-ghost'; + } + } + + function getAmendmentTypeLabel(type: string) { + switch (type) { + case 'DELETE': + return m.deleteClause(); + case 'ADD': + return m.addClause(); + case 'ALTER_TEXT': + return m.alterText(); + case 'ALTER_POSITION': + return m.alterPosition(); + default: + return type; + } + } @@ -565,13 +772,15 @@ {#snippet preambleAnnotations({ clause })} {@const lock = locksByClauseId.get(clause.id)} @@ -677,6 +886,98 @@ {/if} + + {#if paper.status === 'AMENDMENT_PHASE'} +
+
+ + OP {currentOpIndex + 1} / {operativeClauses.length} + + +
+
+ +
+ {#if sortedSubmittedAmendments.length === 0} +

{m.noAmendments()}

+ {:else} +
+ {#each sortedSubmittedAmendments as amendment (amendment.id)} + {@const isCurrentParagraph = + (amendment.targetOperativeIndex ?? -1) === currentOpIndex} +
+
+
+ + {getAmendmentTypeLabel(amendment.type)} + + {#if amendment.targetOperativeIndex != null} + + OP {amendment.targetOperativeIndex + 1} + + {/if} + {#if amendment.proposer?.representation} +
+ + + {amendment.proposer.representation.name ?? + getTranslatedCountryNameFromAlpha3Code( + amendment.proposer.representation.alpha3Code + )} + +
+ {/if} + + {amendment.sponsors?.length ?? 0} + {m.sponsors()} + +
+
+ + + +
+
+
+ {/each} +
+ {/if} +
+ {/if} +
+ + + +
+

{m.adoptByConsensus()}

+

{m.confirmAdoptByConsensus()}

+
+ + +
+
+
+ + + +
+

{m.amendmentRejected()}

+

{m.confirmRejectAmendment()}

+
+ + +
+
+
{/if} diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/chairAmendmentsSubscription.ts b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/chairAmendmentsSubscription.ts new file mode 100644 index 00000000..88d17eaf --- /dev/null +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/chairAmendmentsSubscription.ts @@ -0,0 +1,39 @@ +import { graphql } from '$houdini'; + +export const ChairAmendmentsSubscription = graphql(` + subscription ChairAmendmentsSubscription($paperId: ID!) { + findManyAmendment(where: { paperId: $paperId }) { + id + type + status + targetClauseId + targetOperativeIndex + targetPosition + newContent + proposerCommitteeMemberId + createdAt + proposer { + id + representation { + name + alpha2Code + alpha3Code + faIcon + } + } + sponsors { + id + committeeMemberId + committeeMember { + id + representation { + name + alpha2Code + alpha3Code + faIcon + } + } + } + } + } +`); diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/committeeSubscription.ts b/src/routes/app/[conferenceId]/participant/[committeeId]/committeeSubscription.ts index 0519178c..81138d8b 100644 --- a/src/routes/app/[conferenceId]/participant/[committeeId]/committeeSubscription.ts +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/committeeSubscription.ts @@ -18,6 +18,7 @@ export const ParticipantCommitteeSubscription = graphql(` simpleMajority twoThirdsMajority paperSupportThreshold + currentOperativeIndex activeAgendaItem { id title diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte index 3eee95cf..cd0917b6 100644 --- a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte @@ -9,10 +9,14 @@ import { PaperClauseLocksSubscription } from './lockSubscription'; import { ParticipantCommitteeSubscription } from '../../committeeSubscription'; import { ParticipantPaperCommentsSubscription } from './commentsSubscription'; + import { ParticipantAmendmentsSubscription } from './amendmentsSubscription'; import { ResolutionEditor, migrateResolution, - type Resolution + createEmptyOperativeClause, + type Resolution, + type AmendmentOverlay, + type OperativeClause } from '@deutschemodelunitednations/munify-resolution-editor'; import Modal from '$lib/components/Modal.svelte'; import Fieldset from '$lib/components/Fieldset.svelte'; @@ -158,6 +162,13 @@ } }); + // Start amendments subscription when paper enters amendment phase + $effect(() => { + if (paper?.status === 'AMENDMENT_PHASE') { + ParticipantAmendmentsSubscription.listen({ paperId: page.params.paperId! }); + } + }); + // ===================================================== // Clause-level locking // ===================================================== @@ -541,6 +552,298 @@ // Collapsible metadata let metadataOpen = $state(true); + + // ===================================================== + // Amendments (Phase 6d) + // ===================================================== + + let allAmendments = $derived($ParticipantAmendmentsSubscription.data?.findManyAmendment ?? []); + + // Use subscription data directly for fields not in layout query + let committeeSubscriptionData = $derived( + $ParticipantCommitteeSubscription.data?.findFirstCommittee + ); + let currentOpIndex = $derived(committeeSubscriptionData?.currentOperativeIndex ?? 0); + + let isActiveDr = $derived(paper?.id === committee?.activeDraftResolutionId); + + let showAmendmentUI = $derived(paper?.status === 'AMENDMENT_PHASE' && isActiveDr); + + let operativeClauses = $derived((resolution?.operative ?? []) as OperativeClause[]); + + let myAmendments = $derived( + allAmendments.filter( + (a) => + a.proposerCommitteeMemberId === myCommitteeMemberId && + (a.status === 'PENDING' || a.status === 'SUBMITTED') + ) + ); + + let mySponsoredAmendments = $derived( + allAmendments.filter( + (a) => + a.proposerCommitteeMemberId !== myCommitteeMemberId && + (a.status === 'PENDING' || a.status === 'SUBMITTED') && + a.sponsors?.some((s) => s.committeeMemberId === myCommitteeMemberId) + ) + ); + + let sponsorThresholdNeeded = $derived( + Math.ceil((committeeSubscriptionData?.totalPresent ?? 0) * 0.1) + ); + + // Overlays for editor: SUBMITTED/CONSENSUS_ADOPTED/ACCEPTED always visible; + // PENDING only if user is proposer or sponsor + let amendmentOverlays = $derived.by(() => { + const visible = allAmendments.filter((a) => { + if (a.status === 'SUBMITTED' || a.status === 'CONSENSUS_ADOPTED' || a.status === 'ACCEPTED') + return true; + if (a.status === 'PENDING') { + if (a.proposerCommitteeMemberId === myCommitteeMemberId) return true; + if (a.sponsors?.some((s) => s.committeeMemberId === myCommitteeMemberId)) return true; + } + return false; + }); + return visible.map( + (a) => + ({ + id: a.id, + type: a.type, + status: a.status, + targetClauseId: a.targetClauseId ?? undefined, + targetOperativeIndex: a.targetOperativeIndex ?? undefined, + targetPosition: a.targetPosition ?? undefined, + newContent: a.newContent as OperativeClause | undefined, + proposerName: + a.proposer?.representation?.name ?? + getTranslatedCountryNameFromAlpha3Code(a.proposer?.representation?.alpha3Code), + sponsorCount: a.sponsors?.length ?? 0, + isOwnAmendment: a.proposerCommitteeMemberId === myCommitteeMemberId + }) satisfies AmendmentOverlay + ); + }); + + // Amendment mutations + const CreateAmendmentMutation = graphql(` + mutation ParticipantCreateAmendmentMutation( + $paperId: ID! + $type: AmendmentTypeEnum! + $targetClauseId: String + $targetOperativeIndex: Int + $targetPosition: Int + $newContent: JSON + ) { + createAmendment( + paperId: $paperId + type: $type + targetClauseId: $targetClauseId + targetOperativeIndex: $targetOperativeIndex + targetPosition: $targetPosition + newContent: $newContent + ) { + id + } + } + `); + + const SubmitAmendmentMutation = graphql(` + mutation ParticipantSubmitAmendmentMutation($amendmentId: ID!) { + submitAmendment(amendmentId: $amendmentId) { + id + status + } + } + `); + + const ParticipantWithdrawAmendmentMutation = graphql(` + mutation ParticipantWithdrawAmendmentMutation($amendmentId: ID!) { + withdrawAmendment(amendmentId: $amendmentId) { + id + status + } + } + `); + + const AddAmendmentSponsorMutation = graphql(` + mutation ParticipantAddAmendmentSponsorMutation($amendmentId: ID!, $committeeMemberId: ID!) { + addAmendmentSponsor(amendmentId: $amendmentId, committeeMemberId: $committeeMemberId) { + id + } + } + `); + + const RemoveAmendmentSponsorMutation = graphql(` + mutation ParticipantRemoveAmendmentSponsorMutation($amendmentId: ID!, $committeeMemberId: ID!) { + removeAmendmentSponsor(amendmentId: $amendmentId, committeeMemberId: $committeeMemberId) + } + `); + + // Amendment creation modal state + let showCreateAmendmentModal = $state(false); + let amendmentType = $state<'DELETE' | 'ADD' | 'ALTER_TEXT' | 'ALTER_POSITION'>('DELETE'); + let amendmentTargetIndex = $state(0); + let amendmentTargetClauseId = $state(undefined); + let amendmentTargetPosition = $state(undefined); + let amendmentNewContent = $state(null); + + function openCreateAmendment( + index: number, + type: 'DELETE' | 'ADD' | 'ALTER_TEXT' | 'ALTER_POSITION' + ) { + amendmentType = type; + amendmentTargetIndex = index; + const clause = operativeClauses[index]; + amendmentTargetClauseId = clause?.id; + + if (type === 'ALTER_TEXT' && clause) { + // Deep clone the existing clause for editing + amendmentNewContent = JSON.parse(JSON.stringify(clause)); + } else if (type === 'ADD') { + amendmentNewContent = createEmptyOperativeClause(); + amendmentTargetPosition = index + 1; + } else if (type === 'ALTER_POSITION') { + amendmentTargetPosition = index; + } else { + amendmentNewContent = null; + } + showCreateAmendmentModal = true; + } + + async function handleCreateAmendment() { + if (!paper) return; + try { + await CreateAmendmentMutation.mutate({ + paperId: paper.id, + type: amendmentType, + targetClauseId: amendmentType === 'ADD' ? null : (amendmentTargetClauseId ?? null), + targetOperativeIndex: amendmentType === 'ADD' ? null : amendmentTargetIndex, + targetPosition: + amendmentType === 'ADD' || amendmentType === 'ALTER_POSITION' + ? (amendmentTargetPosition ?? null) + : null, + newContent: + amendmentType === 'ALTER_TEXT' || amendmentType === 'ADD' ? amendmentNewContent : null + }); + showCreateAmendmentModal = false; + toast.success(m.amendmentCreated()); + } catch { + toast.error(m.saveError()); + } + } + + async function handleSubmitAmendment(amendmentId: string) { + try { + await SubmitAmendmentMutation.mutate({ amendmentId }); + toast.success(m.amendmentSubmittedToast()); + } catch { + toast.error(m.thresholdNotMet()); + } + } + + async function handleParticipantWithdrawAmendment(amendmentId: string) { + try { + await ParticipantWithdrawAmendmentMutation.mutate({ amendmentId }); + toast.success(m.amendmentWithdrawnToast()); + } catch { + toast.error(m.saveError()); + } + } + + async function handleSponsorAmendment(amendmentId: string) { + if (!myCommitteeMemberId) return; + try { + await AddAmendmentSponsorMutation.mutate({ + amendmentId, + committeeMemberId: myCommitteeMemberId + }); + } catch { + toast.error(m.saveError()); + } + } + + async function handleWithdrawSponsorship(amendmentId: string) { + if (!myCommitteeMemberId) return; + try { + await RemoveAmendmentSponsorMutation.mutate({ + amendmentId, + committeeMemberId: myCommitteeMemberId + }); + } catch { + toast.error(m.saveError()); + } + } + + function handleAmendmentClick(amendmentId: string) { + const el = document.getElementById(`amendment-${amendmentId}`); + el?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + el?.classList.add('ring-2', 'ring-primary'); + setTimeout(() => el?.classList.remove('ring-2', 'ring-primary'), 2000); + } + + function getAmendmentTypeBadgeClass(type: string) { + switch (type) { + case 'DELETE': + return 'badge-error'; + case 'ADD': + return 'badge-success'; + case 'ALTER_TEXT': + return 'badge-warning'; + case 'ALTER_POSITION': + return 'badge-info'; + default: + return 'badge-ghost'; + } + } + + function getAmendmentTypeLabel(type: string) { + switch (type) { + case 'DELETE': + return m.deleteClause(); + case 'ADD': + return m.addClause(); + case 'ALTER_TEXT': + return m.alterText(); + case 'ALTER_POSITION': + return m.alterPosition(); + default: + return type; + } + } + + function getAmendmentStatusLabel(status: string) { + switch (status) { + case 'PENDING': + return m.amendmentPending(); + case 'SUBMITTED': + return m.amendmentSubmitted(); + case 'CONSENSUS_ADOPTED': + return m.amendmentConsensusAdopted(); + case 'ACCEPTED': + return m.amendmentAccepted(); + case 'REJECTED': + return m.amendmentRejected(); + case 'WITHDRAWN': + return m.amendmentWithdrawn(); + default: + return status; + } + } + + // Mini editor resolution for amendment creation modal + let miniResolution = $derived.by(() => { + if (!amendmentNewContent) return null; + return { + committeeName: '', + preamble: [], + operative: [amendmentNewContent] + } as Resolution; + }); + + function handleMiniResolutionChange(updated: Resolution) { + if (updated.operative?.[0]) { + amendmentNewContent = updated.operative[0] as OperativeClause; + } + } @@ -742,7 +1045,51 @@ onClauseInteraction={collab ? handleClauseInteraction : undefined} lockedClauseIds={collab ? lockedClauseIds : undefined} editableClauseIds={collab ? editableClauseIds : undefined} + amendments={showAmendmentUI ? amendmentOverlays : undefined} + onAmendmentClick={showAmendmentUI ? handleAmendmentClick : undefined} > + {#snippet betweenOperativeClauses({ index })} + {#if showAmendmentUI && isDelegate} +
+ +
+ {/if} + {/snippet} {#snippet preambleAnnotations({ clause })} {@const lock = locksByClauseId.get(clause.id)} {#if lock} @@ -871,6 +1218,163 @@ {/if} + + {#if showAmendmentUI} + +
+ + {m.currentParagraph()}: OP {currentOpIndex + 1} +
+ + + {#if myAmendments.length > 0 || mySponsoredAmendments.length > 0} +
+
+ {#each myAmendments as amendment (amendment.id)} + {@const sponsorCount = amendment.sponsors?.length ?? 0} + {@const thresholdMet = sponsorCount >= sponsorThresholdNeeded} +
+
+
+ + {getAmendmentTypeLabel(amendment.type)} + + + {getAmendmentStatusLabel(amendment.status)} + + {#if amendment.targetOperativeIndex != null} + + OP {amendment.targetOperativeIndex + 1} + + {/if} +
+ + +
+ + + {m.sponsorThreshold({ + current: String(sponsorCount), + needed: String(sponsorThresholdNeeded), + percent: '10' + })} + +
+ + +
+ {#if amendment.status === 'PENDING'} + + {/if} + +
+
+
+ {/each} + + {#each mySponsoredAmendments as amendment (amendment.id)} +
+
+
+ + {getAmendmentTypeLabel(amendment.type)} + + {#if amendment.targetOperativeIndex != null} + + OP {amendment.targetOperativeIndex + 1} + + {/if} + {#if amendment.proposer?.representation} +
+ + + {m.proposedBy({ + name: + amendment.proposer.representation.name ?? + getTranslatedCountryNameFromAlpha3Code( + amendment.proposer.representation.alpha3Code + ) ?? + '' + })} + +
+ {/if} +
+ +
+
+ {/each} +
+
+ {/if} + + + {@const otherPendingAmendments = allAmendments.filter( + (a) => + a.status === 'PENDING' && + a.proposerCommitteeMemberId !== myCommitteeMemberId && + !a.sponsors?.some((s) => s.committeeMemberId === myCommitteeMemberId) + )} + {#if otherPendingAmendments.length > 0 && isDelegate} +
+
+ {#each otherPendingAmendments as amendment (amendment.id)} +
+
+
+ + {getAmendmentTypeLabel(amendment.type)} + + {#if amendment.targetOperativeIndex != null} + + OP {amendment.targetOperativeIndex + 1} + + {/if} + {#if amendment.proposer?.representation} +
+ +
+ {/if} + + {amendment.sponsors?.length ?? 0}/{sponsorThresholdNeeded} + +
+ +
+
+ {/each} +
+
+ {/if} + {/if} + {#if showComments && (commentsByClauseId.get(null)?.length ?? 0) > 0}
@@ -931,4 +1435,78 @@ + + + +
+
+

{m.proposeAmendment()}

+ + {getAmendmentTypeLabel(amendmentType)} + +
+ + {#if amendmentType === 'DELETE'} +

+ {m.deleteClause()} — OP {amendmentTargetIndex + 1} +

+ {:else if amendmentType === 'ALTER_TEXT'} +

+ {m.alterText()} — OP {amendmentTargetIndex + 1} +

+ {#if miniResolution} +
+ +
+ {/if} + {:else if amendmentType === 'ADD'} +

+ {m.addClause()} — {m.targetPosition()}: + OP {(amendmentTargetPosition ?? 0) + 1} +

+ {#if miniResolution} +
+ +
+ {/if} + {:else if amendmentType === 'ALTER_POSITION'} +

+ {m.alterPosition()} — OP {amendmentTargetIndex + 1} +

+ + + {/if} + +
+ + +
+
+
{/if} diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/amendmentsSubscription.ts b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/amendmentsSubscription.ts new file mode 100644 index 00000000..0cc24d07 --- /dev/null +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/amendmentsSubscription.ts @@ -0,0 +1,39 @@ +import { graphql } from '$houdini'; + +export const ParticipantAmendmentsSubscription = graphql(` + subscription ParticipantAmendmentsSubscription($paperId: ID!) { + findManyAmendment(where: { paperId: $paperId }) { + id + type + status + targetClauseId + targetOperativeIndex + targetPosition + newContent + proposerCommitteeMemberId + createdAt + proposer { + id + representation { + name + alpha2Code + alpha3Code + faIcon + } + } + sponsors { + id + committeeMemberId + committeeMember { + id + representation { + name + alpha2Code + alpha3Code + faIcon + } + } + } + } + } +`); diff --git a/vite.config.ts b/vite.config.ts index 331e7923..9754b4d4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -58,5 +58,8 @@ export default defineConfig({ }), houdini(), sveltekit() - ] + ], + server: { + allowedHosts: ['happy-star-9669b56f.tunnl.gg'] + } }); From f6950395d1845919d3145060b35a139675f9ae4b Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Fri, 6 Mar 2026 20:11:12 +0100 Subject: [PATCH 14/89] =?UTF-8?q?feat:=20resolution=20editor=20Phase=207?= =?UTF-8?q?=20=E2=80=94=20voting=20system=20(paragraphs=20+=20final)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add per-paragraph operative clause voting and final roll-call vote to the resolution paper lifecycle. Introduces VOTING_PHASE status between AMENDMENT_PHASE and FINAL, with quick-input voting for chairs, auto-confetti on adoption, and real-time vote display for participants. Co-Authored-By: Claude Opus 4.6 --- messages/de.json | 34 ++ messages/en.json | 34 ++ schema.graphql | 4 + src/api/db/schema.ts | 27 +- src/api/handlers/committee.ts | 6 +- src/api/handlers/operativeClauseVote.ts | 106 +++- src/api/handlers/resolutionPaper.ts | 82 ++- .../(chairs)/resolutions/+page.svelte | 60 +- .../resolutions/[paperId]/+page.svelte | 569 +++++++++++++++++- .../[paperId]/chairClauseVotesSubscription.ts | 14 + .../[paperId]/chairVoteResultSubscription.ts | 13 + .../papers/[paperId]/+page.svelte | 153 ++++- 12 files changed, 1062 insertions(+), 40 deletions(-) create mode 100644 src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/chairClauseVotesSubscription.ts create mode 100644 src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/chairVoteResultSubscription.ts diff --git a/messages/de.json b/messages/de.json index b78dc4ce..b4ba534c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -21,6 +21,8 @@ "addUnActor": "UN-Akteur hinzufügen", "admin": "Admin", "adoptByConsensus": "Per Konsens annehmen", + "adoptClause": "Annehmen", + "adoptResolution": "Resolution annehmen", "adopted": "Angenommen", "adoptionAnnouncement": "BREAKING: Verabschiedung einer Resolution zum Thema \"{agendaItem}\" im Gremium {committeeName}", "advanceToNextParagraph": "Nächster Absatz", @@ -68,6 +70,10 @@ "changesSaved": "Gespeichert", "clauseComments": "auf Klauseln", "clauseLockedBy": "Wird bearbeitet von {country}", + "clauseVoteDeleted": "Absatzstimmung entfernt", + "clauseVoteRecorded": "Absatzstimmung erfasst", + "clauseVoteSummary": "Absatzstimmungen-Übersicht", + "clausesVoted": "{voted}/{total} Absätze abgestimmt", "clearActiveDr": "Aktiv aufheben", "clearFormatting": "Formatierung löschen", "clearList": "Liste zurücksetzen", @@ -108,12 +114,17 @@ "conferenceTitle": "Konferenztitel", "configuration": "Konfiguration", "confirmAdoptByConsensus": "Diesen Änderungsantrag per Konsens annehmen? Die Änderung wird sofort angewendet.", + "confirmAdoptResolution": "Diese Resolution annehmen? Konfetti feiert die Annahme!", "confirmDeleteCommittee": "Möchtest du dieses Gremium wirklich löschen? Alle zugehörigen Daten gehen verloren.", "confirmDeletePaper": "Dieses Arbeitspapier wirklich löschen? Es wird für alle Beteiligten ausgeblendet.", "confirmDeleteRepresentation": "Möchtest du diese Delegation wirklich entfernen? Zugehörige Gremienmitgliedschaften werden entfernt.", + "confirmFinalVote": "Diese Schlussabstimmung erfassen? Der Resolutionsstatus wird auf Final gesetzt.", "confirmRejectAmendment": "Diesen Änderungsantrag ablehnen?", + "confirmRejectResolution": "Diese Resolution ablehnen?", "confirmRemoveMember": "Möchtest du dieses Mitglied wirklich entfernen?", + "confirmSendBack": "Diese Resolution zurückverweisen?", "confirmStartAmendmentPhase": "Änderungsantragsphase für diesen Resolutionsentwurf starten? Delegierte können dann absatzweise Änderungsanträge stellen.", + "confirmStartVotingPhase": "Die Abstimmungsphase für diese Beschlussvorlage starten? Jeder operative Absatz wird einzeln abgestimmt.", "confirmSubmitPaper": "Möchtest du dieses Papier wirklich an den Vorsitz einreichen? Du kannst es danach nicht mehr bearbeiten.", "copyCode": "Kopieren", "countries": "Länder", @@ -163,10 +174,14 @@ "errorUpdatingWhiteboard": "Veröffentlichung fehlgeschlagen", "fileParseError": "Fehler beim Parsen der Datei", "finalResolution": "Endgültige Resolution", + "finalVote": "Schlussabstimmung", + "finalVoteDescription": "Die Schlussabstimmung über die gesamte Resolution erfassen.", + "finishAmendmentPhaseFirst": "Bitte zuerst die Änderungsphase abschließen", "formalDebate": "Formale Debatte", "forward": "Weiter", "general": "Allgemein", "goToAmendments": "Zu Änderungsanträgen", + "goToVoting": "Zur Abstimmung", "gotoSettings": "Zu den Einstellungen", "h1": "Überschrift 1", "h2": "Überschrift 2", @@ -245,10 +260,12 @@ "myAmendments": "Meine Änderungsanträge", "myPapers": "Meine Papiere", "name": "Name", + "nextParagraph": "Weiter", "nextSpeaker": "Nächste Rede", "nextSpeakerDescription": "Möchtest du wirklich die nächste Rede aufrufen? Eventuelle Fragen- und Kurzbemerkungen werden verfallen.", "noActiveAgendaItem": "Kein aktiver Tagesordnungspunkt. Es kann derzeit kein Papier erstellt werden.", "noActiveDr": "Kein aktiver Resolutionsentwurf", + "noActiveDrForVoting": "Keine aktive Beschlussvorlage für Abstimmung", "noAgendaItemSelected": "Kein Tagesordnungspunkt aktiv", "noAgendaItemSelectedDescription": "Um mit Redelisten arbeiten zu können muss zunächst ein Tagesordnungspunkt ausgewählt werden", "noAmendments": "Noch keine Änderungsanträge", @@ -283,6 +300,7 @@ "paperSupportThresholdTooltip": "Benötigte Unterstützerstaaten für das Einreichen eines Änderungsantrags", "paperTitle": "Papiertitel", "papers": "Papiere", + "paragraphVoting": "Absatzweise Abstimmung", "parsedCountries": "Hinzuzufügende Länder:", "participantView": "Teilnehmeransicht", "pause": "Pause", @@ -291,6 +309,7 @@ "present": "Anwesend", "presentationMode": "Präsentationsansicht", "pressWebsite": "Presse-Website", + "previousParagraph": "Zurück", "pro": "Dafür", "promote": "Befördern", "promoteToDraftResolution": "Zum Resolutionsentwurf befördern", @@ -300,6 +319,7 @@ "publicComment": "Öffentlich", "publish": "Veröffentlichen", "publishChanges": "Änderungen Veröffentlichen", + "recordVoteFromVoting": "Stimmung erfassen", "redeemShareCode": "Freigabecode einlösen", "redo": "Wiederholen", "regionalGroup_africa": "Afrika", @@ -308,13 +328,18 @@ "regionalGroup_latinAmericaCaribbean": "Lateinamerika und Karibik", "regionalGroup_westernEuropeOthers": "Westeuropa und Andere", "regionalGroups": "Regionalgruppen", + "rejectClause": "Ablehnen", + "rejectResolution": "Resolution ablehnen", "rejected": "Abgelehnt", "removeFromList": "Von der Liste entfernen", "removeMember": "Entfernen", "removeSponsor": "Unterstützung zurückziehen", "replyToComment": "Antworten", + "resolutionAdopted": "Resolution angenommen!", "resolutionPaper": "Resolutionspapier", "resolutionPapers": "Resolutionspapiere", + "resolutionRejected": "Resolution abgelehnt", + "resolutionSentBack": "Resolution zurückverwiesen", "resolutions": "Resolutionen", "role": "Rolle", "rollCall": "Anwesenheitsfeststellung", @@ -334,6 +359,7 @@ "selectCommitteeMember": "Gremienmitglied auswählen...", "selectConferenceMember": "Konferenzmitglied auswählen...", "selected": "Ausgewählt", + "sendBack": "Zurückverweisen", "sentBack": "Zurückverwiesen", "seoDescription": "MUNify CHASE ist das kostenlose Open-Source-Debattenmanagement-Tool für Model United Nations Konferenzen. Redelisten, Abstimmungen und Resolutionen digital verwalten.", "seoTitle": "MUNify CHASE – Debattenmanagement für Model United Nations", @@ -365,6 +391,8 @@ "sponsors": "Unterstützerstaaten", "startAmendmentPhase": "Änderungsantragsphase starten", "startVote": "Abstimmung starten", + "startVotingPhase": "Abstimmungsphase starten", + "startVotingPhaseDescription": "Zur Abstimmungsphase wechseln, in der jeder operative Absatz einzeln abgestimmt wird.", "stateOfDebate": "Debattenstand", "statusUpdated": "Status wurde gesetzt", "strikethrough": "Durchgestrichen", @@ -418,6 +446,7 @@ "unassigned": "Nicht zugewiesen", "underline": "Unterstrichen", "undo": "Rückgängig", + "undoVote": "Stimmung rückgängig", "unknown": "unbekannt", "unrecognizedCodes": "Nicht erkannte Codes:", "until": "bis {time} Uhr", @@ -428,10 +457,12 @@ "updatingWhiteboard": "Whiteboard veröffentlichen...", "upload": "Hochladen", "url": "URL", + "useFullVoting": "Volles Abstimmungssystem nutzen", "userAlreadyExists": "Benutzer existiert bereits in dieser Konferenz: {email}", "users": "Benutzer", "version": "Version", "viewPaper": "Papier ansehen", + "voteOnParagraph": "Abstimmung über OP {index}", "voteOutcome": "Abstimmungsergebnis", "voteResult": "Abstimmungsergebnis", "voteTitel": "Name der Abstimmung", @@ -441,6 +472,9 @@ "votesFor": "Dafürstimmen", "voting": "Abstimmung", "votingControlsPlaceholder": "Abstimmungssteuerung wird in einem zukünftigen Update verfügbar sein.", + "votingPhase": "Abstimmungsphase", + "votingPhaseActive": "Abstimmungsphase aktiv", + "votingPhaseStarted": "Abstimmungsphase gestartet", "waitingForAssignment": "Warte auf Zuweisung", "waitingForAssignmentDescription": "Du wurdest noch keinem Gremium zugewiesen. Bitte warte, bis ein Admin dich zuweist.", "whiteboard": "Whiteboard", diff --git a/messages/en.json b/messages/en.json index aacf7c76..c0d33cf3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -21,6 +21,8 @@ "addUnActor": "Add UN Actor", "admin": "Admin", "adoptByConsensus": "Adopt by Consensus", + "adoptClause": "Adopt", + "adoptResolution": "Adopt Resolution", "adopted": "Adopted", "adoptionAnnouncement": "BREAKING: Resolution on \"{agendaItem}\" adopted in the committee {committeeName}", "advanceToNextParagraph": "Advance to Next Paragraph", @@ -68,6 +70,10 @@ "changesSaved": "Saved", "clauseComments": "on clauses", "clauseLockedBy": "Being edited by {country}", + "clauseVoteDeleted": "Clause vote removed", + "clauseVoteRecorded": "Clause vote recorded", + "clauseVoteSummary": "Clause Vote Summary", + "clausesVoted": "{voted}/{total} clauses voted", "clearActiveDr": "Clear Active", "clearFormatting": "Delete formatting", "clearList": "Reset List", @@ -108,12 +114,17 @@ "conferenceTitle": "Conference Title", "configuration": "Configuration", "confirmAdoptByConsensus": "Adopt this amendment by consensus? This will immediately apply the change.", + "confirmAdoptResolution": "Adopt this resolution? Confetti will celebrate the adoption!", "confirmDeleteCommittee": "Are you sure you want to delete this committee? All associated data will be lost.", "confirmDeletePaper": "Are you sure you want to delete this working paper? It will be hidden for all participants.", "confirmDeleteRepresentation": "Are you sure you want to remove this delegation? Associated committee memberships will be removed.", + "confirmFinalVote": "Record this final vote? The resolution status will change to Final.", "confirmRejectAmendment": "Reject this amendment?", + "confirmRejectResolution": "Reject this resolution?", "confirmRemoveMember": "Are you sure you want to remove this member?", + "confirmSendBack": "Send this resolution back?", "confirmStartAmendmentPhase": "Start the amendment phase for this draft resolution? Delegates will be able to propose amendments paragraph by paragraph.", + "confirmStartVotingPhase": "Start the voting phase for this draft resolution? Each operative paragraph will be voted on individually.", "confirmSubmitPaper": "Are you sure you want to submit this paper to the chair? You will no longer be able to edit it.", "copyCode": "Copy", "countries": "Countries", @@ -163,10 +174,14 @@ "errorUpdatingWhiteboard": "Publication failed", "fileParseError": "Error parsing file", "finalResolution": "Final Resolution", + "finalVote": "Final Vote", + "finalVoteDescription": "Record the final vote on the entire resolution.", + "finishAmendmentPhaseFirst": "Finish the amendment phase first", "formalDebate": "Formal debate", "forward": "Next", "general": "General", "goToAmendments": "Go to amendments", + "goToVoting": "Go to voting", "gotoSettings": "Go to settings", "h1": "Heading 1", "h2": "Heading 2", @@ -245,10 +260,12 @@ "myAmendments": "My Amendments", "myPapers": "My Papers", "name": "Name", + "nextParagraph": "Next", "nextSpeaker": "Next Speech", "nextSpeakerDescription": "Do you really want to call the next speech? All remaining Points of Information will be discarded.", "noActiveAgendaItem": "No active agenda item. A paper cannot be created right now.", "noActiveDr": "No active draft resolution", + "noActiveDrForVoting": "No active draft resolution for voting", "noAgendaItemSelected": "No agenda item active", "noAgendaItemSelectedDescription": "To work with speakers' lists, you must first select an agenda item.", "noAmendments": "No amendments yet", @@ -283,6 +300,7 @@ "paperSupportThresholdTooltip": "Supporting states required to submit an amendment", "paperTitle": "Paper Title", "papers": "Papers", + "paragraphVoting": "Paragraph Voting", "parsedCountries": "Countries to add:", "participantView": "Participant View", "pause": "Pause", @@ -291,6 +309,7 @@ "present": "Present", "presentationMode": "Presentation View", "pressWebsite": "Press Website", + "previousParagraph": "Previous", "pro": "In Favor", "promote": "Promote", "promoteToDraftResolution": "Promote to Draft Resolution", @@ -300,6 +319,7 @@ "publicComment": "Public", "publish": "Publish", "publishChanges": "Publish changes", + "recordVoteFromVoting": "Record Vote", "redeemShareCode": "Redeem Share Code", "redo": "Repeat", "regionalGroup_africa": "Africa", @@ -308,13 +328,18 @@ "regionalGroup_latinAmericaCaribbean": "Latin America and the Caribbean", "regionalGroup_westernEuropeOthers": "Western Europe and Others", "regionalGroups": "Regional Groups", + "rejectClause": "Reject", + "rejectResolution": "Reject Resolution", "rejected": "Rejected", "removeFromList": "Remove from list", "removeMember": "Remove", "removeSponsor": "Remove Sponsorship", "replyToComment": "Reply", + "resolutionAdopted": "Resolution adopted!", "resolutionPaper": "Resolution Paper", "resolutionPapers": "Resolution Papers", + "resolutionRejected": "Resolution rejected", + "resolutionSentBack": "Resolution sent back", "resolutions": "Resolutions", "role": "Role", "rollCall": "Roll Call", @@ -334,6 +359,7 @@ "selectCommitteeMember": "Select committee member...", "selectConferenceMember": "Select conference member...", "selected": "Selected", + "sendBack": "Send Back", "sentBack": "Sent Back", "seoDescription": "MUNify CHASE is the free, open-source debate management tool for Model United Nations conferences. Manage speakers lists, voting, and resolutions digitally.", "seoTitle": "MUNify CHASE – Debate Management for Model United Nations", @@ -365,6 +391,8 @@ "sponsors": "Sponsors", "startAmendmentPhase": "Start Amendment Phase", "startVote": "Start Vote", + "startVotingPhase": "Start Voting Phase", + "startVotingPhaseDescription": "Move to the voting phase where each operative paragraph will be voted on individually.", "stateOfDebate": "State of Debate", "statusUpdated": "Status has been set", "strikethrough": "Strikethrough", @@ -418,6 +446,7 @@ "unassigned": "Unassigned", "underline": "Underlined", "undo": "Undo", + "undoVote": "Undo Vote", "unknown": "unknown", "unrecognizedCodes": "Unrecognized codes:", "until": "until {time}", @@ -428,10 +457,12 @@ "updatingWhiteboard": "Publish whiteboard...", "upload": "Upload", "url": "URL", + "useFullVoting": "Use Full Voting", "userAlreadyExists": "User already exists in this conference: {email}", "users": "Users", "version": "Version", "viewPaper": "View Paper", + "voteOnParagraph": "Vote on OP {index}", "voteOutcome": "Vote Outcome", "voteResult": "Vote Result", "voteTitel": "Vote Title", @@ -441,6 +472,9 @@ "votesFor": "Votes For", "voting": "Voting", "votingControlsPlaceholder": "Voting controls will be available in a future update.", + "votingPhase": "Voting Phase", + "votingPhaseActive": "Voting phase active", + "votingPhaseStarted": "Voting phase started", "waitingForAssignment": "Waiting for Assignment", "waitingForAssignmentDescription": "You have not been assigned to a committee yet. Please wait for an admin to assign you.", "whiteboard": "Whiteboard", diff --git a/schema.graphql b/schema.graphql index 4370c233..d5e57e7e 100644 --- a/schema.graphql +++ b/schema.graphql @@ -389,6 +389,7 @@ type Mutation { createRepresentation(alpha2Code: String, alpha3Code: String, conferenceId: ID!, faIcon: String, name: String, type: RepresentationTypeEnum!): Representation createResolutionPaper(agendaItemId: ID!, committeeId: ID!, title: String): ResolutionPaper createShareCode(paperId: ID!, permission: ShareCodePermissionEnum!): PaperShareCode + deleteClauseVote(clauseId: String!, paperId: ID!): Boolean deleteComment(commentId: ID!): Boolean deleteCommittee(id: ID!): Boolean deleteCommitteeMember(id: ID!): Boolean @@ -399,6 +400,7 @@ type Mutation { importDelegatorConference(data: ImportData!): Conference moveSpeakerToPosition(id: ID!, position: Int!): SpeakerOnList promoteToDraftResolution(paperId: ID!): ResolutionPaper + recordClauseVote(clauseId: String!, outcome: VoteOutcomeEnum!, paperId: ID!, votesAbstain: Int, votesAgainst: Int!, votesFor: Int!): OperativeClauseVote recordVoteResult(outcome: VoteOutcomeEnum!, paperId: ID!, votesAbstain: Int, votesAgainst: Int!, votesFor: Int!): ResolutionPaper redeemShareCode(code: String!): ShareCodeRedemptionResult rejectAmendment(amendmentId: ID!): Amendment @@ -412,6 +414,7 @@ type Mutation { selfRemoveFromSpeakersList(speakersListId: ID!): SpeakersList setPresenceForCommitteeMembers(ids: [ID!]!, present: Boolean!): [CommitteeMember!] softDeletePaper(paperId: ID!): Boolean + startVotingPhase(paperId: ID!): ResolutionPaper submitAmendment(amendmentId: ID!): Amendment submitPaper(paperId: ID!): ResolutionPaper updateComment(commentId: ID!, content: String!): ResolutionComment @@ -560,6 +563,7 @@ enum PaperStatusEnum { DRAFT_RESOLUTION FINAL SUBMITTED + VOTING_PHASE WORKING_PAPER } diff --git a/src/api/db/schema.ts b/src/api/db/schema.ts index d7682c1c..7422747b 100644 --- a/src/api/db/schema.ts +++ b/src/api/db/schema.ts @@ -238,6 +238,7 @@ export const paperStatus = pgEnum('paper_status', [ 'SUBMITTED', 'DRAFT_RESOLUTION', 'AMENDMENT_PHASE', + 'VOTING_PHASE', 'FINAL' ]); @@ -376,17 +377,21 @@ export const amendmentSponsor = pgTable( (t) => [unique().on(t.amendmentId, t.committeeMemberId)] ); -export const operativeClauseVote = pgTable('operative_clause_vote', { - ...defaultIdAndTimestamps, - paperId: text() - .notNull() - .references(() => resolutionPaper.id, { onDelete: 'cascade' }), - clauseId: text().notNull(), - outcome: voteOutcome().notNull(), - votesFor: integer().notNull(), - votesAgainst: integer().notNull(), - votesAbstain: integer().notNull().default(0) -}); +export const operativeClauseVote = pgTable( + 'operative_clause_vote', + { + ...defaultIdAndTimestamps, + paperId: text() + .notNull() + .references(() => resolutionPaper.id, { onDelete: 'cascade' }), + clauseId: text().notNull(), + outcome: voteOutcome().notNull(), + votesFor: integer().notNull(), + votesAgainst: integer().notNull(), + votesAbstain: integer().notNull().default(0) + }, + (t) => [unique().on(t.paperId, t.clauseId)] +); export const paperClauseLock = pgTable( 'paper_clause_lock', diff --git a/src/api/handlers/committee.ts b/src/api/handlers/committee.ts index 300a049f..1678ed91 100644 --- a/src/api/handlers/committee.ts +++ b/src/api/handlers/committee.ts @@ -213,7 +213,11 @@ schemaBuilder.mutationFields((t) => { if (paper.committeeId !== args.id) { throw new GraphQLError('Paper does not belong to this committee'); } - if (paper.status !== 'DRAFT_RESOLUTION' && paper.status !== 'AMENDMENT_PHASE') { + if ( + paper.status !== 'DRAFT_RESOLUTION' && + paper.status !== 'AMENDMENT_PHASE' && + paper.status !== 'VOTING_PHASE' + ) { throw new GraphQLError('Only draft resolutions can be set as active'); } } diff --git a/src/api/handlers/operativeClauseVote.ts b/src/api/handlers/operativeClauseVote.ts index 010a251b..22c6bf93 100644 --- a/src/api/handlers/operativeClauseVote.ts +++ b/src/api/handlers/operativeClauseVote.ts @@ -1,8 +1,14 @@ -import { abilityBuilder } from '$api/rumble'; +import { db, schema } from '$api/db/db'; +import { abilityBuilder, enum_, schemaBuilder, pubsub as rumblePubsub } from '$api/rumble'; import { basics } from './basics'; import { isWhitelistedEmail } from '$api/services/isDMUNEmail'; +import { assertCommitteeChairOrAdmin } from './resolutionPaper'; +import { assertFindFirstExists } from '@m1212e/rumble'; +import { GraphQLError } from 'graphql'; +import { eq, and } from 'drizzle-orm'; -const { arg, ref, pubsub, table } = basics('operativeClauseVote'); +const { ref, pubsub } = basics('operativeClauseVote'); +const paperPubsub = rumblePubsub({ table: 'resolutionPaper' }); abilityBuilder.operativeClauseVote.allow('read').when(({ mustBeLoggedIn }) => { const user = mustBeLoggedIn(); @@ -15,3 +21,99 @@ abilityBuilder.operativeClauseVote.allow('read').when(({ mustBeLoggedIn }) => { mustBeLoggedIn(); return 'allow'; }); + +schemaBuilder.mutationFields((t) => ({ + recordClauseVote: t.drizzleField({ + type: ref, + args: { + paperId: t.arg.id({ required: true }), + clauseId: t.arg.string({ required: true }), + outcome: t.arg({ type: enum_({ tsName: 'voteOutcome' }), required: true }), + votesFor: t.arg.int({ required: true }), + votesAgainst: t.arg.int({ required: true }), + votesAbstain: t.arg.int() + }, + resolve: async (query, root, args, ctx, info) => { + const paper = await db.query.resolutionPaper + .findFirst({ where: { id: args.paperId } }) + .then(assertFindFirstExists); + + if (paper.status !== 'VOTING_PHASE') { + throw new GraphQLError('Paper must be in VOTING_PHASE to record clause votes'); + } + + if (args.outcome === 'SENT_BACK') { + throw new GraphQLError('Clause votes can only be ADOPTED or REJECTED'); + } + + await assertCommitteeChairOrAdmin(ctx, paper.committeeId); + + await db + .insert(schema.operativeClauseVote) + .values({ + paperId: args.paperId, + clauseId: args.clauseId, + outcome: args.outcome, + votesFor: args.votesFor, + votesAgainst: args.votesAgainst, + votesAbstain: args.votesAbstain ?? 0 + }) + .onConflictDoUpdate({ + target: [schema.operativeClauseVote.paperId, schema.operativeClauseVote.clauseId], + set: { + outcome: args.outcome, + votesFor: args.votesFor, + votesAgainst: args.votesAgainst, + votesAbstain: args.votesAbstain ?? 0 + } + }); + + pubsub.updated(args.paperId); + paperPubsub.updated(args.paperId); + + return db.query.operativeClauseVote + .findFirst( + query({ + where: { + paperId: args.paperId, + clauseId: args.clauseId + } + }) + ) + .then(assertFindFirstExists); + } + }), + + deleteClauseVote: t.field({ + type: 'Boolean', + args: { + paperId: t.arg.id({ required: true }), + clauseId: t.arg.string({ required: true }) + }, + resolve: async (root, args, ctx) => { + const paper = await db.query.resolutionPaper + .findFirst({ where: { id: args.paperId } }) + .then(assertFindFirstExists); + + if (paper.status !== 'VOTING_PHASE') { + throw new GraphQLError('Paper must be in VOTING_PHASE to delete clause votes'); + } + + await assertCommitteeChairOrAdmin(ctx, paper.committeeId); + + await db + .delete(schema.operativeClauseVote) + .where( + and( + eq(schema.operativeClauseVote.paperId, args.paperId), + eq(schema.operativeClauseVote.clauseId, args.clauseId) + ) + ); + + pubsub.updated(args.paperId); + paperPubsub.updated(args.paperId); + + return true; + } + }) +})); diff --git a/src/api/handlers/resolutionPaper.ts b/src/api/handlers/resolutionPaper.ts index 9ee85d2f..d0d8d96e 100644 --- a/src/api/handlers/resolutionPaper.ts +++ b/src/api/handlers/resolutionPaper.ts @@ -516,6 +516,60 @@ schemaBuilder.mutationFields((t) => ({ } }), + startVotingPhase: t.drizzleField({ + type: ref, + args: { + paperId: t.arg.id({ required: true }) + }, + resolve: async (query, root, args, ctx, info) => { + const paper = await db.query.resolutionPaper + .findFirst({ + where: { id: args.paperId } + }) + .then(assertFindFirstExists); + + if (paper.status !== 'AMENDMENT_PHASE') { + throw new GraphQLError('Paper must be in AMENDMENT_PHASE to start voting'); + } + + await assertCommitteeChairOrAdmin(ctx, paper.committeeId); + + await db.transaction(async (tx) => { + await tx + .update(schema.resolutionPaper) + .set({ status: 'VOTING_PHASE' }) + .where(eq(schema.resolutionPaper.id, args.paperId)); + + await tx.insert(schema.paperContentSnapshot).values({ + paperId: args.paperId, + content: paper.content, + trigger: 'VOTING_PHASE' + }); + }); + + // Reset currentOperativeIndex to 0 for voting navigation + await db + .update(schema.committee) + .set({ currentOperativeIndex: 0 }) + .where(eq(schema.committee.id, paper.committeeId)); + + pubsub.updated(args.paperId); + committeePubsub.updated(paper.committeeId); + + return db.query.resolutionPaper + .findFirst( + query( + ctx.abilities.resolutionPaper.filter('read', { + inject: { + where: { id: args.paperId } + } + }).query.single + ) + ) + .then(assertFindFirstExists); + } + }), + recordVoteResult: t.drizzleField({ type: ref, args: { @@ -532,6 +586,10 @@ schemaBuilder.mutationFields((t) => ({ }) .then(assertFindFirstExists); + if (paper.status !== 'VOTING_PHASE' && paper.status !== 'AMENDMENT_PHASE') { + throw new GraphQLError('Paper must be in VOTING_PHASE to record final vote'); + } + await assertCommitteeChairOrAdmin(ctx, paper.committeeId); await db.transaction(async (tx) => { @@ -549,20 +607,22 @@ schemaBuilder.mutationFields((t) => ({ .where(eq(schema.resolutionPaper.id, args.paperId)); }); - // Clear activeDraftResolutionId if this was the active DR - const committee = await db.query.committee - .findFirst({ where: { id: paper.committeeId } }) - .then(assertFindFirstExists); - - if (committee.activeDraftResolutionId === args.paperId) { - await db - .update(schema.committee) - .set({ activeDraftResolutionId: null }) - .where(eq(schema.committee.id, paper.committeeId)); + // Always clear activeDraftResolutionId and currentOperativeIndex + const updateSet: Record = { + activeDraftResolutionId: null, + currentOperativeIndex: null + }; - committeePubsub.updated(paper.committeeId); + if (args.outcome === 'ADOPTED') { + updateSet.lastResolutionAdoptionDate = new Date(); } + await db + .update(schema.committee) + .set(updateSet) + .where(eq(schema.committee.id, paper.committeeId)); + + committeePubsub.updated(paper.committeeId); pubsub.updated(args.paperId); return db.query.resolutionPaper diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.svelte b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.svelte index ce4ba388..c6923195 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.svelte +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.svelte @@ -34,14 +34,17 @@ .sort((a, b) => b.sponsors.length - a.sponsors.length) ); - // Draft resolutions (DR, AMENDMENT_PHASE, FINAL) + // Draft resolutions (DR, AMENDMENT_PHASE, VOTING_PHASE, FINAL) // During re-evaluation: sorted by sponsor count (descending) to show ranking // Otherwise: sorted by sequenceNumber let draftResolutions = $derived( papers .filter( (p) => - p.status === 'DRAFT_RESOLUTION' || p.status === 'AMENDMENT_PHASE' || p.status === 'FINAL' + p.status === 'DRAFT_RESOLUTION' || + p.status === 'AMENDMENT_PHASE' || + p.status === 'VOTING_PHASE' || + p.status === 'FINAL' ) .sort((a, b) => committee?.supportReEvaluationOpen @@ -176,6 +179,8 @@ return 'badge-info'; case 'AMENDMENT_PHASE': return 'badge-secondary'; + case 'VOTING_PHASE': + return 'badge-accent'; case 'FINAL': return 'badge-success'; default: @@ -189,6 +194,8 @@ return m.draftResolution(); case 'AMENDMENT_PHASE': return m.amendmentPhase(); + case 'VOTING_PHASE': + return m.votingPhase(); case 'FINAL': return m.finalResolution(); default: @@ -196,6 +203,8 @@ } } + let isInVotingPhase = $derived(activeDr && activeDr.status === 'VOTING_PHASE'); + function timeAgo(dateStr: string | Date | null | undefined) { if (!dateStr) return ''; const date = dateStr instanceof Date ? dateStr : new Date(dateStr); @@ -314,7 +323,9 @@ {@const isActive = paper.id === committee.activeDraftResolutionId} {@const canSetActive = !isActive && - (paper.status === 'DRAFT_RESOLUTION' || paper.status === 'AMENDMENT_PHASE')} + (paper.status === 'DRAFT_RESOLUTION' || + paper.status === 'AMENDMENT_PHASE' || + paper.status === 'VOTING_PHASE')}
+
+
+ {m.votingPhaseActive()} + {activeDr!.documentNumber} +
+ + {m.goToVoting()} → + +
{:else if isInAmendmentPhase}
@@ -426,11 +448,33 @@
- + -
-

{m.votingControlsPlaceholder()}

-
+ {#if isInVotingPhase} +
+
+ {m.votingPhaseActive()} + {activeDr!.documentNumber} +
+ + {m.goToVoting()} → + +
+ {:else if isInAmendmentPhase} +
+ + {m.finishAmendmentPhaseFirst()} +
+ {:else if activeDr} +
+ + {m.finishAmendmentPhaseFirst()} +
+ {:else} +
+

{m.noActiveDrForVoting()}

+
+ {/if}
diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte index 0bdb2bcb..0af5bdb6 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte @@ -9,6 +9,8 @@ import { ChairPaperClauseLocksSubscription } from './chairLockSubscription'; import { ChairPaperCommentsSubscription } from './chairCommentsSubscription'; import { ChairAmendmentsSubscription } from './chairAmendmentsSubscription'; + import { ChairClauseVotesSubscription } from './chairClauseVotesSubscription'; + import { ChairVoteResultSubscription } from './chairVoteResultSubscription'; import { ResolutionEditor, migrateResolution, @@ -67,6 +69,8 @@ ChairPaperClauseLocksSubscription.listen({ paperId: page.params.paperId! }); ChairPaperCommentsSubscription.listen({ paperId: page.params.paperId! }); ChairAmendmentsSubscription.listen({ paperId: page.params.paperId! }); + ChairClauseVotesSubscription.listen({ paperId: page.params.paperId! }); + ChairVoteResultSubscription.listen({ paperId: page.params.paperId! }); // Hybrid heartbeat — only fires when idle with held locks const heartbeatInterval = setInterval(() => { @@ -250,6 +254,8 @@ return 'badge-info'; case 'AMENDMENT_PHASE': return 'badge-secondary'; + case 'VOTING_PHASE': + return 'badge-accent'; case 'FINAL': return 'badge-success'; case 'SUBMITTED': @@ -269,6 +275,8 @@ return m.draftResolution(); case 'AMENDMENT_PHASE': return m.amendmentPhase(); + case 'VOTING_PHASE': + return m.votingPhase(); case 'FINAL': return m.finalResolution(); default: @@ -637,6 +645,183 @@ return type; } } + + // ===================================================== + // Voting Phase (Phase 7) + // ===================================================== + + let clauseVotes = $derived($ChairClauseVotesSubscription.data?.findManyOperativeClauseVote ?? []); + let voteResult = $derived( + $ChairVoteResultSubscription.data?.findFirstResolutionVoteResult ?? null + ); + + // Map clauseId → vote for quick lookup + let clauseVoteMap = $derived.by(() => { + const map = new SvelteMap(); + for (const v of clauseVotes) { + map.set(v.clauseId, v); + } + return map; + }); + + // Rejected clause IDs for editor strikethrough + let rejectedClauseIds = $derived( + clauseVotes.filter((v) => v.outcome === 'REJECTED').map((v) => v.clauseId) + ); + + let votedClauseCount = $derived(clauseVotes.length); + let allClausesVoted = $derived( + operativeClauses.length > 0 && votedClauseCount >= operativeClauses.length + ); + + // Quick vote inputs + let quickVotesFor = $state(0); + let quickVotesAgainst = $state(0); + let quickVotesAbstain = $state(0); + + // Final vote inputs + let finalVotesFor = $state(0); + let finalVotesAgainst = $state(0); + let finalVotesAbstain = $state(0); + + // Modals + let showStartVotingPhaseModal = $state(false); + let showFinalVoteConfirmModal = $state(false); + let finalVoteOutcome = $state<'ADOPTED' | 'REJECTED' | 'SENT_BACK'>('ADOPTED'); + + // Voting mutations + const StartVotingPhaseMutation = graphql(` + mutation ChairStartVotingPhaseMutation($paperId: ID!) { + startVotingPhase(paperId: $paperId) { + id + status + } + } + `); + + const RecordClauseVoteMutation = graphql(` + mutation ChairRecordClauseVoteMutation( + $paperId: ID! + $clauseId: String! + $outcome: VoteOutcomeEnum! + $votesFor: Int! + $votesAgainst: Int! + $votesAbstain: Int + ) { + recordClauseVote( + paperId: $paperId + clauseId: $clauseId + outcome: $outcome + votesFor: $votesFor + votesAgainst: $votesAgainst + votesAbstain: $votesAbstain + ) { + id + clauseId + outcome + } + } + `); + + const DeleteClauseVoteMutation = graphql(` + mutation ChairDeleteClauseVoteMutation($paperId: ID!, $clauseId: String!) { + deleteClauseVote(paperId: $paperId, clauseId: $clauseId) + } + `); + + const RecordFinalVoteMutation = graphql(` + mutation ChairRecordFinalVoteMutation( + $paperId: ID! + $outcome: VoteOutcomeEnum! + $votesFor: Int! + $votesAgainst: Int! + $votesAbstain: Int + ) { + recordVoteResult( + paperId: $paperId + outcome: $outcome + votesFor: $votesFor + votesAgainst: $votesAgainst + votesAbstain: $votesAbstain + ) { + id + status + } + } + `); + + async function handleStartVotingPhase() { + try { + await StartVotingPhaseMutation.mutate({ paperId: page.params.paperId! }); + showStartVotingPhaseModal = false; + toast.success(m.votingPhaseStarted()); + } catch { + toast.error(m.saveError()); + } + } + + async function handleRecordClauseVote(outcome: 'ADOPTED' | 'REJECTED') { + const clause = operativeClauses[currentOpIndex]; + if (!clause) return; + try { + await RecordClauseVoteMutation.mutate({ + paperId: page.params.paperId!, + clauseId: clause.id, + outcome, + votesFor: quickVotesFor, + votesAgainst: quickVotesAgainst, + votesAbstain: quickVotesAbstain + }); + toast.success(m.clauseVoteRecorded()); + quickVotesFor = 0; + quickVotesAgainst = 0; + quickVotesAbstain = 0; + } catch { + toast.error(m.saveError()); + } + } + + async function handleDeleteClauseVote(clauseId: string) { + try { + await DeleteClauseVoteMutation.mutate({ + paperId: page.params.paperId!, + clauseId + }); + toast.success(m.clauseVoteDeleted()); + } catch { + toast.error(m.saveError()); + } + } + + async function handleRecordFinalVote() { + try { + await RecordFinalVoteMutation.mutate({ + paperId: page.params.paperId!, + outcome: finalVoteOutcome, + votesFor: finalVotesFor, + votesAgainst: finalVotesAgainst, + votesAbstain: finalVotesAbstain + }); + showFinalVoteConfirmModal = false; + if (finalVoteOutcome === 'ADOPTED') { + toast.success(m.resolutionAdopted()); + } else if (finalVoteOutcome === 'REJECTED') { + toast.success(m.resolutionRejected()); + } else { + toast.success(m.resolutionSentBack()); + } + } catch { + toast.error(m.saveError()); + } + } + + function navigateToVotingClause(index: number) { + if (!committee) return; + UpdateCommitteeMutation.mutate({ + id: committee.id, + currentOperativeIndex: index + }).catch(() => toast.error(m.saveError())); + } @@ -745,6 +930,40 @@ {/if} + + {#if paper.status === 'FINAL' && voteResult} +
+ +
+ + {voteResult.outcome === 'ADOPTED' + ? m.adopted() + : voteResult.outcome === 'REJECTED' + ? m.rejected() + : m.sentBack()} + + + {m.votesFor()}: {voteResult.votesFor} | {m.votesAgainst()}: {voteResult.votesAgainst} + {#if voteResult.votesAbstain > 0} + | {m.votesAbstain()}: {voteResult.votesAbstain} + {/if} + +
+
+ {/if} + {#if allComments.length > 0}
@@ -772,7 +991,9 @@ {#snippet preambleAnnotations({ clause })} @@ -976,6 +1200,302 @@
{/if}
+ + +
+ +
+ {/if} + + + {#if paper.status === 'VOTING_PHASE'} + +
+
+ + OP {currentOpIndex + 1} / {operativeClauses.length} + + + {m.clausesVoted({ + voted: String(votedClauseCount), + total: String(operativeClauses.length) + })} + +
+ + +
+ + +
+ + + {@const currentClause = operativeClauses[currentOpIndex]} + {#if currentClause} + {@const existingVote = clauseVoteMap.get(currentClause.id)} + {#if existingVote} + +
+ + + OP {currentOpIndex + 1}: + + {existingVote.outcome === 'ADOPTED' ? m.adopted() : m.rejected()} + + — {m.votesFor()}: {existingVote.votesFor} | {m.votesAgainst()}: {existingVote.votesAgainst} + {#if existingVote.votesAbstain > 0} + | {m.votesAbstain()}: {existingVote.votesAbstain} + {/if} + + +
+ {:else} + +
+

+ {m.voteOnParagraph({ index: String(currentOpIndex + 1) })} +

+
+ + + + + +
+
+ {/if} + {/if} +
+ + +
+
+ {#each operativeClauses as clause, i (clause.id)} + {@const vote = clauseVoteMap.get(clause.id)} + + {/each} +
+
+ + +
+ {#if voteResult} +
+ + + + {voteResult.outcome === 'ADOPTED' + ? m.adopted() + : voteResult.outcome === 'REJECTED' + ? m.rejected() + : m.sentBack()} + + — {m.votesFor()}: {voteResult.votesFor} | {m.votesAgainst()}: {voteResult.votesAgainst} + {#if voteResult.votesAbstain > 0} + | {m.votesAbstain()}: {voteResult.votesAbstain} + {/if} + +
+ {:else} +

{m.finalVoteDescription()}

+
+ + + +
+
+ + + +
+ {/if} +
+ {/if} + + + {#if paper.status === 'FINAL' && clauseVotes.length > 0} +
+
+ {#each operativeClauses as clause, i (clause.id)} + {@const vote = clauseVoteMap.get(clause.id)} +
+ OP {i + 1} + {#if vote} + + {vote.outcome === 'ADOPTED' ? m.adopted() : m.rejected()} + + + {vote.votesFor}/{vote.votesAgainst} + {#if vote.votesAbstain > 0}/{vote.votesAbstain}{/if} + + {:else} + + {/if} +
+ {/each} +
+
{/if} @@ -1075,4 +1595,51 @@ + + + +
+

{m.startVotingPhase()}

+

{m.confirmStartVotingPhase()}

+
+ + +
+
+
+ + + +
+

{m.finalVote()}

+

+ {#if finalVoteOutcome === 'ADOPTED'} + {m.confirmAdoptResolution()} + {:else if finalVoteOutcome === 'REJECTED'} + {m.confirmRejectResolution()} + {:else} + {m.confirmSendBack()} + {/if} +

+
+ + +
+
+
{/if} diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/chairClauseVotesSubscription.ts b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/chairClauseVotesSubscription.ts new file mode 100644 index 00000000..b7319201 --- /dev/null +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/chairClauseVotesSubscription.ts @@ -0,0 +1,14 @@ +import { graphql } from '$houdini'; + +export const ChairClauseVotesSubscription = graphql(` + subscription ChairClauseVotesSubscription($paperId: ID!) { + findManyOperativeClauseVote(where: { paperId: $paperId }) { + id + clauseId + outcome + votesFor + votesAgainst + votesAbstain + } + } +`); diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/chairVoteResultSubscription.ts b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/chairVoteResultSubscription.ts new file mode 100644 index 00000000..2e399bab --- /dev/null +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/chairVoteResultSubscription.ts @@ -0,0 +1,13 @@ +import { graphql } from '$houdini'; + +export const ChairVoteResultSubscription = graphql(` + subscription ChairVoteResultSubscription($paperId: ID!) { + findFirstResolutionVoteResult(where: { paperId: $paperId }) { + id + outcome + votesFor + votesAgainst + votesAbstain + } + } +`); diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte index cd0917b6..cc0c82f2 100644 --- a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte @@ -25,6 +25,7 @@ import { getTranslatedCountryNameFromAlpha3Code } from '$lib/utils/nationTranslationHelper.svelte'; import toast from 'svelte-french-toast'; import { fly, fade } from 'svelte/transition'; + import { SvelteMap } from 'svelte/reactivity'; let { data }: { data: PageData } = $props(); @@ -78,7 +79,9 @@ // DR support: delegate can toggle support during re-evaluation let isDrStatus = $derived( - paper?.status === 'DRAFT_RESOLUTION' || paper?.status === 'AMENDMENT_PHASE' + paper?.status === 'DRAFT_RESOLUTION' || + paper?.status === 'AMENDMENT_PHASE' || + paper?.status === 'VOTING_PHASE' ); let canToggleDrSupport = $derived( isDelegate && isDrStatus && committee?.supportReEvaluationOpen === true @@ -162,9 +165,9 @@ } }); - // Start amendments subscription when paper enters amendment phase + // Start amendments subscription when paper enters amendment phase or voting phase $effect(() => { - if (paper?.status === 'AMENDMENT_PHASE') { + if (paper?.status === 'AMENDMENT_PHASE' || paper?.status === 'VOTING_PHASE') { ParticipantAmendmentsSubscription.listen({ paperId: page.params.paperId! }); } }); @@ -829,6 +832,62 @@ } } + // ===================================================== + // Clause Votes (Phase 7 — participant view) + // ===================================================== + + const ParticipantClauseVotesSubscription = graphql(` + subscription ParticipantClauseVotesSubscription($paperId: ID!) { + findManyOperativeClauseVote(where: { paperId: $paperId }) { + id + clauseId + outcome + votesFor + votesAgainst + votesAbstain + } + } + `); + + const ParticipantVoteResultSubscription = graphql(` + subscription ParticipantVoteResultSubscription($paperId: ID!) { + findFirstResolutionVoteResult(where: { paperId: $paperId }) { + id + outcome + votesFor + votesAgainst + votesAbstain + } + } + `); + + // Start clause votes subscription when paper enters voting or final phase + $effect(() => { + if (paper?.status === 'VOTING_PHASE' || paper?.status === 'FINAL') { + ParticipantClauseVotesSubscription.listen({ paperId: page.params.paperId! }); + ParticipantVoteResultSubscription.listen({ paperId: page.params.paperId! }); + } + }); + + let clauseVotes = $derived( + $ParticipantClauseVotesSubscription.data?.findManyOperativeClauseVote ?? [] + ); + let voteResult = $derived( + $ParticipantVoteResultSubscription.data?.findFirstResolutionVoteResult ?? null + ); + + let rejectedClauseIds = $derived( + clauseVotes.filter((v) => v.outcome === 'REJECTED').map((v) => v.clauseId) + ); + + let clauseVoteMap = $derived.by(() => { + const map = new SvelteMap(); + for (const v of clauseVotes) { + map.set(v.clauseId, v); + } + return map; + }); + // Mini editor resolution for amendment creation modal let miniResolution = $derived.by(() => { if (!amendmentNewContent) return null; @@ -900,7 +959,11 @@ ? 'badge-warning' : paper.status === 'DRAFT_RESOLUTION' ? 'badge-info' - : 'badge-success'}" + : paper.status === 'AMENDMENT_PHASE' + ? 'badge-secondary' + : paper.status === 'VOTING_PHASE' + ? 'badge-accent' + : 'badge-success'}" > {paper.status === 'WORKING_PAPER' ? m.workingPaper() @@ -908,7 +971,11 @@ ? m.submitted() : paper.status === 'DRAFT_RESOLUTION' ? m.draftResolution() - : m.finalResolution()} + : paper.status === 'AMENDMENT_PHASE' + ? m.amendmentPhase() + : paper.status === 'VOTING_PHASE' + ? m.votingPhase() + : m.finalResolution()} @@ -1031,6 +1098,48 @@ {/if} + + {#if paper.status === 'FINAL' && voteResult} +
+ +
+ + {voteResult.outcome === 'ADOPTED' + ? m.adopted() + : voteResult.outcome === 'REJECTED' + ? m.rejected() + : m.sentBack()} + + + {m.votesFor()}: {voteResult.votesFor} | {m.votesAgainst()}: {voteResult.votesAgainst} + {#if voteResult.votesAbstain > 0} + | {m.votesAbstain()}: {voteResult.votesAbstain} + {/if} + +
+
+ {/if} + + + {#if paper.status === 'VOTING_PHASE'} +
+ + {m.votingPhaseActive()} +
+ {/if} +
{#if resolution} @@ -1038,7 +1147,7 @@ {#snippet betweenOperativeClauses({ index })} @@ -1375,6 +1487,35 @@ {/if} {/if} + + {#if paper.status === 'FINAL' && clauseVotes.length > 0} +
+
+ {#each operativeClauses as clause, i (clause.id)} + {@const vote = clauseVoteMap.get(clause.id)} +
+ OP {i + 1} + {#if vote} + + {vote.outcome === 'ADOPTED' ? m.adopted() : m.rejected()} + + + {vote.votesFor}/{vote.votesAgainst} + {#if vote.votesAbstain > 0}/{vote.votesAbstain}{/if} + + {:else} + + {/if} +
+ {/each} +
+
+ {/if} + {#if showComments && (commentsByClauseId.get(null)?.length ?? 0) > 0}
From 19e6af0877aa37c8153a9d1a13a0454b14395f69 Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Fri, 6 Mar 2026 20:31:41 +0100 Subject: [PATCH 15/89] =?UTF-8?q?fix:=20chair=20dock=20=E2=80=94=20absolut?= =?UTF-8?q?e=20links,=20active=20DR=20tab,=20and=20keyboard=20hint=20on=20?= =?UTF-8?q?hover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix relative dock links breaking on nested pages by using absolute paths. Add a sixth dock tab linking to the active draft resolution (reactive via subscription). Show keyboard shortcuts (⌥1–⌥6) on hover with backdrop blur. Co-Authored-By: Claude Opus 4.6 --- .../[committeeId]/(chairs)/+layout.svelte | 11 ++- .../[committeeId]/(chairs)/ChairNavbar.svelte | 70 ++++++++++++++----- 2 files changed, 62 insertions(+), 19 deletions(-) diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/+layout.svelte b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/+layout.svelte index e14bb51d..b6ab2531 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/+layout.svelte +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/+layout.svelte @@ -12,6 +12,7 @@ import { serverTime } from '$lib/state/serverTime.svelte'; import hotkeys from 'hotkeys-js'; import AdoptionConfetti from '$lib/components/AdoptionConfetti.svelte'; + import { CommitteeSubscription } from './committeeSubscription'; interface Props { children: Snippet; @@ -21,7 +22,9 @@ let { data, children }: Props = $props(); let query = $derived(data?.CommitteeTeamQuery); - let committee = $derived($query.data?.findFirstCommittee); + let committee = $derived( + $CommitteeSubscription.data?.findFirstCommittee ?? $query.data?.findFirstCommittee + ); let committeeStatusExpiredAlerted = $state(false); let speakersListOvertimeAlerted = $state(false); @@ -77,6 +80,7 @@ }); onMount(() => { + CommitteeSubscription.listen({ id: data.committeeId }); hotkeys('alt+p', (event) => { event.preventDefault(); window.open('.', '_blank'); @@ -92,7 +96,10 @@ {committee?.abbreviation ?? 'N/A'} {m.chairControls()} - MUNify CHASE - +
{@render children()} diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/ChairNavbar.svelte b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/ChairNavbar.svelte index 1e05365f..593e25ec 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/ChairNavbar.svelte +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/ChairNavbar.svelte @@ -9,45 +9,77 @@ interface Props { title?: string; + activeDraftResolutionId?: string | null; } - let { title }: Props = $props(); + let { title, activeDraftResolutionId }: Props = $props(); - const dockItems = [ - { icon: 'fa-gears', label: () => m.setup(), href: './setup', key: 'setup' }, - { icon: 'fa-users', label: () => m.presence(), href: './presence', key: 'presence' }, + const basePath = $derived(`/app/${page.params.conferenceId}/${page.params.committeeId}`); + + const dockItems = $derived([ + { icon: 'fa-gears', label: () => m.setup(), href: `${basePath}/setup`, key: 'setup' }, + { icon: 'fa-users', label: () => m.presence(), href: `${basePath}/presence`, key: 'presence' }, { icon: 'fa-podium', label: () => m.speakersList(), - href: './speakers-list', + href: `${basePath}/speakers-list`, key: 'speakers-list' }, - { icon: 'fa-box-ballot', label: () => m.voting(), href: './voting', key: 'voting' }, - { icon: 'fa-scroll', label: () => m.resolutions(), href: './resolutions', key: 'resolutions' } - ]; + { + icon: 'fa-box-ballot', + label: () => m.voting(), + href: `${basePath}/voting`, + key: 'voting' + }, + { + icon: 'fa-scroll', + label: () => m.resolutions(), + href: `${basePath}/resolutions`, + key: 'resolutions' + }, + ...(activeDraftResolutionId + ? [ + { + icon: 'fa-file-lines', + label: () => m.activeDraftResolution(), + href: `${basePath}/resolutions/${activeDraftResolutionId}`, + key: activeDraftResolutionId + } + ] + : []) + ]); function isActive(key: string) { + // If we're on the active DR's paper page, highlight the active DR tab, not the resolutions tab + if (activeDraftResolutionId && page.params.paperId === activeDraftResolutionId) { + return key === activeDraftResolutionId; + } return page.route.id?.includes(key) ?? false; } $effect(() => { - hotkeys('alt+1, alt+2, alt+3, alt+4, alt+5', (event, handler) => { + hotkeys('alt+1, alt+2, alt+3, alt+4, alt+5, alt+6', (event, handler) => { event.preventDefault(); switch (handler.key) { case 'alt+1': - goto('./setup'); + goto(`${basePath}/setup`); break; case 'alt+2': - goto('./presence'); + goto(`${basePath}/presence`); break; case 'alt+3': - goto('./speakers-list'); + goto(`${basePath}/speakers-list`); break; case 'alt+4': - goto('./voting'); + goto(`${basePath}/voting`); break; case 'alt+5': - goto('./resolutions'); + goto(`${basePath}/resolutions`); + break; + case 'alt+6': + if (activeDraftResolutionId) { + goto(`${basePath}/resolutions/${activeDraftResolutionId}`); + } break; } }); @@ -74,7 +106,7 @@ { faIcon: 'fa-rocket-launch', title: m.missionControl(), - href: '../mission-control' + href: `/app/${page.params.conferenceId}/${page.params.committeeId}/mission-control` } ]} /> @@ -83,10 +115,14 @@ From edf0ddfe32eec344c4b04598ce15ffe6eaf4f3e9 Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Fri, 6 Mar 2026 20:50:47 +0100 Subject: [PATCH 16/89] feat: add revertPaperStatus mutation for chair status reversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow chairs to revert a resolution paper to its previous lifecycle status (FINAL→VOTING→AMENDMENT→DR→SUBMITTED→WORKING_PAPER) with appropriate side effects per transition, confirmation modal with status-specific warnings, and optional snapshot restore for amendments. Co-Authored-By: Claude Opus 4.6 --- messages/de.json | 7 + messages/en.json | 7 + schema.graphql | 1 + src/api/handlers/resolutionPaper.ts | 138 +++++++++++++++++- .../resolutions/[paperId]/+page.svelte | 101 +++++++++++++ 5 files changed, 253 insertions(+), 1 deletion(-) diff --git a/messages/de.json b/messages/de.json index b4ba534c..e7675252 100644 --- a/messages/de.json +++ b/messages/de.json @@ -122,6 +122,7 @@ "confirmRejectAmendment": "Diesen Änderungsantrag ablehnen?", "confirmRejectResolution": "Diese Resolution ablehnen?", "confirmRemoveMember": "Möchtest du dieses Mitglied wirklich entfernen?", + "confirmRevertStatus": "Dieses Papier von {from} auf {to} zurücksetzen?", "confirmSendBack": "Diese Resolution zurückverweisen?", "confirmStartAmendmentPhase": "Änderungsantragsphase für diesen Resolutionsentwurf starten? Delegierte können dann absatzweise Änderungsanträge stellen.", "confirmStartVotingPhase": "Die Abstimmungsphase für diese Beschlussvorlage starten? Jeder operative Absatz wird einzeln abgestimmt.", @@ -341,6 +342,11 @@ "resolutionRejected": "Resolution abgelehnt", "resolutionSentBack": "Resolution zurückverwiesen", "resolutions": "Resolutionen", + "restoreContentFromSnapshot": "Inhalt von vor den Änderungsanträgen wiederherstellen", + "restoreContentFromSnapshotDescription": "Alle angewendeten Änderungsanträge rückgängig machen und den Resolutionsinhalt auf die Version vor der Änderungsantragsphase zurücksetzen. Angewendete Änderungsanträge werden auf 'Ausstehend' zurückgesetzt.", + "revertDrWarning": "Durch das Zurücksetzen wird die Dokumentennummer entfernt. Das Papier kann später erneut befördert werden.", + "revertStatus": "Status zurücksetzen", + "revertVotingWarning": "Durch das Zurücksetzen werden alle Absatzstimmungen für dieses Papier gelöscht.", "role": "Rolle", "rollCall": "Anwesenheitsfeststellung", "rollCallError": "Gremienmitglied nicht gefunden", @@ -394,6 +400,7 @@ "startVotingPhase": "Abstimmungsphase starten", "startVotingPhaseDescription": "Zur Abstimmungsphase wechseln, in der jeder operative Absatz einzeln abgestimmt wird.", "stateOfDebate": "Debattenstand", + "statusReverted": "Status zurückgesetzt", "statusUpdated": "Status wurde gesetzt", "strikethrough": "Durchgestrichen", "submit": "Absenden", diff --git a/messages/en.json b/messages/en.json index c0d33cf3..7e2386ad 100644 --- a/messages/en.json +++ b/messages/en.json @@ -122,6 +122,7 @@ "confirmRejectAmendment": "Reject this amendment?", "confirmRejectResolution": "Reject this resolution?", "confirmRemoveMember": "Are you sure you want to remove this member?", + "confirmRevertStatus": "Revert this paper from {from} back to {to}?", "confirmSendBack": "Send this resolution back?", "confirmStartAmendmentPhase": "Start the amendment phase for this draft resolution? Delegates will be able to propose amendments paragraph by paragraph.", "confirmStartVotingPhase": "Start the voting phase for this draft resolution? Each operative paragraph will be voted on individually.", @@ -341,6 +342,11 @@ "resolutionRejected": "Resolution rejected", "resolutionSentBack": "Resolution sent back", "resolutions": "Resolutions", + "restoreContentFromSnapshot": "Restore content from before amendments", + "restoreContentFromSnapshotDescription": "Undo all applied amendments and restore the resolution content to the version before the amendment phase began. Applied amendments will be reset to pending.", + "revertDrWarning": "Reverting will clear the document number. The paper can be re-promoted later.", + "revertStatus": "Revert Status", + "revertVotingWarning": "Reverting will delete all clause vote results for this paper.", "role": "Role", "rollCall": "Roll Call", "rollCallError": "Committee member not found", @@ -394,6 +400,7 @@ "startVotingPhase": "Start Voting Phase", "startVotingPhaseDescription": "Move to the voting phase where each operative paragraph will be voted on individually.", "stateOfDebate": "State of Debate", + "statusReverted": "Status reverted", "statusUpdated": "Status has been set", "strikethrough": "Strikethrough", "submit": "Submit", diff --git a/schema.graphql b/schema.graphql index d5e57e7e..8c70706c 100644 --- a/schema.graphql +++ b/schema.graphql @@ -410,6 +410,7 @@ type Mutation { removeEditor(conferenceUserId: ID!, paperId: ID!): Boolean removeSpeakerOnList(speakerOnListId: ID!): SpeakersList removeSponsor(committeeMemberId: ID!, paperId: ID!): Boolean + revertPaperStatus(paperId: ID!, restoreSnapshot: Boolean): ResolutionPaper selfAddToSpeakersList(speakersListId: ID!): SpeakerOnList selfRemoveFromSpeakersList(speakersListId: ID!): SpeakersList setPresenceForCommitteeMembers(ids: [ID!]!, present: Boolean!): [CommitteeMember!] diff --git a/src/api/handlers/resolutionPaper.ts b/src/api/handlers/resolutionPaper.ts index d0d8d96e..92e73127 100644 --- a/src/api/handlers/resolutionPaper.ts +++ b/src/api/handlers/resolutionPaper.ts @@ -1,6 +1,6 @@ import { db, schema } from '$api/db/db'; import { abilityBuilder, enum_, schemaBuilder, pubsub as rumblePubsub } from '$api/rumble'; -import { and, eq, isNull, count as drizzleCount } from 'drizzle-orm'; +import { and, eq, isNull, count as drizzleCount, desc, inArray } from 'drizzle-orm'; import { basics } from './basics'; import { isWhitelistedEmail } from '$api/services/isDMUNEmail'; import { assertFindFirstExists, assertFirstEntryExists } from '@m1212e/rumble'; @@ -676,5 +676,141 @@ schemaBuilder.mutationFields((t) => ({ return true; } + }), + + revertPaperStatus: t.drizzleField({ + type: ref, + args: { + paperId: t.arg.id({ required: true }), + restoreSnapshot: t.arg.boolean() + }, + resolve: async (query, root, args, ctx, info) => { + const paper = await db.query.resolutionPaper + .findFirst({ + where: { id: args.paperId } + }) + .then(assertFindFirstExists); + + await assertCommitteeChairOrAdmin(ctx, paper.committeeId); + + const statusOrder = [ + 'WORKING_PAPER', + 'SUBMITTED', + 'DRAFT_RESOLUTION', + 'AMENDMENT_PHASE', + 'VOTING_PHASE', + 'FINAL' + ] as const; + const currentIndex = statusOrder.indexOf(paper.status as (typeof statusOrder)[number]); + if (currentIndex <= 0) { + throw new GraphQLError('Paper is already at initial status and cannot be reverted'); + } + const targetStatus = statusOrder[currentIndex - 1]; + + await db.transaction(async (tx) => { + // Status-specific side effects + if (paper.status === 'FINAL') { + // Delete the resolution vote result + await tx + .delete(schema.resolutionVoteResult) + .where(eq(schema.resolutionVoteResult.paperId, args.paperId)); + // Restore as active DR if committee has none + const committee = await tx.query.committee + .findFirst({ where: { id: paper.committeeId } }) + .then(assertFindFirstExists); + if (!committee.activeDraftResolutionId) { + await tx + .update(schema.committee) + .set({ + activeDraftResolutionId: args.paperId, + currentOperativeIndex: 0 + }) + .where(eq(schema.committee.id, paper.committeeId)); + } + } else if (paper.status === 'VOTING_PHASE') { + // Delete all operative clause votes for this paper + await tx + .delete(schema.operativeClauseVote) + .where(eq(schema.operativeClauseVote.paperId, args.paperId)); + } else if (paper.status === 'AMENDMENT_PHASE') { + // Clear currentOperativeIndex on committee + await tx + .update(schema.committee) + .set({ currentOperativeIndex: null }) + .where(eq(schema.committee.id, paper.committeeId)); + if (args.restoreSnapshot) { + // Restore content from latest AMENDMENT_PHASE snapshot + const snapshot = await tx.query.paperContentSnapshot.findFirst({ + where: { paperId: args.paperId, trigger: 'AMENDMENT_PHASE' }, + orderBy: { createdAt: 'desc' } + }); + if (snapshot?.content) { + await tx + .update(schema.resolutionPaper) + .set({ content: snapshot.content }) + .where(eq(schema.resolutionPaper.id, args.paperId)); + } + // Reset applied amendments back to PENDING + await tx + .update(schema.amendment) + .set({ status: 'PENDING' }) + .where( + and( + eq(schema.amendment.paperId, args.paperId), + inArray(schema.amendment.status, ['CONSENSUS_ADOPTED', 'ACCEPTED']) + ) + ); + } + } else if (paper.status === 'DRAFT_RESOLUTION') { + // Clear document number and sequence + await tx + .update(schema.resolutionPaper) + .set({ documentNumber: null, sequenceNumber: null }) + .where(eq(schema.resolutionPaper.id, args.paperId)); + // Clear active DR if this paper was active + const committee = await tx.query.committee + .findFirst({ where: { id: paper.committeeId } }) + .then(assertFindFirstExists); + if (committee.activeDraftResolutionId === args.paperId) { + await tx + .update(schema.committee) + .set({ + activeDraftResolutionId: null, + currentOperativeIndex: null + }) + .where(eq(schema.committee.id, paper.committeeId)); + } + } + // SUBMITTED → WORKING_PAPER: no side effects + + // Update the paper status + await tx + .update(schema.resolutionPaper) + .set({ status: targetStatus }) + .where(eq(schema.resolutionPaper.id, args.paperId)); + + // Create audit snapshot + await tx.insert(schema.paperContentSnapshot).values({ + paperId: args.paperId, + content: paper.content, + trigger: `REVERT_FROM_${paper.status}` + }); + }); + + pubsub.updated(args.paperId); + committeePubsub.updated(paper.committeeId); + + return db.query.resolutionPaper + .findFirst( + query( + ctx.abilities.resolutionPaper.filter('read', { + inject: { + where: { id: args.paperId } + } + }).query.single + ) + ) + .then(assertFindFirstExists); + } }) })); diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte index 0af5bdb6..0e0788d4 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte @@ -688,6 +688,8 @@ let showStartVotingPhaseModal = $state(false); let showFinalVoteConfirmModal = $state(false); let finalVoteOutcome = $state<'ADOPTED' | 'REJECTED' | 'SENT_BACK'>('ADOPTED'); + let showRevertStatusModal = $state(false); + let revertRestoreSnapshot = $state(false); // Voting mutations const StartVotingPhaseMutation = graphql(` @@ -750,6 +752,42 @@ } `); + const RevertPaperStatusMutation = graphql(` + mutation ChairRevertPaperStatusMutation($paperId: ID!, $restoreSnapshot: Boolean) { + revertPaperStatus(paperId: $paperId, restoreSnapshot: $restoreSnapshot) { + id + status + } + } + `); + + async function handleRevertStatus() { + try { + await RevertPaperStatusMutation.mutate({ + paperId: page.params.paperId!, + restoreSnapshot: revertRestoreSnapshot + }); + showRevertStatusModal = false; + revertRestoreSnapshot = false; + toast.success(m.statusReverted()); + } catch { + toast.error(m.saveError()); + } + } + + function getPreviousStatus(status: string): string { + const order = [ + 'WORKING_PAPER', + 'SUBMITTED', + 'DRAFT_RESOLUTION', + 'AMENDMENT_PHASE', + 'VOTING_PHASE', + 'FINAL' + ]; + const idx = order.indexOf(status); + return idx > 0 ? order[idx - 1] : status; + } + async function handleStartVotingPhase() { try { await StartVotingPhaseMutation.mutate({ paperId: page.params.paperId! }); @@ -864,6 +902,19 @@ {getStatusText(paper.status)} + {#if paper.status !== 'WORKING_PAPER'} + + {/if}
@@ -1642,4 +1693,54 @@
+ + + +
+

{m.revertStatus()}

+

+ {m.confirmRevertStatus({ + from: getStatusText(paper.status), + to: getStatusText(getPreviousStatus(paper.status)) + })} +

+ + {#if paper.status === 'AMENDMENT_PHASE'} + + {/if} + + {#if paper.status === 'VOTING_PHASE'} +
+ + {m.revertVotingWarning()} +
+ {/if} + + {#if paper.status === 'DRAFT_RESOLUTION'} +
+ + {m.revertDrWarning()} +
+ {/if} + +
+ + +
+
+
{/if} From ea06241268e91e88c7d86ac3018649942685c5f3 Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Fri, 6 Mar 2026 20:53:50 +0100 Subject: [PATCH 17/89] fix: move revert button outside collapse to make it clickable The revert button was inside the DaisyUI collapse-title, causing clicks to toggle the accordion instead of triggering the button. Moved it outside the collapse as a sibling element. Co-Authored-By: Claude Opus 4.6 --- .../resolutions/[paperId]/+page.svelte | 149 +++++++++--------- 1 file changed, 75 insertions(+), 74 deletions(-) diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte index 0e0788d4..aed0d7c4 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte @@ -892,85 +892,86 @@ -
- -
-
- - {paper.documentNumber ?? m.draftResolution()} - - - {getStatusText(paper.status)} - - {#if paper.status !== 'WORKING_PAPER'} - - {/if} -
-
-
- - {#if paper.agendaItem} -
- {m.agendaItem()}: - {paper.agendaItem.title} +
+
+ +
+
+ + {paper.documentNumber ?? m.draftResolution()} + + + {getStatusText(paper.status)} +
- {/if} +
+
+ + {#if paper.agendaItem} +
+ {m.agendaItem()}: + {paper.agendaItem.title} +
+ {/if} - - {#if paper.creator?.representation} -
- {m.submittingNation()}: - - {paper.creator.representation.name ?? - getTranslatedCountryNameFromAlpha3Code(paper.creator.representation.alpha3Code)} -
- {/if} + + {#if paper.creator?.representation} +
+ {m.submittingNation()}: + + {paper.creator.representation.name ?? + getTranslatedCountryNameFromAlpha3Code(paper.creator.representation.alpha3Code)} +
+ {/if} - -
-
- {#each sortedSponsors as sponsor (sponsor.id)} -
- - -
- {/each} - -
-

- {m.sponsorCount({ count: String(paper.sponsors.length) })} -

-
+ + +
+ {/each} + +
+

+ {m.sponsorCount({ count: String(paper.sponsors.length) })} +

+
+ + {#if paper.status !== 'WORKING_PAPER'} + + {/if} From 5b045541bf2365bab700623f61b94d80173f8381 Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Fri, 6 Mar 2026 20:55:52 +0100 Subject: [PATCH 18/89] fix: move revert button inside accordion content with label text Place the revert button at the bottom of the expanded collapse-content section instead of outside the accordion. Added text label so the button clearly communicates its action. Co-Authored-By: Claude Opus 4.6 --- .../resolutions/[paperId]/+page.svelte | 149 +++++++++--------- 1 file changed, 74 insertions(+), 75 deletions(-) diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte index aed0d7c4..26bc922c 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte @@ -892,86 +892,85 @@ -
-
- -
-
- - {paper.documentNumber ?? m.draftResolution()} - - - {getStatusText(paper.status)} - -
+
+ +
+
+ + {paper.documentNumber ?? m.draftResolution()} + + + {getStatusText(paper.status)} +
-
- - {#if paper.agendaItem} -
- {m.agendaItem()}: - {paper.agendaItem.title} -
- {/if} +
+
+ + {#if paper.agendaItem} +
+ {m.agendaItem()}: + {paper.agendaItem.title} +
+ {/if} - - {#if paper.creator?.representation} -
- {m.submittingNation()}: - - {paper.creator.representation.name ?? - getTranslatedCountryNameFromAlpha3Code(paper.creator.representation.alpha3Code)} -
- {/if} + + {#if paper.creator?.representation} +
+ {m.submittingNation()}: + + {paper.creator.representation.name ?? + getTranslatedCountryNameFromAlpha3Code(paper.creator.representation.alpha3Code)} +
+ {/if} - -
-
- {#each sortedSponsors as sponsor (sponsor.id)} -
- - -
- {/each} - -
-

- {m.sponsorCount({ count: String(paper.sponsors.length) })} -

-
-
+ + +
+ {/each} + +
+

+ {m.sponsorCount({ count: String(paper.sponsors.length) })} +

+ + + {#if paper.status !== 'WORKING_PAPER'} + + {/if}
- {#if paper.status !== 'WORKING_PAPER'} - - {/if}
From 607d34ac2d86e6e15431696ca19ed6b77133e649 Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Fri, 6 Mar 2026 20:58:16 +0100 Subject: [PATCH 19/89] feat: add previous paragraph button in amendment phase controls Add a ghost back-arrow button next to the existing "Advance to Next Paragraph" button, allowing chairs to navigate to the previous operative clause during the amendment phase. Co-Authored-By: Claude Opus 4.6 --- .../resolutions/[paperId]/+page.svelte | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte index 26bc922c..84daad80 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte @@ -1168,14 +1168,33 @@ OP {currentOpIndex + 1} / {operativeClauses.length} - +
+ + +
From 8130a3b778124a2035dcc38b479b89cab68038dc Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Fri, 6 Mar 2026 20:59:58 +0100 Subject: [PATCH 20/89] fix: add type annotations to vite.config.ts devAutoRestart plugin Co-Authored-By: Claude Opus 4.6 --- vite.config.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/vite.config.ts b/vite.config.ts index 9754b4d4..35ca107a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,7 +2,7 @@ import houdini from 'houdini/vite'; import { paraglideVitePlugin } from '@inlang/paraglide-js'; import tailwindcss from '@tailwindcss/vite'; import { sveltekit } from '@sveltejs/kit/vite'; -import { defineConfig } from 'vite'; +import { defineConfig, type ViteDevServer } from 'vite'; function devAutoRestart() { const RACE_CONDITION_PATTERNS = [ @@ -12,20 +12,20 @@ function devAutoRestart() { return { name: 'dev-auto-restart', - configureServer(server) { + configureServer(server: ViteDevServer) { let restarting = false; - const triggerRestart = (label) => { + const triggerRestart = (label: string) => { if (restarting) return; restarting = true; console.warn(`\n⚠️ ${label}, restarting dev server...\n`); server.restart(); }; - const isRaceCondition = (message) => + const isRaceCondition = (message: string | undefined) => RACE_CONDITION_PATTERNS.some((pattern) => message?.includes(pattern)); - const onUnhandledRejection = (reason) => { + const onUnhandledRejection = (reason: unknown) => { if (reason instanceof Error && isRaceCondition(reason.message)) { triggerRestart('Race condition detected'); } @@ -37,7 +37,7 @@ function devAutoRestart() { }); const originalSsrFixStacktrace = server.ssrFixStacktrace; - server.ssrFixStacktrace = function (e) { + server.ssrFixStacktrace = function (e: Error) { originalSsrFixStacktrace.call(this, e); if (isRaceCondition(e?.message)) { triggerRestart('SSR race condition detected'); From 7e99cacea79dd2c209eec89fb555282ea196af57 Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Fri, 6 Mar 2026 21:05:24 +0100 Subject: [PATCH 21/89] feat: add global voting modal with promise-based API Introduces a hotkey-toggled (alt+v) voting modal available across all chair pages, with a programmatic openVotingModal() API that returns a Promise resolving to the vote result. Extracts VotingSetupForm as a reusable component and adds oncomplete callbacks to both voting types. Co-Authored-By: Claude Opus 4.6 --- .../voting/RollCallVotingChair.svelte | 42 +++++- .../voting/ShowOfHandsVotingChair.svelte | 39 +++++- src/lib/components/voting/VotingModal.svelte | 124 ++++++++++++++++++ src/lib/components/voting/VotingSetup.svelte | 74 ++--------- .../components/voting/VotingSetupForm.svelte | 71 ++++++++++ src/lib/components/voting/votingModal.ts | 50 +++++++ .../[committeeId]/(chairs)/+layout.svelte | 5 + 7 files changed, 337 insertions(+), 68 deletions(-) create mode 100644 src/lib/components/voting/VotingModal.svelte create mode 100644 src/lib/components/voting/VotingSetupForm.svelte create mode 100644 src/lib/components/voting/votingModal.ts diff --git a/src/lib/components/voting/RollCallVotingChair.svelte b/src/lib/components/voting/RollCallVotingChair.svelte index 8f730f85..4340fda4 100644 --- a/src/lib/components/voting/RollCallVotingChair.svelte +++ b/src/lib/components/voting/RollCallVotingChair.svelte @@ -14,6 +14,7 @@ sortTranslatedCountries } from '$lib/utils/nationTranslationHelper.svelte'; import { calculateMajority } from '$lib/utils/majorities'; + import type { VotingResult } from './votingModal'; interface Props { active: boolean; @@ -21,13 +22,46 @@ voteName?: string; majority?: VotingMajority; withAbstentions?: boolean; + oncomplete?: (result: VotingResult) => void; } - let { active = $bindable(), committee, voteName, majority, withAbstentions }: Props = $props(); + let { + active = $bindable(), + committee, + voteName, + majority, + withAbstentions, + oncomplete + }: Props = $props(); let currentIndex = $state(0); let stage = $state<'ROLL_CALL' | 'EVALUATION'>('ROLL_CALL'); + const exitVote = (completed: boolean = false) => { + if (oncomplete) { + if (completed) { + const votesFor = rollCallVotingPro?.length ?? 0; + const votesAgainst = rollCallVotingCon?.length ?? 0; + const votesAbstain = rollCallVotingAbstain?.length ?? 0; + oncomplete({ + outcome: votesFor >= majorityAmount ? 'ADOPTED' : 'REJECTED', + votesFor, + votesAgainst, + votesAbstain, + cancelled: false + }); + } else { + oncomplete({ + votesFor: 0, + votesAgainst: 0, + votesAbstain: 0, + cancelled: true + }); + } + } + active = false; + }; + let members = committee?.members .filter((member) => member.present && member.representation?.type === 'DELEGATION') .sort((a, b) => sortTranslatedCountries(a.representation!, b.representation!)); @@ -162,7 +196,7 @@ } break; case 'esc': - active = false; + exitVote(stage === 'EVALUATION'); break; } }); @@ -270,7 +304,7 @@
-
diff --git a/src/lib/components/voting/VotingModal.svelte b/src/lib/components/voting/VotingModal.svelte new file mode 100644 index 00000000..70d2c4d6 --- /dev/null +++ b/src/lib/components/voting/VotingModal.svelte @@ -0,0 +1,124 @@ + + +{#if setupOpen} + +

{m.voting()}

+ +
+{/if} + +{#if phase === 'EXECUTING' && executingOpen} + {#if voteType === 'SHOW_OF_HANDS'} + + {:else} + + {/if} +{/if} diff --git a/src/lib/components/voting/VotingSetup.svelte b/src/lib/components/voting/VotingSetup.svelte index da6d1bdf..9e3101d9 100644 --- a/src/lib/components/voting/VotingSetup.svelte +++ b/src/lib/components/voting/VotingSetup.svelte @@ -1,10 +1,9 @@ -
-
- {m.typeOfVoting()} - (voteType = tab)} /> -
-
- {m.majoritySettings()} -

{m.majoritySettingsDescriptions()}

- (majority = tab)} /> - (withAbstentions = tab)} - /> -
-
- {m.voteTitel()} - -

{m.voteTitleDescription()}

-
- - -
+ { + if (voteType === 'SHOW_OF_HANDS') { + showOfHandModalOpen = true; + } else if (voteType === 'ROLL_CALL') { + rollCallModalOpen = true; + } + }} +/> + import type { VotingMajority } from '$lib/local-db/localDB'; + import { m } from '$lib/paraglide/messages'; + import Tabs from '../Tabs.svelte'; + + interface Props { + voteType: 'SHOW_OF_HANDS' | 'ROLL_CALL'; + voteName: string; + majority: VotingMajority; + withAbstentions: boolean; + onstart: () => void; + } + + let { + voteType = $bindable(), + voteName = $bindable(), + majority = $bindable(), + withAbstentions = $bindable(), + onstart + }: Props = $props(); + + const voteTypeTabs: { + id: 'SHOW_OF_HANDS' | 'ROLL_CALL'; + label: string; + faIcon: string; + }[] = [ + { id: 'SHOW_OF_HANDS', label: m.showOfHandsVoting(), faIcon: 'hand-wave' }, + { id: 'ROLL_CALL', label: m.rollCallVoting(), faIcon: 'list-check' } + ]; + + const majorityTabs: { + id: VotingMajority; + label: string; + }[] = [ + { id: 'SIMPLE', label: m.simpleMajority() }, + { id: 'ABSOLUTE', label: m.absoluteMajority() }, + { id: 'TWO_THIRDS', label: m.twoThirdsMajority() } + ]; + + const withAbstentionsTabs = [ + { id: false, label: m.withoutAbstentions() }, + { id: true, label: m.withAbstentions() } + ]; + + +
+
+ {m.typeOfVoting()} + (voteType = tab)} /> +
+
+ {m.majoritySettings()} +

{m.majoritySettingsDescriptions()}

+ (majority = tab)} /> + (withAbstentions = tab)} + /> +
+
+ {m.voteTitel()} + +

{m.voteTitleDescription()}

+
+ + +
diff --git a/src/lib/components/voting/votingModal.ts b/src/lib/components/voting/votingModal.ts new file mode 100644 index 00000000..5c22c6ba --- /dev/null +++ b/src/lib/components/voting/votingModal.ts @@ -0,0 +1,50 @@ +import { writable } from 'svelte/store'; +import type { VotingMajority } from '$lib/local-db/localDB'; + +export interface VotingConfig { + voteName?: string; + majority?: VotingMajority; + voteType?: 'SHOW_OF_HANDS' | 'ROLL_CALL'; + withAbstentions?: boolean; +} + +export interface VotingResult { + outcome?: 'ADOPTED' | 'REJECTED'; + votesFor: number; + votesAgainst: number; + votesAbstain: number; + cancelled: boolean; +} + +interface VotingModalState { + config: VotingConfig; + onComplete: (result: VotingResult) => void; +} + +export const votingModalStore = writable(null); + +export function openVotingModal(config: VotingConfig = {}): Promise { + return new Promise((resolve) => { + votingModalStore.set({ + config, + onComplete: (result) => { + votingModalStore.set(null); + resolve(result); + } + }); + }); +} + +export function closeVotingModal(): void { + votingModalStore.update((state) => { + if (state) { + state.onComplete({ + votesFor: 0, + votesAgainst: 0, + votesAbstain: 0, + cancelled: true + }); + } + return null; + }); +} diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/+layout.svelte b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/+layout.svelte index b6ab2531..bd0eb1dd 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/+layout.svelte +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/+layout.svelte @@ -12,6 +12,7 @@ import { serverTime } from '$lib/state/serverTime.svelte'; import hotkeys from 'hotkeys-js'; import AdoptionConfetti from '$lib/components/AdoptionConfetti.svelte'; + import VotingModal from '$lib/components/voting/VotingModal.svelte'; import { CommitteeSubscription } from './committeeSubscription'; interface Props { @@ -118,6 +119,10 @@ oldStateOfDebate={committee?.stateOfDebate} /> +{#if committee} + +{/if} + Date: Fri, 6 Mar 2026 22:15:42 +0100 Subject: [PATCH 22/89] feat: add vote button to amendment queue for formal voting Co-Authored-By: Claude Opus 4.6 --- .../resolutions/[paperId]/+page.svelte | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte index 84daad80..62dbebd1 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte @@ -25,6 +25,7 @@ import CommentSection from '$lib/components/CommentSection.svelte'; import { getTranslatedCountryNameFromAlpha3Code } from '$lib/utils/nationTranslationHelper.svelte'; import toast from 'svelte-french-toast'; + import { openVotingModal } from '$lib/components/voting/votingModal'; import { fly, fade } from 'svelte/transition'; import { SvelteSet, SvelteMap } from 'svelte/reactivity'; @@ -577,6 +578,39 @@ } } + async function handleAmendmentVote(amendment: { + id: string; + type: string; + targetOperativeIndex?: number | null; + }) { + const typeLabel = getAmendmentTypeLabel(amendment.type); + const clauseLabel = + amendment.targetOperativeIndex != null ? `OP ${amendment.targetOperativeIndex + 1}` : ''; + const docNumber = paper?.documentNumber ?? m.draftResolution(); + const voteName = `${docNumber} – ${typeLabel} ${clauseLabel}`.trim(); + + const result = await openVotingModal({ + voteName, + majority: 'SIMPLE', + voteType: 'SHOW_OF_HANDS', + withAbstentions: true + }); + + if (!result.cancelled) { + try { + if (result.outcome === 'ADOPTED') { + await AcceptAmendmentMutation.mutate({ amendmentId: amendment.id }); + toast.success(m.amendmentAdopted()); + } else { + await RejectAmendmentMutation.mutate({ amendmentId: amendment.id }); + toast.success(m.amendmentRejectedToast()); + } + } catch { + toast.error(m.saveError()); + } + } + } + async function handleRejectAmendment(amendmentId: string) { try { await RejectAmendmentMutation.mutate({ amendmentId }); @@ -1248,6 +1282,13 @@ > {m.adoptByConsensus()} + + + {/if} +
{#if resolution} @@ -1147,9 +1170,7 @@ {resolution} {headerData} labels={getResolutionLabels()} - editable={paper.status !== 'AMENDMENT_PHASE' && - paper.status !== 'VOTING_PHASE' && - paper.status !== 'FINAL'} + editable={canEdit && editorMode === 'edit'} onResolutionChange={handleResolutionChange} onClauseLock={handleClauseLock} onClauseUnlock={handleClauseUnlock} diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte index 8d432aee..0dc16a0b 100644 --- a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte @@ -965,6 +965,14 @@ {/if} + + +
diff --git a/vite.config.ts b/vite.config.ts index 35ca107a..14ac629d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -60,6 +60,6 @@ export default defineConfig({ sveltekit() ], server: { - allowedHosts: ['happy-star-9669b56f.tunnl.gg'] + allowedHosts: ['svelte-dev.munify.cloud'] } }); From efd6679564b91bcd690d2045be4003ccef0bdb56 Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Sun, 8 Mar 2026 22:13:54 +0100 Subject: [PATCH 30/89] fix: change DR to RES in document number on adoption and remove abbreviation duplication Co-Authored-By: Claude Opus 4.6 --- src/api/handlers/resolutionPaper.ts | 9 +++- .../resolutions/[paperId]/+page.svelte | 2 +- .../papers/[paperId]/+page.svelte | 2 +- .../app/print/[documentId]/+page.svelte | 52 +++++++++++++++++++ 4 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 src/routes/app/print/[documentId]/+page.svelte diff --git a/src/api/handlers/resolutionPaper.ts b/src/api/handlers/resolutionPaper.ts index d11566e6..e54afc5c 100644 --- a/src/api/handlers/resolutionPaper.ts +++ b/src/api/handlers/resolutionPaper.ts @@ -601,7 +601,9 @@ schemaBuilder.mutationFields((t) => ({ votesAbstain: args.votesAbstain ?? 0 }); - const updateSet: { status: 'FINAL'; content?: unknown } = { status: 'FINAL' }; + const updateSet: { status: 'FINAL'; content?: unknown; documentNumber?: string } = { + status: 'FINAL' + }; if (args.outcome === 'ADOPTED') { const rejectedVotes = await tx.query.operativeClauseVote.findMany({ @@ -618,6 +620,11 @@ schemaBuilder.mutationFields((t) => ({ updateSet.content = parsed.data; } } + + // Change DR to RES in document number + if (paper.documentNumber) { + updateSet.documentNumber = paper.documentNumber.replace('/DR.', '/RES.'); + } } await tx diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte index e6424fbd..f7d43426 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte @@ -74,7 +74,7 @@ committeeAbbreviation: committee?.abbreviation ?? undefined, committeeFullName: committee?.name ?? undefined, committeeResolutionHeadline: committee?.resolutionHeadline ?? undefined, - documentNumber: paper?.documentNumber ?? undefined, + documentNumber: paper?.documentNumber?.replace(`${committee?.abbreviation}/`, '') ?? undefined, topic: paper?.agendaItem?.title ?? committee?.activeAgendaItem?.title ?? undefined, authoringDelegation: getTranslatedCountryNameFromAlpha3Code(paper?.creator?.representation?.alpha3Code) ?? diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte index 0dc16a0b..77c70bc3 100644 --- a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte @@ -120,7 +120,7 @@ committeeAbbreviation: committee?.abbreviation ?? undefined, committeeFullName: committee?.name ?? undefined, committeeResolutionHeadline: committee?.resolutionHeadline ?? undefined, - documentNumber: paper?.documentNumber ?? undefined, + documentNumber: paper?.documentNumber?.replace(`${committee?.abbreviation}/`, '') ?? undefined, topic: committee?.activeAgendaItem?.title ?? undefined, authoringDelegation: getTranslatedCountryNameFromAlpha3Code(paper?.creator?.representation?.alpha3Code) ?? diff --git a/src/routes/app/print/[documentId]/+page.svelte b/src/routes/app/print/[documentId]/+page.svelte new file mode 100644 index 00000000..d4d3e624 --- /dev/null +++ b/src/routes/app/print/[documentId]/+page.svelte @@ -0,0 +1,52 @@ + + +
+ {#if resolution} + + {:else} +
+ +
+ {/if} +
From d9df5dadbd37a6d21c4b9dfba69a3648a6a139f5 Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Sun, 8 Mar 2026 22:34:19 +0100 Subject: [PATCH 31/89] feat: add active amendment tracking for chairs and participants Allow chairs to mark which amendment is currently being discussed via a toggle button, visible to both chairs and participants with a green ring and badge indicator. Co-Authored-By: Claude Opus 4.6 --- messages/de.json | 2 ++ messages/en.json | 2 ++ .../[committeeId]/(chairs)/+layout.ts | 1 + .../(chairs)/committeeSubscription.ts | 1 + .../resolutions/[paperId]/+page.svelte | 33 ++++++++++++++++++- .../[committeeId]/committeeSubscription.ts | 1 + .../papers/[paperId]/+page.svelte | 23 +++++++++++-- 7 files changed, 60 insertions(+), 3 deletions(-) diff --git a/messages/de.json b/messages/de.json index 1f70c5db..cd014e03 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5,6 +5,7 @@ "absent": "Abwesend", "absoluteMajority": "Absolut", "abstain": "Enthaltung", + "activeAmendment": "In Behandlung", "activeDraftResolution": "In Behandlung", "addAgendaItem": "Punkt hinzufügen", "addAll": "Alle hinzufügen", @@ -445,6 +446,7 @@ "sentBack": "Zurückverwiesen", "seoDescription": "MUNify CHASE ist das kostenlose Open-Source-Debattenmanagement-Tool für Model United Nations Konferenzen. Redelisten, Abstimmungen und Resolutionen digital verwalten.", "seoTitle": "MUNify CHASE – Debattenmanagement für Model United Nations", + "setActiveAmendment": "Behandeln", "setActiveDr": "Aktiv setzen", "setActiveDrHint": "Setze einen Resolutionsentwurf in der Vorsitzansicht als aktiv, um ihn hier anzuzeigen.", "setAllAbsent": "Alle Abwesend setzen", diff --git a/messages/en.json b/messages/en.json index 0002c641..8c3dfb6c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5,6 +5,7 @@ "absent": "Absent", "absoluteMajority": "Absolute", "abstain": "Abstain", + "activeAmendment": "Being Discussed", "activeDraftResolution": "In Progress", "addAgendaItem": "Add Item", "addAll": "Add All", @@ -445,6 +446,7 @@ "sentBack": "Sent Back", "seoDescription": "MUNify CHASE is the free, open-source debate management tool for Model United Nations conferences. Manage speakers lists, voting, and resolutions digitally.", "seoTitle": "MUNify CHASE – Debate Management for Model United Nations", + "setActiveAmendment": "Discuss", "setActiveDr": "Set Active", "setActiveDrHint": "Set a draft resolution as active in the chair view to display it here.", "setAllAbsent": "Set All Absent", diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/+layout.ts b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/+layout.ts index 5a0079aa..cd50584d 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/+layout.ts +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/+layout.ts @@ -20,6 +20,7 @@ export const _houdini_load = graphql(` activeDraftResolutionId supportReEvaluationOpen currentOperativeIndex + activeAmendmentId whiteboardContent lastResolutionAdoptionDate allowDelegationsToAddThemselvesToSpeakersList diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/committeeSubscription.ts b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/committeeSubscription.ts index acff65ca..aa404e3e 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/committeeSubscription.ts +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/committeeSubscription.ts @@ -19,6 +19,7 @@ export const CommitteeSubscription = graphql(` activeDraftResolutionId supportReEvaluationOpen currentOperativeIndex + activeAmendmentId whiteboardContent lastResolutionAdoptionDate allowDelegationsToAddThemselvesToSpeakersList diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte index f7d43426..53c4cf30 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte @@ -481,6 +481,7 @@ let submittedAmendments = $derived(allAmendments.filter((a) => a.status === 'SUBMITTED')); let currentOpIndex = $derived(committee?.currentOperativeIndex ?? 0); + let activeAmendmentId = $derived(committee?.activeAmendmentId ?? null); let operativeClauses = $derived((resolution?.operative ?? []) as OperativeClause[]); @@ -701,6 +702,25 @@ } } + async function handleSetActiveAmendment(amendmentId: string | null) { + if (!committee) return; + try { + if (amendmentId) { + await UpdateCommitteeMutation.mutate({ + id: committee.id, + activeAmendmentId: amendmentId + }); + } else { + await UpdateCommitteeMutation.mutate({ + id: committee.id, + clearActiveAmendment: true + }); + } + } catch { + toast.error(m.saveError()); + } + } + async function handleAdvanceParagraph() { if (!committee) return; try { @@ -1332,11 +1352,12 @@ {#each sortedSubmittedAmendments as amendment (amendment.id)} {@const isCurrentParagraph = (amendment.targetOperativeIndex ?? -1) === currentOpIndex} + {@const isActive = amendment.id === activeAmendmentId}
@@ -1363,8 +1384,18 @@ {amendment.sponsors?.length ?? 0} {m.sponsors()} + {#if isActive} + {m.activeAmendment()} + {/if}
+ +
+ + +
    + {#if isChairMode} +
  • {m.selectProposerDelegation()}
  • + {/if} +
  • {m.selectAmendmentType()}
  • + {#if totalSteps > (isChairMode ? 2 : 1)} +
  • {m.edit()}
  • + {/if} +
+ + {#if isChairMode && step === 1} + + +
+ {#each filteredMembers as member (member.id)} + + {/each} + {#if filteredMembers.length === 0} +

{m.noResults()}

+ {/if} +
+ {:else if step === typeStep} + +
+
+ + + + +
+ + {#if selectedType} +
+ + {#if selectedType === 'ADD'} + + {:else if operativeClauses.length > 0} + + {:else} +

{m.noResults()}

+ {/if} +
+ +
+ {#if isChairMode} + + {/if} + +
+ {/if} +
+ {:else if step === contentStep} + +
+ {#if selectedType === 'ALTER_TEXT' || selectedType === 'ADD'} + {#if selectedType === 'ALTER_TEXT'} +

+ {m.alterText()} — OP {selectedSourceIndex + 1} +

+ {:else} +

+ {m.addClause()} — {m.targetPosition()}: + + {#if targetPosition === -1} + {m.insertAtBeginning()} + {:else} + {m.insertAfterPresentation({ index: String(targetPosition + 1) })} + {/if} + +

+ {/if} + {#if miniResolution} +
+ { + if (updated.operative[0]) { + newContent = updated.operative[0] as OperativeClause; + } + }} + /> +
+ {/if} + {:else if selectedType === 'ALTER_POSITION'} +

+ {m.alterPosition()} — OP {selectedSourceIndex + 1} +

+
+ + +
+ {/if} + +
+ + +
+
+ {/if} + diff --git a/src/lib/components/Modal.svelte b/src/lib/components/Modal.svelte index d52e2ce1..b107c1a1 100644 --- a/src/lib/components/Modal.svelte +++ b/src/lib/components/Modal.svelte @@ -15,14 +15,17 @@ hotkeys('esc', () => { open = false; }); - } else { - hotkeys.unbind('esc'); + return () => { + hotkeys.unbind('esc'); + }; } }); - - - +{#if open} + + + +{/if} diff --git a/src/lib/components/voting/VotingModal.svelte b/src/lib/components/voting/VotingModal.svelte index 70d2c4d6..a7d99511 100644 --- a/src/lib/components/voting/VotingModal.svelte +++ b/src/lib/components/voting/VotingModal.svelte @@ -1,6 +1,6 @@ @@ -1383,7 +1285,6 @@
{#each myAmendments as amendment (amendment.id)} {@const sponsorCount = amendment.sponsors?.length ?? 0} - {@const thresholdMet = sponsorCount >= sponsorThresholdNeeded} {@const isActive = amendment.id === activeAmendmentId}
- - -
- {#if amendment.status === 'PENDING'} - - {/if} - -
{/each} @@ -1484,12 +1366,6 @@
{/if} - {/each} @@ -1497,10 +1373,10 @@ {/if} - + {@const otherPendingAmendments = allAmendments.filter( (a) => - a.status === 'PENDING' && + a.status === 'SUBMITTED' && a.proposerCommitteeMemberId !== myCommitteeMemberId && !a.sponsors?.some((s) => s.committeeMemberId === myCommitteeMemberId) )} @@ -1656,78 +1532,12 @@ - -
-
-

{m.proposeAmendment()}

- - {getAmendmentTypeLabel(amendmentType)} - -
- - {#if amendmentType === 'DELETE'} -

- {m.deleteClause()} — OP {amendmentTargetIndex + 1} -

- {:else if amendmentType === 'ALTER_TEXT'} -

- {m.alterText()} — OP {amendmentTargetIndex + 1} -

- {#if miniResolution} -
- -
- {/if} - {:else if amendmentType === 'ADD'} -

- {m.addClause()} — {m.targetPosition()}: - OP {(amendmentTargetPosition ?? 0) + 1} -

- {#if miniResolution} -
- -
- {/if} - {:else if amendmentType === 'ALTER_POSITION'} -

- {m.alterPosition()} — OP {amendmentTargetIndex + 1} -

- - - {/if} - -
- - -
-
-
+ {/if} diff --git a/src/routes/app/print/[documentId]/+page.svelte b/src/routes/app/print/[documentId]/+page.svelte index e211c177..7f40da48 100644 --- a/src/routes/app/print/[documentId]/+page.svelte +++ b/src/routes/app/print/[documentId]/+page.svelte @@ -17,7 +17,7 @@ let query = $derived(data?.PrintPaperQuery); let paper = $derived($query.data?.findFirstResolutionPaper); let clauseVotes = $derived($query.data?.findManyOperativeClauseVote ?? []); - let voteResult = $derived($query.data?.findFirstResolutionVoteResult ?? null); + let voteResult = $derived($query.data?.findManyResolutionVoteResult?.[0] ?? null); let resolution = $derived(paper?.content ? migrateResolution(paper.content as Resolution) : null); let operativeClauses = $derived((resolution?.operative ?? []) as OperativeClause[]); diff --git a/src/routes/app/print/[documentId]/+page.ts b/src/routes/app/print/[documentId]/+page.ts index d90da69f..b518d567 100644 --- a/src/routes/app/print/[documentId]/+page.ts +++ b/src/routes/app/print/[documentId]/+page.ts @@ -44,7 +44,7 @@ export const _houdini_load = graphql(` votesAgainst votesAbstain } - findFirstResolutionVoteResult(where: { paperId: $documentId }) { + findManyResolutionVoteResult(where: { paperId: $documentId }, limit: 1) { id outcome votesFor From 2cddf42dade308456f87295ade4689e1ff5b42d0 Mon Sep 17 00:00:00 2001 From: m1212e <14091540+m1212e@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:36:22 +0100 Subject: [PATCH 43/89] improvement: show state handing in in list view --- messages/de.json | 3 ++- messages/en.json | 4 +++- .../[committeeId]/(chairs)/resolutions/+page.svelte | 2 ++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/messages/de.json b/messages/de.json index 23f46707..f5adc971 100644 --- a/messages/de.json +++ b/messages/de.json @@ -587,5 +587,6 @@ "yes": "Ja", "you": "Du", "youCannotEditYourself": "Du kannst deine eigene Rolle nicht bearbeiten", - "youreUp": "Du bist dran!" + "youreUp": "Du bist dran!", + "submittedBy": "Eingereicht durch" } diff --git a/messages/en.json b/messages/en.json index c2f8da8c..10f61e28 100644 --- a/messages/en.json +++ b/messages/en.json @@ -587,5 +587,7 @@ "yes": "Yes", "you": "You", "youCannotEditYourself": "You cannot edit your own role", - "youreUp": "You're up!" + "youreUp": "You're up!", + "submittedBy": "Submitted by" + } diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.svelte b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.svelte index 70b54e58..f5cf9cc0 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.svelte +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.svelte @@ -426,6 +426,8 @@ count: String(paper.sponsors.length) })} + {m.submittedBy()}: {paper.creator.represenation?.name || + getTranslatedCountryNameFromAlpha3Code(paper.creator.represenation?.alpha3Code)} {#if paper.status === 'DRAFT_RESOLUTION' || paper.status === 'AMENDMENT_PHASE' || paper.status === 'VOTING_PHASE'} From b63146c962b03ef338c7c02b3475dcf46d927f4a Mon Sep 17 00:00:00 2001 From: m1212e <14091540+m1212e@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:46:25 +0100 Subject: [PATCH 44/89] format: run formatter --- messages/de.json | 4 ++-- messages/en.json | 6 +++--- .../[committeeId]/(chairs)/resolutions/+page.svelte | 8 ++++++-- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/messages/de.json b/messages/de.json index f5adc971..5cd46877 100644 --- a/messages/de.json +++ b/messages/de.json @@ -499,6 +499,7 @@ "submitStatus": "Status setzen", "submitToChair": "An Vorsitz einreichen", "submitted": "Eingereicht", + "submittedBy": "Eingereicht durch", "submittedPapers": "Eingereichte Papiere", "submittedPapersDescription": "Von Delegierten eingereichte Papiere, sortiert nach Unterstützerzahl", "submittingNation": "Einreichender Staat", @@ -587,6 +588,5 @@ "yes": "Ja", "you": "Du", "youCannotEditYourself": "Du kannst deine eigene Rolle nicht bearbeiten", - "youreUp": "Du bist dran!", - "submittedBy": "Eingereicht durch" + "youreUp": "Du bist dran!" } diff --git a/messages/en.json b/messages/en.json index 10f61e28..dc0683c2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -499,6 +499,8 @@ "submitStatus": "Set status", "submitToChair": "Submit to Chair", "submitted": "Submitted", + "submittedBy": "Submitted by", + "submittedPapers": "Submitted Papers", "submittedPapersDescription": "Papers submitted by delegates, ranked by sponsor count", "submittingNation": "Submitting Nation", @@ -587,7 +589,5 @@ "yes": "Yes", "you": "You", "youCannotEditYourself": "You cannot edit your own role", - "youreUp": "You're up!", - "submittedBy": "Submitted by" - + "youreUp": "You're up!" } diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.svelte b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.svelte index f5cf9cc0..1b6728e5 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.svelte +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.svelte @@ -426,8 +426,12 @@ count: String(paper.sponsors.length) })} - {m.submittedBy()}: {paper.creator.represenation?.name || - getTranslatedCountryNameFromAlpha3Code(paper.creator.represenation?.alpha3Code)} + {m.submittedBy()}: {paper.creator.represenation?.name || + getTranslatedCountryNameFromAlpha3Code( + paper.creator.represenation?.alpha3Code + )} {#if paper.status === 'DRAFT_RESOLUTION' || paper.status === 'AMENDMENT_PHASE' || paper.status === 'VOTING_PHASE'} From 234de9f89be423cb2fc60a847dc9f59b31635ff3 Mon Sep 17 00:00:00 2001 From: m1212e <14091540+m1212e@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:48:15 +0100 Subject: [PATCH 45/89] fix: typo --- .../[committeeId]/(chairs)/resolutions/+page.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.svelte b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.svelte index 1b6728e5..9be5a8f2 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.svelte +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.svelte @@ -427,9 +427,9 @@ })} {m.submittedBy()}: {paper.creator.represenation?.name || + >{m.submittedBy()}: {paper.creator.representation?.name || getTranslatedCountryNameFromAlpha3Code( - paper.creator.represenation?.alpha3Code + paper.creator.representation?.alpha3Code )} From c0a47136a7fbbb4f7d78b48ae8b5444ec1ae8210 Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Wed, 11 Mar 2026 01:00:32 +0100 Subject: [PATCH 46/89] feat: allow chairs to edit submitted amendments Add editAmendment mutation and edit mode to CreateAmendmentModal, enabling chairs to modify target, content, and proposer of SUBMITTED amendments while keeping the amendment type fixed. Co-Authored-By: Claude Opus 4.6 --- messages/de.json | 3 + messages/en.json | 3 + schema.graphql | 1 + src/api/handlers/amendment.ts | 162 ++++++++++++++++++ .../components/CreateAmendmentModal.svelte | 133 ++++++++++---- .../resolutions/[paperId]/+page.svelte | 78 +++++++++ 6 files changed, 346 insertions(+), 34 deletions(-) diff --git a/messages/de.json b/messages/de.json index 5cd46877..5c57e108 100644 --- a/messages/de.json +++ b/messages/de.json @@ -58,6 +58,7 @@ "amendmentRejectedToast": "Änderungsantrag abgelehnt", "amendmentSubmitted": "Eingereicht", "amendmentSubmittedToast": "Änderungsantrag eingereicht", + "amendmentUpdated": "Antrag aktualisiert", "amendmentWithdrawn": "Zurückgezogen", "amendmentWithdrawnToast": "Änderungsantrag zurückgezogen", "amendments": "Änderungsanträge", @@ -178,6 +179,7 @@ "draftResolutions": "Resolutionsentwürfe", "edit": "Bearbeiten", "editAccess": "Bearbeitungszugriff", + "editAmendment": "Antrag bearbeiten", "editComment": "Bearbeiten", "editPaper": "Papier bearbeiten", "editUser": "Benutzer bearbeiten", @@ -437,6 +439,7 @@ "rollCollError": "Gremienmitglied nicht gefunden", "rollCollSuccess": "Anwesenheitsfeststellung abgeschlossen", "save": "Speichern", + "saveChanges": "Änderungen speichern", "saveError": "Speichern fehlgeschlagen", "savingChanges": "Speichern...", "searchCommitteeMembers": "Gremienmitglieder durchsuchen", diff --git a/messages/en.json b/messages/en.json index dc0683c2..0ca2f3b9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -58,6 +58,7 @@ "amendmentRejectedToast": "Amendment rejected", "amendmentSubmitted": "Submitted", "amendmentSubmittedToast": "Amendment submitted", + "amendmentUpdated": "Amendment updated", "amendmentWithdrawn": "Withdrawn", "amendmentWithdrawnToast": "Amendment withdrawn", "amendments": "Amendments", @@ -178,6 +179,7 @@ "draftResolutions": "Draft Resolutions", "edit": "Edit", "editAccess": "Edit Access", + "editAmendment": "Edit Amendment", "editComment": "Edit", "editPaper": "Edit Paper", "editUser": "Edit User", @@ -437,6 +439,7 @@ "rollCollError": "Committee member not found", "rollCollSuccess": "Roll call complete", "save": "Save", + "saveChanges": "Save Changes", "saveError": "Save failed", "savingChanges": "Saving...", "searchCommitteeMembers": "Search committee members", diff --git a/schema.graphql b/schema.graphql index 581a7c89..e24db41e 100644 --- a/schema.graphql +++ b/schema.graphql @@ -410,6 +410,7 @@ type Mutation { deleteConferenceUser(id: ID!): Boolean deleteRepresentation(id: ID!): Boolean deleteShareCode(shareCodeId: ID!): Boolean + editAmendment(amendmentId: ID!, newContent: JSON, proposerCommitteeMemberId: ID, targetClauseId: String, targetOperativeIndex: Int, targetPosition: Int): Amendment importDelegatorConference(data: ImportData!): Conference moveSpeakerToPosition(id: ID!, position: Int!): SpeakerOnList promoteToDraftResolution(paperId: ID!): ResolutionPaper diff --git a/src/api/handlers/amendment.ts b/src/api/handlers/amendment.ts index 6010341f..b4e80486 100644 --- a/src/api/handlers/amendment.ts +++ b/src/api/handlers/amendment.ts @@ -591,6 +591,168 @@ schemaBuilder.mutationFields((t) => ({ pubsub.updated(args.amendmentId); paperPubsub.updated(amendment.paperId); + return db.query.amendment + .findFirst( + query( + ctx.abilities.amendment.filter('read', { + inject: { where: { id: args.amendmentId } } + }).query.single + ) + ) + .then(assertFindFirstExists); + } + }), + + editAmendment: t.drizzleField({ + type: ref, + args: { + amendmentId: t.arg.id({ required: true }), + targetClauseId: t.arg.string(), + targetOperativeIndex: t.arg.int(), + targetPosition: t.arg.int(), + newContent: t.arg({ type: 'JSON' }), + proposerCommitteeMemberId: t.arg.id() + }, + resolve: async (query, root, args, ctx, info) => { + const amendment = await db.query.amendment + .findFirst({ where: { id: args.amendmentId } }) + .then(assertFindFirstExists); + + if (amendment.status !== 'SUBMITTED') { + throw new GraphQLError('Only SUBMITTED amendments can be edited'); + } + + const paper = await db.query.resolutionPaper + .findFirst({ where: { id: amendment.paperId } }) + .then(assertFindFirstExists); + + await assertCommitteeChairOrAdmin(ctx, paper.committeeId); + + // Merge provided args with existing values + const merged = { + targetClauseId: + args.targetClauseId !== undefined ? args.targetClauseId : amendment.targetClauseId, + targetOperativeIndex: + args.targetOperativeIndex !== undefined + ? args.targetOperativeIndex + : amendment.targetOperativeIndex, + targetPosition: + args.targetPosition !== undefined ? args.targetPosition : amendment.targetPosition, + newContent: args.newContent !== undefined ? args.newContent : amendment.newContent + }; + + // Re-validate with merged values + validateAmendmentArgs(amendment.type, merged); + + // Validate clauseId matches paper content if provided + if ( + merged.targetClauseId && + merged.targetOperativeIndex !== undefined && + merged.targetOperativeIndex !== null + ) { + const parsed = ResolutionSchema.safeParse(paper.content); + if (parsed.success) { + const clause = parsed.data.operative[merged.targetOperativeIndex]; + if (!clause || clause.id !== merged.targetClauseId) { + throw new GraphQLError('Clause ID does not match at the given index'); + } + } + } + + // Validate newContent if provided + if (args.newContent) { + const parsedContent = OperativeClauseSchema.safeParse(args.newContent); + if (!parsedContent.success) { + throw new GraphQLError('Invalid newContent: ' + parsedContent.error.message); + } + } + + // Check if anything actually changed + const updateFields: Record = {}; + if (args.targetClauseId !== undefined && args.targetClauseId !== amendment.targetClauseId) { + updateFields.targetClauseId = args.targetClauseId; + } + if ( + args.targetOperativeIndex !== undefined && + args.targetOperativeIndex !== amendment.targetOperativeIndex + ) { + updateFields.targetOperativeIndex = args.targetOperativeIndex; + } + if (args.targetPosition !== undefined && args.targetPosition !== amendment.targetPosition) { + updateFields.targetPosition = args.targetPosition; + } + if (args.newContent !== undefined) { + updateFields.newContent = args.newContent; + } + + const proposerChanged = + args.proposerCommitteeMemberId !== undefined && + args.proposerCommitteeMemberId !== null && + args.proposerCommitteeMemberId !== amendment.proposerCommitteeMemberId; + + if (Object.keys(updateFields).length === 0 && !proposerChanged) { + // Nothing changed + return db.query.amendment + .findFirst( + query( + ctx.abilities.amendment.filter('read', { + inject: { where: { id: args.amendmentId } } + }).query.single + ) + ) + .then(assertFindFirstExists); + } + + if (proposerChanged) { + // Validate new proposer belongs to committee + await db.query.committeeMember + .findFirst({ + where: { id: args.proposerCommitteeMemberId!, committeeId: paper.committeeId } + }) + .then(assertFindFirstExists); + + updateFields.proposerCommitteeMemberId = args.proposerCommitteeMemberId; + } + + await db.transaction(async (tx) => { + if (proposerChanged) { + const oldProposerId = amendment.proposerCommitteeMemberId; + const newProposerId = args.proposerCommitteeMemberId!; + + // Remove old proposer's sponsor entry + await tx + .delete(schema.amendmentSponsor) + .where( + and( + eq(schema.amendmentSponsor.amendmentId, args.amendmentId), + eq(schema.amendmentSponsor.committeeMemberId, oldProposerId) + ) + ); + + // Add new proposer as sponsor if not already one + const existingSponsor = await tx.query.amendmentSponsor.findFirst({ + where: { + amendmentId: args.amendmentId, + committeeMemberId: newProposerId + } + }); + if (!existingSponsor) { + await tx.insert(schema.amendmentSponsor).values({ + amendmentId: args.amendmentId, + committeeMemberId: newProposerId + }); + } + } + + await tx + .update(schema.amendment) + .set(updateFields) + .where(eq(schema.amendment.id, args.amendmentId)); + }); + + pubsub.updated(args.amendmentId); + paperPubsub.updated(amendment.paperId); + return db.query.amendment .findFirst( query( diff --git a/src/lib/components/CreateAmendmentModal.svelte b/src/lib/components/CreateAmendmentModal.svelte index 1a9bbc67..f6d94ae1 100644 --- a/src/lib/components/CreateAmendmentModal.svelte +++ b/src/lib/components/CreateAmendmentModal.svelte @@ -29,6 +29,12 @@ initialType?: AmendmentType; initialTargetIndex?: number; + // Edit mode support + editMode?: boolean; + initialProposerId?: string | null; + initialNewContent?: OperativeClause | null; + initialTargetPosition?: number | null; + // Called on submit with all args needed for the mutation onSubmit: (args: { type: AmendmentType; @@ -47,6 +53,10 @@ committeeMembers, initialType, initialTargetIndex, + editMode = false, + initialProposerId, + initialNewContent, + initialTargetPosition, onSubmit }: Props = $props(); @@ -68,7 +78,32 @@ // Reset on open change $effect(() => { if (open) { - if (initialType != null && initialTargetIndex != null) { + if (editMode && initialType != null) { + // Edit mode: pre-fill all fields, type is locked + selectedType = initialType; + selectedSourceIndex = initialTargetIndex ?? 0; + selectedProposer = initialProposerId ?? null; + proposerSearchQuery = ''; + submitting = false; + + if (initialNewContent) { + newContent = JSON.parse(JSON.stringify(initialNewContent)); + } else if (initialType === 'ALTER_TEXT') { + const clause = operativeClauses[selectedSourceIndex]; + if (clause) newContent = JSON.parse(JSON.stringify(clause)); + else newContent = null; + } else { + newContent = null; + } + targetPosition = initialTargetPosition ?? 0; + + if (initialType === 'DELETE') { + // For DELETE, go to type step so they can change target + step = typeStep; + } else { + step = contentStep; + } + } else if (initialType != null && initialTargetIndex != null) { // Pre-filled mode from participant inline buttons selectedType = initialType; selectedSourceIndex = initialTargetIndex; @@ -209,7 +244,7 @@

- {isChairMode ? m.chairCreateAmendment() : m.proposeAmendment()} + {editMode ? m.editAmendment() : isChairMode ? m.chairCreateAmendment() : m.proposeAmendment()}

- - - -
+ {#if editMode && selectedType} + + {@const typeBadgeClass = { + DELETE: 'badge-error', + ADD: 'badge-success', + ALTER_TEXT: 'badge-warning', + ALTER_POSITION: 'badge-info' + }[selectedType]} + {@const typeIcon = { + DELETE: 'fa-trash', + ADD: 'fa-plus', + ALTER_TEXT: 'fa-pen', + ALTER_POSITION: 'fa-arrows-alt' + }[selectedType]} + {@const typeLabel = { + DELETE: m.amendmentDelete(), + ADD: m.amendmentAdd(), + ALTER_TEXT: m.amendmentAlterText(), + ALTER_POSITION: m.amendmentAlterPosition() + }[selectedType]} +
+ + {typeLabel} +
+ {:else} +
+ + + + +
+ {/if} {#if selectedType}
@@ -322,7 +383,11 @@ onclick={handleConfirmType} disabled={selectedType !== 'ADD' && operativeClauses.length === 0} > - {selectedType === 'DELETE' ? m.submitAmendment() : m.forward()} + {selectedType === 'DELETE' + ? editMode + ? m.saveChanges() + : m.submitAmendment() + : m.forward()}
{/if} @@ -390,7 +455,7 @@ {m.back()} diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte index a05a9f12..dbe2268a 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte @@ -933,6 +933,60 @@ toast.success(m.amendmentCreated()); } + // ===================================================== + // Chair Edit Amendment + // ===================================================== + + const ChairEditAmendmentMutation = graphql(` + mutation ChairEditAmendmentMutation( + $amendmentId: ID! + $targetClauseId: String + $targetOperativeIndex: Int + $targetPosition: Int + $newContent: JSON + $proposerCommitteeMemberId: ID + ) { + editAmendment( + amendmentId: $amendmentId + targetClauseId: $targetClauseId + targetOperativeIndex: $targetOperativeIndex + targetPosition: $targetPosition + newContent: $newContent + proposerCommitteeMemberId: $proposerCommitteeMemberId + ) { + id + } + } + `); + + let editingAmendment = $state<(typeof allAmendments)[0] | null>(null); + let showEditAmendmentModal = $state(false); + + function openEditAmendment(amendment: (typeof allAmendments)[0]) { + editingAmendment = amendment; + showEditAmendmentModal = true; + } + + async function handleEditAmendmentSubmit(args: { + type: 'DELETE' | 'ADD' | 'ALTER_TEXT' | 'ALTER_POSITION'; + targetClauseId: string | null; + targetOperativeIndex: number | null; + targetPosition: number | null; + newContent: OperativeClause | null; + committeeMemberId?: string; + }) { + if (!editingAmendment) return; + await ChairEditAmendmentMutation.mutate({ + amendmentId: editingAmendment.id, + targetClauseId: args.targetClauseId, + targetOperativeIndex: args.targetOperativeIndex, + targetPosition: args.targetPosition, + newContent: args.newContent, + proposerCommitteeMemberId: args.committeeMemberId ?? null + }); + toast.success(m.amendmentUpdated()); + } + // ===================================================== // Voting Phase (Phase 7) // ===================================================== @@ -1741,6 +1795,13 @@ > {m.amendmentRejected()} + +
+ {#if marginIcon} + + + {:else} + + + {/if} {#if expanded}
{#if topLevelComments.length === 0} diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/committeeSubscription.ts b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/committeeSubscription.ts index aa404e3e..b515c853 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/committeeSubscription.ts +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/committeeSubscription.ts @@ -18,6 +18,8 @@ export const CommitteeSubscription = graphql(` maxDraftResolutions activeDraftResolutionId supportReEvaluationOpen + amendmentSubmissionOpen + amendmentSponsoringOpen currentOperativeIndex activeAmendmentId whiteboardContent diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.svelte b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.svelte index 9be5a8f2..9a90b05e 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.svelte +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/+page.svelte @@ -83,6 +83,8 @@ $activeDraftResolutionId: ID $clearActiveDraftResolution: Boolean $supportReEvaluationOpen: Boolean + $amendmentSubmissionOpen: Boolean + $amendmentSponsoringOpen: Boolean $currentOperativeIndex: Int ) { updateCommittee( @@ -90,11 +92,15 @@ activeDraftResolutionId: $activeDraftResolutionId clearActiveDraftResolution: $clearActiveDraftResolution supportReEvaluationOpen: $supportReEvaluationOpen + amendmentSubmissionOpen: $amendmentSubmissionOpen + amendmentSponsoringOpen: $amendmentSponsoringOpen currentOperativeIndex: $currentOperativeIndex ) { id activeDraftResolutionId supportReEvaluationOpen + amendmentSubmissionOpen + amendmentSponsoringOpen currentOperativeIndex } } @@ -162,6 +168,28 @@ } } + async function toggleAmendmentSubmission(open: boolean) { + try { + await UpdateCommitteeMutation.mutate({ + id: page.params.committeeId!, + amendmentSubmissionOpen: open + }); + } catch { + toast.error(m.saveError()); + } + } + + async function toggleAmendmentSponsoring(open: boolean) { + try { + await UpdateCommitteeMutation.mutate({ + id: page.params.committeeId!, + amendmentSponsoringOpen: open + }); + } catch { + toast.error(m.saveError()); + } + } + async function startAmendmentPhase() { try { await UpdateCommitteeMutation.mutate({ @@ -483,6 +511,50 @@
+ +
+
+
+

{m.amendmentSubmission()}

+

+ {#if committee.amendmentSubmissionOpen} + {m.amendmentSubmissionOpen()} + {:else} + {m.amendmentSubmissionClosed()} + {/if} +

+
+ toggleAmendmentSubmission(!committee.amendmentSubmissionOpen)} + /> +
+
+ + +
+
+
+

{m.amendmentSponsoring()}

+

+ {#if committee.amendmentSponsoringOpen} + {m.amendmentSponsoringOpen()} + {:else} + {m.amendmentSponsoringClosed()} + {/if} +

+
+ toggleAmendmentSponsoring(!committee.amendmentSponsoringOpen)} + /> +
+
+ {#if canStartAmendmentPhase}
diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte index dbe2268a..8061276e 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/+page.svelte @@ -1560,6 +1560,34 @@ {onDeleteComment} /> {/snippet} + {#snippet afterPreambleClause({ clause })} + + onCreateComment(content, visibility, parentCommentId, clause.id)} + {onUpdateComment} + {onDeleteComment} + /> + {/snippet} + {#snippet afterOperativeClause({ clause })} + + onCreateComment(content, visibility, parentCommentId, clause.id)} + {onUpdateComment} + {onDeleteComment} + /> + {/snippet} {/if}
diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/chairCommentsSubscription.ts b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/chairCommentsSubscription.ts index 838dbb15..93748b3f 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/chairCommentsSubscription.ts +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/resolutions/[paperId]/chairCommentsSubscription.ts @@ -12,6 +12,10 @@ export const ChairPaperCommentsSubscription = graphql(` updatedAt author { id + user { + givenName + familyName + } committeeMember { representation { name diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/committeeSubscription.ts b/src/routes/app/[conferenceId]/participant/[committeeId]/committeeSubscription.ts index e076d3c8..632aa0bd 100644 --- a/src/routes/app/[conferenceId]/participant/[committeeId]/committeeSubscription.ts +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/committeeSubscription.ts @@ -14,6 +14,8 @@ export const ParticipantCommitteeSubscription = graphql(` whiteboardContent allowDelegationsToAddThemselvesToSpeakersList supportReEvaluationOpen + amendmentSubmissionOpen + amendmentSponsoringOpen activeDraftResolutionId totalPresent simpleMajority diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte index 5fdb0889..0b11e46e 100644 --- a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte @@ -1100,12 +1100,11 @@ onAmendmentClick={showAmendmentUI ? handleAmendmentClick : undefined} > {#snippet betweenOperativeClauses({ index })} - {#if showAmendmentUI && isDelegate} + {#if showAmendmentUI && isDelegate && committeeSubscriptionData?.amendmentSubmissionOpen}

{m.baseFontSizeDescription()}

+
+
+
+ + + localDB.committeeSettings.update(committeeId, { + presentationResolutionFontSize: +(e.target as HTMLInputElement).value + })} + class="range range-primary w-full" + /> + {$committeeSettings?.presentationResolutionFontSize || '?'} +
+
+

{m.resolutionFontSizeDescription()}

diff --git a/src/routes/app/[conferenceId]/[committeeId]/(presentation)/+page.svelte b/src/routes/app/[conferenceId]/[committeeId]/(presentation)/+page.svelte index a29e0db6..3aad924c 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(presentation)/+page.svelte +++ b/src/routes/app/[conferenceId]/[committeeId]/(presentation)/+page.svelte @@ -168,7 +168,10 @@ {#if layout.resolutionPreview} {@const gridProps = layout.resolutionPreview} - + {/if} diff --git a/src/routes/app/[conferenceId]/[committeeId]/(presentation)/PresentationResolutionPreview.svelte b/src/routes/app/[conferenceId]/[committeeId]/(presentation)/PresentationResolutionPreview.svelte index 34291991..27301b12 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(presentation)/PresentationResolutionPreview.svelte +++ b/src/routes/app/[conferenceId]/[committeeId]/(presentation)/PresentationResolutionPreview.svelte @@ -13,6 +13,7 @@ import { SvelteMap } from 'svelte/reactivity'; interface Props { + resolutionFontSize?: number; committee: { abbreviation: string; name: string; @@ -98,7 +99,7 @@ }; } - let { committee }: Props = $props(); + let { committee, resolutionFontSize = 16 }: Props = $props(); let dr = $derived(committee.activeDraftResolution); let activeAmendment = $derived(committee.activeAmendment); @@ -215,68 +216,104 @@ } -{#if !dr} - -
- -

{m.noActiveDraftResolution()}

-

{m.setActiveDrHint()}

-
-{:else if dr.status === 'DRAFT_RESOLUTION' && resolution} - -
- -
-{:else if (dr.status === 'AMENDMENT_PHASE' || dr.status === 'VOTING_PHASE') && resolution} - {#if activeAmendment && dr.status === 'AMENDMENT_PHASE'} - -
-
- - {activeAmendment.documentNumber ?? getAmendmentTypeLabel(activeAmendment.type)} - - {m.proposedAmendmentPresentation()} - {#if activeAmendment.proposer?.representation} -
- - {getProposerName(activeAmendment.proposer)} -
- {/if} -
- - {#if activeAmendment.type === 'DELETE' && activeAmendment.targetOperativeIndex != null} - - {@const targetClause = resolution.operative[activeAmendment.targetOperativeIndex]} -
- {m.operativeClausePresentation()} - {activeAmendment.targetOperativeIndex + 1} +
+ {#if !dr} + +
+ +

{m.noActiveDraftResolution()}

+

{m.setActiveDrHint()}

+
+ {:else if dr.status === 'DRAFT_RESOLUTION' && resolution} + +
+ +
+ {:else if (dr.status === 'AMENDMENT_PHASE' || dr.status === 'VOTING_PHASE') && resolution} + {#if activeAmendment && dr.status === 'AMENDMENT_PHASE'} + +
+
+ + {activeAmendment.documentNumber ?? getAmendmentTypeLabel(activeAmendment.type)} + + {m.proposedAmendmentPresentation()} + {#if activeAmendment.proposer?.representation} +
+ + {getProposerName(activeAmendment.proposer)} +
+ {/if}
- {#if targetClause} -
- + {@const targetClause = resolution.operative[activeAmendment.targetOperativeIndex]} +
+ {m.operativeClausePresentation()} + {activeAmendment.targetOperativeIndex + 1} +
+ {#if targetClause} +
- {#snippet previewHeader()}{/snippet} - + + {#snippet previewHeader()}{/snippet} + +
+ {/if} + {:else if activeAmendment.type === 'ALTER_TEXT' && activeAmendment.targetOperativeIndex != null} + + {@const targetClause = resolution.operative[activeAmendment.targetOperativeIndex]} +
+ {m.operativeClausePresentation()} + {activeAmendment.targetOperativeIndex + 1}
- {/if} - {:else if activeAmendment.type === 'ALTER_TEXT' && activeAmendment.targetOperativeIndex != null} - - {@const targetClause = resolution.operative[activeAmendment.targetOperativeIndex]} -
- {m.operativeClausePresentation()} - {activeAmendment.targetOperativeIndex + 1} -
-
-
-
{m.currentText()}
- {#if targetClause} -
+
+
+
{m.currentText()}
+ {#if targetClause} +
+ + {#snippet previewHeader()}{/snippet} + +
+ {/if} +
+
+
{m.proposedText()}
+ {#if activeAmendment.newContent} +
+ + {#snippet previewHeader()}{/snippet} + +
+ {/if} +
+
+ {:else if activeAmendment.type === 'ADD'} + +
+ {m.insertAfterPresentation({ index: (activeAmendment.targetPosition ?? 0) + 1 })} +
+
+ {#if activeAmendment.newContent} +
{#snippet previewHeader()}{/snippet} @@ -284,109 +321,83 @@
{/if}
-
-
{m.proposedText()}
- {#if activeAmendment.newContent} -
+ {:else if activeAmendment.type === 'ALTER_POSITION' && activeAmendment.targetOperativeIndex != null} + + {@const targetClause = resolution.operative[activeAmendment.targetOperativeIndex]} +
+ {#if targetClause} +
{#snippet previewHeader()}{/snippet}
{/if} -
-
- {:else if activeAmendment.type === 'ADD'} - -
- {m.insertAfterPresentation({ index: (activeAmendment.targetPosition ?? 0) + 1 })} -
-
- {#if activeAmendment.newContent} -
- - {#snippet previewHeader()}{/snippet} - -
- {/if} -
- {:else if activeAmendment.type === 'ALTER_POSITION' && activeAmendment.targetOperativeIndex != null} - - {@const targetClause = resolution.operative[activeAmendment.targetOperativeIndex]} -
- {#if targetClause} -
- - {#snippet previewHeader()}{/snippet} - +
+ + + {m.moveToPositionPresentation({ + position: (activeAmendment.targetPosition ?? 0) + 1 + })} +
- {/if} -
- - - {m.moveToPositionPresentation({ - position: (activeAmendment.targetPosition ?? 0) + 1 - })} -
-
- {/if} -
- {:else} - -
-
-
- {dr.documentNumber ?? m.draftResolution()} -
-
- {dr.status === 'VOTING_PHASE' ? m.votingPhase() : m.amendmentPhase()} -
+ {/if}
+ {:else} + +
+
+
+ {dr.documentNumber ?? m.draftResolution()} +
+
+ {dr.status === 'VOTING_PHASE' ? m.votingPhase() : m.amendmentPhase()} +
+
-
- - {m.operativeClausePresentation()} - {currentOpIndex + 1} / {resolution.operative.length} - - {#if dr.status === 'AMENDMENT_PHASE' && pendingAmendmentCounts.get(currentOpIndex)} - - {pendingAmendmentCounts.get(currentOpIndex)} - {m.amendmentPhase()} +
+ + {m.operativeClausePresentation()} + {currentOpIndex + 1} / {resolution.operative.length} + {#if dr.status === 'AMENDMENT_PHASE' && pendingAmendmentCounts.get(currentOpIndex)} + + {pendingAmendmentCounts.get(currentOpIndex)} + {m.amendmentPhase()} + + {/if} +
+ + {#if currentClause} +
+ + {#snippet previewHeader()}{/snippet} + +
+ {:else} +
+

{m.noOperativeClauses()}

+
{/if}
- - {#if currentClause} -
- - {#snippet previewHeader()}{/snippet} - -
- {:else} -
-

{m.noOperativeClauses()}

-
- {/if} + {/if} + {:else} + +
+ +

{m.noActiveDraftResolution()}

{/if} -{:else} - -
- -

{m.noActiveDraftResolution()}

-
-{/if} +
+ + From 0ddab6138c44c658c000ab216520dd8cc8336845 Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Sat, 14 Mar 2026 15:44:30 +0100 Subject: [PATCH 70/89] fix: replace "Klausel" with "Absatz" in German translations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use correct German MUN terminology: "Operativer Absatz", "Unterabsatz", and "Präambelabsatz" instead of "Klausel" throughout the German locale. Co-Authored-By: Claude Opus 4.6 (1M context) --- bun.lock | 1 + messages/de.json | 88 ++++++++++++++++++++++++------------------------ 2 files changed, 45 insertions(+), 44 deletions(-) diff --git a/bun.lock b/bun.lock index e36e23ad..f98096c5 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "munify-chase", diff --git a/messages/de.json b/messages/de.json index 86fd5db9..81a7aeee 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9,8 +9,8 @@ "activeDraftResolution": "In Behandlung", "addAgendaItem": "Punkt hinzufügen", "addAll": "Alle hinzufügen", - "addClause": "Klausel hinzufügen", - "addClausePresentation": "Klausel hinzufügen", + "addClause": "Absatz hinzufügen", + "addClausePresentation": "Absatz hinzufügen", "addComment": "Kommentar hinzufügen", "addCommittee": "Gremium hinzufügen", "addCountriesCount": "{count} Länder hinzufügen", @@ -35,18 +35,18 @@ "allowSelfAddToSpeakersList": "Selbst auf Redeliste setzen", "allowSelfAddToSpeakersListDescription": "Delegierten und nichtstaatlichen Akteuren erlauben, sich selbst auf Redelisten zu setzen.", "alterClausePresentation": "Text ändern", - "alterPosition": "Klausel verschieben", + "alterPosition": "Absatz verschieben", "alterText": "Text ändern", "amendment": "Änderungsantrag", "amendmentAccepted": "Angenommen", "amendmentAcceptedToast": "Änderungsantrag angenommen", - "amendmentAdd": "Klausel hinzufügen", + "amendmentAdd": "Absatz hinzufügen", "amendmentAdopted": "Änderungsantrag angenommen", "amendmentAlterPosition": "Position ändern", "amendmentAlterText": "Text ändern", "amendmentConsensusAdopted": "Per Konsens angenommen", "amendmentCreated": "Änderungsantrag gestellt", - "amendmentDelete": "Klausel streichen", + "amendmentDelete": "Absatz streichen", "amendmentPending": "Ausstehend", "amendmentPhase": "Änderungsantragsphase", "amendmentPhaseActive": "Änderungsantragsphase aktiv", @@ -87,7 +87,7 @@ "changeSpeakersName": "Name ändern", "changeSpeakersTime": "Redezeit ändern", "changesSaved": "Gespeichert", - "clauseComments": "auf Klauseln", + "clauseComments": "auf Absätzen", "clauseLockedBy": "Wird bearbeitet von {country}", "clauseVoteDeleted": "Absatzstimmung entfernt", "clauseVoteRecorded": "Absatzstimmung erfasst", @@ -104,7 +104,7 @@ "codeCopied": "Code kopiert!", "codeRedeemed": "Code erfolgreich eingelöst", "codesUnrecognized": "{count} Codes nicht erkannt", - "collaborativeEditingInfo": "Andere Delegierte bearbeiten diese Resolution. Fahre über eine Klausel und klicke \"Bearbeitung starten\" um zu beginnen. Sperren laufen nach 1 Minute Inaktivität automatisch ab.", + "collaborativeEditingInfo": "Andere Delegierte bearbeiten diese Resolution. Fahre über einen Absatz und klicke \"Bearbeitung starten\" um zu beginnen. Sperren laufen nach 1 Minute Inaktivität automatisch ab.", "comingSoon": "bald verfügbar", "commentDeleted": "Kommentar gelöscht", "commentList": "Fragen und Kurzbemerkungen", @@ -134,7 +134,7 @@ "configuration": "Konfiguration", "confirmAdoptByConsensus": "Diesen Änderungsantrag per Konsens annehmen? Die Änderung wird sofort angewendet.", "confirmAdoptResolution": "Diese Resolution annehmen? Konfetti feiert die Annahme!", - "confirmDeleteAmendment": "Möchtest du wirklich die Streichung dieser Klausel beantragen?", + "confirmDeleteAmendment": "Möchtest du wirklich die Streichung dieses Absatzes beantragen?", "confirmDeleteCommittee": "Möchtest du dieses Gremium wirklich löschen? Alle zugehörigen Daten gehen verloren.", "confirmDeletePaper": "Dieses Arbeitspapier wirklich löschen? Es wird für alle Beteiligten ausgeblendet.", "confirmDeleteRepresentation": "Möchtest du diese Delegation wirklich entfernen? Zugehörige Gremienmitgliedschaften werden entfernt.", @@ -169,8 +169,8 @@ "debateControls": "Debattensteuerung", "delegate": "Delegierte*r", "delegations": "Delegationen", - "deleteClause": "Klausel streichen", - "deleteClausePresentation": "Klausel streichen", + "deleteClause": "Absatz streichen", + "deleteClausePresentation": "Absatz streichen", "deleteCode": "Code löschen", "deleteComment": "Löschen", "deletePaper": "Papier löschen", @@ -280,7 +280,7 @@ "minutesFromNow": "Relative Zeit: Springe X Minuten in die Zukunft", "missionControl": "Mission Control", "moderatedInformalCaucus": "Moderierte informelle Sitzung", - "moveClausePresentation": "Klausel verschieben", + "moveClausePresentation": "Absatz verschieben", "moveToPositionPresentation": "Verschieben an Position {position}", "myAmendments": "Meine Änderungsanträge", "myPapers": "Meine Papiere", @@ -303,7 +303,7 @@ "noDraftResolution": "Kein Resolutionsentwurf als aktiv gesetzt.", "noDraftResolutionsYet": "Noch keine Resolutionsentwürfe.", "noMembers": "Noch keine Mitglieder", - "noOperativeClauses": "Keine Operativklauseln", + "noOperativeClauses": "Keine operativen Absätze", "noPapersYet": "Noch keine Papiere. Erstelle eines oder gib einen Freigabecode ein.", "noResults": "Keine Ergebnisse", "noSubmittedPapers": "Noch keine eingereichten Papiere.", @@ -319,8 +319,8 @@ "onListPosition": "Du bist #{position} auf der Liste", "openPresentation": "Präsentationsansicht öffnen", "openReEvaluation": "Änderung erlauben", - "operativeClause": "Operativklausel", - "operativeClausePresentation": "Operativklausel", + "operativeClause": "Operativer Absatz", + "operativeClausePresentation": "Operativer Absatz", "outcome": "Ergebnis", "over": "drüber", "paperCreated": "Papier erstellt", @@ -340,7 +340,7 @@ "phraseLookupNoResults": "Keine Operatoren gefunden.", "phraseLookupSearch": "Operator suchen...", "phraseLookupTitle": "Operatoren-Nachschlagewerk", - "preambleClause": "Präambelklausel", + "preambleClause": "Präambelabsatz", "presence": "Anwesenheit", "present": "Anwesend", "presentationMode": "Präsentationsansicht", @@ -376,12 +376,12 @@ "removeSponsor": "Unterstützung zurückziehen", "replyToComment": "Antworten", "resolution": "Resolution", - "resolutionAddClause": "Klausel hinzufügen", + "resolutionAddClause": "Absatz hinzufügen", "resolutionAddContinuation": "Fortsetzungstext", - "resolutionAddFirstClause": "Erste Klausel hinzufügen", - "resolutionAddNested": "Verschachtelte Klausel", - "resolutionAddSibling": "Klausel hinzufügen", - "resolutionAddSubClause": "Unterklausel", + "resolutionAddFirstClause": "Ersten Absatz hinzufügen", + "resolutionAddNested": "Verschachtelter Absatz", + "resolutionAddSibling": "Absatz hinzufügen", + "resolutionAddSubClause": "Unterabsatz", "resolutionAdopted": "Resolution angenommen!", "resolutionAuthoringDelegation": "Einreichende Delegation", "resolutionCommittee": "Gremium", @@ -395,46 +395,46 @@ "resolutionHeadline": "Resolutions-Kopfzeile (z.B. Der Sicherheitsrat)", "resolutionHidePreview": "Vorschau ausblenden", "resolutionImport": "Importieren", - "resolutionImportButton": "{count} Klausel(n) importieren", - "resolutionImportHintOperative": "Fügen Sie nummerierte Operativklauseln ein. Unterpunkte werden automatisch erkannt.", - "resolutionImportHintPreamble": "Fügen Sie Präambelklauseln ein, getrennt durch Komma und Zeilenumbruch.", + "resolutionImportButton": "{count} Absätze importieren", + "resolutionImportHintOperative": "Fügen Sie nummerierte operative Absätze ein. Unterpunkte werden automatisch erkannt.", + "resolutionImportHintPreamble": "Fügen Sie Präambelabsätze ein, getrennt durch Komma und Zeilenumbruch.", "resolutionImportLLMCopied": "Kopiert!", "resolutionImportLLMCopyPrompt": "Prompt kopieren", "resolutionImportLLMInstructions": "Kopieren Sie den folgenden Prompt in einen KI-Assistenten, um Ihren Text automatisch formatieren zu lassen:", - "resolutionImportLLMPromptOperative": "Formatiere den folgenden Text als UN-Resolutions-Operativklauseln. Verwende:\n- Nummerierung für Hauptklauseln: 1. 2. 3.\n- Buchstaben für Unterklauseln: a) b) c)\n- Römische Ziffern für weitere Verschachtelung: i) ii) iii)\n- Doppelbuchstaben für tiefste Ebene: aa) bb) cc)\n- Semikolon am Ende jeder Klausel, Punkt am Ende der letzten\n\nBeispielformat:\n1. fordert alle Mitgliedstaaten auf, Maßnahmen zu ergreifen;\n a) zur Förderung des Friedens;\n b) zur Stärkung der Zusammenarbeit;\n i) auf bilateraler Ebene;\n ii) auf multilateraler Ebene;\n2. bittet den Generalsekretär, einen Bericht vorzulegen.\n\nZu formatierender Text:", - "resolutionImportLLMPromptPreamble": "Formatiere den folgenden Text als UN-Resolutions-Präambelklauseln. Jede Klausel sollte:\n- Mit einem Kleinbuchstaben beginnen (außer Eigennamen)\n- Mit einem Komma enden\n- Durch einen Zeilenumbruch getrennt sein\n\nBeispielformat:\nin Anbetracht der Notwendigkeit internationaler Zusammenarbeit,\nbetonend die Bedeutung des Multilateralismus,\nmit Sorge zur Kenntnis nehmend die aktuelle Situation,\n\nZu formatierender Text:", + "resolutionImportLLMPromptOperative": "Formatiere den folgenden Text als operative Absätze einer UN-Resolution. Verwende:\n- Nummerierung für Hauptabsätze: 1. 2. 3.\n- Buchstaben für Unterabsätze: a) b) c)\n- Römische Ziffern für weitere Verschachtelung: i) ii) iii)\n- Doppelbuchstaben für tiefste Ebene: aa) bb) cc)\n- Semikolon am Ende jedes Absatzes, Punkt am Ende des letzten\n\nBeispielformat:\n1. fordert alle Mitgliedstaaten auf, Maßnahmen zu ergreifen;\n a) zur Förderung des Friedens;\n b) zur Stärkung der Zusammenarbeit;\n i) auf bilateraler Ebene;\n ii) auf multilateraler Ebene;\n2. bittet den Generalsekretär, einen Bericht vorzulegen.\n\nZu formatierender Text:", + "resolutionImportLLMPromptPreamble": "Formatiere den folgenden Text als Präambelabsätze einer UN-Resolution. Jeder Absatz sollte:\n- Mit einem Kleinbuchstaben beginnen (außer Eigennamen)\n- Mit einem Komma enden\n- Durch einen Zeilenumbruch getrennt sein\n\nBeispielformat:\nin Anbetracht der Notwendigkeit internationaler Zusammenarbeit,\nbetonend die Bedeutung des Multilateralismus,\nmit Sorge zur Kenntnis nehmend die aktuelle Situation,\n\nZu formatierender Text:", "resolutionImportLLMTitle": "KI-Formatierung", - "resolutionImportOperative": "Operativklauseln importieren", - "resolutionImportPreamble": "Präambelklauseln importieren", - "resolutionImportPreview": "Vorschau: {count} Klausel(n) erkannt", - "resolutionImportTipsOperative1": "Nummerierte Hauptklauseln: 1. 2. 3. oder 1) 2) 3)", - "resolutionImportTipsOperative2": "Unterklauseln mit Buchstaben: a) b) c) oder (a) (b) (c)", - "resolutionImportTipsOperative3": "Verschachtelte Unterklauseln mit römischen Ziffern: i) ii) iii)", + "resolutionImportOperative": "Operative Absätze importieren", + "resolutionImportPreamble": "Präambelabsätze importieren", + "resolutionImportPreview": "Vorschau: {count} Absätze erkannt", + "resolutionImportTipsOperative1": "Nummerierte Hauptabsätze: 1. 2. 3. oder 1) 2) 3)", + "resolutionImportTipsOperative2": "Unterabsätze mit Buchstaben: a) b) c) oder (a) (b) (c)", + "resolutionImportTipsOperative3": "Verschachtelte Unterabsätze mit römischen Ziffern: i) ii) iii)", "resolutionImportTipsOperative4": "Weitere Verschachtelung mit Doppelbuchstaben: aa) bb) cc)", - "resolutionImportTipsPreamble1": "Jede Klausel sollte mit einem Komma enden", - "resolutionImportTipsPreamble2": "Zeilenumbrüche trennen die einzelnen Klauseln", - "resolutionImportTipsPreamble3": "Die Klauseln werden in der eingegebenen Reihenfolge importiert", + "resolutionImportTipsPreamble1": "Jeder Absatz sollte mit einem Komma enden", + "resolutionImportTipsPreamble2": "Zeilenumbrüche trennen die einzelnen Absätze", + "resolutionImportTipsPreamble3": "Die Absätze werden in der eingegebenen Reihenfolge importiert", "resolutionImportTipsTitle": "Tipps für optimale Ergebnisse", "resolutionIndent": "Einrücken", "resolutionMoveDown": "Nach unten", "resolutionMoveUp": "Nach oben", - "resolutionNoClausesYet": "Noch keine Klauseln vorhanden.", - "resolutionNoOperativeClauses": "Noch keine Operativklauseln vorhanden.", - "resolutionNoPreambleClauses": "Noch keine Präambelklauseln vorhanden.", - "resolutionOperativeClauses": "Operativklauseln", - "resolutionOperativePlaceholder": "Operativklausel eingeben...", + "resolutionNoClausesYet": "Noch keine Absätze vorhanden.", + "resolutionNoOperativeClauses": "Noch keine operativen Absätze vorhanden.", + "resolutionNoPreambleClauses": "Noch keine Präambelabsätze vorhanden.", + "resolutionOperativeClauses": "Operative Absätze", + "resolutionOperativePlaceholder": "Operativen Absatz eingeben...", "resolutionOutdent": "Ausrücken", "resolutionPaper": "Resolutionspapier", "resolutionPapers": "Resolutionspapiere", - "resolutionPreambleClauses": "Präambelklauseln", - "resolutionPreamblePlaceholder": "Präambelklausel eingeben...", + "resolutionPreambleClauses": "Präambelabsätze", + "resolutionPreamblePlaceholder": "Präambelabsatz eingeben...", "resolutionPreview": "Vorschau", "resolutionRejected": "Resolution abgelehnt", "resolutionSentBack": "Resolution zurückverwiesen", "resolutionShowPreview": "Vorschau anzeigen", "resolutionSponsoringDelegations": "Unterstützerstaaten", - "resolutionSubClausePlaceholder": "Unterklausel eingeben...", - "resolutionSubClauses": "Unterklauseln", + "resolutionSubClausePlaceholder": "Unterabsatz eingeben...", + "resolutionSubClauses": "Unterabsätze", "resolutionUnknownPhrase": "Unbekannter Operator", "resolutions": "Resolutionen", "restoreContentFromSnapshot": "Inhalt von vor den Änderungsanträgen wiederherstellen", @@ -462,7 +462,7 @@ "selectCommitteeMember": "Gremienmitglied auswählen...", "selectConferenceMember": "Konferenzmitglied auswählen...", "selectProposerDelegation": "Antragstellende Delegation auswählen", - "selectTargetClause": "Zielklausel auswählen", + "selectTargetClause": "Zielabsatz auswählen", "selected": "Ausgewählt", "sendBack": "Zurückverweisen", "sentBack": "Zurückverwiesen", From 95a6a8b444208a4ba8812e6fd5b46b96073f0701 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 29 Mar 2026 17:19:33 +0000 Subject: [PATCH 71/89] fix: persist i18n language choice via cookie and add Portuguese locale The language was resetting to German on every page navigation because the Paraglide strategy only fell back to baseLocale. Adding 'cookie' to the strategy persists the user's language choice. Also adds Portuguese (pt) as a third supported language with placeholder translations. https://claude.ai/code/session_01Pbqm6VJsvVThr4iCczVrcu --- messages/pt.json | 606 ++++++++++++++++++ project.inlang/settings.json | 2 +- src/lib/components/LanguageSwitcher.svelte | 3 +- .../utils/nationTranslationHelper.svelte.ts | 2 + src/lib/utils/paperNameGenerator.ts | 40 +- vite.config.ts | 2 +- 6 files changed, 651 insertions(+), 4 deletions(-) create mode 100644 messages/pt.json diff --git a/messages/pt.json b/messages/pt.json new file mode 100644 index 00000000..b348577f --- /dev/null +++ b/messages/pt.json @@ -0,0 +1,606 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "aServiceBy": "A service from", + "abort": "Cancel", + "absent": "Absent", + "absoluteMajority": "Absolute", + "abstain": "Abstain", + "activeAmendment": "Being Discussed", + "activeDraftResolution": "In Progress", + "addAgendaItem": "Add Item", + "addAll": "Add All", + "addClause": "Add Clause", + "addClausePresentation": "Add Clause", + "addComment": "Add Comment", + "addCommittee": "Add Committee", + "addCountriesCount": "Add {count} countries", + "addCountry": "Add Country", + "addMeToList": "Add me to list", + "addMember": "Add Member", + "addNonStateActor": "Add NGO", + "addRepresentation": "Add Delegation", + "addSponsor": "Add Sponsor", + "addUnActor": "Add UN Actor", + "admin": "Admin", + "adoptByConsensus": "Adopt by Consensus", + "adoptClause": "Adopt", + "adoptResolution": "Adopt Resolution", + "adopted": "Adopted", + "adoptionAnnouncement": "BREAKING: Resolution on \"{agendaItem}\" adopted in the committee {committeeName}", + "advanceToNextParagraph": "Advance to Next Paragraph", + "agendaItem": "Agenda item", + "agendaItemTitle": "Agenda Item Title", + "agendaItems": "Agenda Items", + "allRightsReservedby": "All rights reserved by", + "allowSelfAddToSpeakersList": "Self-add to Speakers List", + "allowSelfAddToSpeakersListDescription": "Allow delegates and non-state actors to add themselves to speakers lists.", + "alterClausePresentation": "Alter Text", + "alterPosition": "Move Clause", + "alterText": "Alter Text", + "amendment": "Amendment", + "amendmentAccepted": "Accepted", + "amendmentAcceptedToast": "Amendment accepted", + "amendmentAdd": "Add clause", + "amendmentAdopted": "Amendment adopted", + "amendmentAlterPosition": "Alter position", + "amendmentAlterText": "Alter text", + "amendmentConsensusAdopted": "Adopted by Consensus", + "amendmentCreated": "Amendment proposed", + "amendmentDelete": "Delete clause", + "amendmentPending": "Pending", + "amendmentPhase": "Amendment Phase", + "amendmentPhaseActive": "Amendment phase active", + "amendmentPhaseStarted": "Amendment phase started", + "amendmentProposed": "Amendment proposed", + "amendmentQueue": "Amendment Queue", + "amendmentRejected": "Rejected", + "amendmentRejectedClause": "Rejected", + "amendmentRejectedToast": "Amendment rejected", + "amendmentSponsoring": "Amendment Sponsoring", + "amendmentSponsoringClosed": "Amendment sponsoring is closed", + "amendmentSponsoringOpen": "Delegates can sponsor amendments", + "amendmentSubmission": "Amendment Submission", + "amendmentSubmissionClosed": "Amendment submission is closed", + "amendmentSubmissionOpen": "Delegates can submit new amendments", + "amendmentSubmitted": "Submitted", + "amendmentSubmittedToast": "Amendment submitted", + "amendmentUpdated": "Amendment updated", + "amendmentWithdrawn": "Withdrawn", + "amendmentWithdrawnToast": "Amendment withdrawn", + "amendments": "Amendments", + "announceAdoption": "Announce Adoption", + "assignedCount": "{count} assigned", + "assignment": "Assignment", + "back": "Back", + "backToResolutions": "Back to Resolutions", + "baseFontSize": "Base Font Size", + "baseFontSizeDescription": "Here you can set the base font size for the presentation view.", + "blockquote": "Quote", + "bold": "Bold", + "bulkAddMembers": "Bulk Add Members", + "bulkEmailPlaceholder": "Enter email addresses (one per line or comma-separated)", + "bulletedList": "Bulleted list", + "cancel": "Cancel", + "chairControls": "Chair Controls", + "chairCreateAmendment": "Create Amendment", + "chairCreateWorkingPaper": "Create Working Paper", + "changeSpeakersName": "Change Name", + "changeSpeakersTime": "Change Speaking Time", + "changesSaved": "Saved", + "clauseComments": "on clauses", + "clauseLockedBy": "Being edited by {country}", + "clauseVoteDeleted": "Clause vote removed", + "clauseVoteRecorded": "Clause vote recorded", + "clauseVoteSummary": "Clause Vote Summary", + "clausesVoted": "{voted}/{total} clauses voted", + "clearActiveDr": "Clear Active", + "clearFormatting": "Delete formatting", + "clearList": "Reset List", + "clearListDescription": "Are you sure you want to reset the entire list?", + "close": "Close", + "closeList": "Close List", + "closeReEvaluation": "Close Re-evaluation", + "code": "Code", + "codeCopied": "Code copied!", + "codeRedeemed": "Code redeemed successfully", + "codesUnrecognized": "{count} codes unrecognized", + "collaborativeEditingInfo": "Other delegates are editing this resolution. Hover a clause and click \"Start editing\" to begin. Locks expire automatically after 1 minute of inactivity.", + "comingSoon": "coming soon", + "commentDeleted": "Comment deleted", + "commentList": "Point of Information", + "commentPlaceholder": "Write a comment...", + "commentPosted": "Comment posted", + "commentUpdated": "Comment updated", + "comments": "Comments", + "commentsOnClause": "{count} comment(s)", + "committee": "Committee", + "committeeAbbreviation": "Committee Abbreviation", + "committeeDoesNotExist": "The committee does not exist.", + "committeeId": "Committee ID", + "committeeMember": "Committee Member", + "committeeMembers": "Committee Members", + "committeeName": "Committee Name", + "committeeOverview": "Committee Overview", + "committeeStatus": "Committee Status", + "committeeStatusExpired": "{status} expired!", + "committees": "Committees", + "con": "Against", + "conferenceCreated": "Conference created!", + "conferenceCreationError": "Could not create conference", + "conferenceCreationSuccessful": "Conference created. You will be redirected shortly...", + "conferenceId": "Conference ID", + "conferenceMembers": "Conference Members", + "conferenceTitle": "Conference Title", + "configuration": "Configuration", + "confirmAdoptByConsensus": "Adopt this amendment by consensus? This will immediately apply the change.", + "confirmAdoptResolution": "Adopt this resolution? Confetti will celebrate the adoption!", + "confirmDeleteAmendment": "Are you sure you want to propose deleting this clause?", + "confirmDeleteCommittee": "Are you sure you want to delete this committee? All associated data will be lost.", + "confirmDeletePaper": "Are you sure you want to delete this working paper? It will be hidden for all participants.", + "confirmDeleteRepresentation": "Are you sure you want to remove this delegation? Associated committee memberships will be removed.", + "confirmFinalVote": "Record this final vote? The resolution status will change to Final.", + "confirmRejectAmendment": "Reject this amendment?", + "confirmRejectResolution": "Reject this resolution?", + "confirmRemoveMember": "Are you sure you want to remove this member?", + "confirmRevertStatus": "Revert this paper from {from} back to {to}?", + "confirmSendBack": "Send this resolution back?", + "confirmStartAmendmentPhase": "Start the amendment phase for this draft resolution? Delegates will be able to propose amendments paragraph by paragraph.", + "confirmStartVotingPhase": "Start the voting phase for this draft resolution? Each operative paragraph will be voted on individually.", + "confirmSubmitPaper": "Are you sure you want to submit this paper to the chair? You will no longer be able to edit it.", + "copy": "Copy", + "copyCode": "Copy", + "copyFailed": "Copy failed", + "countries": "Countries", + "countriesRecognized": "{count} countries recognized", + "countryCodesHelp": "Supports Alpha-2 (DE, US) and Alpha-3 (DEU, USA) codes. Separate with spaces, commas, semicolons, or new lines.", + "countryCodesPlaceholder": "DE, USA, FRA\nor one per line...", + "countryNotFound": "Country not found", + "countryNsaOrCustomRole": "Country, NSA, or special role", + "create": "Create", + "createConference": "Create Conference", + "createPaper": "Create Paper", + "createResolutionPaper": "Create Working Paper", + "createShareCodeEdit": "Create Edit Code", + "createShareCodeSponsor": "Create Sponsor Code", + "currentParagraph": "Current Paragraph", + "currentText": "Current Text", + "customName": "Custom name...", + "dateCannotBeInPast": "The date must not be in the past!", + "debateControls": "Debate Controls", + "delegate": "Delegate", + "delegations": "Delegations", + "deleteClause": "Delete Clause", + "deleteClausePresentation": "Delete Clause", + "deleteCode": "Delete Code", + "deleteComment": "Delete", + "deletePaper": "Delete Paper", + "deleteRepresentation": "Remove Delegation", + "displayRegionalGroups": "Display Regional Blocs", + "documentLevelComments": "Document Comments", + "documentNumber": "Document Number", + "documentWide": "document-wide", + "doneEditing": "Done editing", + "download": "Download", + "downloadPresenceData": "Presence Data", + "draftResolution": "Draft Resolution", + "draftResolutions": "Draft Resolutions", + "edit": "Edit", + "editAccess": "Edit Access", + "editAmendment": "Edit Amendment", + "editComment": "Edit", + "editPaper": "Edit Paper", + "editUser": "Edit User", + "editors": "Editors", + "email": "Email", + "enterAlpha2Code": "Please enter Alpha2Code", + "enterCode": "Enter Code", + "enterCountryCodes": "Enter country codes (Alpha-2 or Alpha-3)", + "errorUpdatingStateOfDebate": "Error saving debate status", + "errorUpdatingStatus": "Status could not be set", + "errorUpdatingTimer": "Could not update speaking time", + "errorUpdatingWhiteboard": "Publication failed", + "fileParseError": "Error parsing file", + "finalResolution": "Final Resolution", + "finalVote": "Final Vote", + "finalVoteDescription": "Record the final vote on the entire resolution.", + "finishAmendmentPhaseFirst": "Finish the amendment phase first", + "formalDebate": "Formal debate", + "forward": "Next", + "general": "General", + "goToAmendments": "Go to amendments", + "goToVoting": "Go to voting", + "gotoSettings": "Go to settings", + "h1": "Heading 1", + "h2": "Heading 2", + "h3": "Heading 3", + "hasModeratedCaucus": "Moderated Caucus", + "hasModeratedCaucusDescription": "Enable moderated informal caucus as a committee status option.", + "home": "Home", + "homeAboutText": "CHASE (CHAirSoftwarE) is a web application for managing and conducting debates at Model United Nations conferences. It is designed for both chairs and delegates. CHASE allows chairs to easily manage debates, while delegates can follow the discussion and collaborate with others in an intuitive and structured way. CHASE is free and open-source software.", + "homeAboutTitle": "About CHASE", + "homeCaption": "in the 21st century", + "homeContactButton": "Get in Touch", + "homeContactText": "Are you organizing a Model United Nations conference and interested in using CHASE? We offer free support (within our abilities) to help you deploy CHASE on your own infrastructure. We can also host CHASE on our servers for your conference. Reach out to us — we are happy to help!", + "homeContactTitle": "Get CHASE for Your Conference", + "homeContributeButtonLabel": "MUNify on GitHub", + "homeContributeText": "CHASE is part of the open-source initiative 'MUNify' by DMUN. This means anyone can contribute to development. We appreciate every bit of help we can get. If you have experience in web development or want to learn new skills and help out, check out our GitHub!", + "homeContributeTitle": "Contribute", + "homeHeroCardResolutionEditorText": "Collaboratively create and edit resolutions with other delegates. No more paper or Google Docs!", + "homeHeroCardResolutionEditorTitle": "Resolutions", + "homeHeroCardSpeakersListText": "Manage the speakers' lists simply and efficiently. No more paper lists!", + "homeHeroCardSpeakersListTitle": "Debates", + "homeHeroCardVotingText": "Manage motions and voting electronically with pre-configured motions based on your Rules of Procedure.", + "homeHeroCardVotingTitle": "Voting", + "homeHeroText": "Debate management at Model United Nations conferences finally gets an upgrade.", + "homeMissionButtonLabel": "Learn more about DMUN", + "homeMissionText": "CHASE is developed by members of the German organization DMUN e.V. We aim to provide a free and accessible alternative to other debate management tools, making it easier even for smaller conferences to participate. CHASE was initially developed for German-speaking DMUN conferences – MUN-SH, MUNBW, and MUNBB – but we are open to adapting it to other conferences.", + "homeMissionTitle": "Our Mission", + "homeVersionButton": "CHASE (CHAirSoftwarE) is a web application for managing and conducting debates in Model United Nations conferences. It is designed for chairmen and delegates alike. CHASE allows chairs to easily manage debates, while delegates can follow the debate and collaborate with other delegates in an intuitive and structured way. CHASE is free and open source software.", + "horizontalRule": "Divider", + "icon": "Icon", + "img": "Picture", + "importFromDelegator": "Import conference from Delegator", + "imprintAndPrivacy": "Imprint & Privacy Policy", + "informalCaucus": "Informal meeting", + "insertAfterPresentation": "Insert after OP.{index}", + "insertAsFirstClause": "Insert as first clause", + "insertAtBeginning": "Insert at beginning", + "invalidShareCode": "Invalid share code", + "italic": "Italics", + "launcher": "Launcher", + "launcherDescription": "Select the conference", + "launcherNoConferences": "You are not registered for a conference.", + "launcherWelcome": "Welcome back, {name}!", + "layout": "Layout", + "layoutDescription": "Layout templates for the presentation view. Please note that changing the layout template here will overwrite manual layout changes.", + "layoutPresetDefault": "Default Layout", + "layoutPresetResolution": "Resolution Layout", + "layoutPresetSmallScreen": "Layout for Small Screens", + "layoutSelect": "Select Layout", + "link": "Hyperlink", + "listClosed": "List closed", + "listClosedCannotAdd": "The list is closed", + "listEmpty": "No speech", + "lockAcquireFailed": "This clause is currently being edited by {country}. Please try again shortly.", + "login": "Register", + "logout": "Log out", + "loose_slow_reindeer_build": "Committee Members", + "majorities": "Majorities", + "majoritySettings": "Majority Settings", + "majoritySettingsDescriptions": "Majority settings help visualize whether a motion has passed.", + "maroon_bland_ray_renew": "Committee abbreviation", + "matching": "matching", + "maxDraftResolutions": "Max Draft Resolutions", + "maxDraftResolutionsReached": "Maximum number of draft resolutions reached", + "member": "Member", + "memberAdded": "Member added successfully", + "memberRemoved": "Member removed successfully", + "memberUpdated": "Member updated successfully", + "minuteOfTheHour": "Absolute time: Jump to the corresponding minute of this or the next hour", + "minutesFromNow": "Relative time: Jump X minutes into the future", + "missionControl": "Mission Control", + "moderatedInformalCaucus": "Moderated informal caucus", + "moveClausePresentation": "Move Clause", + "moveToPositionPresentation": "Move to position {position}", + "myAmendments": "My Amendments", + "myPapers": "My Papers", + "name": "Name", + "nextParagraph": "Next", + "nextSpeaker": "Next Speech", + "nextSpeakerDescription": "Do you really want to call the next speech? All remaining Points of Information will be discarded.", + "noActiveAgendaItem": "No active agenda item. A paper cannot be created right now.", + "noActiveDr": "No active draft resolution", + "noActiveDrForVoting": "No active draft resolution for voting", + "noActiveDraftResolution": "No active draft resolution", + "noAgendaItemSelected": "No agenda item active", + "noAgendaItemSelectedDescription": "To work with speakers' lists, you must first select an agenda item.", + "noAmendments": "No amendments yet", + "noAssignmentNeeded": "No member assignment needed for this role.", + "noCommentList": "No Point of Information List", + "noComments": "No comments yet", + "noCurrentSpeaker": "No speech", + "noData": "No data", + "noDraftResolution": "No draft resolution set as active.", + "noDraftResolutionsYet": "No draft resolutions yet.", + "noMembers": "No members yet", + "noOperativeClauses": "No operative clauses", + "noPapersYet": "No papers yet. Create one or enter a share code.", + "noResults": "No results", + "noSubmittedPapers": "No submitted papers yet.", + "nonStateActor": "Non-state Actor", + "nonStateActors": "Non-state Actors", + "notAuthorized": "You are not authorized to access this page", + "notPresent": "Not present", + "notPresentCannotAdd": "You must be marked as present to add yourself", + "nothingChanged": "Nothing changed", + "numberedList": "Numbered list", + "off": "Off", + "on": "On", + "onListPosition": "You are #{position} on the list", + "openPresentation": "Open Presentation View", + "openReEvaluation": "Open Re-evaluation", + "operativeClause": "Operative Clause", + "operativeClausePresentation": "Operative Clause", + "outcome": "Outcome", + "over": "over", + "paperCreated": "Paper created", + "paperDeleted": "Paper deleted", + "paperPromoted": "Paper promoted to Draft Resolution", + "paperSubmitted": "Paper submitted to chair", + "paperSupportThresholdTooltip": "Supporting states required to submit an amendment", + "paperTitle": "Paper Title", + "papers": "Papers", + "paragraphVoting": "Paragraph Voting", + "parsedCountries": "Countries to add:", + "participantView": "Participant View", + "pause": "Pause", + "phraseCopied": "Phrase copied!", + "phraseLookup": "Phrases", + "phraseLookupDisclaimer": "These phrases are provided as guidance. Please verify correct usage in context.", + "phraseLookupNoResults": "No phrases found.", + "phraseLookupSearch": "Search phrases...", + "phraseLookupTitle": "Phrase Reference", + "preambleClause": "Preamble Clause", + "presence": "Presence", + "present": "Present", + "presentationMode": "Presentation View", + "pressWebsite": "Press Website", + "preview": "Preview", + "previousParagraph": "Previous", + "printResolution": "Print", + "pro": "In Favor", + "promote": "Promote", + "promoteToDraftResolution": "Promote to Draft Resolution", + "promoteToDraftResolutionConfirm": "Promote this paper to a Draft Resolution? This will assign it a document number.", + "proposeAmendment": "Propose Amendment", + "proposedAmendmentPresentation": "Proposed Amendment", + "proposedBy": "Proposed by {name}", + "proposedText": "Proposed Text", + "publicComment": "Public", + "publish": "Publish", + "publishChanges": "Publish changes", + "recordVoteFromVoting": "Record Vote", + "redeemShareCode": "Redeem Share Code", + "redo": "Repeat", + "regionalGroup_africa": "Africa", + "regionalGroup_asiaPacific": "Asia-Pacific", + "regionalGroup_easternEurope": "Eastern Europe", + "regionalGroup_latinAmericaCaribbean": "Latin America and the Caribbean", + "regionalGroup_westernEuropeOthers": "Western Europe and Others", + "regionalGroups": "Regional Groups", + "rejectClause": "Reject", + "rejectResolution": "Reject Resolution", + "rejected": "Rejected", + "removeFromList": "Remove from list", + "removeMember": "Remove", + "removeSponsor": "Remove Sponsorship", + "replyToComment": "Reply", + "resolution": "Resolution", + "resolutionAddClause": "Add Clause", + "resolutionAddContinuation": "Continuation Text", + "resolutionAddFirstClause": "Add First Clause", + "resolutionAddNested": "Nested Clause", + "resolutionAddSibling": "Add Clause", + "resolutionAddSubClause": "Sub-clause", + "resolutionAdopted": "Resolution adopted!", + "resolutionAuthoringDelegation": "Authoring Delegation", + "resolutionCommittee": "Committee", + "resolutionContinuationPlaceholder": "Enter continuation text...", + "resolutionDeleteBlock": "Delete Block", + "resolutionDeleteClause": "Delete", + "resolutionDisclaimer": "This document was created as part of a {conferenceName} simulation and has no legal validity.", + "resolutionEditor": "Resolution Editor", + "resolutionFontSize": "Resolution Font Size", + "resolutionFontSizeDescription": "Set the font size for the resolution text in the presentation view.", + "resolutionHeadline": "Resolution Headline (e.g. The Security Council)", + "resolutionHidePreview": "Hide Preview", + "resolutionImport": "Import", + "resolutionImportButton": "Import {count} clause(s)", + "resolutionImportHintOperative": "Paste numbered operative clauses. Sub-clauses will be detected automatically.", + "resolutionImportHintPreamble": "Paste preamble clauses, separated by comma and line break.", + "resolutionImportLLMCopied": "Copied!", + "resolutionImportLLMCopyPrompt": "Copy Prompt", + "resolutionImportLLMInstructions": "Copy the following prompt into an AI assistant to automatically format your text:", + "resolutionImportLLMPromptOperative": "Format the following text as UN resolution operative clauses. Use:\n- Numbering for main clauses: 1. 2. 3.\n- Letters for sub-clauses: a) b) c)\n- Roman numerals for further nesting: i) ii) iii)\n- Double letters for deepest level: aa) bb) cc)\n- Semicolon at the end of each clause, period at the end of the last\n\nExample format:\n1. Calls upon all Member States to take measures;\n a) to promote peace;\n b) to strengthen cooperation;\n i) at the bilateral level;\n ii) at the multilateral level;\n2. Requests the Secretary-General to submit a report.\n\nText to format:", + "resolutionImportLLMPromptPreamble": "Format the following text as UN resolution preamble clauses. Each clause should:\n- Begin with a lowercase letter (except proper nouns)\n- End with a comma\n- Be separated by a line break\n\nExample format:\nrecalling its resolution 70/1 of 25 September 2015,\nemphasizing the importance of multilateralism,\nnoting with concern the current situation,\n\nText to format:", + "resolutionImportLLMTitle": "AI Formatting", + "resolutionImportOperative": "Import Operative Clauses", + "resolutionImportPreamble": "Import Preamble Clauses", + "resolutionImportPreview": "Preview: {count} clause(s) detected", + "resolutionImportTipsOperative1": "Numbered main clauses: 1. 2. 3. or 1) 2) 3)", + "resolutionImportTipsOperative2": "Lettered sub-clauses: a) b) c) or (a) (b) (c)", + "resolutionImportTipsOperative3": "Nested sub-clauses with Roman numerals: i) ii) iii)", + "resolutionImportTipsOperative4": "Further nesting with double letters: aa) bb) cc)", + "resolutionImportTipsPreamble1": "Each clause should end with a comma", + "resolutionImportTipsPreamble2": "Line breaks separate individual clauses", + "resolutionImportTipsPreamble3": "Clauses are imported in the order entered", + "resolutionImportTipsTitle": "Tips for Best Results", + "resolutionIndent": "Indent", + "resolutionMoveDown": "Move Down", + "resolutionMoveUp": "Move Up", + "resolutionNoClausesYet": "No clauses yet.", + "resolutionNoOperativeClauses": "No operative clauses yet.", + "resolutionNoPreambleClauses": "No preamble clauses yet.", + "resolutionOperativeClauses": "Operative Clauses", + "resolutionOperativePlaceholder": "Enter operative clause...", + "resolutionOutdent": "Outdent", + "resolutionPaper": "Resolution Paper", + "resolutionPapers": "Resolution Papers", + "resolutionPreambleClauses": "Preamble Clauses", + "resolutionPreamblePlaceholder": "Enter preamble clause...", + "resolutionPreview": "Preview", + "resolutionRejected": "Resolution rejected", + "resolutionSentBack": "Resolution sent back", + "resolutionShowPreview": "Show Preview", + "resolutionSponsoringDelegations": "Sponsoring Delegations", + "resolutionSubClausePlaceholder": "Enter sub-clause...", + "resolutionSubClauses": "Sub-clauses", + "resolutionUnknownPhrase": "Unknown phrase", + "resolutions": "Resolutions", + "restoreContentFromSnapshot": "Restore content from before amendments", + "restoreContentFromSnapshotDescription": "Undo all applied amendments and restore the resolution content to the version before the amendment phase began. Applied amendments will be reset to pending.", + "revertDrWarning": "Reverting will clear the document number. The paper can be re-promoted later.", + "revertStatus": "Revert Status", + "revertVotingWarning": "Reverting will delete all clause vote results for this paper.", + "role": "Role", + "rollCall": "Roll Call", + "rollCallError": "Committee member not found", + "rollCallSuccess": "Roll call completed", + "rollCallVoting": "Roll Call Vote", + "rollCollError": "Committee member not found", + "rollCollSuccess": "Roll call complete", + "save": "Save", + "saveChanges": "Save Changes", + "saveError": "Save failed", + "savingChanges": "Saving...", + "searchCommitteeMembers": "Search committee members", + "searchMembers": "Search members...", + "searchUsers": "Search users...", + "selectAgendaItem": "Select agenda item...", + "selectAmendmentType": "Select amendment type", + "selectAuthorDelegation": "Select Author Delegation", + "selectCommitteeMember": "Select committee member...", + "selectConferenceMember": "Select conference member...", + "selectProposerDelegation": "Select Proposing Delegation", + "selectTargetClause": "Select Target Clause", + "selected": "Selected", + "sendBack": "Send Back", + "sentBack": "Sent Back", + "seoDescription": "MUNify CHASE is the free, open-source debate management tool for Model United Nations conferences. Manage speakers lists, voting, and resolutions digitally.", + "seoTitle": "MUNify CHASE – Debate Management for Model United Nations", + "setActiveAmendment": "Discuss", + "setActiveDr": "Set Active", + "setActiveDrHint": "Set a draft resolution as active in the chair view to display it here.", + "setAllAbsent": "Set All Absent", + "setAllPresent": "Set All Present", + "setStatus": "Change status", + "setup": "Set up", + "sha": "SHA", + "shareCode": "Share Code", + "shareCodes": "Share Codes", + "short_sleek_snake_hint": "Committee", + "showOfHandsVoting": "Vote by Show of Hands", + "simpleMajority": "Simple", + "simpleMajorityTooltip": "Needed notes for simple majority", + "speaker": "Speaker", + "speakersList": "General Speakers' List", + "speakersListNamePlaceholder": "New name...", + "speakersListNotFound": "Speakers' list not found", + "speakersListOvertime": "Speaking time over!", + "spectator": "Spectator", + "sponsor": "Sponsor", + "sponsorAdded": "Sponsor added", + "sponsorAmendment": "Sponsor", + "sponsorCount": "{count} sponsors", + "sponsorPaper": "Sponsor", + "sponsorRemoved": "Sponsor removed", + "sponsorThreshold": "{current}/{needed} sponsors ({percent}% needed)", + "sponsors": "Sponsors", + "startAmendmentPhase": "Start Amendment Phase", + "startEditing": "Start editing", + "startVote": "Start Vote", + "startVotingPhase": "Start Voting Phase", + "startVotingPhaseDescription": "Move to the voting phase where each operative paragraph will be voted on individually.", + "stateOfDebate": "State of Debate", + "statusReverted": "Status reverted", + "statusUpdated": "Status has been set", + "strikethrough": "Strikethrough", + "submit": "Submit", + "submitAmendment": "Submit Amendment", + "submitImg": "Insert image", + "submitPaper": "Submit Paper", + "submitStateOfDebate": "Save debate status", + "submitStatus": "Set status", + "submitToChair": "Submit to Chair", + "submitted": "Submitted", + "submittedBy": "Submitted by", + "submittedPapers": "Submitted Papers", + "submittedPapersDescription": "Papers submitted by delegates, ranked by sponsor count", + "submittingNation": "Submitting Nation", + "supportDraftResolution": "Support", + "supportReEvaluation": "Support Re-evaluation", + "supportReEvaluationClosed": "Re-evaluation is closed", + "supportReEvaluationNotOpen": "Support re-evaluation is not currently open", + "supportReEvaluationOpen": "Re-evaluation is open — delegates can now change their support", + "supporterCount": "{count} supporters", + "suspension": "Suspension", + "targetPosition": "Target Position", + "teamMember": "Team Member", + "teamOnly": "Team Only", + "theme": "Theme", + "thresholdNotMet": "Sponsor threshold not met", + "timeOver": "Speaking time is up!", + "timer": "Timer", + "toastAddError": "Could not add { targetName }", + "toastAddLoading": "Adding { targetName }...", + "toastAddSuccess": "{ targetName } added", + "toastCreateError": "Could not create {targetName}", + "toastCreateLoading": "Creating {targetName}...", + "toastCreateSuccess": "{targetName} created", + "toastDeleteError": "Could not delete {targetName}", + "toastDeleteLoading": "Deleting {targetName}...", + "toastDeleteSuccess": "{targetName} deleted", + "toastError": "Could not load {targetName}", + "toastLoading": "Loading {targetName}...", + "toastSuccess": "{targetName} loaded", + "toastUpdateError": "Could not update {targetName}", + "toastUpdateLoading": "Updating {targetName}...", + "toastUpdateSuccess": "{targetName} updated", + "topCandidate": "Top Candidate", + "totalCountriesPresent": "Count of Present Countries", + "twoThirdsMajority": "Two-thirds", + "twoThirdsMajorityTooltip": "Needed votes for two-thrids majority", + "typeOfVoting": "Type of Vote", + "unActor": "UN Actor", + "unActors": "UN Actors", + "unassigned": "Unassigned", + "underline": "Underlined", + "undo": "Undo", + "undoVote": "Undo Vote", + "unknown": "unknown", + "unrecognizedCodes": "Unrecognized codes:", + "until": "until {time}", + "untitledPaper": "Untitled Paper", + "updatedStateOfDebate": "Debate status saved", + "updatingStateOfDebate": "Saving debate status...", + "updatingStatus": "Status is being set...", + "updatingWhiteboard": "Publish whiteboard...", + "upload": "Upload", + "url": "URL", + "useFullVoting": "Use Full Voting", + "userAlreadyExists": "User already exists in this conference: {email}", + "users": "Users", + "version": "Version", + "viewPaper": "View Paper", + "voteOnParagraph": "Vote on OP {index}", + "voteOutcome": "Vote Outcome", + "voteResult": "Vote Result", + "voteTitel": "Vote Title", + "voteTitleDescription": "The vote title will be visible to all participants and is used for identification. If left empty, \"Vote\" will be used as fallback.", + "votesAbstain": "Abstentions", + "votesAgainst": "Votes Against", + "votesFor": "Votes For", + "voting": "Voting", + "votingControlsPlaceholder": "Voting controls will be available in a future update.", + "votingPhase": "Voting Phase", + "votingPhaseActive": "Voting phase active", + "votingPhaseStarted": "Voting phase started", + "votingResults": "Voting Results", + "waitingForAssignment": "Waiting for Assignment", + "waitingForAssignmentDescription": "You have not been assigned to a committee yet. Please wait for an admin to assign you.", + "whiteboard": "Whiteboard", + "whiteboardIsEmpty": "The whiteboard is currently empty...", + "whiteboardPlaceholder": "Start writing here...", + "whiteboardUpdated": "Whiteboard published", + "withAbstentions": "With Abstentions", + "withdrawAmendment": "Withdraw", + "withdrawSponsorship": "Withdraw Sponsorship", + "withdrawSupport": "Withdraw Support", + "withoutAbstentions": "No Abstentions", + "workingPaper": "Working Paper", + "workingPapers": "Working Papers", + "yes": "Yes", + "you": "You", + "youCannotEditYourself": "You cannot edit your own role", + "youreUp": "You're up!" +} diff --git a/project.inlang/settings.json b/project.inlang/settings.json index 40922c58..637ce5f2 100644 --- a/project.inlang/settings.json +++ b/project.inlang/settings.json @@ -1,7 +1,7 @@ { "$schema": "https://inlang.com/schema/project-settings", "baseLocale": "de", - "locales": ["de", "en"], + "locales": ["de", "en", "pt"], "modules": [ "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-empty-pattern@latest/dist/index.js", "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-missing-translation@latest/dist/index.js", diff --git a/src/lib/components/LanguageSwitcher.svelte b/src/lib/components/LanguageSwitcher.svelte index 7f2dcdba..27b81685 100644 --- a/src/lib/components/LanguageSwitcher.svelte +++ b/src/lib/components/LanguageSwitcher.svelte @@ -21,7 +21,8 @@ const localeLookup = { en: 'gb', - de: 'de' + de: 'de', + pt: 'pt' }; diff --git a/src/lib/utils/nationTranslationHelper.svelte.ts b/src/lib/utils/nationTranslationHelper.svelte.ts index 609b1d97..156c2ed2 100644 --- a/src/lib/utils/nationTranslationHelper.svelte.ts +++ b/src/lib/utils/nationTranslationHelper.svelte.ts @@ -251,6 +251,8 @@ function nationCodeToLocalName(code: string, locale = getLocale(), official = fa return 'deu'; case 'en': return 'eng'; + case 'pt': + return 'por'; default: return 'eng'; } diff --git a/src/lib/utils/paperNameGenerator.ts b/src/lib/utils/paperNameGenerator.ts index a0cc2613..16e4ab8c 100644 --- a/src/lib/utils/paperNameGenerator.ts +++ b/src/lib/utils/paperNameGenerator.ts @@ -34,6 +34,23 @@ const adverbs = { 'Enorm', 'Riesig', 'Fantastisch' + ], + pt: [ + 'Muito', + 'Super', + 'Ultra', + 'Bastante', + 'Totalmente', + 'Absolutamente', + 'Razoavelmente', + 'Realmente', + 'Extremamente', + 'Incrivelmente', + 'Notavelmente', + 'Excepcionalmente', + 'Tremendamente', + 'Imensamente', + 'Fantasticamente' ] }; @@ -79,6 +96,27 @@ const adjectives = { 'Edler', 'Würdevoller', 'Optimistischer' + ], + pt: [ + 'Feliz', + 'Calmo', + 'Entusiasmado', + 'Energético', + 'Esperançoso', + 'Contente', + 'Curioso', + 'Motivado', + 'Alegre', + 'Determinado', + 'Confiante', + 'Magnífico', + 'Grandioso', + 'Majestoso', + 'Esplêndido', + 'Glorioso', + 'Nobre', + 'Digno', + 'Otimista' ] }; @@ -99,7 +137,7 @@ function pick(arr: T[]): T { } export function generatePaperName(): string { - const locale = getLocale() as 'en' | 'de'; + const locale = getLocale() as 'en' | 'de' | 'pt'; const adverbList = adverbs[locale] ?? adverbs.en; const adjectiveList = adjectives[locale] ?? adjectives.en; diff --git a/vite.config.ts b/vite.config.ts index 14ac629d..dd311cce 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -54,7 +54,7 @@ export default defineConfig({ paraglideVitePlugin({ project: './project.inlang', outdir: './src/lib/paraglide', - strategy: ['url', 'baseLocale'] + strategy: ['url', 'cookie', 'baseLocale'] }), houdini(), sveltekit() From 61d893153e3dc47cb549f0d363580dd4ea26699f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 29 Mar 2026 17:20:33 +0000 Subject: [PATCH 72/89] chore: update inlang project .gitignore for v2.5+ Auto-generated by inlang tooling to ignore everything except settings.json. https://claude.ai/code/session_01Pbqm6VJsvVThr4iCczVrcu --- project.inlang/.gitignore | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/project.inlang/.gitignore b/project.inlang/.gitignore index 5e465967..04df3303 100644 --- a/project.inlang/.gitignore +++ b/project.inlang/.gitignore @@ -1 +1,19 @@ -cache \ No newline at end of file +# IF GIT SHOWED THAT THIS FILE CHANGED +# +# 1. RUN THE FOLLOWING COMMAND +# +# --- +# git rm --cached '**/*.inlang/.gitignore' +# --- +# +# 2. COMMIT THE CHANGE +# +# --- +# git commit -m "fix: remove tracked .gitignore from inlang project" +# --- +# +# Inlang handles the gitignore itself starting with version ^2.5. +# +# everything is ignored except settings.json +* +!settings.json \ No newline at end of file From acecf7ee5deedfa180f8894c7222d3819652f138 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 29 Mar 2026 17:31:29 +0000 Subject: [PATCH 73/89] feat: replace language toggle with modal picker showing flags and names The cycling toggle was unintuitive with 3 languages. Now clicking the language button opens a modal listing all available languages with their flags and native names for direct selection. https://claude.ai/code/session_01Pbqm6VJsvVThr4iCczVrcu --- src/lib/components/LanguageSwitcher.svelte | 61 +++++++++------------- 1 file changed, 25 insertions(+), 36 deletions(-) diff --git a/src/lib/components/LanguageSwitcher.svelte b/src/lib/components/LanguageSwitcher.svelte index 27b81685..407e1b04 100644 --- a/src/lib/components/LanguageSwitcher.svelte +++ b/src/lib/components/LanguageSwitcher.svelte @@ -1,45 +1,34 @@ - + + +

Language / Sprache / Idioma

+
+ {#each locales as l} + + {/each} +
+
From c1fad63388fe16b7160a3cb009ce696243283384 Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Fri, 3 Apr 2026 16:18:45 +0200 Subject: [PATCH 74/89] fix: switch i18n from URL-based to cookie-based locale strategy URL-based locale prefixes (/en/, /pt/) caused language resets on every internal navigation since links weren't wrapped with localizeHref(). Now locale is persisted via cookie with browser preference detection on first visit. Locale-prefixed URLs are redirected and converted to cookie. Also: use Flag component in language switcher, add i18n "language" key, pre-translate Portuguese messages, change base locale to English. Co-Authored-By: Claude Opus 4.6 (1M context) --- messages/de.json | 1 + messages/en.json | 1 + messages/pt.json | 1199 ++++++++++---------- project.inlang/settings.json | 4 +- src/hooks.server.ts | 27 +- src/hooks.ts | 8 +- src/lib/components/LanguageSwitcher.svelte | 22 +- src/routes/+layout.svelte | 8 - vite.config.ts | 2 +- 9 files changed, 646 insertions(+), 626 deletions(-) diff --git a/messages/de.json b/messages/de.json index 81a7aeee..321f3480 100644 --- a/messages/de.json +++ b/messages/de.json @@ -247,6 +247,7 @@ "insertAtBeginning": "Am Anfang einfügen", "invalidShareCode": "Ungültiger Freigabecode", "italic": "Kursiv", + "language": "Sprache", "launcher": "Launcher", "launcherDescription": "Wähle die Konferenz aus", "launcherNoConferences": "Du bist für keine Konferenz angemeldet.", diff --git a/messages/en.json b/messages/en.json index b348577f..29a14820 100644 --- a/messages/en.json +++ b/messages/en.json @@ -247,6 +247,7 @@ "insertAtBeginning": "Insert at beginning", "invalidShareCode": "Invalid share code", "italic": "Italics", + "language": "Language", "launcher": "Launcher", "launcherDescription": "Select the conference", "launcherNoConferences": "You are not registered for a conference.", diff --git a/messages/pt.json b/messages/pt.json index b348577f..78b44840 100644 --- a/messages/pt.json +++ b/messages/pt.json @@ -1,606 +1,607 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", - "aServiceBy": "A service from", - "abort": "Cancel", - "absent": "Absent", - "absoluteMajority": "Absolute", - "abstain": "Abstain", - "activeAmendment": "Being Discussed", - "activeDraftResolution": "In Progress", - "addAgendaItem": "Add Item", - "addAll": "Add All", - "addClause": "Add Clause", - "addClausePresentation": "Add Clause", - "addComment": "Add Comment", - "addCommittee": "Add Committee", - "addCountriesCount": "Add {count} countries", - "addCountry": "Add Country", - "addMeToList": "Add me to list", - "addMember": "Add Member", - "addNonStateActor": "Add NGO", - "addRepresentation": "Add Delegation", - "addSponsor": "Add Sponsor", - "addUnActor": "Add UN Actor", - "admin": "Admin", - "adoptByConsensus": "Adopt by Consensus", - "adoptClause": "Adopt", - "adoptResolution": "Adopt Resolution", - "adopted": "Adopted", - "adoptionAnnouncement": "BREAKING: Resolution on \"{agendaItem}\" adopted in the committee {committeeName}", - "advanceToNextParagraph": "Advance to Next Paragraph", - "agendaItem": "Agenda item", - "agendaItemTitle": "Agenda Item Title", - "agendaItems": "Agenda Items", - "allRightsReservedby": "All rights reserved by", - "allowSelfAddToSpeakersList": "Self-add to Speakers List", - "allowSelfAddToSpeakersListDescription": "Allow delegates and non-state actors to add themselves to speakers lists.", - "alterClausePresentation": "Alter Text", - "alterPosition": "Move Clause", - "alterText": "Alter Text", - "amendment": "Amendment", - "amendmentAccepted": "Accepted", - "amendmentAcceptedToast": "Amendment accepted", - "amendmentAdd": "Add clause", - "amendmentAdopted": "Amendment adopted", - "amendmentAlterPosition": "Alter position", - "amendmentAlterText": "Alter text", - "amendmentConsensusAdopted": "Adopted by Consensus", - "amendmentCreated": "Amendment proposed", - "amendmentDelete": "Delete clause", - "amendmentPending": "Pending", - "amendmentPhase": "Amendment Phase", - "amendmentPhaseActive": "Amendment phase active", - "amendmentPhaseStarted": "Amendment phase started", - "amendmentProposed": "Amendment proposed", - "amendmentQueue": "Amendment Queue", - "amendmentRejected": "Rejected", - "amendmentRejectedClause": "Rejected", - "amendmentRejectedToast": "Amendment rejected", - "amendmentSponsoring": "Amendment Sponsoring", - "amendmentSponsoringClosed": "Amendment sponsoring is closed", - "amendmentSponsoringOpen": "Delegates can sponsor amendments", - "amendmentSubmission": "Amendment Submission", - "amendmentSubmissionClosed": "Amendment submission is closed", - "amendmentSubmissionOpen": "Delegates can submit new amendments", - "amendmentSubmitted": "Submitted", - "amendmentSubmittedToast": "Amendment submitted", - "amendmentUpdated": "Amendment updated", - "amendmentWithdrawn": "Withdrawn", - "amendmentWithdrawnToast": "Amendment withdrawn", - "amendments": "Amendments", - "announceAdoption": "Announce Adoption", - "assignedCount": "{count} assigned", - "assignment": "Assignment", - "back": "Back", - "backToResolutions": "Back to Resolutions", - "baseFontSize": "Base Font Size", - "baseFontSizeDescription": "Here you can set the base font size for the presentation view.", - "blockquote": "Quote", - "bold": "Bold", - "bulkAddMembers": "Bulk Add Members", - "bulkEmailPlaceholder": "Enter email addresses (one per line or comma-separated)", - "bulletedList": "Bulleted list", - "cancel": "Cancel", - "chairControls": "Chair Controls", - "chairCreateAmendment": "Create Amendment", - "chairCreateWorkingPaper": "Create Working Paper", - "changeSpeakersName": "Change Name", - "changeSpeakersTime": "Change Speaking Time", - "changesSaved": "Saved", - "clauseComments": "on clauses", - "clauseLockedBy": "Being edited by {country}", - "clauseVoteDeleted": "Clause vote removed", - "clauseVoteRecorded": "Clause vote recorded", - "clauseVoteSummary": "Clause Vote Summary", - "clausesVoted": "{voted}/{total} clauses voted", - "clearActiveDr": "Clear Active", - "clearFormatting": "Delete formatting", - "clearList": "Reset List", - "clearListDescription": "Are you sure you want to reset the entire list?", - "close": "Close", - "closeList": "Close List", - "closeReEvaluation": "Close Re-evaluation", - "code": "Code", - "codeCopied": "Code copied!", - "codeRedeemed": "Code redeemed successfully", - "codesUnrecognized": "{count} codes unrecognized", - "collaborativeEditingInfo": "Other delegates are editing this resolution. Hover a clause and click \"Start editing\" to begin. Locks expire automatically after 1 minute of inactivity.", - "comingSoon": "coming soon", - "commentDeleted": "Comment deleted", - "commentList": "Point of Information", - "commentPlaceholder": "Write a comment...", - "commentPosted": "Comment posted", - "commentUpdated": "Comment updated", - "comments": "Comments", - "commentsOnClause": "{count} comment(s)", - "committee": "Committee", - "committeeAbbreviation": "Committee Abbreviation", - "committeeDoesNotExist": "The committee does not exist.", - "committeeId": "Committee ID", - "committeeMember": "Committee Member", - "committeeMembers": "Committee Members", - "committeeName": "Committee Name", - "committeeOverview": "Committee Overview", - "committeeStatus": "Committee Status", - "committeeStatusExpired": "{status} expired!", - "committees": "Committees", - "con": "Against", - "conferenceCreated": "Conference created!", - "conferenceCreationError": "Could not create conference", - "conferenceCreationSuccessful": "Conference created. You will be redirected shortly...", - "conferenceId": "Conference ID", - "conferenceMembers": "Conference Members", - "conferenceTitle": "Conference Title", - "configuration": "Configuration", - "confirmAdoptByConsensus": "Adopt this amendment by consensus? This will immediately apply the change.", - "confirmAdoptResolution": "Adopt this resolution? Confetti will celebrate the adoption!", - "confirmDeleteAmendment": "Are you sure you want to propose deleting this clause?", - "confirmDeleteCommittee": "Are you sure you want to delete this committee? All associated data will be lost.", - "confirmDeletePaper": "Are you sure you want to delete this working paper? It will be hidden for all participants.", - "confirmDeleteRepresentation": "Are you sure you want to remove this delegation? Associated committee memberships will be removed.", - "confirmFinalVote": "Record this final vote? The resolution status will change to Final.", - "confirmRejectAmendment": "Reject this amendment?", - "confirmRejectResolution": "Reject this resolution?", - "confirmRemoveMember": "Are you sure you want to remove this member?", - "confirmRevertStatus": "Revert this paper from {from} back to {to}?", - "confirmSendBack": "Send this resolution back?", - "confirmStartAmendmentPhase": "Start the amendment phase for this draft resolution? Delegates will be able to propose amendments paragraph by paragraph.", - "confirmStartVotingPhase": "Start the voting phase for this draft resolution? Each operative paragraph will be voted on individually.", - "confirmSubmitPaper": "Are you sure you want to submit this paper to the chair? You will no longer be able to edit it.", - "copy": "Copy", - "copyCode": "Copy", - "copyFailed": "Copy failed", - "countries": "Countries", - "countriesRecognized": "{count} countries recognized", - "countryCodesHelp": "Supports Alpha-2 (DE, US) and Alpha-3 (DEU, USA) codes. Separate with spaces, commas, semicolons, or new lines.", - "countryCodesPlaceholder": "DE, USA, FRA\nor one per line...", - "countryNotFound": "Country not found", - "countryNsaOrCustomRole": "Country, NSA, or special role", - "create": "Create", - "createConference": "Create Conference", - "createPaper": "Create Paper", - "createResolutionPaper": "Create Working Paper", - "createShareCodeEdit": "Create Edit Code", - "createShareCodeSponsor": "Create Sponsor Code", - "currentParagraph": "Current Paragraph", - "currentText": "Current Text", - "customName": "Custom name...", - "dateCannotBeInPast": "The date must not be in the past!", - "debateControls": "Debate Controls", - "delegate": "Delegate", - "delegations": "Delegations", - "deleteClause": "Delete Clause", - "deleteClausePresentation": "Delete Clause", - "deleteCode": "Delete Code", - "deleteComment": "Delete", - "deletePaper": "Delete Paper", - "deleteRepresentation": "Remove Delegation", - "displayRegionalGroups": "Display Regional Blocs", - "documentLevelComments": "Document Comments", - "documentNumber": "Document Number", - "documentWide": "document-wide", - "doneEditing": "Done editing", - "download": "Download", - "downloadPresenceData": "Presence Data", - "draftResolution": "Draft Resolution", - "draftResolutions": "Draft Resolutions", - "edit": "Edit", - "editAccess": "Edit Access", - "editAmendment": "Edit Amendment", - "editComment": "Edit", - "editPaper": "Edit Paper", - "editUser": "Edit User", - "editors": "Editors", - "email": "Email", - "enterAlpha2Code": "Please enter Alpha2Code", - "enterCode": "Enter Code", - "enterCountryCodes": "Enter country codes (Alpha-2 or Alpha-3)", - "errorUpdatingStateOfDebate": "Error saving debate status", - "errorUpdatingStatus": "Status could not be set", - "errorUpdatingTimer": "Could not update speaking time", - "errorUpdatingWhiteboard": "Publication failed", - "fileParseError": "Error parsing file", - "finalResolution": "Final Resolution", - "finalVote": "Final Vote", - "finalVoteDescription": "Record the final vote on the entire resolution.", - "finishAmendmentPhaseFirst": "Finish the amendment phase first", - "formalDebate": "Formal debate", - "forward": "Next", - "general": "General", - "goToAmendments": "Go to amendments", - "goToVoting": "Go to voting", - "gotoSettings": "Go to settings", - "h1": "Heading 1", - "h2": "Heading 2", - "h3": "Heading 3", - "hasModeratedCaucus": "Moderated Caucus", - "hasModeratedCaucusDescription": "Enable moderated informal caucus as a committee status option.", - "home": "Home", - "homeAboutText": "CHASE (CHAirSoftwarE) is a web application for managing and conducting debates at Model United Nations conferences. It is designed for both chairs and delegates. CHASE allows chairs to easily manage debates, while delegates can follow the discussion and collaborate with others in an intuitive and structured way. CHASE is free and open-source software.", - "homeAboutTitle": "About CHASE", - "homeCaption": "in the 21st century", - "homeContactButton": "Get in Touch", - "homeContactText": "Are you organizing a Model United Nations conference and interested in using CHASE? We offer free support (within our abilities) to help you deploy CHASE on your own infrastructure. We can also host CHASE on our servers for your conference. Reach out to us — we are happy to help!", - "homeContactTitle": "Get CHASE for Your Conference", - "homeContributeButtonLabel": "MUNify on GitHub", - "homeContributeText": "CHASE is part of the open-source initiative 'MUNify' by DMUN. This means anyone can contribute to development. We appreciate every bit of help we can get. If you have experience in web development or want to learn new skills and help out, check out our GitHub!", - "homeContributeTitle": "Contribute", - "homeHeroCardResolutionEditorText": "Collaboratively create and edit resolutions with other delegates. No more paper or Google Docs!", - "homeHeroCardResolutionEditorTitle": "Resolutions", - "homeHeroCardSpeakersListText": "Manage the speakers' lists simply and efficiently. No more paper lists!", + "aServiceBy": "Um serviço de", + "abort": "Cancelar", + "absent": "Ausente", + "absoluteMajority": "Absoluta", + "abstain": "Abstenção", + "activeAmendment": "Em Discussão", + "activeDraftResolution": "Em Andamento", + "addAgendaItem": "Adicionar Item", + "addAll": "Adicionar Todos", + "addClause": "Adicionar Cláusula", + "addClausePresentation": "Adicionar Cláusula", + "addComment": "Adicionar Comentário", + "addCommittee": "Adicionar Comitê", + "addCountriesCount": "Adicionar {count} países", + "addCountry": "Adicionar País", + "addMeToList": "Inscrever-me na lista", + "addMember": "Adicionar Membro", + "addNonStateActor": "Adicionar NGO", + "addRepresentation": "Adicionar Delegação", + "addSponsor": "Adicionar Patrocinador", + "addUnActor": "Adicionar Ator da ONU", + "admin": "Administrador", + "adoptByConsensus": "Adotar por Consenso", + "adoptClause": "Adotar", + "adoptResolution": "Adotar Resolução", + "adopted": "Adotada", + "adoptionAnnouncement": "URGENTE: Resolução sobre \"{agendaItem}\" adotada no comitê {committeeName}", + "advanceToNextParagraph": "Avançar para o Próximo Parágrafo", + "agendaItem": "Item da pauta", + "agendaItemTitle": "Título do Item da Pauta", + "agendaItems": "Itens da Pauta", + "allRightsReservedby": "Todos os direitos reservados por", + "allowSelfAddToSpeakersList": "Autoinscrição na Lista de Oradores", + "allowSelfAddToSpeakersListDescription": "Permitir que delegados e atores não estatais se inscrevam nas listas de oradores.", + "alterClausePresentation": "Alterar Texto", + "alterPosition": "Mover Cláusula", + "alterText": "Alterar Texto", + "amendment": "Emenda", + "amendmentAccepted": "Aceita", + "amendmentAcceptedToast": "Emenda aceita", + "amendmentAdd": "Adicionar cláusula", + "amendmentAdopted": "Emenda adotada", + "amendmentAlterPosition": "Alterar posição", + "amendmentAlterText": "Alterar texto", + "amendmentConsensusAdopted": "Adotada por Consenso", + "amendmentCreated": "Emenda proposta", + "amendmentDelete": "Excluir cláusula", + "amendmentPending": "Pendente", + "amendmentPhase": "Fase de Emendas", + "amendmentPhaseActive": "Fase de emendas ativa", + "amendmentPhaseStarted": "Fase de emendas iniciada", + "amendmentProposed": "Emenda proposta", + "amendmentQueue": "Fila de Emendas", + "amendmentRejected": "Rejeitada", + "amendmentRejectedClause": "Rejeitada", + "amendmentRejectedToast": "Emenda rejeitada", + "amendmentSponsoring": "Patrocínio de Emendas", + "amendmentSponsoringClosed": "O patrocínio de emendas está encerrado", + "amendmentSponsoringOpen": "Delegados podem patrocinar emendas", + "amendmentSubmission": "Submissão de Emendas", + "amendmentSubmissionClosed": "A submissão de emendas está encerrada", + "amendmentSubmissionOpen": "Delegados podem submeter novas emendas", + "amendmentSubmitted": "Submetida", + "amendmentSubmittedToast": "Emenda submetida", + "amendmentUpdated": "Emenda atualizada", + "amendmentWithdrawn": "Retirada", + "amendmentWithdrawnToast": "Emenda retirada", + "amendments": "Emendas", + "announceAdoption": "Anunciar Adoção", + "assignedCount": "{count} atribuídos", + "assignment": "Atribuição", + "back": "Voltar", + "backToResolutions": "Voltar às Resoluções", + "baseFontSize": "Tamanho Base da Fonte", + "baseFontSizeDescription": "Aqui você pode definir o tamanho base da fonte para a visualização de apresentação.", + "blockquote": "Citação", + "bold": "Negrito", + "bulkAddMembers": "Adicionar Membros em Massa", + "bulkEmailPlaceholder": "Insira endereços de e-mail (um por linha ou separados por vírgula)", + "bulletedList": "Lista com marcadores", + "cancel": "Cancelar", + "chairControls": "Controles da Presidência", + "chairCreateAmendment": "Criar Emenda", + "chairCreateWorkingPaper": "Criar Documento de Trabalho", + "changeSpeakersName": "Alterar Nome", + "changeSpeakersTime": "Alterar Tempo de Fala", + "changesSaved": "Salvo", + "clauseComments": "nas cláusulas", + "clauseLockedBy": "Sendo editada por {country}", + "clauseVoteDeleted": "Votação de cláusula removida", + "clauseVoteRecorded": "Votação de cláusula registrada", + "clauseVoteSummary": "Resumo da Votação de Cláusulas", + "clausesVoted": "{voted}/{total} cláusulas votadas", + "clearActiveDr": "Limpar Ativo", + "clearFormatting": "Limpar formatação", + "clearList": "Limpar Lista", + "clearListDescription": "Tem certeza de que deseja limpar toda a lista?", + "close": "Fechar", + "closeList": "Fechar Lista", + "closeReEvaluation": "Fechar Reavaliação", + "code": "Código", + "codeCopied": "Código copiado!", + "codeRedeemed": "Código resgatado com sucesso", + "codesUnrecognized": "{count} códigos não reconhecidos", + "collaborativeEditingInfo": "Outros delegados estão editando esta resolução. Passe o mouse sobre uma cláusula e clique em \"Começar a editar\" para iniciar. Os bloqueios expiram automaticamente após 1 minuto de inatividade.", + "comingSoon": "em breve", + "commentDeleted": "Comentário excluído", + "commentList": "Ponto de Informação", + "commentPlaceholder": "Escreva um comentário...", + "commentPosted": "Comentário publicado", + "commentUpdated": "Comentário atualizado", + "comments": "Comentários", + "commentsOnClause": "{count} comentário(s)", + "committee": "Comitê", + "committeeAbbreviation": "Abreviação do Comitê", + "committeeDoesNotExist": "O comitê não existe.", + "committeeId": "ID do Comitê", + "committeeMember": "Membro do Comitê", + "committeeMembers": "Membros do Comitê", + "committeeName": "Nome do Comitê", + "committeeOverview": "Visão Geral do Comitê", + "committeeStatus": "Status do Comitê", + "committeeStatusExpired": "{status} expirou!", + "committees": "Comitês", + "con": "Contra", + "conferenceCreated": "Conferência criada!", + "conferenceCreationError": "Não foi possível criar a conferência", + "conferenceCreationSuccessful": "Conferência criada. Você será redirecionado em breve...", + "conferenceId": "ID da Conferência", + "conferenceMembers": "Membros da Conferência", + "conferenceTitle": "Título da Conferência", + "configuration": "Configuração", + "confirmAdoptByConsensus": "Adotar esta emenda por consenso? Isso aplicará a alteração imediatamente.", + "confirmAdoptResolution": "Adotar esta resolução? Confetes celebrarão a adoção!", + "confirmDeleteAmendment": "Tem certeza de que deseja propor a exclusão desta cláusula?", + "confirmDeleteCommittee": "Tem certeza de que deseja excluir este comitê? Todos os dados associados serão perdidos.", + "confirmDeletePaper": "Tem certeza de que deseja excluir este documento de trabalho? Ele ficará oculto para todos os participantes.", + "confirmDeleteRepresentation": "Tem certeza de que deseja remover esta delegação? As associações ao comitê serão removidas.", + "confirmFinalVote": "Registrar esta votação final? O status da resolução será alterado para Final.", + "confirmRejectAmendment": "Rejeitar esta emenda?", + "confirmRejectResolution": "Rejeitar esta resolução?", + "confirmRemoveMember": "Tem certeza de que deseja remover este membro?", + "confirmRevertStatus": "Reverter este documento de {from} para {to}?", + "confirmSendBack": "Devolver esta resolução?", + "confirmStartAmendmentPhase": "Iniciar a fase de emendas para este projeto de resolução? Os delegados poderão propor emendas parágrafo por parágrafo.", + "confirmStartVotingPhase": "Iniciar a fase de votação para este projeto de resolução? Cada parágrafo operativo será votado individualmente.", + "confirmSubmitPaper": "Tem certeza de que deseja submeter este documento à presidência? Você não poderá mais editá-lo.", + "copy": "Copiar", + "copyCode": "Copiar", + "copyFailed": "Falha ao copiar", + "countries": "Países", + "countriesRecognized": "{count} países reconhecidos", + "countryCodesHelp": "Suporta códigos Alpha-2 (DE, US) e Alpha-3 (DEU, USA). Separe com espaços, vírgulas, ponto e vírgula ou novas linhas.", + "countryCodesPlaceholder": "DE, USA, FRA\nou um por linha...", + "countryNotFound": "País não encontrado", + "countryNsaOrCustomRole": "País, ator não estatal ou função especial", + "create": "Criar", + "createConference": "Criar Conferência", + "createPaper": "Criar Documento", + "createResolutionPaper": "Criar Documento de Trabalho", + "createShareCodeEdit": "Criar Código de Edição", + "createShareCodeSponsor": "Criar Código de Patrocínio", + "currentParagraph": "Parágrafo Atual", + "currentText": "Texto Atual", + "customName": "Nome personalizado...", + "dateCannotBeInPast": "A data não pode estar no passado!", + "debateControls": "Controles do Debate", + "delegate": "Delegado", + "delegations": "Delegações", + "deleteClause": "Excluir Cláusula", + "deleteClausePresentation": "Excluir Cláusula", + "deleteCode": "Excluir Código", + "deleteComment": "Excluir", + "deletePaper": "Excluir Documento", + "deleteRepresentation": "Remover Delegação", + "displayRegionalGroups": "Exibir Blocos Regionais", + "documentLevelComments": "Comentários do Documento", + "documentNumber": "Número do Documento", + "documentWide": "todo o documento", + "doneEditing": "Finalizar edição", + "download": "Baixar", + "downloadPresenceData": "Dados de Presença", + "draftResolution": "Projeto de Resolução", + "draftResolutions": "Projetos de Resolução", + "edit": "Editar", + "editAccess": "Acesso de Edição", + "editAmendment": "Editar Emenda", + "editComment": "Editar", + "editPaper": "Editar Documento", + "editUser": "Editar Usuário", + "editors": "Editores", + "email": "E-mail", + "enterAlpha2Code": "Por favor, insira o código Alpha2", + "enterCode": "Inserir Código", + "enterCountryCodes": "Insira os códigos dos países (Alpha-2 ou Alpha-3)", + "errorUpdatingStateOfDebate": "Erro ao salvar o status do debate", + "errorUpdatingStatus": "Não foi possível definir o status", + "errorUpdatingTimer": "Não foi possível atualizar o tempo de fala", + "errorUpdatingWhiteboard": "Falha na publicação", + "fileParseError": "Erro ao analisar o arquivo", + "finalResolution": "Resolução Final", + "finalVote": "Votação Final", + "finalVoteDescription": "Registrar a votação final sobre a resolução inteira.", + "finishAmendmentPhaseFirst": "Finalize a fase de emendas primeiro", + "formalDebate": "Debate formal", + "forward": "Próximo", + "general": "Geral", + "goToAmendments": "Ir para emendas", + "goToVoting": "Ir para votação", + "gotoSettings": "Ir para configurações", + "h1": "Título 1", + "h2": "Título 2", + "h3": "Título 3", + "hasModeratedCaucus": "Caucus Moderado", + "hasModeratedCaucusDescription": "Habilitar caucus informal moderado como opção de status do comitê.", + "home": "Início", + "homeAboutText": "CHASE (CHAirSoftwarE) é uma aplicação web para gerenciar e conduzir debates em conferências de Modelo das Nações Unidas. Foi projetada tanto para presidências quanto para delegados. CHASE permite que as presidências gerenciem debates facilmente, enquanto os delegados podem acompanhar a discussão e colaborar com outros de forma intuitiva e estruturada. CHASE é um software livre e de código aberto.", + "homeAboutTitle": "Sobre o CHASE", + "homeCaption": "no século 21", + "homeContactButton": "Entre em Contato", + "homeContactText": "Você está organizando uma conferência de Modelo das Nações Unidas e tem interesse em usar o CHASE? Oferecemos suporte gratuito (dentro de nossas possibilidades) para ajudá-lo a implantar o CHASE em sua própria infraestrutura. Também podemos hospedar o CHASE em nossos servidores para a sua conferência. Entre em contato — teremos prazer em ajudar!", + "homeContactTitle": "Obtenha o CHASE para Sua Conferência", + "homeContributeButtonLabel": "MUNify no GitHub", + "homeContributeText": "CHASE faz parte da iniciativa de código aberto 'MUNify' da DMUN. Isso significa que qualquer pessoa pode contribuir com o desenvolvimento. Agradecemos toda ajuda que pudermos receber. Se você tem experiência em desenvolvimento web ou quer aprender novas habilidades e ajudar, confira nosso GitHub!", + "homeContributeTitle": "Contribua", + "homeHeroCardResolutionEditorText": "Crie e edite resoluções de forma colaborativa com outros delegados. Sem mais papel ou Google Docs!", + "homeHeroCardResolutionEditorTitle": "Resoluções", + "homeHeroCardSpeakersListText": "Gerencie as listas de oradores de forma simples e eficiente. Sem mais listas em papel!", "homeHeroCardSpeakersListTitle": "Debates", - "homeHeroCardVotingText": "Manage motions and voting electronically with pre-configured motions based on your Rules of Procedure.", - "homeHeroCardVotingTitle": "Voting", - "homeHeroText": "Debate management at Model United Nations conferences finally gets an upgrade.", - "homeMissionButtonLabel": "Learn more about DMUN", - "homeMissionText": "CHASE is developed by members of the German organization DMUN e.V. We aim to provide a free and accessible alternative to other debate management tools, making it easier even for smaller conferences to participate. CHASE was initially developed for German-speaking DMUN conferences – MUN-SH, MUNBW, and MUNBB – but we are open to adapting it to other conferences.", - "homeMissionTitle": "Our Mission", - "homeVersionButton": "CHASE (CHAirSoftwarE) is a web application for managing and conducting debates in Model United Nations conferences. It is designed for chairmen and delegates alike. CHASE allows chairs to easily manage debates, while delegates can follow the debate and collaborate with other delegates in an intuitive and structured way. CHASE is free and open source software.", - "horizontalRule": "Divider", - "icon": "Icon", - "img": "Picture", - "importFromDelegator": "Import conference from Delegator", - "imprintAndPrivacy": "Imprint & Privacy Policy", - "informalCaucus": "Informal meeting", - "insertAfterPresentation": "Insert after OP.{index}", - "insertAsFirstClause": "Insert as first clause", - "insertAtBeginning": "Insert at beginning", - "invalidShareCode": "Invalid share code", - "italic": "Italics", - "launcher": "Launcher", - "launcherDescription": "Select the conference", - "launcherNoConferences": "You are not registered for a conference.", - "launcherWelcome": "Welcome back, {name}!", + "homeHeroCardVotingText": "Gerencie moções e votações eletronicamente com moções pré-configuradas baseadas nas suas Regras de Procedimento.", + "homeHeroCardVotingTitle": "Votação", + "homeHeroText": "O gerenciamento de debates em conferências de Modelo das Nações Unidas finalmente recebe uma atualização.", + "homeMissionButtonLabel": "Saiba mais sobre a DMUN", + "homeMissionText": "CHASE é desenvolvido por membros da organização alemã DMUN e.V. Nosso objetivo é oferecer uma alternativa gratuita e acessível a outras ferramentas de gerenciamento de debates, facilitando a participação até de conferências menores. CHASE foi inicialmente desenvolvido para conferências de língua alemã da DMUN — MUN-SH, MUNBW e MUNBB — mas estamos abertos a adaptá-lo para outras conferências.", + "homeMissionTitle": "Nossa Missão", + "homeVersionButton": "CHASE (CHAirSoftwarE) é uma aplicação web para gerenciar e conduzir debates em conferências de Modelo das Nações Unidas. Foi projetada tanto para presidências quanto para delegados. CHASE permite que as presidências gerenciem debates facilmente, enquanto os delegados podem acompanhar o debate e colaborar com outros delegados de forma intuitiva e estruturada. CHASE é um software livre e de código aberto.", + "horizontalRule": "Divisor", + "icon": "Ícone", + "img": "Imagem", + "importFromDelegator": "Importar conferência do Delegator", + "imprintAndPrivacy": "Imprensa & Política de Privacidade", + "informalCaucus": "Reunião informal", + "insertAfterPresentation": "Inserir após CO.{index}", + "insertAsFirstClause": "Inserir como primeira cláusula", + "insertAtBeginning": "Inserir no início", + "invalidShareCode": "Código de compartilhamento inválido", + "italic": "Itálico", + "language": "Idioma", + "launcher": "Lançador", + "launcherDescription": "Selecione a conferência", + "launcherNoConferences": "Você não está registrado em nenhuma conferência.", + "launcherWelcome": "Bem-vindo de volta, {name}!", "layout": "Layout", - "layoutDescription": "Layout templates for the presentation view. Please note that changing the layout template here will overwrite manual layout changes.", - "layoutPresetDefault": "Default Layout", - "layoutPresetResolution": "Resolution Layout", - "layoutPresetSmallScreen": "Layout for Small Screens", - "layoutSelect": "Select Layout", - "link": "Hyperlink", - "listClosed": "List closed", - "listClosedCannotAdd": "The list is closed", - "listEmpty": "No speech", - "lockAcquireFailed": "This clause is currently being edited by {country}. Please try again shortly.", - "login": "Register", - "logout": "Log out", - "loose_slow_reindeer_build": "Committee Members", - "majorities": "Majorities", - "majoritySettings": "Majority Settings", - "majoritySettingsDescriptions": "Majority settings help visualize whether a motion has passed.", - "maroon_bland_ray_renew": "Committee abbreviation", - "matching": "matching", - "maxDraftResolutions": "Max Draft Resolutions", - "maxDraftResolutionsReached": "Maximum number of draft resolutions reached", - "member": "Member", - "memberAdded": "Member added successfully", - "memberRemoved": "Member removed successfully", - "memberUpdated": "Member updated successfully", - "minuteOfTheHour": "Absolute time: Jump to the corresponding minute of this or the next hour", - "minutesFromNow": "Relative time: Jump X minutes into the future", - "missionControl": "Mission Control", - "moderatedInformalCaucus": "Moderated informal caucus", - "moveClausePresentation": "Move Clause", - "moveToPositionPresentation": "Move to position {position}", - "myAmendments": "My Amendments", - "myPapers": "My Papers", - "name": "Name", - "nextParagraph": "Next", - "nextSpeaker": "Next Speech", - "nextSpeakerDescription": "Do you really want to call the next speech? All remaining Points of Information will be discarded.", - "noActiveAgendaItem": "No active agenda item. A paper cannot be created right now.", - "noActiveDr": "No active draft resolution", - "noActiveDrForVoting": "No active draft resolution for voting", - "noActiveDraftResolution": "No active draft resolution", - "noAgendaItemSelected": "No agenda item active", - "noAgendaItemSelectedDescription": "To work with speakers' lists, you must first select an agenda item.", - "noAmendments": "No amendments yet", - "noAssignmentNeeded": "No member assignment needed for this role.", - "noCommentList": "No Point of Information List", - "noComments": "No comments yet", - "noCurrentSpeaker": "No speech", - "noData": "No data", - "noDraftResolution": "No draft resolution set as active.", - "noDraftResolutionsYet": "No draft resolutions yet.", - "noMembers": "No members yet", - "noOperativeClauses": "No operative clauses", - "noPapersYet": "No papers yet. Create one or enter a share code.", - "noResults": "No results", - "noSubmittedPapers": "No submitted papers yet.", - "nonStateActor": "Non-state Actor", - "nonStateActors": "Non-state Actors", - "notAuthorized": "You are not authorized to access this page", - "notPresent": "Not present", - "notPresentCannotAdd": "You must be marked as present to add yourself", - "nothingChanged": "Nothing changed", - "numberedList": "Numbered list", - "off": "Off", - "on": "On", - "onListPosition": "You are #{position} on the list", - "openPresentation": "Open Presentation View", - "openReEvaluation": "Open Re-evaluation", - "operativeClause": "Operative Clause", - "operativeClausePresentation": "Operative Clause", - "outcome": "Outcome", - "over": "over", - "paperCreated": "Paper created", - "paperDeleted": "Paper deleted", - "paperPromoted": "Paper promoted to Draft Resolution", - "paperSubmitted": "Paper submitted to chair", - "paperSupportThresholdTooltip": "Supporting states required to submit an amendment", - "paperTitle": "Paper Title", - "papers": "Papers", - "paragraphVoting": "Paragraph Voting", - "parsedCountries": "Countries to add:", - "participantView": "Participant View", - "pause": "Pause", - "phraseCopied": "Phrase copied!", - "phraseLookup": "Phrases", - "phraseLookupDisclaimer": "These phrases are provided as guidance. Please verify correct usage in context.", - "phraseLookupNoResults": "No phrases found.", - "phraseLookupSearch": "Search phrases...", - "phraseLookupTitle": "Phrase Reference", - "preambleClause": "Preamble Clause", - "presence": "Presence", - "present": "Present", - "presentationMode": "Presentation View", - "pressWebsite": "Press Website", - "preview": "Preview", - "previousParagraph": "Previous", - "printResolution": "Print", - "pro": "In Favor", - "promote": "Promote", - "promoteToDraftResolution": "Promote to Draft Resolution", - "promoteToDraftResolutionConfirm": "Promote this paper to a Draft Resolution? This will assign it a document number.", - "proposeAmendment": "Propose Amendment", - "proposedAmendmentPresentation": "Proposed Amendment", - "proposedBy": "Proposed by {name}", - "proposedText": "Proposed Text", - "publicComment": "Public", - "publish": "Publish", - "publishChanges": "Publish changes", - "recordVoteFromVoting": "Record Vote", - "redeemShareCode": "Redeem Share Code", - "redo": "Repeat", - "regionalGroup_africa": "Africa", - "regionalGroup_asiaPacific": "Asia-Pacific", - "regionalGroup_easternEurope": "Eastern Europe", - "regionalGroup_latinAmericaCaribbean": "Latin America and the Caribbean", - "regionalGroup_westernEuropeOthers": "Western Europe and Others", - "regionalGroups": "Regional Groups", - "rejectClause": "Reject", - "rejectResolution": "Reject Resolution", - "rejected": "Rejected", - "removeFromList": "Remove from list", - "removeMember": "Remove", - "removeSponsor": "Remove Sponsorship", - "replyToComment": "Reply", - "resolution": "Resolution", - "resolutionAddClause": "Add Clause", - "resolutionAddContinuation": "Continuation Text", - "resolutionAddFirstClause": "Add First Clause", - "resolutionAddNested": "Nested Clause", - "resolutionAddSibling": "Add Clause", - "resolutionAddSubClause": "Sub-clause", - "resolutionAdopted": "Resolution adopted!", - "resolutionAuthoringDelegation": "Authoring Delegation", - "resolutionCommittee": "Committee", - "resolutionContinuationPlaceholder": "Enter continuation text...", - "resolutionDeleteBlock": "Delete Block", - "resolutionDeleteClause": "Delete", - "resolutionDisclaimer": "This document was created as part of a {conferenceName} simulation and has no legal validity.", - "resolutionEditor": "Resolution Editor", - "resolutionFontSize": "Resolution Font Size", - "resolutionFontSizeDescription": "Set the font size for the resolution text in the presentation view.", - "resolutionHeadline": "Resolution Headline (e.g. The Security Council)", - "resolutionHidePreview": "Hide Preview", - "resolutionImport": "Import", - "resolutionImportButton": "Import {count} clause(s)", - "resolutionImportHintOperative": "Paste numbered operative clauses. Sub-clauses will be detected automatically.", - "resolutionImportHintPreamble": "Paste preamble clauses, separated by comma and line break.", - "resolutionImportLLMCopied": "Copied!", - "resolutionImportLLMCopyPrompt": "Copy Prompt", - "resolutionImportLLMInstructions": "Copy the following prompt into an AI assistant to automatically format your text:", - "resolutionImportLLMPromptOperative": "Format the following text as UN resolution operative clauses. Use:\n- Numbering for main clauses: 1. 2. 3.\n- Letters for sub-clauses: a) b) c)\n- Roman numerals for further nesting: i) ii) iii)\n- Double letters for deepest level: aa) bb) cc)\n- Semicolon at the end of each clause, period at the end of the last\n\nExample format:\n1. Calls upon all Member States to take measures;\n a) to promote peace;\n b) to strengthen cooperation;\n i) at the bilateral level;\n ii) at the multilateral level;\n2. Requests the Secretary-General to submit a report.\n\nText to format:", - "resolutionImportLLMPromptPreamble": "Format the following text as UN resolution preamble clauses. Each clause should:\n- Begin with a lowercase letter (except proper nouns)\n- End with a comma\n- Be separated by a line break\n\nExample format:\nrecalling its resolution 70/1 of 25 September 2015,\nemphasizing the importance of multilateralism,\nnoting with concern the current situation,\n\nText to format:", - "resolutionImportLLMTitle": "AI Formatting", - "resolutionImportOperative": "Import Operative Clauses", - "resolutionImportPreamble": "Import Preamble Clauses", - "resolutionImportPreview": "Preview: {count} clause(s) detected", - "resolutionImportTipsOperative1": "Numbered main clauses: 1. 2. 3. or 1) 2) 3)", - "resolutionImportTipsOperative2": "Lettered sub-clauses: a) b) c) or (a) (b) (c)", - "resolutionImportTipsOperative3": "Nested sub-clauses with Roman numerals: i) ii) iii)", - "resolutionImportTipsOperative4": "Further nesting with double letters: aa) bb) cc)", - "resolutionImportTipsPreamble1": "Each clause should end with a comma", - "resolutionImportTipsPreamble2": "Line breaks separate individual clauses", - "resolutionImportTipsPreamble3": "Clauses are imported in the order entered", - "resolutionImportTipsTitle": "Tips for Best Results", - "resolutionIndent": "Indent", - "resolutionMoveDown": "Move Down", - "resolutionMoveUp": "Move Up", - "resolutionNoClausesYet": "No clauses yet.", - "resolutionNoOperativeClauses": "No operative clauses yet.", - "resolutionNoPreambleClauses": "No preamble clauses yet.", - "resolutionOperativeClauses": "Operative Clauses", - "resolutionOperativePlaceholder": "Enter operative clause...", - "resolutionOutdent": "Outdent", - "resolutionPaper": "Resolution Paper", - "resolutionPapers": "Resolution Papers", - "resolutionPreambleClauses": "Preamble Clauses", - "resolutionPreamblePlaceholder": "Enter preamble clause...", - "resolutionPreview": "Preview", - "resolutionRejected": "Resolution rejected", - "resolutionSentBack": "Resolution sent back", - "resolutionShowPreview": "Show Preview", - "resolutionSponsoringDelegations": "Sponsoring Delegations", - "resolutionSubClausePlaceholder": "Enter sub-clause...", - "resolutionSubClauses": "Sub-clauses", - "resolutionUnknownPhrase": "Unknown phrase", - "resolutions": "Resolutions", - "restoreContentFromSnapshot": "Restore content from before amendments", - "restoreContentFromSnapshotDescription": "Undo all applied amendments and restore the resolution content to the version before the amendment phase began. Applied amendments will be reset to pending.", - "revertDrWarning": "Reverting will clear the document number. The paper can be re-promoted later.", - "revertStatus": "Revert Status", - "revertVotingWarning": "Reverting will delete all clause vote results for this paper.", - "role": "Role", - "rollCall": "Roll Call", - "rollCallError": "Committee member not found", - "rollCallSuccess": "Roll call completed", - "rollCallVoting": "Roll Call Vote", - "rollCollError": "Committee member not found", - "rollCollSuccess": "Roll call complete", - "save": "Save", - "saveChanges": "Save Changes", - "saveError": "Save failed", - "savingChanges": "Saving...", - "searchCommitteeMembers": "Search committee members", - "searchMembers": "Search members...", - "searchUsers": "Search users...", - "selectAgendaItem": "Select agenda item...", - "selectAmendmentType": "Select amendment type", - "selectAuthorDelegation": "Select Author Delegation", - "selectCommitteeMember": "Select committee member...", - "selectConferenceMember": "Select conference member...", - "selectProposerDelegation": "Select Proposing Delegation", - "selectTargetClause": "Select Target Clause", - "selected": "Selected", - "sendBack": "Send Back", - "sentBack": "Sent Back", - "seoDescription": "MUNify CHASE is the free, open-source debate management tool for Model United Nations conferences. Manage speakers lists, voting, and resolutions digitally.", - "seoTitle": "MUNify CHASE – Debate Management for Model United Nations", - "setActiveAmendment": "Discuss", - "setActiveDr": "Set Active", - "setActiveDrHint": "Set a draft resolution as active in the chair view to display it here.", - "setAllAbsent": "Set All Absent", - "setAllPresent": "Set All Present", - "setStatus": "Change status", - "setup": "Set up", + "layoutDescription": "Modelos de layout para a visualização de apresentação. Observe que alterar o modelo de layout aqui substituirá as alterações manuais de layout.", + "layoutPresetDefault": "Layout Padrão", + "layoutPresetResolution": "Layout de Resolução", + "layoutPresetSmallScreen": "Layout para Telas Pequenas", + "layoutSelect": "Selecionar Layout", + "link": "Hiperlink", + "listClosed": "Lista fechada", + "listClosedCannotAdd": "A lista está fechada", + "listEmpty": "Nenhum discurso", + "lockAcquireFailed": "Esta cláusula está sendo editada por {country}. Por favor, tente novamente em instantes.", + "login": "Registrar", + "logout": "Sair", + "loose_slow_reindeer_build": "Membros do Comitê", + "majorities": "Maiorias", + "majoritySettings": "Configurações de Maioria", + "majoritySettingsDescriptions": "As configurações de maioria ajudam a visualizar se uma moção foi aprovada.", + "maroon_bland_ray_renew": "Abreviação do comitê", + "matching": "correspondente", + "maxDraftResolutions": "Máximo de Projetos de Resolução", + "maxDraftResolutionsReached": "Número máximo de projetos de resolução atingido", + "member": "Membro", + "memberAdded": "Membro adicionado com sucesso", + "memberRemoved": "Membro removido com sucesso", + "memberUpdated": "Membro atualizado com sucesso", + "minuteOfTheHour": "Horário absoluto: Ir para o minuto correspondente desta ou da próxima hora", + "minutesFromNow": "Horário relativo: Avançar X minutos no futuro", + "missionControl": "Controle de Missão", + "moderatedInformalCaucus": "Caucus informal moderado", + "moveClausePresentation": "Mover Cláusula", + "moveToPositionPresentation": "Mover para a posição {position}", + "myAmendments": "Minhas Emendas", + "myPapers": "Meus Documentos", + "name": "Nome", + "nextParagraph": "Próximo", + "nextSpeaker": "Próximo Discurso", + "nextSpeakerDescription": "Deseja realmente chamar o próximo discurso? Todos os Pontos de Informação restantes serão descartados.", + "noActiveAgendaItem": "Nenhum item da pauta ativo. Não é possível criar um documento agora.", + "noActiveDr": "Nenhum projeto de resolução ativo", + "noActiveDrForVoting": "Nenhum projeto de resolução ativo para votação", + "noActiveDraftResolution": "Nenhum projeto de resolução ativo", + "noAgendaItemSelected": "Nenhum item da pauta ativo", + "noAgendaItemSelectedDescription": "Para trabalhar com as listas de oradores, você deve primeiro selecionar um item da pauta.", + "noAmendments": "Nenhuma emenda ainda", + "noAssignmentNeeded": "Nenhuma atribuição de membro necessária para esta função.", + "noCommentList": "Sem Lista de Pontos de Informação", + "noComments": "Nenhum comentário ainda", + "noCurrentSpeaker": "Nenhum discurso", + "noData": "Sem dados", + "noDraftResolution": "Nenhum projeto de resolução definido como ativo.", + "noDraftResolutionsYet": "Nenhum projeto de resolução ainda.", + "noMembers": "Nenhum membro ainda", + "noOperativeClauses": "Nenhuma cláusula operativa", + "noPapersYet": "Nenhum documento ainda. Crie um ou insira um código de compartilhamento.", + "noResults": "Sem resultados", + "noSubmittedPapers": "Nenhum documento submetido ainda.", + "nonStateActor": "Ator Não Estatal", + "nonStateActors": "Atores Não Estatais", + "notAuthorized": "Você não está autorizado a acessar esta página", + "notPresent": "Não presente", + "notPresentCannotAdd": "Você deve estar marcado como presente para se inscrever", + "nothingChanged": "Nada alterado", + "numberedList": "Lista numerada", + "off": "Desligado", + "on": "Ligado", + "onListPosition": "Você é o #{position} na lista", + "openPresentation": "Abrir Visualização de Apresentação", + "openReEvaluation": "Abrir Reavaliação", + "operativeClause": "Cláusula Operativa", + "operativeClausePresentation": "Cláusula Operativa", + "outcome": "Resultado", + "over": "encerrado", + "paperCreated": "Documento criado", + "paperDeleted": "Documento excluído", + "paperPromoted": "Documento promovido a Projeto de Resolução", + "paperSubmitted": "Documento submetido à presidência", + "paperSupportThresholdTooltip": "Estados apoiadores necessários para submeter uma emenda", + "paperTitle": "Título do Documento", + "papers": "Documentos", + "paragraphVoting": "Votação por Parágrafo", + "parsedCountries": "Países a adicionar:", + "participantView": "Visão do Participante", + "pause": "Pausar", + "phraseCopied": "Frase copiada!", + "phraseLookup": "Frases", + "phraseLookupDisclaimer": "Estas frases são fornecidas como orientação. Por favor, verifique o uso correto no contexto.", + "phraseLookupNoResults": "Nenhuma frase encontrada.", + "phraseLookupSearch": "Pesquisar frases...", + "phraseLookupTitle": "Referência de Frases", + "preambleClause": "Cláusula Preambular", + "presence": "Presença", + "present": "Presente", + "presentationMode": "Visualização de Apresentação", + "pressWebsite": "Site de Imprensa", + "preview": "Pré-visualização", + "previousParagraph": "Anterior", + "printResolution": "Imprimir", + "pro": "A Favor", + "promote": "Promover", + "promoteToDraftResolution": "Promover a Projeto de Resolução", + "promoteToDraftResolutionConfirm": "Promover este documento a Projeto de Resolução? Isso atribuirá um número de documento.", + "proposeAmendment": "Propor Emenda", + "proposedAmendmentPresentation": "Emenda Proposta", + "proposedBy": "Proposta por {name}", + "proposedText": "Texto Proposto", + "publicComment": "Público", + "publish": "Publicar", + "publishChanges": "Publicar alterações", + "recordVoteFromVoting": "Registrar Voto", + "redeemShareCode": "Resgatar Código de Compartilhamento", + "redo": "Refazer", + "regionalGroup_africa": "África", + "regionalGroup_asiaPacific": "Ásia-Pacífico", + "regionalGroup_easternEurope": "Europa Oriental", + "regionalGroup_latinAmericaCaribbean": "América Latina e Caribe", + "regionalGroup_westernEuropeOthers": "Europa Ocidental e Outros", + "regionalGroups": "Grupos Regionais", + "rejectClause": "Rejeitar", + "rejectResolution": "Rejeitar Resolução", + "rejected": "Rejeitada", + "removeFromList": "Remover da lista", + "removeMember": "Remover", + "removeSponsor": "Remover Patrocínio", + "replyToComment": "Responder", + "resolution": "Resolução", + "resolutionAddClause": "Adicionar Cláusula", + "resolutionAddContinuation": "Texto de Continuação", + "resolutionAddFirstClause": "Adicionar Primeira Cláusula", + "resolutionAddNested": "Cláusula Aninhada", + "resolutionAddSibling": "Adicionar Cláusula", + "resolutionAddSubClause": "Subcláusula", + "resolutionAdopted": "Resolução adotada!", + "resolutionAuthoringDelegation": "Delegação Autora", + "resolutionCommittee": "Comitê", + "resolutionContinuationPlaceholder": "Insira o texto de continuação...", + "resolutionDeleteBlock": "Excluir Bloco", + "resolutionDeleteClause": "Excluir", + "resolutionDisclaimer": "Este documento foi criado como parte de uma simulação {conferenceName} e não possui validade jurídica.", + "resolutionEditor": "Editor de Resolução", + "resolutionFontSize": "Tamanho da Fonte da Resolução", + "resolutionFontSizeDescription": "Defina o tamanho da fonte para o texto da resolução na visualização de apresentação.", + "resolutionHeadline": "Título da Resolução (ex.: O Conselho de Segurança)", + "resolutionHidePreview": "Ocultar Pré-visualização", + "resolutionImport": "Importar", + "resolutionImportButton": "Importar {count} cláusula(s)", + "resolutionImportHintOperative": "Cole cláusulas operativas numeradas. Subcláusulas serão detectadas automaticamente.", + "resolutionImportHintPreamble": "Cole cláusulas preambulares, separadas por vírgula e quebra de linha.", + "resolutionImportLLMCopied": "Copiado!", + "resolutionImportLLMCopyPrompt": "Copiar Prompt", + "resolutionImportLLMInstructions": "Copie o seguinte prompt em um assistente de IA para formatar seu texto automaticamente:", + "resolutionImportLLMPromptOperative": "Formate o seguinte texto como cláusulas operativas de resolução da ONU. Use:\n- Numeração para cláusulas principais: 1. 2. 3.\n- Letras para subcláusulas: a) b) c)\n- Algarismos romanos para mais aninhamento: i) ii) iii)\n- Letras duplas para o nível mais profundo: aa) bb) cc)\n- Ponto e vírgula no final de cada cláusula, ponto final na última\n\nFormato de exemplo:\n1. Calls upon all Member States to take measures;\n a) to promote peace;\n b) to strengthen cooperation;\n i) at the bilateral level;\n ii) at the multilateral level;\n2. Requests the Secretary-General to submit a report.\n\nTexto a formatar:", + "resolutionImportLLMPromptPreamble": "Formate o seguinte texto como cláusulas preambulares de resolução da ONU. Cada cláusula deve:\n- Começar com letra minúscula (exceto nomes próprios)\n- Terminar com vírgula\n- Ser separada por quebra de linha\n\nFormato de exemplo:\nrecalling its resolution 70/1 of 25 September 2015,\nemphasizing the importance of multilateralism,\nnoting with concern the current situation,\n\nTexto a formatar:", + "resolutionImportLLMTitle": "Formatação com IA", + "resolutionImportOperative": "Importar Cláusulas Operativas", + "resolutionImportPreamble": "Importar Cláusulas Preambulares", + "resolutionImportPreview": "Pré-visualização: {count} cláusula(s) detectada(s)", + "resolutionImportTipsOperative1": "Cláusulas principais numeradas: 1. 2. 3. ou 1) 2) 3)", + "resolutionImportTipsOperative2": "Subcláusulas com letras: a) b) c) ou (a) (b) (c)", + "resolutionImportTipsOperative3": "Subcláusulas aninhadas com algarismos romanos: i) ii) iii)", + "resolutionImportTipsOperative4": "Mais aninhamento com letras duplas: aa) bb) cc)", + "resolutionImportTipsPreamble1": "Cada cláusula deve terminar com vírgula", + "resolutionImportTipsPreamble2": "Quebras de linha separam cláusulas individuais", + "resolutionImportTipsPreamble3": "As cláusulas são importadas na ordem inserida", + "resolutionImportTipsTitle": "Dicas para Melhores Resultados", + "resolutionIndent": "Recuar", + "resolutionMoveDown": "Mover para Baixo", + "resolutionMoveUp": "Mover para Cima", + "resolutionNoClausesYet": "Nenhuma cláusula ainda.", + "resolutionNoOperativeClauses": "Nenhuma cláusula operativa ainda.", + "resolutionNoPreambleClauses": "Nenhuma cláusula preambular ainda.", + "resolutionOperativeClauses": "Cláusulas Operativas", + "resolutionOperativePlaceholder": "Insira a cláusula operativa...", + "resolutionOutdent": "Diminuir recuo", + "resolutionPaper": "Documento de Resolução", + "resolutionPapers": "Documentos de Resolução", + "resolutionPreambleClauses": "Cláusulas Preambulares", + "resolutionPreamblePlaceholder": "Insira a cláusula preambular...", + "resolutionPreview": "Pré-visualização", + "resolutionRejected": "Resolução rejeitada", + "resolutionSentBack": "Resolução devolvida", + "resolutionShowPreview": "Mostrar Pré-visualização", + "resolutionSponsoringDelegations": "Delegações Patrocinadoras", + "resolutionSubClausePlaceholder": "Insira a subcláusula...", + "resolutionSubClauses": "Subcláusulas", + "resolutionUnknownPhrase": "Frase desconhecida", + "resolutions": "Resoluções", + "restoreContentFromSnapshot": "Restaurar conteúdo anterior às emendas", + "restoreContentFromSnapshotDescription": "Desfazer todas as emendas aplicadas e restaurar o conteúdo da resolução para a versão anterior ao início da fase de emendas. As emendas aplicadas serão redefinidas para pendentes.", + "revertDrWarning": "Reverter irá apagar o número do documento. O documento pode ser promovido novamente depois.", + "revertStatus": "Reverter Status", + "revertVotingWarning": "Reverter irá excluir todos os resultados de votação de cláusulas deste documento.", + "role": "Função", + "rollCall": "Chamada", + "rollCallError": "Membro do comitê não encontrado", + "rollCallSuccess": "Chamada concluída", + "rollCallVoting": "Votação Nominal", + "rollCollError": "Membro do comitê não encontrado", + "rollCollSuccess": "Chamada concluída", + "save": "Salvar", + "saveChanges": "Salvar Alterações", + "saveError": "Falha ao salvar", + "savingChanges": "Salvando...", + "searchCommitteeMembers": "Pesquisar membros do comitê", + "searchMembers": "Pesquisar membros...", + "searchUsers": "Pesquisar usuários...", + "selectAgendaItem": "Selecionar item da pauta...", + "selectAmendmentType": "Selecionar tipo de emenda", + "selectAuthorDelegation": "Selecionar Delegação Autora", + "selectCommitteeMember": "Selecionar membro do comitê...", + "selectConferenceMember": "Selecionar membro da conferência...", + "selectProposerDelegation": "Selecionar Delegação Proponente", + "selectTargetClause": "Selecionar Cláusula Alvo", + "selected": "Selecionado", + "sendBack": "Devolver", + "sentBack": "Devolvida", + "seoDescription": "MUNify CHASE é a ferramenta gratuita e de código aberto para gerenciamento de debates em conferências de Modelo das Nações Unidas. Gerencie listas de oradores, votações e resoluções digitalmente.", + "seoTitle": "MUNify CHASE – Gerenciamento de Debates para Modelo das Nações Unidas", + "setActiveAmendment": "Discutir", + "setActiveDr": "Definir como Ativo", + "setActiveDrHint": "Defina um projeto de resolução como ativo na visão da presidência para exibi-lo aqui.", + "setAllAbsent": "Marcar Todos como Ausentes", + "setAllPresent": "Marcar Todos como Presentes", + "setStatus": "Alterar status", + "setup": "Configurar", "sha": "SHA", - "shareCode": "Share Code", - "shareCodes": "Share Codes", - "short_sleek_snake_hint": "Committee", - "showOfHandsVoting": "Vote by Show of Hands", - "simpleMajority": "Simple", - "simpleMajorityTooltip": "Needed notes for simple majority", - "speaker": "Speaker", - "speakersList": "General Speakers' List", - "speakersListNamePlaceholder": "New name...", - "speakersListNotFound": "Speakers' list not found", - "speakersListOvertime": "Speaking time over!", - "spectator": "Spectator", - "sponsor": "Sponsor", - "sponsorAdded": "Sponsor added", - "sponsorAmendment": "Sponsor", - "sponsorCount": "{count} sponsors", - "sponsorPaper": "Sponsor", - "sponsorRemoved": "Sponsor removed", - "sponsorThreshold": "{current}/{needed} sponsors ({percent}% needed)", - "sponsors": "Sponsors", - "startAmendmentPhase": "Start Amendment Phase", - "startEditing": "Start editing", - "startVote": "Start Vote", - "startVotingPhase": "Start Voting Phase", - "startVotingPhaseDescription": "Move to the voting phase where each operative paragraph will be voted on individually.", - "stateOfDebate": "State of Debate", - "statusReverted": "Status reverted", - "statusUpdated": "Status has been set", - "strikethrough": "Strikethrough", - "submit": "Submit", - "submitAmendment": "Submit Amendment", - "submitImg": "Insert image", - "submitPaper": "Submit Paper", - "submitStateOfDebate": "Save debate status", - "submitStatus": "Set status", - "submitToChair": "Submit to Chair", - "submitted": "Submitted", - "submittedBy": "Submitted by", - "submittedPapers": "Submitted Papers", - "submittedPapersDescription": "Papers submitted by delegates, ranked by sponsor count", - "submittingNation": "Submitting Nation", - "supportDraftResolution": "Support", - "supportReEvaluation": "Support Re-evaluation", - "supportReEvaluationClosed": "Re-evaluation is closed", - "supportReEvaluationNotOpen": "Support re-evaluation is not currently open", - "supportReEvaluationOpen": "Re-evaluation is open — delegates can now change their support", - "supporterCount": "{count} supporters", - "suspension": "Suspension", - "targetPosition": "Target Position", - "teamMember": "Team Member", - "teamOnly": "Team Only", - "theme": "Theme", - "thresholdNotMet": "Sponsor threshold not met", - "timeOver": "Speaking time is up!", - "timer": "Timer", - "toastAddError": "Could not add { targetName }", - "toastAddLoading": "Adding { targetName }...", - "toastAddSuccess": "{ targetName } added", - "toastCreateError": "Could not create {targetName}", - "toastCreateLoading": "Creating {targetName}...", - "toastCreateSuccess": "{targetName} created", - "toastDeleteError": "Could not delete {targetName}", - "toastDeleteLoading": "Deleting {targetName}...", - "toastDeleteSuccess": "{targetName} deleted", - "toastError": "Could not load {targetName}", - "toastLoading": "Loading {targetName}...", - "toastSuccess": "{targetName} loaded", - "toastUpdateError": "Could not update {targetName}", - "toastUpdateLoading": "Updating {targetName}...", - "toastUpdateSuccess": "{targetName} updated", - "topCandidate": "Top Candidate", - "totalCountriesPresent": "Count of Present Countries", - "twoThirdsMajority": "Two-thirds", - "twoThirdsMajorityTooltip": "Needed votes for two-thrids majority", - "typeOfVoting": "Type of Vote", - "unActor": "UN Actor", - "unActors": "UN Actors", - "unassigned": "Unassigned", - "underline": "Underlined", - "undo": "Undo", - "undoVote": "Undo Vote", - "unknown": "unknown", - "unrecognizedCodes": "Unrecognized codes:", - "until": "until {time}", - "untitledPaper": "Untitled Paper", - "updatedStateOfDebate": "Debate status saved", - "updatingStateOfDebate": "Saving debate status...", - "updatingStatus": "Status is being set...", - "updatingWhiteboard": "Publish whiteboard...", - "upload": "Upload", + "shareCode": "Código de Compartilhamento", + "shareCodes": "Códigos de Compartilhamento", + "short_sleek_snake_hint": "Comitê", + "showOfHandsVoting": "Votação por Levantamento de Mãos", + "simpleMajority": "Simples", + "simpleMajorityTooltip": "Votos necessários para maioria simples", + "speaker": "Orador", + "speakersList": "Lista Geral de Oradores", + "speakersListNamePlaceholder": "Novo nome...", + "speakersListNotFound": "Lista de oradores não encontrada", + "speakersListOvertime": "Tempo de fala esgotado!", + "spectator": "Espectador", + "sponsor": "Patrocinador", + "sponsorAdded": "Patrocinador adicionado", + "sponsorAmendment": "Patrocinar", + "sponsorCount": "{count} patrocinadores", + "sponsorPaper": "Patrocinar", + "sponsorRemoved": "Patrocinador removido", + "sponsorThreshold": "{current}/{needed} patrocinadores ({percent}% necessários)", + "sponsors": "Patrocinadores", + "startAmendmentPhase": "Iniciar Fase de Emendas", + "startEditing": "Começar a editar", + "startVote": "Iniciar Votação", + "startVotingPhase": "Iniciar Fase de Votação", + "startVotingPhaseDescription": "Passar para a fase de votação, onde cada parágrafo operativo será votado individualmente.", + "stateOfDebate": "Estado do Debate", + "statusReverted": "Status revertido", + "statusUpdated": "Status definido", + "strikethrough": "Tachado", + "submit": "Submeter", + "submitAmendment": "Submeter Emenda", + "submitImg": "Inserir imagem", + "submitPaper": "Submeter Documento", + "submitStateOfDebate": "Salvar status do debate", + "submitStatus": "Definir status", + "submitToChair": "Submeter à Presidência", + "submitted": "Submetido", + "submittedBy": "Submetido por", + "submittedPapers": "Documentos Submetidos", + "submittedPapersDescription": "Documentos submetidos por delegados, ordenados por número de patrocinadores", + "submittingNation": "Nação Proponente", + "supportDraftResolution": "Apoiar", + "supportReEvaluation": "Reavaliação de Apoio", + "supportReEvaluationClosed": "A reavaliação está encerrada", + "supportReEvaluationNotOpen": "A reavaliação de apoio não está aberta no momento", + "supportReEvaluationOpen": "A reavaliação está aberta — delegados podem alterar seu apoio agora", + "supporterCount": "{count} apoiadores", + "suspension": "Suspensão", + "targetPosition": "Posição Alvo", + "teamMember": "Membro da Equipe", + "teamOnly": "Somente Equipe", + "theme": "Tema", + "thresholdNotMet": "Limite de patrocinadores não atingido", + "timeOver": "Tempo de fala esgotado!", + "timer": "Cronômetro", + "toastAddError": "Não foi possível adicionar { targetName }", + "toastAddLoading": "Adicionando { targetName }...", + "toastAddSuccess": "{ targetName } adicionado(a)", + "toastCreateError": "Não foi possível criar {targetName}", + "toastCreateLoading": "Criando {targetName}...", + "toastCreateSuccess": "{targetName} criado(a)", + "toastDeleteError": "Não foi possível excluir {targetName}", + "toastDeleteLoading": "Excluindo {targetName}...", + "toastDeleteSuccess": "{targetName} excluído(a)", + "toastError": "Não foi possível carregar {targetName}", + "toastLoading": "Carregando {targetName}...", + "toastSuccess": "{targetName} carregado(a)", + "toastUpdateError": "Não foi possível atualizar {targetName}", + "toastUpdateLoading": "Atualizando {targetName}...", + "toastUpdateSuccess": "{targetName} atualizado(a)", + "topCandidate": "Principal Candidato", + "totalCountriesPresent": "Total de Países Presentes", + "twoThirdsMajority": "Dois terços", + "twoThirdsMajorityTooltip": "Votos necessários para maioria de dois terços", + "typeOfVoting": "Tipo de Votação", + "unActor": "Ator da ONU", + "unActors": "Atores da ONU", + "unassigned": "Não atribuído", + "underline": "Sublinhado", + "undo": "Desfazer", + "undoVote": "Desfazer Voto", + "unknown": "desconhecido", + "unrecognizedCodes": "Códigos não reconhecidos:", + "until": "até {time}", + "untitledPaper": "Documento Sem Título", + "updatedStateOfDebate": "Status do debate salvo", + "updatingStateOfDebate": "Salvando status do debate...", + "updatingStatus": "Definindo status...", + "updatingWhiteboard": "Publicando quadro branco...", + "upload": "Enviar", "url": "URL", - "useFullVoting": "Use Full Voting", - "userAlreadyExists": "User already exists in this conference: {email}", - "users": "Users", - "version": "Version", - "viewPaper": "View Paper", - "voteOnParagraph": "Vote on OP {index}", - "voteOutcome": "Vote Outcome", - "voteResult": "Vote Result", - "voteTitel": "Vote Title", - "voteTitleDescription": "The vote title will be visible to all participants and is used for identification. If left empty, \"Vote\" will be used as fallback.", - "votesAbstain": "Abstentions", - "votesAgainst": "Votes Against", - "votesFor": "Votes For", - "voting": "Voting", - "votingControlsPlaceholder": "Voting controls will be available in a future update.", - "votingPhase": "Voting Phase", - "votingPhaseActive": "Voting phase active", - "votingPhaseStarted": "Voting phase started", - "votingResults": "Voting Results", - "waitingForAssignment": "Waiting for Assignment", - "waitingForAssignmentDescription": "You have not been assigned to a committee yet. Please wait for an admin to assign you.", - "whiteboard": "Whiteboard", - "whiteboardIsEmpty": "The whiteboard is currently empty...", - "whiteboardPlaceholder": "Start writing here...", - "whiteboardUpdated": "Whiteboard published", - "withAbstentions": "With Abstentions", - "withdrawAmendment": "Withdraw", - "withdrawSponsorship": "Withdraw Sponsorship", - "withdrawSupport": "Withdraw Support", - "withoutAbstentions": "No Abstentions", - "workingPaper": "Working Paper", - "workingPapers": "Working Papers", - "yes": "Yes", - "you": "You", - "youCannotEditYourself": "You cannot edit your own role", - "youreUp": "You're up!" + "useFullVoting": "Usar Votação Completa", + "userAlreadyExists": "Usuário já existe nesta conferência: {email}", + "users": "Usuários", + "version": "Versão", + "viewPaper": "Ver Documento", + "voteOnParagraph": "Votar no CO {index}", + "voteOutcome": "Resultado da Votação", + "voteResult": "Resultado da Votação", + "voteTitel": "Título da Votação", + "voteTitleDescription": "O título da votação será visível para todos os participantes e é usado para identificação. Se deixado em branco, \"Votação\" será usado como padrão.", + "votesAbstain": "Abstenções", + "votesAgainst": "Votos Contra", + "votesFor": "Votos a Favor", + "voting": "Votação", + "votingControlsPlaceholder": "Os controles de votação estarão disponíveis em uma atualização futura.", + "votingPhase": "Fase de Votação", + "votingPhaseActive": "Fase de votação ativa", + "votingPhaseStarted": "Fase de votação iniciada", + "votingResults": "Resultados da Votação", + "waitingForAssignment": "Aguardando Atribuição", + "waitingForAssignmentDescription": "Você ainda não foi atribuído a um comitê. Por favor, aguarde um administrador atribuí-lo.", + "whiteboard": "Quadro Branco", + "whiteboardIsEmpty": "O quadro branco está vazio no momento...", + "whiteboardPlaceholder": "Comece a escrever aqui...", + "whiteboardUpdated": "Quadro branco publicado", + "withAbstentions": "Com Abstenções", + "withdrawAmendment": "Retirar", + "withdrawSponsorship": "Retirar Patrocínio", + "withdrawSupport": "Retirar Apoio", + "withoutAbstentions": "Sem Abstenções", + "workingPaper": "Documento de Trabalho", + "workingPapers": "Documentos de Trabalho", + "yes": "Sim", + "you": "Você", + "youCannotEditYourself": "Você não pode editar sua própria função", + "youreUp": "É a sua vez!" } diff --git a/project.inlang/settings.json b/project.inlang/settings.json index 637ce5f2..c51d7514 100644 --- a/project.inlang/settings.json +++ b/project.inlang/settings.json @@ -1,7 +1,7 @@ { "$schema": "https://inlang.com/schema/project-settings", - "baseLocale": "de", - "locales": ["de", "en", "pt"], + "baseLocale": "en", + "locales": ["en", "de", "pt"], "modules": [ "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-empty-pattern@latest/dist/index.js", "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-missing-translation@latest/dist/index.js", diff --git a/src/hooks.server.ts b/src/hooks.server.ts index c4fed324..5abe1549 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,9 +1,32 @@ -import type { Handle } from '@sveltejs/kit'; +import { type Handle, redirect } from '@sveltejs/kit'; import { paraglideMiddleware } from '$lib/paraglide/server'; import { sequence } from '@sveltejs/kit/hooks'; import { OIDC } from '$api/services/OIDC'; +import { locales, baseLocale, cookieName, cookieMaxAge } from '$lib/paraglide/runtime'; -export const handle: Handle = sequence(OIDC.handle, ({ event, resolve }) => +const nonBaseLocales = locales.filter((l) => l !== baseLocale); + +/** Redirect locale-prefixed URLs to bare paths, setting the cookie instead. */ +const localeRedirect: Handle = ({ event, resolve }) => { + const { pathname } = event.url; + for (const locale of nonBaseLocales) { + if (pathname === `/${locale}` || pathname.startsWith(`/${locale}/`)) { + const bare = pathname.slice(`/${locale}`.length) || '/'; + const domain = event.url.hostname; + event.cookies.set(cookieName, locale, { + path: '/', + maxAge: cookieMaxAge, + domain, + httpOnly: false, + sameSite: 'lax' + }); + redirect(302, bare + event.url.search); + } + } + return resolve(event); +}; + +export const handle: Handle = sequence(OIDC.handle, localeRedirect, ({ event, resolve }) => paraglideMiddleware(event.request, ({ request: localizedRequest, locale }) => { event.request = localizedRequest; diff --git a/src/hooks.ts b/src/hooks.ts index f088616d..446fd89c 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1,6 +1,2 @@ -import type { Reroute } from '@sveltejs/kit'; -import { deLocalizeUrl } from '$lib/paraglide/runtime'; - -export const reroute: Reroute = (request) => { - return deLocalizeUrl(request.url).pathname; -}; +// Reroute hook removed — locale is now cookie-based, not URL-based. +// Locale-prefixed URLs are redirected by the server hook. diff --git a/src/lib/components/LanguageSwitcher.svelte b/src/lib/components/LanguageSwitcher.svelte index 407e1b04..5677230a 100644 --- a/src/lib/components/LanguageSwitcher.svelte +++ b/src/lib/components/LanguageSwitcher.svelte @@ -1,7 +1,8 @@ -

Language / Sprache / Idioma

+

+ {m.language()} +

{#each locales as l} {/each} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 80600bf5..478c9e1e 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,7 +1,5 @@ -
+
- +

diff --git a/src/routes/(pages)/ContactSection.svelte b/src/routes/(pages)/ContactSection.svelte index 01bd4060..536f8050 100644 --- a/src/routes/(pages)/ContactSection.svelte +++ b/src/routes/(pages)/ContactSection.svelte @@ -8,13 +8,11 @@ class="flex flex-col items-center gap-6 mx-4 p-4 py-20 lg:p-20 bg-base-100 rounded-box border-base-300 border shadow-lg" >

{m.homeContactTitle()}

-

+

{m.homeContactText()}

diff --git a/src/routes/(pages)/LandingHero.svelte b/src/routes/(pages)/LandingHero.svelte index 9aa77f11..b7cd3737 100644 --- a/src/routes/(pages)/LandingHero.svelte +++ b/src/routes/(pages)/LandingHero.svelte @@ -39,7 +39,7 @@ class="mb-4 text-center font-serif text-5xl leading-tight font-bold lg:text-right lg:text-6xl" > MUN @@ -47,9 +47,7 @@
-

+

{m.homeHeroText()}

diff --git a/src/routes/(pages)/TextSection.svelte b/src/routes/(pages)/TextSection.svelte index cc590d75..cbbcb718 100644 --- a/src/routes/(pages)/TextSection.svelte +++ b/src/routes/(pages)/TextSection.svelte @@ -11,14 +11,12 @@

{title}

-

+

{text}

{#if children} From 919732f6e48f810820be5aff61d564003220ed7e Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Fri, 3 Apr 2026 17:00:38 +0200 Subject: [PATCH 76/89] feat: add per-conference toggle to enable/disable resolution features Adds a `resolutionFeatureEnabled` boolean (default: true) to the conference table. When disabled, resolution/papers navbar icons are hidden for both chairs and participants. Configurable via Mission Control > General. Co-Authored-By: Claude Opus 4.6 (1M context) --- messages/de.json | 2 ++ messages/en.json | 2 ++ src/api/db/schema.ts | 4 ++- src/api/handlers/conference.ts | 6 +++-- .../[committeeId]/(chairs)/+layout.svelte | 1 + .../[committeeId]/(chairs)/ChairNavbar.svelte | 25 ++++++++++--------- .../(chairs)/committeeSubscription.ts | 2 ++ .../(presentation)/committeeSubscription.ts | 2 ++ .../mission-control/config/+page.ts | 1 + .../mission-control/config/GeneralTab.svelte | 24 +++++++++++++++++- .../participant/[committeeId]/+layout.svelte | 16 ++++++------ .../participant/[committeeId]/+layout.ts | 1 + 12 files changed, 63 insertions(+), 23 deletions(-) diff --git a/messages/de.json b/messages/de.json index 321f3480..1e0e4890 100644 --- a/messages/de.json +++ b/messages/de.json @@ -391,6 +391,8 @@ "resolutionDeleteClause": "Löschen", "resolutionDisclaimer": "Dieses Dokument wurde im Rahmen einer {conferenceName}-Simulation erstellt und besitzt keine rechtliche Gültigkeit.", "resolutionEditor": "Resolutions-Editor", + "resolutionFeatureEnabled": "Resolutionsfunktionen", + "resolutionFeatureEnabledDescription": "Resolutionseditor, Arbeitspapiere und Änderungsanträge für diese Konferenz aktivieren.", "resolutionFontSize": "Resolutions-Schriftgröße", "resolutionFontSizeDescription": "Hier kann die Schriftgröße für den Resolutionstext in der Präsentationsansicht festgelegt werden.", "resolutionHeadline": "Resolutions-Kopfzeile (z.B. Der Sicherheitsrat)", diff --git a/messages/en.json b/messages/en.json index 29a14820..cf756585 100644 --- a/messages/en.json +++ b/messages/en.json @@ -391,6 +391,8 @@ "resolutionDeleteClause": "Delete", "resolutionDisclaimer": "This document was created as part of a {conferenceName} simulation and has no legal validity.", "resolutionEditor": "Resolution Editor", + "resolutionFeatureEnabled": "Resolution Features", + "resolutionFeatureEnabledDescription": "Enable resolution editor, papers, and amendment features for this conference.", "resolutionFontSize": "Resolution Font Size", "resolutionFontSizeDescription": "Set the font size for the resolution text in the presentation view.", "resolutionHeadline": "Resolution Headline (e.g. The Security Council)", diff --git a/src/api/db/schema.ts b/src/api/db/schema.ts index c1f3ed62..aa32d1b0 100644 --- a/src/api/db/schema.ts +++ b/src/api/db/schema.ts @@ -42,7 +42,8 @@ export const conference = pgTable('conference', { ...defaultIdAndTimestamps, title: text().notNull(), pressWebsite: text(), - hasModeratedCaucus: boolean().notNull().default(false) + hasModeratedCaucus: boolean().notNull().default(false), + resolutionFeatureEnabled: boolean().notNull().default(true) }); export const committeeStatus = pgEnum('committee_status', [ @@ -78,6 +79,7 @@ export const committee = pgTable( maxDraftResolutions: smallint().notNull().default(3), activeDraftResolutionId: text().references((): AnyPgColumn => resolutionPaper.id), currentOperativeIndex: smallint(), + currentOperativeClauseId: text(), supportReEvaluationOpen: boolean().notNull().default(false), amendmentSubmissionOpen: boolean().notNull().default(true), amendmentSponsoringOpen: boolean().notNull().default(true), diff --git a/src/api/handlers/conference.ts b/src/api/handlers/conference.ts index 40d8d646..cb77e06a 100644 --- a/src/api/handlers/conference.ts +++ b/src/api/handlers/conference.ts @@ -79,7 +79,8 @@ schemaBuilder.mutationFields((t) => ({ id: t.arg.id({ required: true }), title: t.arg.string(), pressWebsite: t.arg.string(), - hasModeratedCaucus: t.arg.boolean() + hasModeratedCaucus: t.arg.boolean(), + resolutionFeatureEnabled: t.arg.boolean() }, resolve: async (query, root, args, ctx, info) => { await assertConferenceAdmin(ctx, args.id); @@ -89,7 +90,8 @@ schemaBuilder.mutationFields((t) => ({ .set({ title: args.title ?? undefined, pressWebsite: args.pressWebsite ?? undefined, - hasModeratedCaucus: args.hasModeratedCaucus ?? undefined + hasModeratedCaucus: args.hasModeratedCaucus ?? undefined, + resolutionFeatureEnabled: args.resolutionFeatureEnabled ?? undefined }) .where(eq(schema.conference.id, args.id)); diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/+layout.svelte b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/+layout.svelte index bd0eb1dd..9a38cde4 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/+layout.svelte +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/+layout.svelte @@ -100,6 +100,7 @@
diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/ChairNavbar.svelte b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/ChairNavbar.svelte index 108535b8..ebff5b9a 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/ChairNavbar.svelte +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/ChairNavbar.svelte @@ -10,9 +10,10 @@ interface Props { title?: string; activeDraftResolutionId?: string | null; + resolutionFeatureEnabled?: boolean; } - let { title, activeDraftResolutionId }: Props = $props(); + let { title, activeDraftResolutionId, resolutionFeatureEnabled = true }: Props = $props(); const basePath = $derived(`/app/${page.params.conferenceId}/${page.params.committeeId}`); @@ -31,13 +32,17 @@ href: `${basePath}/voting`, key: 'voting' }, - { - icon: 'fa-scroll', - label: () => m.resolutions(), - href: `${basePath}/resolutions`, - key: 'resolutions' - }, - ...(activeDraftResolutionId + ...(resolutionFeatureEnabled + ? [ + { + icon: 'fa-scroll', + label: () => m.resolutions(), + href: `${basePath}/resolutions`, + key: 'resolutions' + } + ] + : []), + ...(resolutionFeatureEnabled && activeDraftResolutionId ? [ { icon: 'fa-file-lines', @@ -96,10 +101,6 @@
-
- -
-
{ title = conference.title; pressWebsite = conference.pressWebsite ?? ''; hasModeratedCaucus = conference.hasModeratedCaucus; + resolutionFeatureEnabled = conference.resolutionFeatureEnabled; }); const UpdateConferenceMutation = graphql(` @@ -34,17 +37,20 @@ $title: String $pressWebsite: String $hasModeratedCaucus: Boolean + $resolutionFeatureEnabled: Boolean ) { updateConference( id: $id title: $title pressWebsite: $pressWebsite hasModeratedCaucus: $hasModeratedCaucus + resolutionFeatureEnabled: $resolutionFeatureEnabled ) { id title pressWebsite hasModeratedCaucus + resolutionFeatureEnabled } } `); @@ -57,7 +63,8 @@ id: conference.id, title, pressWebsite: pressWebsite || null, - hasModeratedCaucus + hasModeratedCaucus, + resolutionFeatureEnabled }), promiseToastStrings(m.configuration(), 'update') ); @@ -111,6 +118,21 @@
+
+ +
+
{/if} diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/+layout.ts b/src/routes/app/[conferenceId]/participant/[committeeId]/+layout.ts index 2760a159..ddf19dc5 100644 --- a/src/routes/app/[conferenceId]/participant/[committeeId]/+layout.ts +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/+layout.ts @@ -12,6 +12,7 @@ export const _houdini_load = graphql(` activeDraftResolutionId conference { title + resolutionFeatureEnabled } activeAgendaItem { id From 24d1d5aad4a48ef5f566dfb555fed96d42bc5728 Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Fri, 3 Apr 2026 17:07:56 +0200 Subject: [PATCH 77/89] feat: resolution feature toggle, operative clause tracking, launcher cache fix, and UI improvements Add per-conference resolution feature toggle, track current operative clause by ID, update schema and migration. Fix launcher showing stale data after conference creation by marking Houdini cache stale. Update info box components and presentation/participant views. Co-Authored-By: Claude Opus 4.6 (1M context) --- drizzle/0007_typical_the_renegades.sql | 2 + drizzle/meta/0007_snapshot.json | 2436 +++++++++++++++++ drizzle/meta/_journal.json | 7 + schema.graphql | 8 +- src/api/handlers/amendment.ts | 159 +- src/api/handlers/committee.ts | 2 + src/api/handlers/resolutionPaper.ts | 17 +- src/lib/components/AbbreviationInfoBox.svelte | 6 +- src/lib/components/IconInfoBox.svelte | 4 +- src/routes/app/(launcher)/import/+page.svelte | 6 +- .../[committeeId]/(chairs)/+layout.ts | 1 + .../resolutions/[paperId]/+page.svelte | 51 +- .../PresentationResolutionPreview.svelte | 49 +- .../[committeeId]/committeeSubscription.ts | 1 + .../papers/[paperId]/+page.svelte | 48 +- 15 files changed, 2691 insertions(+), 106 deletions(-) create mode 100644 drizzle/0007_typical_the_renegades.sql create mode 100644 drizzle/meta/0007_snapshot.json diff --git a/drizzle/0007_typical_the_renegades.sql b/drizzle/0007_typical_the_renegades.sql new file mode 100644 index 00000000..13ec3697 --- /dev/null +++ b/drizzle/0007_typical_the_renegades.sql @@ -0,0 +1,2 @@ +ALTER TABLE "committee" ADD COLUMN "current_operative_clause_id" text;--> statement-breakpoint +ALTER TABLE "conference" ADD COLUMN "resolution_feature_enabled" boolean DEFAULT true NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 00000000..6138d64c --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,2436 @@ +{ + "id": "5381be8e-1f70-46f8-a855-c48c44161730", + "prevId": "bab6ce6b-1ddb-4477-aa04-11321db188a9", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.agenda_item": { + "name": "agenda_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "committee_id": { + "name": "committee_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "agenda_item_committee_id_committee_id_fk": { + "name": "agenda_item_committee_id_committee_id_fk", + "tableFrom": "agenda_item", + "tableTo": "committee", + "columnsFrom": [ + "committee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.amendment": { + "name": "amendment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "paper_id": { + "name": "paper_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposer_committee_member_id": { + "name": "proposer_committee_member_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "amendment_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "amendment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'PENDING'" + }, + "target_clause_id": { + "name": "target_clause_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_operative_index": { + "name": "target_operative_index", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "new_content": { + "name": "new_content", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "target_position": { + "name": "target_position", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "document_number": { + "name": "document_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sequence_number": { + "name": "sequence_number", + "type": "smallint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "amendment_paper_id_resolution_paper_id_fk": { + "name": "amendment_paper_id_resolution_paper_id_fk", + "tableFrom": "amendment", + "tableTo": "resolution_paper", + "columnsFrom": [ + "paper_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "amendment_proposer_committee_member_id_committee_member_id_fk": { + "name": "amendment_proposer_committee_member_id_committee_member_id_fk", + "tableFrom": "amendment", + "tableTo": "committee_member", + "columnsFrom": [ + "proposer_committee_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.amendment_sponsor": { + "name": "amendment_sponsor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "amendment_id": { + "name": "amendment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "committee_member_id": { + "name": "committee_member_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "amendment_sponsor_amendment_id_amendment_id_fk": { + "name": "amendment_sponsor_amendment_id_amendment_id_fk", + "tableFrom": "amendment_sponsor", + "tableTo": "amendment", + "columnsFrom": [ + "amendment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "amendment_sponsor_committee_member_id_committee_member_id_fk": { + "name": "amendment_sponsor_committee_member_id_committee_member_id_fk", + "tableFrom": "amendment_sponsor", + "tableTo": "committee_member", + "columnsFrom": [ + "committee_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "amendment_sponsor_amendmentId_committeeMemberId_unique": { + "name": "amendment_sponsor_amendmentId_committeeMemberId_unique", + "nullsNotDistinct": false, + "columns": [ + "amendment_id", + "committee_member_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.committee": { + "name": "committee", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "abbreviation": { + "name": "abbreviation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conference_id": { + "name": "conference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "whiteboard_content": { + "name": "whiteboard_content", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'

'" + }, + "show_whiteboard": { + "name": "show_whiteboard", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status": { + "name": "status", + "type": "committee_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'SUSPENSION'" + }, + "status_headline": { + "name": "status_headline", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status_until": { + "name": "status_until", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "state_of_debate": { + "name": "state_of_debate", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allow_delegations_to_add_themselves_to_speakers_list": { + "name": "allow_delegations_to_add_themselves_to_speakers_list", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "active_agenda_item_id": { + "name": "active_agenda_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_simple_majority": { + "name": "custom_simple_majority", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "custom_two_thirds_majority": { + "name": "custom_two_thirds_majority", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "custom_paper_support_threshold": { + "name": "custom_paper_support_threshold", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "last_resolution_adoption_date": { + "name": "last_resolution_adoption_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "max_draft_resolutions": { + "name": "max_draft_resolutions", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "active_draft_resolution_id": { + "name": "active_draft_resolution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_operative_index": { + "name": "current_operative_index", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "current_operative_clause_id": { + "name": "current_operative_clause_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "support_re_evaluation_open": { + "name": "support_re_evaluation_open", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "amendment_submission_open": { + "name": "amendment_submission_open", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "amendment_sponsoring_open": { + "name": "amendment_sponsoring_open", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "active_amendment_id": { + "name": "active_amendment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolution_headline": { + "name": "resolution_headline", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "committee_conference_id_conference_id_fk": { + "name": "committee_conference_id_conference_id_fk", + "tableFrom": "committee", + "tableTo": "conference", + "columnsFrom": [ + "conference_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "committee_active_agenda_item_id_agenda_item_id_fk": { + "name": "committee_active_agenda_item_id_agenda_item_id_fk", + "tableFrom": "committee", + "tableTo": "agenda_item", + "columnsFrom": [ + "active_agenda_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "committee_active_draft_resolution_id_resolution_paper_id_fk": { + "name": "committee_active_draft_resolution_id_resolution_paper_id_fk", + "tableFrom": "committee", + "tableTo": "resolution_paper", + "columnsFrom": [ + "active_draft_resolution_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "committee_active_amendment_id_amendment_id_fk": { + "name": "committee_active_amendment_id_amendment_id_fk", + "tableFrom": "committee", + "tableTo": "amendment", + "columnsFrom": [ + "active_amendment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "committee_conferenceId_name_unique": { + "name": "committee_conferenceId_name_unique", + "nullsNotDistinct": false, + "columns": [ + "conference_id", + "name" + ] + }, + "committee_conferenceId_abbreviation_unique": { + "name": "committee_conferenceId_abbreviation_unique", + "nullsNotDistinct": false, + "columns": [ + "conference_id", + "abbreviation" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.committee_member": { + "name": "committee_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "present": { + "name": "present", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "committee_id": { + "name": "committee_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "representation_id": { + "name": "representation_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "committee_member_committee_id_committee_id_fk": { + "name": "committee_member_committee_id_committee_id_fk", + "tableFrom": "committee_member", + "tableTo": "committee", + "columnsFrom": [ + "committee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "committee_member_representation_id_representation_id_fk": { + "name": "committee_member_representation_id_representation_id_fk", + "tableFrom": "committee_member", + "tableTo": "representation", + "columnsFrom": [ + "representation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.committee_topic_changed_timestamp": { + "name": "committee_topic_changed_timestamp", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "committee_id": { + "name": "committee_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agenda_item_id": { + "name": "agenda_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "committee_topic_changed_timestamp_committee_id_committee_id_fk": { + "name": "committee_topic_changed_timestamp_committee_id_committee_id_fk", + "tableFrom": "committee_topic_changed_timestamp", + "tableTo": "committee", + "columnsFrom": [ + "committee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "committee_topic_changed_timestamp_agenda_item_id_agenda_item_id_fk": { + "name": "committee_topic_changed_timestamp_agenda_item_id_agenda_item_id_fk", + "tableFrom": "committee_topic_changed_timestamp", + "tableTo": "agenda_item", + "columnsFrom": [ + "agenda_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conference": { + "name": "conference", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "press_website": { + "name": "press_website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_moderated_caucus": { + "name": "has_moderated_caucus", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "resolution_feature_enabled": { + "name": "resolution_feature_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conference_member": { + "name": "conference_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "conference_id": { + "name": "conference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "representation_id": { + "name": "representation_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "conference_member_conference_id_conference_id_fk": { + "name": "conference_member_conference_id_conference_id_fk", + "tableFrom": "conference_member", + "tableTo": "conference", + "columnsFrom": [ + "conference_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conference_member_representation_id_representation_id_fk": { + "name": "conference_member_representation_id_representation_id_fk", + "tableFrom": "conference_member", + "tableTo": "representation", + "columnsFrom": [ + "representation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conference_user": { + "name": "conference_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "conference_user_type": { + "name": "conference_user_type", + "type": "conference_user_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conference_id": { + "name": "conference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conference_member_id": { + "name": "conference_member_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "committee_member_id": { + "name": "committee_member_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "conference_user_conference_id_conference_id_fk": { + "name": "conference_user_conference_id_conference_id_fk", + "tableFrom": "conference_user", + "tableTo": "conference", + "columnsFrom": [ + "conference_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conference_user_conference_member_id_conference_member_id_fk": { + "name": "conference_user_conference_member_id_conference_member_id_fk", + "tableFrom": "conference_user", + "tableTo": "conference_member", + "columnsFrom": [ + "conference_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conference_user_committee_member_id_committee_member_id_fk": { + "name": "conference_user_committee_member_id_committee_member_id_fk", + "tableFrom": "conference_user", + "tableTo": "committee_member", + "columnsFrom": [ + "committee_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.operative_clause_vote": { + "name": "operative_clause_vote", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "paper_id": { + "name": "paper_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "clause_id": { + "name": "clause_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "vote_outcome", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "votes_for": { + "name": "votes_for", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "votes_against": { + "name": "votes_against", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "votes_abstain": { + "name": "votes_abstain", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "operative_clause_vote_paper_id_resolution_paper_id_fk": { + "name": "operative_clause_vote_paper_id_resolution_paper_id_fk", + "tableFrom": "operative_clause_vote", + "tableTo": "resolution_paper", + "columnsFrom": [ + "paper_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "operative_clause_vote_paperId_clauseId_unique": { + "name": "operative_clause_vote_paperId_clauseId_unique", + "nullsNotDistinct": false, + "columns": [ + "paper_id", + "clause_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paper_clause_lock": { + "name": "paper_clause_lock", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "paper_id": { + "name": "paper_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "clause_id": { + "name": "clause_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conference_user_id": { + "name": "conference_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "acquired_at": { + "name": "acquired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "paper_clause_lock_paper_id_resolution_paper_id_fk": { + "name": "paper_clause_lock_paper_id_resolution_paper_id_fk", + "tableFrom": "paper_clause_lock", + "tableTo": "resolution_paper", + "columnsFrom": [ + "paper_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "paper_clause_lock_conference_user_id_conference_user_id_fk": { + "name": "paper_clause_lock_conference_user_id_conference_user_id_fk", + "tableFrom": "paper_clause_lock", + "tableTo": "conference_user", + "columnsFrom": [ + "conference_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "paper_clause_lock_paperId_clauseId_unique": { + "name": "paper_clause_lock_paperId_clauseId_unique", + "nullsNotDistinct": false, + "columns": [ + "paper_id", + "clause_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paper_content_snapshot": { + "name": "paper_content_snapshot", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "paper_id": { + "name": "paper_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "paper_content_snapshot_paper_id_resolution_paper_id_fk": { + "name": "paper_content_snapshot_paper_id_resolution_paper_id_fk", + "tableFrom": "paper_content_snapshot", + "tableTo": "resolution_paper", + "columnsFrom": [ + "paper_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paper_editor": { + "name": "paper_editor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "paper_id": { + "name": "paper_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conference_user_id": { + "name": "conference_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "paper_editor_paper_id_resolution_paper_id_fk": { + "name": "paper_editor_paper_id_resolution_paper_id_fk", + "tableFrom": "paper_editor", + "tableTo": "resolution_paper", + "columnsFrom": [ + "paper_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "paper_editor_conference_user_id_conference_user_id_fk": { + "name": "paper_editor_conference_user_id_conference_user_id_fk", + "tableFrom": "paper_editor", + "tableTo": "conference_user", + "columnsFrom": [ + "conference_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "paper_editor_paperId_conferenceUserId_unique": { + "name": "paper_editor_paperId_conferenceUserId_unique", + "nullsNotDistinct": false, + "columns": [ + "paper_id", + "conference_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paper_share_code": { + "name": "paper_share_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "paper_id": { + "name": "paper_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "share_code_permission", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "paper_share_code_paper_id_resolution_paper_id_fk": { + "name": "paper_share_code_paper_id_resolution_paper_id_fk", + "tableFrom": "paper_share_code", + "tableTo": "resolution_paper", + "columnsFrom": [ + "paper_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "paper_share_code_code_unique": { + "name": "paper_share_code_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paper_sponsor": { + "name": "paper_sponsor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "paper_id": { + "name": "paper_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "committee_member_id": { + "name": "committee_member_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "paper_sponsor_paper_id_resolution_paper_id_fk": { + "name": "paper_sponsor_paper_id_resolution_paper_id_fk", + "tableFrom": "paper_sponsor", + "tableTo": "resolution_paper", + "columnsFrom": [ + "paper_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "paper_sponsor_committee_member_id_committee_member_id_fk": { + "name": "paper_sponsor_committee_member_id_committee_member_id_fk", + "tableFrom": "paper_sponsor", + "tableTo": "committee_member", + "columnsFrom": [ + "committee_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "paper_sponsor_paperId_committeeMemberId_unique": { + "name": "paper_sponsor_paperId_committeeMemberId_unique", + "nullsNotDistinct": false, + "columns": [ + "paper_id", + "committee_member_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.presence_changed_timestamp": { + "name": "presence_changed_timestamp", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "committee_member_id": { + "name": "committee_member_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "present_set_to": { + "name": "present_set_to", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "presence_changed_timestamp_committee_member_id_committee_member_id_fk": { + "name": "presence_changed_timestamp_committee_member_id_committee_member_id_fk", + "tableFrom": "presence_changed_timestamp", + "tableTo": "committee_member", + "columnsFrom": [ + "committee_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.representation": { + "name": "representation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "alpha2_code": { + "name": "alpha2_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "alpha3_code": { + "name": "alpha3_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "representation_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "fa_icon": { + "name": "fa_icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "regional_group": { + "name": "regional_group", + "type": "regional_group", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "conference_id": { + "name": "conference_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "representation_conference_id_conference_id_fk": { + "name": "representation_conference_id_conference_id_fk", + "tableFrom": "representation", + "tableTo": "conference", + "columnsFrom": [ + "conference_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "representation_conferenceId_name_unique": { + "name": "representation_conferenceId_name_unique", + "nullsNotDistinct": false, + "columns": [ + "conference_id", + "name" + ] + }, + "representation_conferenceId_alpha2Code_alpha3Code_unique": { + "name": "representation_conferenceId_alpha2Code_alpha3Code_unique", + "nullsNotDistinct": false, + "columns": [ + "conference_id", + "alpha2_code", + "alpha3_code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resolution_comment": { + "name": "resolution_comment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "paper_id": { + "name": "paper_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "clause_id": { + "name": "clause_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_conference_user_id": { + "name": "author_conference_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "comment_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'PUBLIC'" + }, + "parent_comment_id": { + "name": "parent_comment_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "resolution_comment_paper_id_resolution_paper_id_fk": { + "name": "resolution_comment_paper_id_resolution_paper_id_fk", + "tableFrom": "resolution_comment", + "tableTo": "resolution_paper", + "columnsFrom": [ + "paper_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resolution_comment_author_conference_user_id_conference_user_id_fk": { + "name": "resolution_comment_author_conference_user_id_conference_user_id_fk", + "tableFrom": "resolution_comment", + "tableTo": "conference_user", + "columnsFrom": [ + "author_conference_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resolution_comment_parent_comment_id_resolution_comment_id_fk": { + "name": "resolution_comment_parent_comment_id_resolution_comment_id_fk", + "tableFrom": "resolution_comment", + "tableTo": "resolution_comment", + "columnsFrom": [ + "parent_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resolution_paper": { + "name": "resolution_paper", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "committee_id": { + "name": "committee_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agenda_item_id": { + "name": "agenda_item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_committee_member_id": { + "name": "creator_committee_member_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "paper_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'WORKING_PAPER'" + }, + "content": { + "name": "content", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "document_number": { + "name": "document_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sequence_number": { + "name": "sequence_number", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "resolution_paper_committee_id_committee_id_fk": { + "name": "resolution_paper_committee_id_committee_id_fk", + "tableFrom": "resolution_paper", + "tableTo": "committee", + "columnsFrom": [ + "committee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resolution_paper_agenda_item_id_agenda_item_id_fk": { + "name": "resolution_paper_agenda_item_id_agenda_item_id_fk", + "tableFrom": "resolution_paper", + "tableTo": "agenda_item", + "columnsFrom": [ + "agenda_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resolution_paper_creator_committee_member_id_committee_member_id_fk": { + "name": "resolution_paper_creator_committee_member_id_committee_member_id_fk", + "tableFrom": "resolution_paper", + "tableTo": "committee_member", + "columnsFrom": [ + "creator_committee_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resolution_vote_result": { + "name": "resolution_vote_result", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "paper_id": { + "name": "paper_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "vote_outcome", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "votes_for": { + "name": "votes_for", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "votes_against": { + "name": "votes_against", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "votes_abstain": { + "name": "votes_abstain", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "resolution_vote_result_paper_id_resolution_paper_id_fk": { + "name": "resolution_vote_result_paper_id_resolution_paper_id_fk", + "tableFrom": "resolution_vote_result", + "tableTo": "resolution_paper", + "columnsFrom": [ + "paper_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "resolution_vote_result_paperId_unique": { + "name": "resolution_vote_result_paperId_unique", + "nullsNotDistinct": false, + "columns": [ + "paper_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.speaker_on_list": { + "name": "speaker_on_list", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "committee_member_id": { + "name": "committee_member_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conference_member_id": { + "name": "conference_member_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "speakers_list_id": { + "name": "speakers_list_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "overwrite_name": { + "name": "overwrite_name", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "speaker_on_list_committee_member_id_committee_member_id_fk": { + "name": "speaker_on_list_committee_member_id_committee_member_id_fk", + "tableFrom": "speaker_on_list", + "tableTo": "committee_member", + "columnsFrom": [ + "committee_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "speaker_on_list_conference_member_id_conference_member_id_fk": { + "name": "speaker_on_list_conference_member_id_conference_member_id_fk", + "tableFrom": "speaker_on_list", + "tableTo": "conference_member", + "columnsFrom": [ + "conference_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "speaker_on_list_speakers_list_id_speakers_list_id_fk": { + "name": "speaker_on_list_speakers_list_id_speakers_list_id_fk", + "tableFrom": "speaker_on_list", + "tableTo": "speakers_list", + "columnsFrom": [ + "speakers_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "speaker_on_list_speakersListId_position_unique": { + "name": "speaker_on_list_speakersListId_position_unique", + "nullsNotDistinct": false, + "columns": [ + "speakers_list_id", + "position" + ] + }, + "speaker_on_list_speakersListId_committeeMemberId_unique": { + "name": "speaker_on_list_speakersListId_committeeMemberId_unique", + "nullsNotDistinct": false, + "columns": [ + "speakers_list_id", + "committee_member_id" + ] + }, + "speaker_on_list_speakersListId_conferenceMemberId_unique": { + "name": "speaker_on_list_speakersListId_conferenceMemberId_unique", + "nullsNotDistinct": false, + "columns": [ + "speakers_list_id", + "conference_member_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.speakers_list": { + "name": "speakers_list", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "agenda_item_id": { + "name": "agenda_item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "speakers_list_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "speaking_time": { + "name": "speaking_time", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "time_left": { + "name": "time_left", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "start_timestamp": { + "name": "start_timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_closed": { + "name": "is_closed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "speakers_list_agenda_item_id_agenda_item_id_fk": { + "name": "speakers_list_agenda_item_id_agenda_item_id_fk", + "tableFrom": "speakers_list", + "tableTo": "agenda_item", + "columnsFrom": [ + "agenda_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "speakers_list_agendaItemId_type_unique": { + "name": "speakers_list_agendaItemId_type_unique", + "nullsNotDistinct": false, + "columns": [ + "agenda_item_id", + "type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spoken_time_period": { + "name": "spoken_time_period", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "committee_member_id": { + "name": "committee_member_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conference_member_id": { + "name": "conference_member_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "speakers_list_id": { + "name": "speakers_list_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_timestamp": { + "name": "start_timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_timestamp": { + "name": "end_timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "spoken_time_period_committee_member_id_committee_member_id_fk": { + "name": "spoken_time_period_committee_member_id_committee_member_id_fk", + "tableFrom": "spoken_time_period", + "tableTo": "committee_member", + "columnsFrom": [ + "committee_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "spoken_time_period_conference_member_id_conference_member_id_fk": { + "name": "spoken_time_period_conference_member_id_conference_member_id_fk", + "tableFrom": "spoken_time_period", + "tableTo": "conference_member", + "columnsFrom": [ + "conference_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "spoken_time_period_speakers_list_id_speakers_list_id_fk": { + "name": "spoken_time_period_speakers_list_id_speakers_list_id_fk", + "tableFrom": "spoken_time_period", + "tableTo": "speakers_list", + "columnsFrom": [ + "speakers_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "family_name": { + "name": "family_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "given_name": { + "name": "given_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preferred_username": { + "name": "preferred_username", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_id_unique": { + "name": "user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + }, + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.amendment_status": { + "name": "amendment_status", + "schema": "public", + "values": [ + "PENDING", + "SUBMITTED", + "CONSENSUS_ADOPTED", + "ACCEPTED", + "REJECTED", + "WITHDRAWN" + ] + }, + "public.amendment_type": { + "name": "amendment_type", + "schema": "public", + "values": [ + "DELETE", + "ADD", + "ALTER_TEXT", + "ALTER_POSITION" + ] + }, + "public.comment_visibility": { + "name": "comment_visibility", + "schema": "public", + "values": [ + "PUBLIC", + "TEAM_ONLY" + ] + }, + "public.committee_status": { + "name": "committee_status", + "schema": "public", + "values": [ + "FORMAL", + "INFORMAL", + "MODERATED_INFORMAL", + "PAUSE", + "SUSPENSION" + ] + }, + "public.conference_user_type": { + "name": "conference_user_type", + "schema": "public", + "values": [ + "ADMIN", + "TEAM", + "SPECTATOR", + "DELEGATE", + "NON_STATE_ACTOR" + ] + }, + "public.paper_status": { + "name": "paper_status", + "schema": "public", + "values": [ + "WORKING_PAPER", + "SUBMITTED", + "DRAFT_RESOLUTION", + "AMENDMENT_PHASE", + "VOTING_PHASE", + "FINAL" + ] + }, + "public.regional_group": { + "name": "regional_group", + "schema": "public", + "values": [ + "AFRICA", + "ASIA_PACIFIC", + "EASTERN_EUROPE", + "LATIN_AMERICA_CARIBBEAN", + "WESTERN_EUROPE_OTHERS" + ] + }, + "public.representation_type": { + "name": "representation_type", + "schema": "public", + "values": [ + "DELEGATION", + "NSA", + "UN" + ] + }, + "public.share_code_permission": { + "name": "share_code_permission", + "schema": "public", + "values": [ + "SPONSOR", + "EDIT" + ] + }, + "public.speakers_list_category": { + "name": "speakers_list_category", + "schema": "public", + "values": [ + "SPEAKERS_LIST", + "COMMENT_LIST" + ] + }, + "public.vote_outcome": { + "name": "vote_outcome", + "schema": "public", + "values": [ + "ADOPTED", + "REJECTED", + "SENT_BACK" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 9cdee634..21ba6f1e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1773189153562, "tag": "0006_cuddly_sersi", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1775228132434, + "tag": "0007_typical_the_renegades", + "breakpoints": true } ] } \ No newline at end of file diff --git a/schema.graphql b/schema.graphql index ff54687a..82162523 100644 --- a/schema.graphql +++ b/schema.graphql @@ -115,6 +115,7 @@ type Committee { conference(where: ConferenceWhereInputArgument): Conference conferenceId: ID! createdAt: DateTime! + currentOperativeClauseId: ID currentOperativeIndex: Int customPaperSupportThreshold: Int customSimpleMajority: Int @@ -197,6 +198,7 @@ input CommitteeWhereInputArgument { conference: ConferenceWhereInputArgument conferenceId: ID createdAt: DateTime + currentOperativeClauseId: ID currentOperativeIndex: Int customPaperSupportThreshold: Int customSimpleMajority: Int @@ -226,6 +228,7 @@ type Conference { members(limit: Int, offset: Int, where: ConferenceMemberWhereInputArgument): [ConferenceMember!]! pressWebsite: String representations(limit: Int, offset: Int, where: RepresentationWhereInputArgument): [Representation!]! + resolutionFeatureEnabled: Boolean! title: String! """ @@ -312,6 +315,7 @@ input ConferenceWhereInputArgument { members: ConferenceMemberWhereInputArgument pressWebsite: String representations: RepresentationWhereInputArgument + resolutionFeatureEnabled: Boolean title: String updatedAt: DateTime users: ConferenceUserWhereInputArgument @@ -436,8 +440,8 @@ type Mutation { startVotingPhase(paperId: ID!): ResolutionPaper submitPaper(paperId: ID!): ResolutionPaper updateComment(commentId: ID!, content: String!): ResolutionComment - updateCommittee(abbreviation: String, activeAgendaItemId: ID, activeAmendmentId: ID, activeDraftResolutionId: ID, allowDelegationsToAddThemselvesToSpeakersList: Boolean, amendmentSponsoringOpen: Boolean, amendmentSubmissionOpen: Boolean, clearActiveAmendment: Boolean, clearActiveDraftResolution: Boolean, currentOperativeIndex: Int, id: ID!, lastResolutionAdoptionDate: DateTime, maxDraftResolutions: Int, name: String, showWhiteboard: Boolean, stateOfDebate: String, status: CommitteeStatusEnum, statusHeadline: String, statusUntil: DateTime, supportReEvaluationOpen: Boolean, whiteboardContent: String): Committee - updateConference(hasModeratedCaucus: Boolean, id: ID!, pressWebsite: String, title: String): Conference + updateCommittee(abbreviation: String, activeAgendaItemId: ID, activeAmendmentId: ID, activeDraftResolutionId: ID, allowDelegationsToAddThemselvesToSpeakersList: Boolean, amendmentSponsoringOpen: Boolean, amendmentSubmissionOpen: Boolean, clearActiveAmendment: Boolean, clearActiveDraftResolution: Boolean, currentOperativeClauseId: String, currentOperativeIndex: Int, id: ID!, lastResolutionAdoptionDate: DateTime, maxDraftResolutions: Int, name: String, showWhiteboard: Boolean, stateOfDebate: String, status: CommitteeStatusEnum, statusHeadline: String, statusUntil: DateTime, supportReEvaluationOpen: Boolean, whiteboardContent: String): Committee + updateConference(hasModeratedCaucus: Boolean, id: ID!, pressWebsite: String, resolutionFeatureEnabled: Boolean, title: String): Conference updateConferenceUser(committeeMemberId: ID, conferenceMemberId: ID, conferenceUserType: ConferenceUserTypeEnum!, id: ID!): ConferenceUser updatePaperContent(content: JSON!, paperId: ID!): ResolutionPaper updatePaperTitle(paperId: ID!, title: String!): ResolutionPaper diff --git a/src/api/handlers/amendment.ts b/src/api/handlers/amendment.ts index 69884374..53af8257 100644 --- a/src/api/handlers/amendment.ts +++ b/src/api/handlers/amendment.ts @@ -3,7 +3,7 @@ import { abilityBuilder, enum_, schemaBuilder, pubsub as rumblePubsub } from '$a import { basics } from './basics'; import { isWhitelistedEmail } from '$api/services/isDMUNEmail'; import { assertFindFirstExists, assertFirstEntryExists } from '@m1212e/rumble'; -import { and, eq, count as drizzleCount, not, inArray } from 'drizzle-orm'; +import { and, eq, count as drizzleCount, not, inArray, gt, gte, sql } from 'drizzle-orm'; import { GraphQLError } from 'graphql'; import { assertCommitteeChairOrAdmin } from './resolutionPaper'; import { @@ -83,6 +83,52 @@ function validateAmendmentArgs( type Resolution = { committeeName: string; preamble: unknown[]; operative: unknown[] }; +/** + * Find the current index of a clause by its stable ID. + * Throws if the clause is not found (e.g. already deleted by a prior amendment). + */ +function findClauseIndex(operative: { id: string }[], clauseId: string): number { + const idx = operative.findIndex((c) => c.id === clauseId); + if (idx === -1) { + throw new GraphQLError( + `Clause "${clauseId}" not found in resolution — it may have been deleted by a prior amendment` + ); + } + return idx; +} + +/** + * Auto-adjust targetPosition on remaining PENDING/SUBMITTED ADD/ALTER_POSITION amendments + * after a structural change (deletion or insertion) shifts operative clause indices. + */ +async function adjustPendingPositions( + tx: Parameters[0]>[0], + paperId: string, + excludeAmendmentId: string, + direction: 'decrement' | 'increment', + thresholdIndex: number, + comparison: 'gt' | 'gte' +) { + const delta = direction === 'decrement' ? -1 : 1; + const cmp = + comparison === 'gt' + ? gt(schema.amendment.targetPosition, thresholdIndex) + : gte(schema.amendment.targetPosition, thresholdIndex); + + await tx + .update(schema.amendment) + .set({ targetPosition: sql`${schema.amendment.targetPosition} + ${delta}` }) + .where( + and( + eq(schema.amendment.paperId, paperId), + inArray(schema.amendment.status, ['PENDING', 'SUBMITTED']), + inArray(schema.amendment.type, ['ADD', 'ALTER_POSITION']), + not(eq(schema.amendment.id, excludeAmendmentId)), + cmp + ) + ); +} + async function applyAmendmentToResolution( tx: Parameters[0]>[0], amendment: typeof schema.amendment.$inferSelect, @@ -103,13 +149,8 @@ async function applyAmendmentToResolution( switch (amendment.type) { case 'DELETE': { - const idx = amendment.targetOperativeIndex!; - if (idx < 0 || idx >= resolution.operative.length) { - throw new GraphQLError('Target operative index out of range'); - } - if (resolution.operative[idx].id !== amendment.targetClauseId) { - throw new GraphQLError('Clause ID mismatch at target index'); - } + // Resolve current index from stable clause ID (not stored index) + const idx = findClauseIndex(resolution.operative, amendment.targetClauseId!); resolution.operative.splice(idx, 1); // Auto-withdraw other PENDING/SUBMITTED amendments targeting the deleted clause await tx @@ -123,6 +164,8 @@ async function applyAmendmentToResolution( not(eq(schema.amendment.id, amendment.id)) ) ); + // Adjust targetPosition on remaining ADD/ALTER_POSITION amendments + await adjustPendingPositions(tx, paper.id, amendment.id, 'decrement', idx, 'gt'); break; } case 'ADD': { @@ -132,13 +175,13 @@ async function applyAmendmentToResolution( } const insertAfter = amendment.targetPosition!; resolution.operative.splice(insertAfter + 1, 0, parsedClause.data); + // Adjust targetPosition on remaining ADD/ALTER_POSITION amendments + await adjustPendingPositions(tx, paper.id, amendment.id, 'increment', insertAfter, 'gte'); break; } case 'ALTER_TEXT': { - const idx = amendment.targetOperativeIndex!; - if (idx < 0 || idx >= resolution.operative.length) { - throw new GraphQLError('Target operative index out of range'); - } + // Resolve current index from stable clause ID + const idx = findClauseIndex(resolution.operative, amendment.targetClauseId!); const parsedClause = OperativeClauseSchema.safeParse(amendment.newContent); if (!parsedClause.success) { throw new GraphQLError('Invalid newContent for ALTER_TEXT amendment'); @@ -151,11 +194,9 @@ async function applyAmendmentToResolution( break; } case 'ALTER_POSITION': { - const sourceIdx = amendment.targetOperativeIndex!; + // Resolve current index from stable clause ID + const sourceIdx = findClauseIndex(resolution.operative, amendment.targetClauseId!); const destIdx = amendment.targetPosition!; - if (sourceIdx < 0 || sourceIdx >= resolution.operative.length) { - throw new GraphQLError('Source operative index out of range'); - } if (destIdx < 0 || destIdx > resolution.operative.length) { throw new GraphQLError('Destination index out of range'); } @@ -163,6 +204,11 @@ async function applyAmendmentToResolution( // After removing from source, the target index might shift const adjustedDest = destIdx > sourceIdx ? destIdx - 1 : destIdx; resolution.operative.splice(adjustedDest, 0, clause); + // Adjust other pending amendments' targetPosition for the structural shift + // First: source removal shifts indices down + await adjustPendingPositions(tx, paper.id, amendment.id, 'decrement', sourceIdx, 'gt'); + // Then: destination insertion shifts indices up + await adjustPendingPositions(tx, paper.id, amendment.id, 'increment', adjustedDest, 'gte'); break; } } @@ -233,6 +279,21 @@ schemaBuilder.mutationFields((t) => ({ // Validate type-specific args validateAmendmentArgs(args.type, args); + // If targetClauseId is provided, resolve and auto-correct the operative index + if (args.targetClauseId) { + const parsed = ResolutionSchema.safeParse(paper.content); + if (parsed.success) { + const actualIdx = parsed.data.operative.findIndex((c) => c.id === args.targetClauseId); + if (actualIdx === -1) { + throw new GraphQLError('Target clause no longer exists in the resolution'); + } + // Auto-correct stale index from client + if (args.targetOperativeIndex !== actualIdx) { + args.targetOperativeIndex = actualIdx; + } + } + } + // For DELETE and ALTER_TEXT, validate targetOperativeIndex >= currentOperativeIndex if ( (args.type === 'DELETE' || args.type === 'ALTER_TEXT') && @@ -244,21 +305,6 @@ schemaBuilder.mutationFields((t) => ({ throw new GraphQLError('Cannot amend a clause that has already been passed'); } - // Validate clauseId exists if provided - if ( - args.targetClauseId && - args.targetOperativeIndex !== undefined && - args.targetOperativeIndex !== null - ) { - const parsed = ResolutionSchema.safeParse(paper.content); - if (parsed.success) { - const clause = parsed.data.operative[args.targetOperativeIndex]; - if (!clause || clause.id !== args.targetClauseId) { - throw new GraphQLError('Clause ID does not match at the given index'); - } - } - } - // Validate newContent if provided if (args.newContent) { const parsedContent = OperativeClauseSchema.safeParse(args.newContent); @@ -276,10 +322,9 @@ schemaBuilder.mutationFields((t) => ({ inArray(schema.amendment.status, ['PENDING', 'SUBMITTED']) ]; - if (args.targetOperativeIndex !== undefined && args.targetOperativeIndex !== null) { - duplicateConditions.push( - eq(schema.amendment.targetOperativeIndex, args.targetOperativeIndex) - ); + // Use targetClauseId for duplicate detection (stable, not affected by index drift) + if (args.targetClauseId) { + duplicateConditions.push(eq(schema.amendment.targetClauseId, args.targetClauseId)); } const [{ count: duplicateCount }] = await db @@ -394,17 +439,17 @@ schemaBuilder.mutationFields((t) => ({ // Validate type-specific args validateAmendmentArgs(args.type, args); - // Validate clauseId exists if provided - if ( - args.targetClauseId && - args.targetOperativeIndex !== undefined && - args.targetOperativeIndex !== null - ) { + // If targetClauseId is provided, resolve and auto-correct the operative index + if (args.targetClauseId) { const parsed = ResolutionSchema.safeParse(paper.content); if (parsed.success) { - const clause = parsed.data.operative[args.targetOperativeIndex]; - if (!clause || clause.id !== args.targetClauseId) { - throw new GraphQLError('Clause ID does not match at the given index'); + const actualIdx = parsed.data.operative.findIndex((c) => c.id === args.targetClauseId); + if (actualIdx === -1) { + throw new GraphQLError('Target clause no longer exists in the resolution'); + } + // Auto-correct stale index from client + if (args.targetOperativeIndex !== actualIdx) { + args.targetOperativeIndex = actualIdx; } } } @@ -426,10 +471,9 @@ schemaBuilder.mutationFields((t) => ({ inArray(schema.amendment.status, ['PENDING', 'SUBMITTED']) ]; - if (args.targetOperativeIndex !== undefined && args.targetOperativeIndex !== null) { - duplicateConditions.push( - eq(schema.amendment.targetOperativeIndex, args.targetOperativeIndex) - ); + // Use targetClauseId for duplicate detection (stable, not affected by index drift) + if (args.targetClauseId) { + duplicateConditions.push(eq(schema.amendment.targetClauseId, args.targetClauseId)); } const [{ count: duplicateCount }] = await db @@ -714,17 +758,18 @@ schemaBuilder.mutationFields((t) => ({ // Re-validate with merged values validateAmendmentArgs(amendment.type, merged); - // Validate clauseId matches paper content if provided - if ( - merged.targetClauseId && - merged.targetOperativeIndex !== undefined && - merged.targetOperativeIndex !== null - ) { + // If targetClauseId is provided, resolve and auto-correct the operative index + if (merged.targetClauseId) { const parsed = ResolutionSchema.safeParse(paper.content); if (parsed.success) { - const clause = parsed.data.operative[merged.targetOperativeIndex]; - if (!clause || clause.id !== merged.targetClauseId) { - throw new GraphQLError('Clause ID does not match at the given index'); + const actualIdx = parsed.data.operative.findIndex((c) => c.id === merged.targetClauseId); + if (actualIdx === -1) { + throw new GraphQLError('Target clause no longer exists in the resolution'); + } + // Auto-correct stale index + if (merged.targetOperativeIndex !== actualIdx) { + merged.targetOperativeIndex = actualIdx; + args.targetOperativeIndex = actualIdx; } } } diff --git a/src/api/handlers/committee.ts b/src/api/handlers/committee.ts index 7d28dc41..4b29dd28 100644 --- a/src/api/handlers/committee.ts +++ b/src/api/handlers/committee.ts @@ -198,6 +198,7 @@ schemaBuilder.mutationFields((t) => { activeDraftResolutionId: t.arg.id(), clearActiveDraftResolution: t.arg.boolean(), currentOperativeIndex: t.arg.int(), + currentOperativeClauseId: t.arg.string(), supportReEvaluationOpen: t.arg.boolean(), amendmentSubmissionOpen: t.arg.boolean(), amendmentSponsoringOpen: t.arg.boolean(), @@ -251,6 +252,7 @@ schemaBuilder.mutationFields((t) => { ? null : (args.activeDraftResolutionId ?? undefined), currentOperativeIndex: args.currentOperativeIndex ?? undefined, + currentOperativeClauseId: args.currentOperativeClauseId ?? undefined, supportReEvaluationOpen, amendmentSubmissionOpen: args.amendmentSubmissionOpen ?? undefined, amendmentSponsoringOpen: args.amendmentSponsoringOpen ?? undefined, diff --git a/src/api/handlers/resolutionPaper.ts b/src/api/handlers/resolutionPaper.ts index f44fd1a0..a1802bfc 100644 --- a/src/api/handlers/resolutionPaper.ts +++ b/src/api/handlers/resolutionPaper.ts @@ -620,9 +620,11 @@ schemaBuilder.mutationFields((t) => ({ }); // Reset currentOperativeIndex to 0 for voting navigation + const parsed = ResolutionSchema.safeParse(paper.content); + const firstClauseId = parsed.success ? (parsed.data.operative[0]?.id ?? null) : null; await tx .update(schema.committee) - .set({ currentOperativeIndex: 0 }) + .set({ currentOperativeIndex: 0, currentOperativeClauseId: firstClauseId }) .where(eq(schema.committee.id, paper.committeeId)); }); @@ -709,7 +711,8 @@ schemaBuilder.mutationFields((t) => ({ // Always clear activeDraftResolutionId and currentOperativeIndex const updateSet: Record = { activeDraftResolutionId: null, - currentOperativeIndex: null + currentOperativeIndex: null, + currentOperativeClauseId: null }; if (args.outcome === 'ADOPTED') { @@ -819,11 +822,14 @@ schemaBuilder.mutationFields((t) => ({ .findFirst({ where: { id: paper.committeeId } }) .then(assertFindFirstExists); if (!committee.activeDraftResolutionId) { + const parsed = ResolutionSchema.safeParse(paper.content); + const firstClauseId = parsed.success ? (parsed.data.operative[0]?.id ?? null) : null; await tx .update(schema.committee) .set({ activeDraftResolutionId: args.paperId, - currentOperativeIndex: 0 + currentOperativeIndex: 0, + currentOperativeClauseId: firstClauseId }) .where(eq(schema.committee.id, paper.committeeId)); } @@ -836,7 +842,7 @@ schemaBuilder.mutationFields((t) => ({ // Clear currentOperativeIndex on committee await tx .update(schema.committee) - .set({ currentOperativeIndex: null }) + .set({ currentOperativeIndex: null, currentOperativeClauseId: null }) .where(eq(schema.committee.id, paper.committeeId)); if (args.restoreSnapshot) { // Restore content from latest AMENDMENT_PHASE snapshot @@ -876,7 +882,8 @@ schemaBuilder.mutationFields((t) => ({ .update(schema.committee) .set({ activeDraftResolutionId: null, - currentOperativeIndex: null + currentOperativeIndex: null, + currentOperativeClauseId: null }) .where(eq(schema.committee.id, paper.committeeId)); } diff --git a/src/lib/components/AbbreviationInfoBox.svelte b/src/lib/components/AbbreviationInfoBox.svelte index 95dbeafb..b4a07dd6 100644 --- a/src/lib/components/AbbreviationInfoBox.svelte +++ b/src/lib/components/AbbreviationInfoBox.svelte @@ -24,8 +24,10 @@ }); -
-
+
+
{#if abbreviation}
{abbreviation}
{/if} diff --git a/src/lib/components/IconInfoBox.svelte b/src/lib/components/IconInfoBox.svelte index 9d8c5d9e..93123f4e 100644 --- a/src/lib/components/IconInfoBox.svelte +++ b/src/lib/components/IconInfoBox.svelte @@ -71,11 +71,11 @@
-
+
{#if iconText}
{iconText}
{:else if faIcon} diff --git a/src/routes/app/(launcher)/import/+page.svelte b/src/routes/app/(launcher)/import/+page.svelte index 2f5af6f6..6aab1f29 100644 --- a/src/routes/app/(launcher)/import/+page.svelte +++ b/src/routes/app/(launcher)/import/+page.svelte @@ -1,7 +1,7 @@ @@ -1640,9 +1667,11 @@ onclick={async () => { if (!committee) return; try { + const newIndex = currentOpIndex - 1; await UpdateCommitteeMutation.mutate({ id: committee.id, - currentOperativeIndex: currentOpIndex - 1 + currentOperativeIndex: newIndex, + currentOperativeClauseId: operativeClauses[newIndex]?.id ?? null }); } catch { toast.error(m.saveError()); diff --git a/src/routes/app/[conferenceId]/[committeeId]/(presentation)/PresentationResolutionPreview.svelte b/src/routes/app/[conferenceId]/[committeeId]/(presentation)/PresentationResolutionPreview.svelte index 27301b12..d0fe3360 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(presentation)/PresentationResolutionPreview.svelte +++ b/src/routes/app/[conferenceId]/[committeeId]/(presentation)/PresentationResolutionPreview.svelte @@ -19,6 +19,7 @@ name: string; resolutionHeadline?: string | null; currentOperativeIndex?: number | null; + currentOperativeClauseId?: string | null; activeAmendment?: { id: string; type: string; @@ -103,7 +104,14 @@ let dr = $derived(committee.activeDraftResolution); let activeAmendment = $derived(committee.activeAmendment); - let currentOpIndex = $derived(committee.currentOperativeIndex ?? 0); + let currentOpIndex = $derived.by(() => { + const clauseId = committee.currentOperativeClauseId; + if (clauseId && resolution) { + const idx = resolution.operative.findIndex((c) => c.id === clauseId); + if (idx !== -1) return idx; + } + return committee.currentOperativeIndex ?? 0; + }); let resolution = $derived.by(() => { if (!dr?.content) return null; @@ -182,17 +190,34 @@ } let pendingAmendmentCounts = $derived.by(() => { - if (!dr?.amendments) return new SvelteMap(); + if (!dr?.amendments || !resolution) return new SvelteMap(); const counts = new SvelteMap(); for (const a of dr.amendments) { if (a.status !== 'SUBMITTED') continue; if (a.type !== 'ALTER_TEXT' && a.type !== 'DELETE') continue; - if (a.targetOperativeIndex == null) continue; - counts.set(a.targetOperativeIndex, (counts.get(a.targetOperativeIndex) ?? 0) + 1); + let idx: number | null = null; + if (a.targetClauseId) { + const found = resolution.operative.findIndex((c) => c.id === a.targetClauseId); + if (found !== -1) idx = found; + } else if (a.targetOperativeIndex != null) { + idx = a.targetOperativeIndex; + } + if (idx == null) continue; + counts.set(idx, (counts.get(idx) ?? 0) + 1); } return counts; }); + // Resolve active amendment's target index from stable clause ID + let resolvedActiveAmendIdx = $derived.by(() => { + if (!activeAmendment || !resolution) return -1; + if (activeAmendment.targetClauseId) { + const idx = resolution.operative.findIndex((c) => c.id === activeAmendment.targetClauseId); + if (idx !== -1) return idx; + } + return activeAmendment.targetOperativeIndex ?? -1; + }); + function getProposerName( proposer: | { @@ -246,12 +271,12 @@ {/if}
- {#if activeAmendment.type === 'DELETE' && activeAmendment.targetOperativeIndex != null} + {#if activeAmendment.type === 'DELETE' && resolvedActiveAmendIdx >= 0} - {@const targetClause = resolution.operative[activeAmendment.targetOperativeIndex]} + {@const targetClause = resolution.operative[resolvedActiveAmendIdx]}
{m.operativeClausePresentation()} - {activeAmendment.targetOperativeIndex + 1} + {resolvedActiveAmendIdx + 1}
{#if targetClause}
{/if} - {:else if activeAmendment.type === 'ALTER_TEXT' && activeAmendment.targetOperativeIndex != null} + {:else if activeAmendment.type === 'ALTER_TEXT' && resolvedActiveAmendIdx >= 0} - {@const targetClause = resolution.operative[activeAmendment.targetOperativeIndex]} + {@const targetClause = resolution.operative[resolvedActiveAmendIdx]}
{m.operativeClausePresentation()} - {activeAmendment.targetOperativeIndex + 1} + {resolvedActiveAmendIdx + 1}
@@ -321,9 +346,9 @@
{/if}
- {:else if activeAmendment.type === 'ALTER_POSITION' && activeAmendment.targetOperativeIndex != null} + {:else if activeAmendment.type === 'ALTER_POSITION' && resolvedActiveAmendIdx >= 0} - {@const targetClause = resolution.operative[activeAmendment.targetOperativeIndex]} + {@const targetClause = resolution.operative[resolvedActiveAmendIdx]}
{#if targetClause}
diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/committeeSubscription.ts b/src/routes/app/[conferenceId]/participant/[committeeId]/committeeSubscription.ts index 632aa0bd..ef928ddc 100644 --- a/src/routes/app/[conferenceId]/participant/[committeeId]/committeeSubscription.ts +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/committeeSubscription.ts @@ -22,6 +22,7 @@ export const ParticipantCommitteeSubscription = graphql(` twoThirdsMajority paperSupportThreshold currentOperativeIndex + currentOperativeClauseId activeAmendmentId activeAgendaItem { id diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte index fa64a6f5..f048a799 100644 --- a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte @@ -592,7 +592,14 @@ let committeeSubscriptionData = $derived( $ParticipantCommitteeSubscription.data?.findFirstCommittee ); - let currentOpIndex = $derived(committeeSubscriptionData?.currentOperativeIndex ?? 0); + let currentOpIndex = $derived.by(() => { + const clauseId = committeeSubscriptionData?.currentOperativeClauseId; + if (clauseId && resolution) { + const idx = resolution.operative.findIndex((c: { id: string }) => c.id === clauseId); + if (idx !== -1) return idx; + } + return committeeSubscriptionData?.currentOperativeIndex ?? 0; + }); let activeAmendmentId = $derived(committeeSubscriptionData?.activeAmendmentId ?? null); let isActiveDr = $derived(paper?.id === committee?.activeDraftResolutionId); @@ -1301,10 +1308,15 @@ {getAmendmentStatusLabel(amendment.status)} - {#if amendment.targetOperativeIndex != null} - - OP {amendment.targetOperativeIndex + 1} - + {#if amendment.targetClauseId || amendment.targetOperativeIndex != null} + {@const resolvedOpIdx = amendment.targetClauseId + ? operativeClauses.findIndex((c) => c.id === amendment.targetClauseId) + : (amendment.targetOperativeIndex ?? -1)} + {#if resolvedOpIdx >= 0} + + OP {resolvedOpIdx + 1} + + {/if} {/if} {#if isActive} {m.activeAmendment()} @@ -1343,10 +1355,15 @@ {amendment.documentNumber ?? getAmendmentTypeLabel(amendment.type)} - {#if amendment.targetOperativeIndex != null} - - OP {amendment.targetOperativeIndex + 1} - + {#if amendment.targetClauseId || amendment.targetOperativeIndex != null} + {@const resolvedOpIdx = amendment.targetClauseId + ? operativeClauses.findIndex((c) => c.id === amendment.targetClauseId) + : (amendment.targetOperativeIndex ?? -1)} + {#if resolvedOpIdx >= 0} + + OP {resolvedOpIdx + 1} + + {/if} {/if} {#if isActive} {m.activeAmendment()} @@ -1391,10 +1408,15 @@ {amendment.documentNumber ?? getAmendmentTypeLabel(amendment.type)} - {#if amendment.targetOperativeIndex != null} - - OP {amendment.targetOperativeIndex + 1} - + {#if amendment.targetClauseId || amendment.targetOperativeIndex != null} + {@const resolvedOpIdx = amendment.targetClauseId + ? operativeClauses.findIndex((c) => c.id === amendment.targetClauseId) + : (amendment.targetOperativeIndex ?? -1)} + {#if resolvedOpIdx >= 0} + + OP {resolvedOpIdx + 1} + + {/if} {/if} {#if amendment.proposer?.representation}
From bdb2d3a58a56b4b164eee94f9042de2ca29172fc Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Fri, 3 Apr 2026 17:12:00 +0200 Subject: [PATCH 78/89] fix: update trivy-action from 0.34.0 to 0.35.0 Version 0.34.0 was removed from the action repository, causing CI failures. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d138c603..f952e792 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,7 @@ jobs: steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup-bun - - uses: aquasecurity/trivy-action@0.34.0 + - uses: aquasecurity/trivy-action@0.35.0 with: scan-type: 'fs' scan-ref: '.' @@ -148,7 +148,7 @@ jobs: - id: split-tags run: echo "fragment=$(echo "${DOCKER_METADATA_OUTPUT_TAGS}" | head -n 1)" >> "$GITHUB_OUTPUT" - - uses: aquasecurity/trivy-action@0.34.0 + - uses: aquasecurity/trivy-action@0.35.0 with: image-ref: ${{ steps.split-tags.outputs.fragment }} format: 'table' From 891927463d246886656c7fd412a370ff052066b6 Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Fri, 3 Apr 2026 17:17:14 +0200 Subject: [PATCH 79/89] fix: add missing resolutionFeatureEnabled to query and pt.json Add resolutionFeatureEnabled to CommitteeTeamQuery so generated types match the layout component. Add missing Portuguese translations. Co-Authored-By: Claude Opus 4.6 (1M context) --- messages/pt.json | 2 ++ src/routes/app/[conferenceId]/[committeeId]/(chairs)/+layout.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/messages/pt.json b/messages/pt.json index 78b44840..f12f1e51 100644 --- a/messages/pt.json +++ b/messages/pt.json @@ -391,6 +391,8 @@ "resolutionDeleteClause": "Excluir", "resolutionDisclaimer": "Este documento foi criado como parte de uma simulação {conferenceName} e não possui validade jurídica.", "resolutionEditor": "Editor de Resolução", + "resolutionFeatureEnabled": "Funcionalidades de Resolução", + "resolutionFeatureEnabledDescription": "Ativar editor de resoluções, documentos de trabalho e funcionalidades de emendas para esta conferência.", "resolutionFontSize": "Tamanho da Fonte da Resolução", "resolutionFontSizeDescription": "Defina o tamanho da fonte para o texto da resolução na visualização de apresentação.", "resolutionHeadline": "Título da Resolução (ex.: O Conselho de Segurança)", diff --git a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/+layout.ts b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/+layout.ts index 7c5a4f2d..5a7d3142 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(chairs)/+layout.ts +++ b/src/routes/app/[conferenceId]/[committeeId]/(chairs)/+layout.ts @@ -89,6 +89,7 @@ export const _houdini_load = graphql(` conference { title hasModeratedCaucus + resolutionFeatureEnabled uniqueConferenceMembers { id representation { From fcf9dfc5ee6445a5ed4b4f7b397a197876d233b3 Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Fri, 3 Apr 2026 17:19:03 +0200 Subject: [PATCH 80/89] feat: Add Portuguese language support to CHASE (#331) --- .github/workflows/ci.yml | 4 +- drizzle/0007_typical_the_renegades.sql | 2 + drizzle/meta/0007_snapshot.json | 2436 +++++++++++++++++ drizzle/meta/_journal.json | 7 + messages/de.json | 3 + messages/en.json | 3 + messages/pt.json | 609 +++++ project.inlang/.gitignore | 20 +- project.inlang/settings.json | 4 +- schema.graphql | 8 +- src/api/db/schema.ts | 4 +- src/api/handlers/amendment.ts | 159 +- src/api/handlers/committee.ts | 2 + src/api/handlers/conference.ts | 6 +- src/api/handlers/resolutionPaper.ts | 17 +- src/app.css | 1 + src/hooks.server.ts | 27 +- src/hooks.ts | 8 +- src/lib/components/AbbreviationInfoBox.svelte | 6 +- src/lib/components/IconInfoBox.svelte | 4 +- src/lib/components/LanguageSwitcher.svelte | 68 +- .../utils/nationTranslationHelper.svelte.ts | 2 + src/lib/utils/paperNameGenerator.ts | 40 +- src/routes/(pages)/Card.svelte | 6 +- src/routes/(pages)/ContactSection.svelte | 6 +- src/routes/(pages)/LandingHero.svelte | 6 +- src/routes/(pages)/TextSection.svelte | 6 +- src/routes/+layout.svelte | 8 - src/routes/app/(launcher)/import/+page.svelte | 6 +- .../[committeeId]/(chairs)/+layout.svelte | 1 + .../[committeeId]/(chairs)/+layout.ts | 2 + .../[committeeId]/(chairs)/ChairNavbar.svelte | 25 +- .../(chairs)/committeeSubscription.ts | 2 + .../resolutions/[paperId]/+page.svelte | 51 +- .../PresentationResolutionPreview.svelte | 49 +- .../(presentation)/committeeSubscription.ts | 2 + .../mission-control/config/+page.ts | 1 + .../mission-control/config/GeneralTab.svelte | 24 +- .../participant/[committeeId]/+layout.svelte | 16 +- .../participant/[committeeId]/+layout.ts | 1 + .../[committeeId]/committeeSubscription.ts | 1 + .../papers/[paperId]/+page.svelte | 48 +- vite.config.ts | 2 +- 43 files changed, 3501 insertions(+), 202 deletions(-) create mode 100644 drizzle/0007_typical_the_renegades.sql create mode 100644 drizzle/meta/0007_snapshot.json create mode 100644 messages/pt.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d138c603..f952e792 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,7 @@ jobs: steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup-bun - - uses: aquasecurity/trivy-action@0.34.0 + - uses: aquasecurity/trivy-action@0.35.0 with: scan-type: 'fs' scan-ref: '.' @@ -148,7 +148,7 @@ jobs: - id: split-tags run: echo "fragment=$(echo "${DOCKER_METADATA_OUTPUT_TAGS}" | head -n 1)" >> "$GITHUB_OUTPUT" - - uses: aquasecurity/trivy-action@0.34.0 + - uses: aquasecurity/trivy-action@0.35.0 with: image-ref: ${{ steps.split-tags.outputs.fragment }} format: 'table' diff --git a/drizzle/0007_typical_the_renegades.sql b/drizzle/0007_typical_the_renegades.sql new file mode 100644 index 00000000..13ec3697 --- /dev/null +++ b/drizzle/0007_typical_the_renegades.sql @@ -0,0 +1,2 @@ +ALTER TABLE "committee" ADD COLUMN "current_operative_clause_id" text;--> statement-breakpoint +ALTER TABLE "conference" ADD COLUMN "resolution_feature_enabled" boolean DEFAULT true NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 00000000..6138d64c --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,2436 @@ +{ + "id": "5381be8e-1f70-46f8-a855-c48c44161730", + "prevId": "bab6ce6b-1ddb-4477-aa04-11321db188a9", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.agenda_item": { + "name": "agenda_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "committee_id": { + "name": "committee_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "agenda_item_committee_id_committee_id_fk": { + "name": "agenda_item_committee_id_committee_id_fk", + "tableFrom": "agenda_item", + "tableTo": "committee", + "columnsFrom": [ + "committee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.amendment": { + "name": "amendment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "paper_id": { + "name": "paper_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposer_committee_member_id": { + "name": "proposer_committee_member_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "amendment_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "amendment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'PENDING'" + }, + "target_clause_id": { + "name": "target_clause_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_operative_index": { + "name": "target_operative_index", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "new_content": { + "name": "new_content", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "target_position": { + "name": "target_position", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "document_number": { + "name": "document_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sequence_number": { + "name": "sequence_number", + "type": "smallint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "amendment_paper_id_resolution_paper_id_fk": { + "name": "amendment_paper_id_resolution_paper_id_fk", + "tableFrom": "amendment", + "tableTo": "resolution_paper", + "columnsFrom": [ + "paper_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "amendment_proposer_committee_member_id_committee_member_id_fk": { + "name": "amendment_proposer_committee_member_id_committee_member_id_fk", + "tableFrom": "amendment", + "tableTo": "committee_member", + "columnsFrom": [ + "proposer_committee_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.amendment_sponsor": { + "name": "amendment_sponsor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "amendment_id": { + "name": "amendment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "committee_member_id": { + "name": "committee_member_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "amendment_sponsor_amendment_id_amendment_id_fk": { + "name": "amendment_sponsor_amendment_id_amendment_id_fk", + "tableFrom": "amendment_sponsor", + "tableTo": "amendment", + "columnsFrom": [ + "amendment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "amendment_sponsor_committee_member_id_committee_member_id_fk": { + "name": "amendment_sponsor_committee_member_id_committee_member_id_fk", + "tableFrom": "amendment_sponsor", + "tableTo": "committee_member", + "columnsFrom": [ + "committee_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "amendment_sponsor_amendmentId_committeeMemberId_unique": { + "name": "amendment_sponsor_amendmentId_committeeMemberId_unique", + "nullsNotDistinct": false, + "columns": [ + "amendment_id", + "committee_member_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.committee": { + "name": "committee", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "abbreviation": { + "name": "abbreviation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conference_id": { + "name": "conference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "whiteboard_content": { + "name": "whiteboard_content", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'

'" + }, + "show_whiteboard": { + "name": "show_whiteboard", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status": { + "name": "status", + "type": "committee_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'SUSPENSION'" + }, + "status_headline": { + "name": "status_headline", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status_until": { + "name": "status_until", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "state_of_debate": { + "name": "state_of_debate", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allow_delegations_to_add_themselves_to_speakers_list": { + "name": "allow_delegations_to_add_themselves_to_speakers_list", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "active_agenda_item_id": { + "name": "active_agenda_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_simple_majority": { + "name": "custom_simple_majority", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "custom_two_thirds_majority": { + "name": "custom_two_thirds_majority", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "custom_paper_support_threshold": { + "name": "custom_paper_support_threshold", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "last_resolution_adoption_date": { + "name": "last_resolution_adoption_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "max_draft_resolutions": { + "name": "max_draft_resolutions", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "active_draft_resolution_id": { + "name": "active_draft_resolution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_operative_index": { + "name": "current_operative_index", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "current_operative_clause_id": { + "name": "current_operative_clause_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "support_re_evaluation_open": { + "name": "support_re_evaluation_open", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "amendment_submission_open": { + "name": "amendment_submission_open", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "amendment_sponsoring_open": { + "name": "amendment_sponsoring_open", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "active_amendment_id": { + "name": "active_amendment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolution_headline": { + "name": "resolution_headline", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "committee_conference_id_conference_id_fk": { + "name": "committee_conference_id_conference_id_fk", + "tableFrom": "committee", + "tableTo": "conference", + "columnsFrom": [ + "conference_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "committee_active_agenda_item_id_agenda_item_id_fk": { + "name": "committee_active_agenda_item_id_agenda_item_id_fk", + "tableFrom": "committee", + "tableTo": "agenda_item", + "columnsFrom": [ + "active_agenda_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "committee_active_draft_resolution_id_resolution_paper_id_fk": { + "name": "committee_active_draft_resolution_id_resolution_paper_id_fk", + "tableFrom": "committee", + "tableTo": "resolution_paper", + "columnsFrom": [ + "active_draft_resolution_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "committee_active_amendment_id_amendment_id_fk": { + "name": "committee_active_amendment_id_amendment_id_fk", + "tableFrom": "committee", + "tableTo": "amendment", + "columnsFrom": [ + "active_amendment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "committee_conferenceId_name_unique": { + "name": "committee_conferenceId_name_unique", + "nullsNotDistinct": false, + "columns": [ + "conference_id", + "name" + ] + }, + "committee_conferenceId_abbreviation_unique": { + "name": "committee_conferenceId_abbreviation_unique", + "nullsNotDistinct": false, + "columns": [ + "conference_id", + "abbreviation" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.committee_member": { + "name": "committee_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "present": { + "name": "present", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "committee_id": { + "name": "committee_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "representation_id": { + "name": "representation_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "committee_member_committee_id_committee_id_fk": { + "name": "committee_member_committee_id_committee_id_fk", + "tableFrom": "committee_member", + "tableTo": "committee", + "columnsFrom": [ + "committee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "committee_member_representation_id_representation_id_fk": { + "name": "committee_member_representation_id_representation_id_fk", + "tableFrom": "committee_member", + "tableTo": "representation", + "columnsFrom": [ + "representation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.committee_topic_changed_timestamp": { + "name": "committee_topic_changed_timestamp", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "committee_id": { + "name": "committee_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agenda_item_id": { + "name": "agenda_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "committee_topic_changed_timestamp_committee_id_committee_id_fk": { + "name": "committee_topic_changed_timestamp_committee_id_committee_id_fk", + "tableFrom": "committee_topic_changed_timestamp", + "tableTo": "committee", + "columnsFrom": [ + "committee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "committee_topic_changed_timestamp_agenda_item_id_agenda_item_id_fk": { + "name": "committee_topic_changed_timestamp_agenda_item_id_agenda_item_id_fk", + "tableFrom": "committee_topic_changed_timestamp", + "tableTo": "agenda_item", + "columnsFrom": [ + "agenda_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conference": { + "name": "conference", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "press_website": { + "name": "press_website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_moderated_caucus": { + "name": "has_moderated_caucus", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "resolution_feature_enabled": { + "name": "resolution_feature_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conference_member": { + "name": "conference_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "conference_id": { + "name": "conference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "representation_id": { + "name": "representation_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "conference_member_conference_id_conference_id_fk": { + "name": "conference_member_conference_id_conference_id_fk", + "tableFrom": "conference_member", + "tableTo": "conference", + "columnsFrom": [ + "conference_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conference_member_representation_id_representation_id_fk": { + "name": "conference_member_representation_id_representation_id_fk", + "tableFrom": "conference_member", + "tableTo": "representation", + "columnsFrom": [ + "representation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conference_user": { + "name": "conference_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "conference_user_type": { + "name": "conference_user_type", + "type": "conference_user_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conference_id": { + "name": "conference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conference_member_id": { + "name": "conference_member_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "committee_member_id": { + "name": "committee_member_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "conference_user_conference_id_conference_id_fk": { + "name": "conference_user_conference_id_conference_id_fk", + "tableFrom": "conference_user", + "tableTo": "conference", + "columnsFrom": [ + "conference_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conference_user_conference_member_id_conference_member_id_fk": { + "name": "conference_user_conference_member_id_conference_member_id_fk", + "tableFrom": "conference_user", + "tableTo": "conference_member", + "columnsFrom": [ + "conference_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conference_user_committee_member_id_committee_member_id_fk": { + "name": "conference_user_committee_member_id_committee_member_id_fk", + "tableFrom": "conference_user", + "tableTo": "committee_member", + "columnsFrom": [ + "committee_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.operative_clause_vote": { + "name": "operative_clause_vote", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "paper_id": { + "name": "paper_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "clause_id": { + "name": "clause_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "vote_outcome", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "votes_for": { + "name": "votes_for", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "votes_against": { + "name": "votes_against", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "votes_abstain": { + "name": "votes_abstain", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "operative_clause_vote_paper_id_resolution_paper_id_fk": { + "name": "operative_clause_vote_paper_id_resolution_paper_id_fk", + "tableFrom": "operative_clause_vote", + "tableTo": "resolution_paper", + "columnsFrom": [ + "paper_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "operative_clause_vote_paperId_clauseId_unique": { + "name": "operative_clause_vote_paperId_clauseId_unique", + "nullsNotDistinct": false, + "columns": [ + "paper_id", + "clause_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paper_clause_lock": { + "name": "paper_clause_lock", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "paper_id": { + "name": "paper_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "clause_id": { + "name": "clause_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conference_user_id": { + "name": "conference_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "acquired_at": { + "name": "acquired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "paper_clause_lock_paper_id_resolution_paper_id_fk": { + "name": "paper_clause_lock_paper_id_resolution_paper_id_fk", + "tableFrom": "paper_clause_lock", + "tableTo": "resolution_paper", + "columnsFrom": [ + "paper_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "paper_clause_lock_conference_user_id_conference_user_id_fk": { + "name": "paper_clause_lock_conference_user_id_conference_user_id_fk", + "tableFrom": "paper_clause_lock", + "tableTo": "conference_user", + "columnsFrom": [ + "conference_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "paper_clause_lock_paperId_clauseId_unique": { + "name": "paper_clause_lock_paperId_clauseId_unique", + "nullsNotDistinct": false, + "columns": [ + "paper_id", + "clause_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paper_content_snapshot": { + "name": "paper_content_snapshot", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "paper_id": { + "name": "paper_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "paper_content_snapshot_paper_id_resolution_paper_id_fk": { + "name": "paper_content_snapshot_paper_id_resolution_paper_id_fk", + "tableFrom": "paper_content_snapshot", + "tableTo": "resolution_paper", + "columnsFrom": [ + "paper_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paper_editor": { + "name": "paper_editor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "paper_id": { + "name": "paper_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conference_user_id": { + "name": "conference_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "paper_editor_paper_id_resolution_paper_id_fk": { + "name": "paper_editor_paper_id_resolution_paper_id_fk", + "tableFrom": "paper_editor", + "tableTo": "resolution_paper", + "columnsFrom": [ + "paper_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "paper_editor_conference_user_id_conference_user_id_fk": { + "name": "paper_editor_conference_user_id_conference_user_id_fk", + "tableFrom": "paper_editor", + "tableTo": "conference_user", + "columnsFrom": [ + "conference_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "paper_editor_paperId_conferenceUserId_unique": { + "name": "paper_editor_paperId_conferenceUserId_unique", + "nullsNotDistinct": false, + "columns": [ + "paper_id", + "conference_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paper_share_code": { + "name": "paper_share_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "paper_id": { + "name": "paper_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "share_code_permission", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "paper_share_code_paper_id_resolution_paper_id_fk": { + "name": "paper_share_code_paper_id_resolution_paper_id_fk", + "tableFrom": "paper_share_code", + "tableTo": "resolution_paper", + "columnsFrom": [ + "paper_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "paper_share_code_code_unique": { + "name": "paper_share_code_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paper_sponsor": { + "name": "paper_sponsor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "paper_id": { + "name": "paper_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "committee_member_id": { + "name": "committee_member_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "paper_sponsor_paper_id_resolution_paper_id_fk": { + "name": "paper_sponsor_paper_id_resolution_paper_id_fk", + "tableFrom": "paper_sponsor", + "tableTo": "resolution_paper", + "columnsFrom": [ + "paper_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "paper_sponsor_committee_member_id_committee_member_id_fk": { + "name": "paper_sponsor_committee_member_id_committee_member_id_fk", + "tableFrom": "paper_sponsor", + "tableTo": "committee_member", + "columnsFrom": [ + "committee_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "paper_sponsor_paperId_committeeMemberId_unique": { + "name": "paper_sponsor_paperId_committeeMemberId_unique", + "nullsNotDistinct": false, + "columns": [ + "paper_id", + "committee_member_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.presence_changed_timestamp": { + "name": "presence_changed_timestamp", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "committee_member_id": { + "name": "committee_member_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "present_set_to": { + "name": "present_set_to", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "presence_changed_timestamp_committee_member_id_committee_member_id_fk": { + "name": "presence_changed_timestamp_committee_member_id_committee_member_id_fk", + "tableFrom": "presence_changed_timestamp", + "tableTo": "committee_member", + "columnsFrom": [ + "committee_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.representation": { + "name": "representation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "alpha2_code": { + "name": "alpha2_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "alpha3_code": { + "name": "alpha3_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "representation_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "fa_icon": { + "name": "fa_icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "regional_group": { + "name": "regional_group", + "type": "regional_group", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "conference_id": { + "name": "conference_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "representation_conference_id_conference_id_fk": { + "name": "representation_conference_id_conference_id_fk", + "tableFrom": "representation", + "tableTo": "conference", + "columnsFrom": [ + "conference_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "representation_conferenceId_name_unique": { + "name": "representation_conferenceId_name_unique", + "nullsNotDistinct": false, + "columns": [ + "conference_id", + "name" + ] + }, + "representation_conferenceId_alpha2Code_alpha3Code_unique": { + "name": "representation_conferenceId_alpha2Code_alpha3Code_unique", + "nullsNotDistinct": false, + "columns": [ + "conference_id", + "alpha2_code", + "alpha3_code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resolution_comment": { + "name": "resolution_comment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "paper_id": { + "name": "paper_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "clause_id": { + "name": "clause_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_conference_user_id": { + "name": "author_conference_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "comment_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'PUBLIC'" + }, + "parent_comment_id": { + "name": "parent_comment_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "resolution_comment_paper_id_resolution_paper_id_fk": { + "name": "resolution_comment_paper_id_resolution_paper_id_fk", + "tableFrom": "resolution_comment", + "tableTo": "resolution_paper", + "columnsFrom": [ + "paper_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resolution_comment_author_conference_user_id_conference_user_id_fk": { + "name": "resolution_comment_author_conference_user_id_conference_user_id_fk", + "tableFrom": "resolution_comment", + "tableTo": "conference_user", + "columnsFrom": [ + "author_conference_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resolution_comment_parent_comment_id_resolution_comment_id_fk": { + "name": "resolution_comment_parent_comment_id_resolution_comment_id_fk", + "tableFrom": "resolution_comment", + "tableTo": "resolution_comment", + "columnsFrom": [ + "parent_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resolution_paper": { + "name": "resolution_paper", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "committee_id": { + "name": "committee_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agenda_item_id": { + "name": "agenda_item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_committee_member_id": { + "name": "creator_committee_member_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "paper_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'WORKING_PAPER'" + }, + "content": { + "name": "content", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "document_number": { + "name": "document_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sequence_number": { + "name": "sequence_number", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "resolution_paper_committee_id_committee_id_fk": { + "name": "resolution_paper_committee_id_committee_id_fk", + "tableFrom": "resolution_paper", + "tableTo": "committee", + "columnsFrom": [ + "committee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resolution_paper_agenda_item_id_agenda_item_id_fk": { + "name": "resolution_paper_agenda_item_id_agenda_item_id_fk", + "tableFrom": "resolution_paper", + "tableTo": "agenda_item", + "columnsFrom": [ + "agenda_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resolution_paper_creator_committee_member_id_committee_member_id_fk": { + "name": "resolution_paper_creator_committee_member_id_committee_member_id_fk", + "tableFrom": "resolution_paper", + "tableTo": "committee_member", + "columnsFrom": [ + "creator_committee_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resolution_vote_result": { + "name": "resolution_vote_result", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "paper_id": { + "name": "paper_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "vote_outcome", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "votes_for": { + "name": "votes_for", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "votes_against": { + "name": "votes_against", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "votes_abstain": { + "name": "votes_abstain", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "resolution_vote_result_paper_id_resolution_paper_id_fk": { + "name": "resolution_vote_result_paper_id_resolution_paper_id_fk", + "tableFrom": "resolution_vote_result", + "tableTo": "resolution_paper", + "columnsFrom": [ + "paper_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "resolution_vote_result_paperId_unique": { + "name": "resolution_vote_result_paperId_unique", + "nullsNotDistinct": false, + "columns": [ + "paper_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.speaker_on_list": { + "name": "speaker_on_list", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "committee_member_id": { + "name": "committee_member_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conference_member_id": { + "name": "conference_member_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "speakers_list_id": { + "name": "speakers_list_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "overwrite_name": { + "name": "overwrite_name", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "speaker_on_list_committee_member_id_committee_member_id_fk": { + "name": "speaker_on_list_committee_member_id_committee_member_id_fk", + "tableFrom": "speaker_on_list", + "tableTo": "committee_member", + "columnsFrom": [ + "committee_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "speaker_on_list_conference_member_id_conference_member_id_fk": { + "name": "speaker_on_list_conference_member_id_conference_member_id_fk", + "tableFrom": "speaker_on_list", + "tableTo": "conference_member", + "columnsFrom": [ + "conference_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "speaker_on_list_speakers_list_id_speakers_list_id_fk": { + "name": "speaker_on_list_speakers_list_id_speakers_list_id_fk", + "tableFrom": "speaker_on_list", + "tableTo": "speakers_list", + "columnsFrom": [ + "speakers_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "speaker_on_list_speakersListId_position_unique": { + "name": "speaker_on_list_speakersListId_position_unique", + "nullsNotDistinct": false, + "columns": [ + "speakers_list_id", + "position" + ] + }, + "speaker_on_list_speakersListId_committeeMemberId_unique": { + "name": "speaker_on_list_speakersListId_committeeMemberId_unique", + "nullsNotDistinct": false, + "columns": [ + "speakers_list_id", + "committee_member_id" + ] + }, + "speaker_on_list_speakersListId_conferenceMemberId_unique": { + "name": "speaker_on_list_speakersListId_conferenceMemberId_unique", + "nullsNotDistinct": false, + "columns": [ + "speakers_list_id", + "conference_member_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.speakers_list": { + "name": "speakers_list", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "agenda_item_id": { + "name": "agenda_item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "speakers_list_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "speaking_time": { + "name": "speaking_time", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "time_left": { + "name": "time_left", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "start_timestamp": { + "name": "start_timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_closed": { + "name": "is_closed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "speakers_list_agenda_item_id_agenda_item_id_fk": { + "name": "speakers_list_agenda_item_id_agenda_item_id_fk", + "tableFrom": "speakers_list", + "tableTo": "agenda_item", + "columnsFrom": [ + "agenda_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "speakers_list_agendaItemId_type_unique": { + "name": "speakers_list_agendaItemId_type_unique", + "nullsNotDistinct": false, + "columns": [ + "agenda_item_id", + "type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spoken_time_period": { + "name": "spoken_time_period", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "committee_member_id": { + "name": "committee_member_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conference_member_id": { + "name": "conference_member_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "speakers_list_id": { + "name": "speakers_list_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_timestamp": { + "name": "start_timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_timestamp": { + "name": "end_timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "spoken_time_period_committee_member_id_committee_member_id_fk": { + "name": "spoken_time_period_committee_member_id_committee_member_id_fk", + "tableFrom": "spoken_time_period", + "tableTo": "committee_member", + "columnsFrom": [ + "committee_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "spoken_time_period_conference_member_id_conference_member_id_fk": { + "name": "spoken_time_period_conference_member_id_conference_member_id_fk", + "tableFrom": "spoken_time_period", + "tableTo": "conference_member", + "columnsFrom": [ + "conference_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "spoken_time_period_speakers_list_id_speakers_list_id_fk": { + "name": "spoken_time_period_speakers_list_id_speakers_list_id_fk", + "tableFrom": "spoken_time_period", + "tableTo": "speakers_list", + "columnsFrom": [ + "speakers_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "family_name": { + "name": "family_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "given_name": { + "name": "given_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preferred_username": { + "name": "preferred_username", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_id_unique": { + "name": "user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + }, + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.amendment_status": { + "name": "amendment_status", + "schema": "public", + "values": [ + "PENDING", + "SUBMITTED", + "CONSENSUS_ADOPTED", + "ACCEPTED", + "REJECTED", + "WITHDRAWN" + ] + }, + "public.amendment_type": { + "name": "amendment_type", + "schema": "public", + "values": [ + "DELETE", + "ADD", + "ALTER_TEXT", + "ALTER_POSITION" + ] + }, + "public.comment_visibility": { + "name": "comment_visibility", + "schema": "public", + "values": [ + "PUBLIC", + "TEAM_ONLY" + ] + }, + "public.committee_status": { + "name": "committee_status", + "schema": "public", + "values": [ + "FORMAL", + "INFORMAL", + "MODERATED_INFORMAL", + "PAUSE", + "SUSPENSION" + ] + }, + "public.conference_user_type": { + "name": "conference_user_type", + "schema": "public", + "values": [ + "ADMIN", + "TEAM", + "SPECTATOR", + "DELEGATE", + "NON_STATE_ACTOR" + ] + }, + "public.paper_status": { + "name": "paper_status", + "schema": "public", + "values": [ + "WORKING_PAPER", + "SUBMITTED", + "DRAFT_RESOLUTION", + "AMENDMENT_PHASE", + "VOTING_PHASE", + "FINAL" + ] + }, + "public.regional_group": { + "name": "regional_group", + "schema": "public", + "values": [ + "AFRICA", + "ASIA_PACIFIC", + "EASTERN_EUROPE", + "LATIN_AMERICA_CARIBBEAN", + "WESTERN_EUROPE_OTHERS" + ] + }, + "public.representation_type": { + "name": "representation_type", + "schema": "public", + "values": [ + "DELEGATION", + "NSA", + "UN" + ] + }, + "public.share_code_permission": { + "name": "share_code_permission", + "schema": "public", + "values": [ + "SPONSOR", + "EDIT" + ] + }, + "public.speakers_list_category": { + "name": "speakers_list_category", + "schema": "public", + "values": [ + "SPEAKERS_LIST", + "COMMENT_LIST" + ] + }, + "public.vote_outcome": { + "name": "vote_outcome", + "schema": "public", + "values": [ + "ADOPTED", + "REJECTED", + "SENT_BACK" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 9cdee634..21ba6f1e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1773189153562, "tag": "0006_cuddly_sersi", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1775228132434, + "tag": "0007_typical_the_renegades", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/de.json b/messages/de.json index 81a7aeee..1e0e4890 100644 --- a/messages/de.json +++ b/messages/de.json @@ -247,6 +247,7 @@ "insertAtBeginning": "Am Anfang einfügen", "invalidShareCode": "Ungültiger Freigabecode", "italic": "Kursiv", + "language": "Sprache", "launcher": "Launcher", "launcherDescription": "Wähle die Konferenz aus", "launcherNoConferences": "Du bist für keine Konferenz angemeldet.", @@ -390,6 +391,8 @@ "resolutionDeleteClause": "Löschen", "resolutionDisclaimer": "Dieses Dokument wurde im Rahmen einer {conferenceName}-Simulation erstellt und besitzt keine rechtliche Gültigkeit.", "resolutionEditor": "Resolutions-Editor", + "resolutionFeatureEnabled": "Resolutionsfunktionen", + "resolutionFeatureEnabledDescription": "Resolutionseditor, Arbeitspapiere und Änderungsanträge für diese Konferenz aktivieren.", "resolutionFontSize": "Resolutions-Schriftgröße", "resolutionFontSizeDescription": "Hier kann die Schriftgröße für den Resolutionstext in der Präsentationsansicht festgelegt werden.", "resolutionHeadline": "Resolutions-Kopfzeile (z.B. Der Sicherheitsrat)", diff --git a/messages/en.json b/messages/en.json index b348577f..cf756585 100644 --- a/messages/en.json +++ b/messages/en.json @@ -247,6 +247,7 @@ "insertAtBeginning": "Insert at beginning", "invalidShareCode": "Invalid share code", "italic": "Italics", + "language": "Language", "launcher": "Launcher", "launcherDescription": "Select the conference", "launcherNoConferences": "You are not registered for a conference.", @@ -390,6 +391,8 @@ "resolutionDeleteClause": "Delete", "resolutionDisclaimer": "This document was created as part of a {conferenceName} simulation and has no legal validity.", "resolutionEditor": "Resolution Editor", + "resolutionFeatureEnabled": "Resolution Features", + "resolutionFeatureEnabledDescription": "Enable resolution editor, papers, and amendment features for this conference.", "resolutionFontSize": "Resolution Font Size", "resolutionFontSizeDescription": "Set the font size for the resolution text in the presentation view.", "resolutionHeadline": "Resolution Headline (e.g. The Security Council)", diff --git a/messages/pt.json b/messages/pt.json new file mode 100644 index 00000000..f12f1e51 --- /dev/null +++ b/messages/pt.json @@ -0,0 +1,609 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "aServiceBy": "Um serviço de", + "abort": "Cancelar", + "absent": "Ausente", + "absoluteMajority": "Absoluta", + "abstain": "Abstenção", + "activeAmendment": "Em Discussão", + "activeDraftResolution": "Em Andamento", + "addAgendaItem": "Adicionar Item", + "addAll": "Adicionar Todos", + "addClause": "Adicionar Cláusula", + "addClausePresentation": "Adicionar Cláusula", + "addComment": "Adicionar Comentário", + "addCommittee": "Adicionar Comitê", + "addCountriesCount": "Adicionar {count} países", + "addCountry": "Adicionar País", + "addMeToList": "Inscrever-me na lista", + "addMember": "Adicionar Membro", + "addNonStateActor": "Adicionar NGO", + "addRepresentation": "Adicionar Delegação", + "addSponsor": "Adicionar Patrocinador", + "addUnActor": "Adicionar Ator da ONU", + "admin": "Administrador", + "adoptByConsensus": "Adotar por Consenso", + "adoptClause": "Adotar", + "adoptResolution": "Adotar Resolução", + "adopted": "Adotada", + "adoptionAnnouncement": "URGENTE: Resolução sobre \"{agendaItem}\" adotada no comitê {committeeName}", + "advanceToNextParagraph": "Avançar para o Próximo Parágrafo", + "agendaItem": "Item da pauta", + "agendaItemTitle": "Título do Item da Pauta", + "agendaItems": "Itens da Pauta", + "allRightsReservedby": "Todos os direitos reservados por", + "allowSelfAddToSpeakersList": "Autoinscrição na Lista de Oradores", + "allowSelfAddToSpeakersListDescription": "Permitir que delegados e atores não estatais se inscrevam nas listas de oradores.", + "alterClausePresentation": "Alterar Texto", + "alterPosition": "Mover Cláusula", + "alterText": "Alterar Texto", + "amendment": "Emenda", + "amendmentAccepted": "Aceita", + "amendmentAcceptedToast": "Emenda aceita", + "amendmentAdd": "Adicionar cláusula", + "amendmentAdopted": "Emenda adotada", + "amendmentAlterPosition": "Alterar posição", + "amendmentAlterText": "Alterar texto", + "amendmentConsensusAdopted": "Adotada por Consenso", + "amendmentCreated": "Emenda proposta", + "amendmentDelete": "Excluir cláusula", + "amendmentPending": "Pendente", + "amendmentPhase": "Fase de Emendas", + "amendmentPhaseActive": "Fase de emendas ativa", + "amendmentPhaseStarted": "Fase de emendas iniciada", + "amendmentProposed": "Emenda proposta", + "amendmentQueue": "Fila de Emendas", + "amendmentRejected": "Rejeitada", + "amendmentRejectedClause": "Rejeitada", + "amendmentRejectedToast": "Emenda rejeitada", + "amendmentSponsoring": "Patrocínio de Emendas", + "amendmentSponsoringClosed": "O patrocínio de emendas está encerrado", + "amendmentSponsoringOpen": "Delegados podem patrocinar emendas", + "amendmentSubmission": "Submissão de Emendas", + "amendmentSubmissionClosed": "A submissão de emendas está encerrada", + "amendmentSubmissionOpen": "Delegados podem submeter novas emendas", + "amendmentSubmitted": "Submetida", + "amendmentSubmittedToast": "Emenda submetida", + "amendmentUpdated": "Emenda atualizada", + "amendmentWithdrawn": "Retirada", + "amendmentWithdrawnToast": "Emenda retirada", + "amendments": "Emendas", + "announceAdoption": "Anunciar Adoção", + "assignedCount": "{count} atribuídos", + "assignment": "Atribuição", + "back": "Voltar", + "backToResolutions": "Voltar às Resoluções", + "baseFontSize": "Tamanho Base da Fonte", + "baseFontSizeDescription": "Aqui você pode definir o tamanho base da fonte para a visualização de apresentação.", + "blockquote": "Citação", + "bold": "Negrito", + "bulkAddMembers": "Adicionar Membros em Massa", + "bulkEmailPlaceholder": "Insira endereços de e-mail (um por linha ou separados por vírgula)", + "bulletedList": "Lista com marcadores", + "cancel": "Cancelar", + "chairControls": "Controles da Presidência", + "chairCreateAmendment": "Criar Emenda", + "chairCreateWorkingPaper": "Criar Documento de Trabalho", + "changeSpeakersName": "Alterar Nome", + "changeSpeakersTime": "Alterar Tempo de Fala", + "changesSaved": "Salvo", + "clauseComments": "nas cláusulas", + "clauseLockedBy": "Sendo editada por {country}", + "clauseVoteDeleted": "Votação de cláusula removida", + "clauseVoteRecorded": "Votação de cláusula registrada", + "clauseVoteSummary": "Resumo da Votação de Cláusulas", + "clausesVoted": "{voted}/{total} cláusulas votadas", + "clearActiveDr": "Limpar Ativo", + "clearFormatting": "Limpar formatação", + "clearList": "Limpar Lista", + "clearListDescription": "Tem certeza de que deseja limpar toda a lista?", + "close": "Fechar", + "closeList": "Fechar Lista", + "closeReEvaluation": "Fechar Reavaliação", + "code": "Código", + "codeCopied": "Código copiado!", + "codeRedeemed": "Código resgatado com sucesso", + "codesUnrecognized": "{count} códigos não reconhecidos", + "collaborativeEditingInfo": "Outros delegados estão editando esta resolução. Passe o mouse sobre uma cláusula e clique em \"Começar a editar\" para iniciar. Os bloqueios expiram automaticamente após 1 minuto de inatividade.", + "comingSoon": "em breve", + "commentDeleted": "Comentário excluído", + "commentList": "Ponto de Informação", + "commentPlaceholder": "Escreva um comentário...", + "commentPosted": "Comentário publicado", + "commentUpdated": "Comentário atualizado", + "comments": "Comentários", + "commentsOnClause": "{count} comentário(s)", + "committee": "Comitê", + "committeeAbbreviation": "Abreviação do Comitê", + "committeeDoesNotExist": "O comitê não existe.", + "committeeId": "ID do Comitê", + "committeeMember": "Membro do Comitê", + "committeeMembers": "Membros do Comitê", + "committeeName": "Nome do Comitê", + "committeeOverview": "Visão Geral do Comitê", + "committeeStatus": "Status do Comitê", + "committeeStatusExpired": "{status} expirou!", + "committees": "Comitês", + "con": "Contra", + "conferenceCreated": "Conferência criada!", + "conferenceCreationError": "Não foi possível criar a conferência", + "conferenceCreationSuccessful": "Conferência criada. Você será redirecionado em breve...", + "conferenceId": "ID da Conferência", + "conferenceMembers": "Membros da Conferência", + "conferenceTitle": "Título da Conferência", + "configuration": "Configuração", + "confirmAdoptByConsensus": "Adotar esta emenda por consenso? Isso aplicará a alteração imediatamente.", + "confirmAdoptResolution": "Adotar esta resolução? Confetes celebrarão a adoção!", + "confirmDeleteAmendment": "Tem certeza de que deseja propor a exclusão desta cláusula?", + "confirmDeleteCommittee": "Tem certeza de que deseja excluir este comitê? Todos os dados associados serão perdidos.", + "confirmDeletePaper": "Tem certeza de que deseja excluir este documento de trabalho? Ele ficará oculto para todos os participantes.", + "confirmDeleteRepresentation": "Tem certeza de que deseja remover esta delegação? As associações ao comitê serão removidas.", + "confirmFinalVote": "Registrar esta votação final? O status da resolução será alterado para Final.", + "confirmRejectAmendment": "Rejeitar esta emenda?", + "confirmRejectResolution": "Rejeitar esta resolução?", + "confirmRemoveMember": "Tem certeza de que deseja remover este membro?", + "confirmRevertStatus": "Reverter este documento de {from} para {to}?", + "confirmSendBack": "Devolver esta resolução?", + "confirmStartAmendmentPhase": "Iniciar a fase de emendas para este projeto de resolução? Os delegados poderão propor emendas parágrafo por parágrafo.", + "confirmStartVotingPhase": "Iniciar a fase de votação para este projeto de resolução? Cada parágrafo operativo será votado individualmente.", + "confirmSubmitPaper": "Tem certeza de que deseja submeter este documento à presidência? Você não poderá mais editá-lo.", + "copy": "Copiar", + "copyCode": "Copiar", + "copyFailed": "Falha ao copiar", + "countries": "Países", + "countriesRecognized": "{count} países reconhecidos", + "countryCodesHelp": "Suporta códigos Alpha-2 (DE, US) e Alpha-3 (DEU, USA). Separe com espaços, vírgulas, ponto e vírgula ou novas linhas.", + "countryCodesPlaceholder": "DE, USA, FRA\nou um por linha...", + "countryNotFound": "País não encontrado", + "countryNsaOrCustomRole": "País, ator não estatal ou função especial", + "create": "Criar", + "createConference": "Criar Conferência", + "createPaper": "Criar Documento", + "createResolutionPaper": "Criar Documento de Trabalho", + "createShareCodeEdit": "Criar Código de Edição", + "createShareCodeSponsor": "Criar Código de Patrocínio", + "currentParagraph": "Parágrafo Atual", + "currentText": "Texto Atual", + "customName": "Nome personalizado...", + "dateCannotBeInPast": "A data não pode estar no passado!", + "debateControls": "Controles do Debate", + "delegate": "Delegado", + "delegations": "Delegações", + "deleteClause": "Excluir Cláusula", + "deleteClausePresentation": "Excluir Cláusula", + "deleteCode": "Excluir Código", + "deleteComment": "Excluir", + "deletePaper": "Excluir Documento", + "deleteRepresentation": "Remover Delegação", + "displayRegionalGroups": "Exibir Blocos Regionais", + "documentLevelComments": "Comentários do Documento", + "documentNumber": "Número do Documento", + "documentWide": "todo o documento", + "doneEditing": "Finalizar edição", + "download": "Baixar", + "downloadPresenceData": "Dados de Presença", + "draftResolution": "Projeto de Resolução", + "draftResolutions": "Projetos de Resolução", + "edit": "Editar", + "editAccess": "Acesso de Edição", + "editAmendment": "Editar Emenda", + "editComment": "Editar", + "editPaper": "Editar Documento", + "editUser": "Editar Usuário", + "editors": "Editores", + "email": "E-mail", + "enterAlpha2Code": "Por favor, insira o código Alpha2", + "enterCode": "Inserir Código", + "enterCountryCodes": "Insira os códigos dos países (Alpha-2 ou Alpha-3)", + "errorUpdatingStateOfDebate": "Erro ao salvar o status do debate", + "errorUpdatingStatus": "Não foi possível definir o status", + "errorUpdatingTimer": "Não foi possível atualizar o tempo de fala", + "errorUpdatingWhiteboard": "Falha na publicação", + "fileParseError": "Erro ao analisar o arquivo", + "finalResolution": "Resolução Final", + "finalVote": "Votação Final", + "finalVoteDescription": "Registrar a votação final sobre a resolução inteira.", + "finishAmendmentPhaseFirst": "Finalize a fase de emendas primeiro", + "formalDebate": "Debate formal", + "forward": "Próximo", + "general": "Geral", + "goToAmendments": "Ir para emendas", + "goToVoting": "Ir para votação", + "gotoSettings": "Ir para configurações", + "h1": "Título 1", + "h2": "Título 2", + "h3": "Título 3", + "hasModeratedCaucus": "Caucus Moderado", + "hasModeratedCaucusDescription": "Habilitar caucus informal moderado como opção de status do comitê.", + "home": "Início", + "homeAboutText": "CHASE (CHAirSoftwarE) é uma aplicação web para gerenciar e conduzir debates em conferências de Modelo das Nações Unidas. Foi projetada tanto para presidências quanto para delegados. CHASE permite que as presidências gerenciem debates facilmente, enquanto os delegados podem acompanhar a discussão e colaborar com outros de forma intuitiva e estruturada. CHASE é um software livre e de código aberto.", + "homeAboutTitle": "Sobre o CHASE", + "homeCaption": "no século 21", + "homeContactButton": "Entre em Contato", + "homeContactText": "Você está organizando uma conferência de Modelo das Nações Unidas e tem interesse em usar o CHASE? Oferecemos suporte gratuito (dentro de nossas possibilidades) para ajudá-lo a implantar o CHASE em sua própria infraestrutura. Também podemos hospedar o CHASE em nossos servidores para a sua conferência. Entre em contato — teremos prazer em ajudar!", + "homeContactTitle": "Obtenha o CHASE para Sua Conferência", + "homeContributeButtonLabel": "MUNify no GitHub", + "homeContributeText": "CHASE faz parte da iniciativa de código aberto 'MUNify' da DMUN. Isso significa que qualquer pessoa pode contribuir com o desenvolvimento. Agradecemos toda ajuda que pudermos receber. Se você tem experiência em desenvolvimento web ou quer aprender novas habilidades e ajudar, confira nosso GitHub!", + "homeContributeTitle": "Contribua", + "homeHeroCardResolutionEditorText": "Crie e edite resoluções de forma colaborativa com outros delegados. Sem mais papel ou Google Docs!", + "homeHeroCardResolutionEditorTitle": "Resoluções", + "homeHeroCardSpeakersListText": "Gerencie as listas de oradores de forma simples e eficiente. Sem mais listas em papel!", + "homeHeroCardSpeakersListTitle": "Debates", + "homeHeroCardVotingText": "Gerencie moções e votações eletronicamente com moções pré-configuradas baseadas nas suas Regras de Procedimento.", + "homeHeroCardVotingTitle": "Votação", + "homeHeroText": "O gerenciamento de debates em conferências de Modelo das Nações Unidas finalmente recebe uma atualização.", + "homeMissionButtonLabel": "Saiba mais sobre a DMUN", + "homeMissionText": "CHASE é desenvolvido por membros da organização alemã DMUN e.V. Nosso objetivo é oferecer uma alternativa gratuita e acessível a outras ferramentas de gerenciamento de debates, facilitando a participação até de conferências menores. CHASE foi inicialmente desenvolvido para conferências de língua alemã da DMUN — MUN-SH, MUNBW e MUNBB — mas estamos abertos a adaptá-lo para outras conferências.", + "homeMissionTitle": "Nossa Missão", + "homeVersionButton": "CHASE (CHAirSoftwarE) é uma aplicação web para gerenciar e conduzir debates em conferências de Modelo das Nações Unidas. Foi projetada tanto para presidências quanto para delegados. CHASE permite que as presidências gerenciem debates facilmente, enquanto os delegados podem acompanhar o debate e colaborar com outros delegados de forma intuitiva e estruturada. CHASE é um software livre e de código aberto.", + "horizontalRule": "Divisor", + "icon": "Ícone", + "img": "Imagem", + "importFromDelegator": "Importar conferência do Delegator", + "imprintAndPrivacy": "Imprensa & Política de Privacidade", + "informalCaucus": "Reunião informal", + "insertAfterPresentation": "Inserir após CO.{index}", + "insertAsFirstClause": "Inserir como primeira cláusula", + "insertAtBeginning": "Inserir no início", + "invalidShareCode": "Código de compartilhamento inválido", + "italic": "Itálico", + "language": "Idioma", + "launcher": "Lançador", + "launcherDescription": "Selecione a conferência", + "launcherNoConferences": "Você não está registrado em nenhuma conferência.", + "launcherWelcome": "Bem-vindo de volta, {name}!", + "layout": "Layout", + "layoutDescription": "Modelos de layout para a visualização de apresentação. Observe que alterar o modelo de layout aqui substituirá as alterações manuais de layout.", + "layoutPresetDefault": "Layout Padrão", + "layoutPresetResolution": "Layout de Resolução", + "layoutPresetSmallScreen": "Layout para Telas Pequenas", + "layoutSelect": "Selecionar Layout", + "link": "Hiperlink", + "listClosed": "Lista fechada", + "listClosedCannotAdd": "A lista está fechada", + "listEmpty": "Nenhum discurso", + "lockAcquireFailed": "Esta cláusula está sendo editada por {country}. Por favor, tente novamente em instantes.", + "login": "Registrar", + "logout": "Sair", + "loose_slow_reindeer_build": "Membros do Comitê", + "majorities": "Maiorias", + "majoritySettings": "Configurações de Maioria", + "majoritySettingsDescriptions": "As configurações de maioria ajudam a visualizar se uma moção foi aprovada.", + "maroon_bland_ray_renew": "Abreviação do comitê", + "matching": "correspondente", + "maxDraftResolutions": "Máximo de Projetos de Resolução", + "maxDraftResolutionsReached": "Número máximo de projetos de resolução atingido", + "member": "Membro", + "memberAdded": "Membro adicionado com sucesso", + "memberRemoved": "Membro removido com sucesso", + "memberUpdated": "Membro atualizado com sucesso", + "minuteOfTheHour": "Horário absoluto: Ir para o minuto correspondente desta ou da próxima hora", + "minutesFromNow": "Horário relativo: Avançar X minutos no futuro", + "missionControl": "Controle de Missão", + "moderatedInformalCaucus": "Caucus informal moderado", + "moveClausePresentation": "Mover Cláusula", + "moveToPositionPresentation": "Mover para a posição {position}", + "myAmendments": "Minhas Emendas", + "myPapers": "Meus Documentos", + "name": "Nome", + "nextParagraph": "Próximo", + "nextSpeaker": "Próximo Discurso", + "nextSpeakerDescription": "Deseja realmente chamar o próximo discurso? Todos os Pontos de Informação restantes serão descartados.", + "noActiveAgendaItem": "Nenhum item da pauta ativo. Não é possível criar um documento agora.", + "noActiveDr": "Nenhum projeto de resolução ativo", + "noActiveDrForVoting": "Nenhum projeto de resolução ativo para votação", + "noActiveDraftResolution": "Nenhum projeto de resolução ativo", + "noAgendaItemSelected": "Nenhum item da pauta ativo", + "noAgendaItemSelectedDescription": "Para trabalhar com as listas de oradores, você deve primeiro selecionar um item da pauta.", + "noAmendments": "Nenhuma emenda ainda", + "noAssignmentNeeded": "Nenhuma atribuição de membro necessária para esta função.", + "noCommentList": "Sem Lista de Pontos de Informação", + "noComments": "Nenhum comentário ainda", + "noCurrentSpeaker": "Nenhum discurso", + "noData": "Sem dados", + "noDraftResolution": "Nenhum projeto de resolução definido como ativo.", + "noDraftResolutionsYet": "Nenhum projeto de resolução ainda.", + "noMembers": "Nenhum membro ainda", + "noOperativeClauses": "Nenhuma cláusula operativa", + "noPapersYet": "Nenhum documento ainda. Crie um ou insira um código de compartilhamento.", + "noResults": "Sem resultados", + "noSubmittedPapers": "Nenhum documento submetido ainda.", + "nonStateActor": "Ator Não Estatal", + "nonStateActors": "Atores Não Estatais", + "notAuthorized": "Você não está autorizado a acessar esta página", + "notPresent": "Não presente", + "notPresentCannotAdd": "Você deve estar marcado como presente para se inscrever", + "nothingChanged": "Nada alterado", + "numberedList": "Lista numerada", + "off": "Desligado", + "on": "Ligado", + "onListPosition": "Você é o #{position} na lista", + "openPresentation": "Abrir Visualização de Apresentação", + "openReEvaluation": "Abrir Reavaliação", + "operativeClause": "Cláusula Operativa", + "operativeClausePresentation": "Cláusula Operativa", + "outcome": "Resultado", + "over": "encerrado", + "paperCreated": "Documento criado", + "paperDeleted": "Documento excluído", + "paperPromoted": "Documento promovido a Projeto de Resolução", + "paperSubmitted": "Documento submetido à presidência", + "paperSupportThresholdTooltip": "Estados apoiadores necessários para submeter uma emenda", + "paperTitle": "Título do Documento", + "papers": "Documentos", + "paragraphVoting": "Votação por Parágrafo", + "parsedCountries": "Países a adicionar:", + "participantView": "Visão do Participante", + "pause": "Pausar", + "phraseCopied": "Frase copiada!", + "phraseLookup": "Frases", + "phraseLookupDisclaimer": "Estas frases são fornecidas como orientação. Por favor, verifique o uso correto no contexto.", + "phraseLookupNoResults": "Nenhuma frase encontrada.", + "phraseLookupSearch": "Pesquisar frases...", + "phraseLookupTitle": "Referência de Frases", + "preambleClause": "Cláusula Preambular", + "presence": "Presença", + "present": "Presente", + "presentationMode": "Visualização de Apresentação", + "pressWebsite": "Site de Imprensa", + "preview": "Pré-visualização", + "previousParagraph": "Anterior", + "printResolution": "Imprimir", + "pro": "A Favor", + "promote": "Promover", + "promoteToDraftResolution": "Promover a Projeto de Resolução", + "promoteToDraftResolutionConfirm": "Promover este documento a Projeto de Resolução? Isso atribuirá um número de documento.", + "proposeAmendment": "Propor Emenda", + "proposedAmendmentPresentation": "Emenda Proposta", + "proposedBy": "Proposta por {name}", + "proposedText": "Texto Proposto", + "publicComment": "Público", + "publish": "Publicar", + "publishChanges": "Publicar alterações", + "recordVoteFromVoting": "Registrar Voto", + "redeemShareCode": "Resgatar Código de Compartilhamento", + "redo": "Refazer", + "regionalGroup_africa": "África", + "regionalGroup_asiaPacific": "Ásia-Pacífico", + "regionalGroup_easternEurope": "Europa Oriental", + "regionalGroup_latinAmericaCaribbean": "América Latina e Caribe", + "regionalGroup_westernEuropeOthers": "Europa Ocidental e Outros", + "regionalGroups": "Grupos Regionais", + "rejectClause": "Rejeitar", + "rejectResolution": "Rejeitar Resolução", + "rejected": "Rejeitada", + "removeFromList": "Remover da lista", + "removeMember": "Remover", + "removeSponsor": "Remover Patrocínio", + "replyToComment": "Responder", + "resolution": "Resolução", + "resolutionAddClause": "Adicionar Cláusula", + "resolutionAddContinuation": "Texto de Continuação", + "resolutionAddFirstClause": "Adicionar Primeira Cláusula", + "resolutionAddNested": "Cláusula Aninhada", + "resolutionAddSibling": "Adicionar Cláusula", + "resolutionAddSubClause": "Subcláusula", + "resolutionAdopted": "Resolução adotada!", + "resolutionAuthoringDelegation": "Delegação Autora", + "resolutionCommittee": "Comitê", + "resolutionContinuationPlaceholder": "Insira o texto de continuação...", + "resolutionDeleteBlock": "Excluir Bloco", + "resolutionDeleteClause": "Excluir", + "resolutionDisclaimer": "Este documento foi criado como parte de uma simulação {conferenceName} e não possui validade jurídica.", + "resolutionEditor": "Editor de Resolução", + "resolutionFeatureEnabled": "Funcionalidades de Resolução", + "resolutionFeatureEnabledDescription": "Ativar editor de resoluções, documentos de trabalho e funcionalidades de emendas para esta conferência.", + "resolutionFontSize": "Tamanho da Fonte da Resolução", + "resolutionFontSizeDescription": "Defina o tamanho da fonte para o texto da resolução na visualização de apresentação.", + "resolutionHeadline": "Título da Resolução (ex.: O Conselho de Segurança)", + "resolutionHidePreview": "Ocultar Pré-visualização", + "resolutionImport": "Importar", + "resolutionImportButton": "Importar {count} cláusula(s)", + "resolutionImportHintOperative": "Cole cláusulas operativas numeradas. Subcláusulas serão detectadas automaticamente.", + "resolutionImportHintPreamble": "Cole cláusulas preambulares, separadas por vírgula e quebra de linha.", + "resolutionImportLLMCopied": "Copiado!", + "resolutionImportLLMCopyPrompt": "Copiar Prompt", + "resolutionImportLLMInstructions": "Copie o seguinte prompt em um assistente de IA para formatar seu texto automaticamente:", + "resolutionImportLLMPromptOperative": "Formate o seguinte texto como cláusulas operativas de resolução da ONU. Use:\n- Numeração para cláusulas principais: 1. 2. 3.\n- Letras para subcláusulas: a) b) c)\n- Algarismos romanos para mais aninhamento: i) ii) iii)\n- Letras duplas para o nível mais profundo: aa) bb) cc)\n- Ponto e vírgula no final de cada cláusula, ponto final na última\n\nFormato de exemplo:\n1. Calls upon all Member States to take measures;\n a) to promote peace;\n b) to strengthen cooperation;\n i) at the bilateral level;\n ii) at the multilateral level;\n2. Requests the Secretary-General to submit a report.\n\nTexto a formatar:", + "resolutionImportLLMPromptPreamble": "Formate o seguinte texto como cláusulas preambulares de resolução da ONU. Cada cláusula deve:\n- Começar com letra minúscula (exceto nomes próprios)\n- Terminar com vírgula\n- Ser separada por quebra de linha\n\nFormato de exemplo:\nrecalling its resolution 70/1 of 25 September 2015,\nemphasizing the importance of multilateralism,\nnoting with concern the current situation,\n\nTexto a formatar:", + "resolutionImportLLMTitle": "Formatação com IA", + "resolutionImportOperative": "Importar Cláusulas Operativas", + "resolutionImportPreamble": "Importar Cláusulas Preambulares", + "resolutionImportPreview": "Pré-visualização: {count} cláusula(s) detectada(s)", + "resolutionImportTipsOperative1": "Cláusulas principais numeradas: 1. 2. 3. ou 1) 2) 3)", + "resolutionImportTipsOperative2": "Subcláusulas com letras: a) b) c) ou (a) (b) (c)", + "resolutionImportTipsOperative3": "Subcláusulas aninhadas com algarismos romanos: i) ii) iii)", + "resolutionImportTipsOperative4": "Mais aninhamento com letras duplas: aa) bb) cc)", + "resolutionImportTipsPreamble1": "Cada cláusula deve terminar com vírgula", + "resolutionImportTipsPreamble2": "Quebras de linha separam cláusulas individuais", + "resolutionImportTipsPreamble3": "As cláusulas são importadas na ordem inserida", + "resolutionImportTipsTitle": "Dicas para Melhores Resultados", + "resolutionIndent": "Recuar", + "resolutionMoveDown": "Mover para Baixo", + "resolutionMoveUp": "Mover para Cima", + "resolutionNoClausesYet": "Nenhuma cláusula ainda.", + "resolutionNoOperativeClauses": "Nenhuma cláusula operativa ainda.", + "resolutionNoPreambleClauses": "Nenhuma cláusula preambular ainda.", + "resolutionOperativeClauses": "Cláusulas Operativas", + "resolutionOperativePlaceholder": "Insira a cláusula operativa...", + "resolutionOutdent": "Diminuir recuo", + "resolutionPaper": "Documento de Resolução", + "resolutionPapers": "Documentos de Resolução", + "resolutionPreambleClauses": "Cláusulas Preambulares", + "resolutionPreamblePlaceholder": "Insira a cláusula preambular...", + "resolutionPreview": "Pré-visualização", + "resolutionRejected": "Resolução rejeitada", + "resolutionSentBack": "Resolução devolvida", + "resolutionShowPreview": "Mostrar Pré-visualização", + "resolutionSponsoringDelegations": "Delegações Patrocinadoras", + "resolutionSubClausePlaceholder": "Insira a subcláusula...", + "resolutionSubClauses": "Subcláusulas", + "resolutionUnknownPhrase": "Frase desconhecida", + "resolutions": "Resoluções", + "restoreContentFromSnapshot": "Restaurar conteúdo anterior às emendas", + "restoreContentFromSnapshotDescription": "Desfazer todas as emendas aplicadas e restaurar o conteúdo da resolução para a versão anterior ao início da fase de emendas. As emendas aplicadas serão redefinidas para pendentes.", + "revertDrWarning": "Reverter irá apagar o número do documento. O documento pode ser promovido novamente depois.", + "revertStatus": "Reverter Status", + "revertVotingWarning": "Reverter irá excluir todos os resultados de votação de cláusulas deste documento.", + "role": "Função", + "rollCall": "Chamada", + "rollCallError": "Membro do comitê não encontrado", + "rollCallSuccess": "Chamada concluída", + "rollCallVoting": "Votação Nominal", + "rollCollError": "Membro do comitê não encontrado", + "rollCollSuccess": "Chamada concluída", + "save": "Salvar", + "saveChanges": "Salvar Alterações", + "saveError": "Falha ao salvar", + "savingChanges": "Salvando...", + "searchCommitteeMembers": "Pesquisar membros do comitê", + "searchMembers": "Pesquisar membros...", + "searchUsers": "Pesquisar usuários...", + "selectAgendaItem": "Selecionar item da pauta...", + "selectAmendmentType": "Selecionar tipo de emenda", + "selectAuthorDelegation": "Selecionar Delegação Autora", + "selectCommitteeMember": "Selecionar membro do comitê...", + "selectConferenceMember": "Selecionar membro da conferência...", + "selectProposerDelegation": "Selecionar Delegação Proponente", + "selectTargetClause": "Selecionar Cláusula Alvo", + "selected": "Selecionado", + "sendBack": "Devolver", + "sentBack": "Devolvida", + "seoDescription": "MUNify CHASE é a ferramenta gratuita e de código aberto para gerenciamento de debates em conferências de Modelo das Nações Unidas. Gerencie listas de oradores, votações e resoluções digitalmente.", + "seoTitle": "MUNify CHASE – Gerenciamento de Debates para Modelo das Nações Unidas", + "setActiveAmendment": "Discutir", + "setActiveDr": "Definir como Ativo", + "setActiveDrHint": "Defina um projeto de resolução como ativo na visão da presidência para exibi-lo aqui.", + "setAllAbsent": "Marcar Todos como Ausentes", + "setAllPresent": "Marcar Todos como Presentes", + "setStatus": "Alterar status", + "setup": "Configurar", + "sha": "SHA", + "shareCode": "Código de Compartilhamento", + "shareCodes": "Códigos de Compartilhamento", + "short_sleek_snake_hint": "Comitê", + "showOfHandsVoting": "Votação por Levantamento de Mãos", + "simpleMajority": "Simples", + "simpleMajorityTooltip": "Votos necessários para maioria simples", + "speaker": "Orador", + "speakersList": "Lista Geral de Oradores", + "speakersListNamePlaceholder": "Novo nome...", + "speakersListNotFound": "Lista de oradores não encontrada", + "speakersListOvertime": "Tempo de fala esgotado!", + "spectator": "Espectador", + "sponsor": "Patrocinador", + "sponsorAdded": "Patrocinador adicionado", + "sponsorAmendment": "Patrocinar", + "sponsorCount": "{count} patrocinadores", + "sponsorPaper": "Patrocinar", + "sponsorRemoved": "Patrocinador removido", + "sponsorThreshold": "{current}/{needed} patrocinadores ({percent}% necessários)", + "sponsors": "Patrocinadores", + "startAmendmentPhase": "Iniciar Fase de Emendas", + "startEditing": "Começar a editar", + "startVote": "Iniciar Votação", + "startVotingPhase": "Iniciar Fase de Votação", + "startVotingPhaseDescription": "Passar para a fase de votação, onde cada parágrafo operativo será votado individualmente.", + "stateOfDebate": "Estado do Debate", + "statusReverted": "Status revertido", + "statusUpdated": "Status definido", + "strikethrough": "Tachado", + "submit": "Submeter", + "submitAmendment": "Submeter Emenda", + "submitImg": "Inserir imagem", + "submitPaper": "Submeter Documento", + "submitStateOfDebate": "Salvar status do debate", + "submitStatus": "Definir status", + "submitToChair": "Submeter à Presidência", + "submitted": "Submetido", + "submittedBy": "Submetido por", + "submittedPapers": "Documentos Submetidos", + "submittedPapersDescription": "Documentos submetidos por delegados, ordenados por número de patrocinadores", + "submittingNation": "Nação Proponente", + "supportDraftResolution": "Apoiar", + "supportReEvaluation": "Reavaliação de Apoio", + "supportReEvaluationClosed": "A reavaliação está encerrada", + "supportReEvaluationNotOpen": "A reavaliação de apoio não está aberta no momento", + "supportReEvaluationOpen": "A reavaliação está aberta — delegados podem alterar seu apoio agora", + "supporterCount": "{count} apoiadores", + "suspension": "Suspensão", + "targetPosition": "Posição Alvo", + "teamMember": "Membro da Equipe", + "teamOnly": "Somente Equipe", + "theme": "Tema", + "thresholdNotMet": "Limite de patrocinadores não atingido", + "timeOver": "Tempo de fala esgotado!", + "timer": "Cronômetro", + "toastAddError": "Não foi possível adicionar { targetName }", + "toastAddLoading": "Adicionando { targetName }...", + "toastAddSuccess": "{ targetName } adicionado(a)", + "toastCreateError": "Não foi possível criar {targetName}", + "toastCreateLoading": "Criando {targetName}...", + "toastCreateSuccess": "{targetName} criado(a)", + "toastDeleteError": "Não foi possível excluir {targetName}", + "toastDeleteLoading": "Excluindo {targetName}...", + "toastDeleteSuccess": "{targetName} excluído(a)", + "toastError": "Não foi possível carregar {targetName}", + "toastLoading": "Carregando {targetName}...", + "toastSuccess": "{targetName} carregado(a)", + "toastUpdateError": "Não foi possível atualizar {targetName}", + "toastUpdateLoading": "Atualizando {targetName}...", + "toastUpdateSuccess": "{targetName} atualizado(a)", + "topCandidate": "Principal Candidato", + "totalCountriesPresent": "Total de Países Presentes", + "twoThirdsMajority": "Dois terços", + "twoThirdsMajorityTooltip": "Votos necessários para maioria de dois terços", + "typeOfVoting": "Tipo de Votação", + "unActor": "Ator da ONU", + "unActors": "Atores da ONU", + "unassigned": "Não atribuído", + "underline": "Sublinhado", + "undo": "Desfazer", + "undoVote": "Desfazer Voto", + "unknown": "desconhecido", + "unrecognizedCodes": "Códigos não reconhecidos:", + "until": "até {time}", + "untitledPaper": "Documento Sem Título", + "updatedStateOfDebate": "Status do debate salvo", + "updatingStateOfDebate": "Salvando status do debate...", + "updatingStatus": "Definindo status...", + "updatingWhiteboard": "Publicando quadro branco...", + "upload": "Enviar", + "url": "URL", + "useFullVoting": "Usar Votação Completa", + "userAlreadyExists": "Usuário já existe nesta conferência: {email}", + "users": "Usuários", + "version": "Versão", + "viewPaper": "Ver Documento", + "voteOnParagraph": "Votar no CO {index}", + "voteOutcome": "Resultado da Votação", + "voteResult": "Resultado da Votação", + "voteTitel": "Título da Votação", + "voteTitleDescription": "O título da votação será visível para todos os participantes e é usado para identificação. Se deixado em branco, \"Votação\" será usado como padrão.", + "votesAbstain": "Abstenções", + "votesAgainst": "Votos Contra", + "votesFor": "Votos a Favor", + "voting": "Votação", + "votingControlsPlaceholder": "Os controles de votação estarão disponíveis em uma atualização futura.", + "votingPhase": "Fase de Votação", + "votingPhaseActive": "Fase de votação ativa", + "votingPhaseStarted": "Fase de votação iniciada", + "votingResults": "Resultados da Votação", + "waitingForAssignment": "Aguardando Atribuição", + "waitingForAssignmentDescription": "Você ainda não foi atribuído a um comitê. Por favor, aguarde um administrador atribuí-lo.", + "whiteboard": "Quadro Branco", + "whiteboardIsEmpty": "O quadro branco está vazio no momento...", + "whiteboardPlaceholder": "Comece a escrever aqui...", + "whiteboardUpdated": "Quadro branco publicado", + "withAbstentions": "Com Abstenções", + "withdrawAmendment": "Retirar", + "withdrawSponsorship": "Retirar Patrocínio", + "withdrawSupport": "Retirar Apoio", + "withoutAbstentions": "Sem Abstenções", + "workingPaper": "Documento de Trabalho", + "workingPapers": "Documentos de Trabalho", + "yes": "Sim", + "you": "Você", + "youCannotEditYourself": "Você não pode editar sua própria função", + "youreUp": "É a sua vez!" +} diff --git a/project.inlang/.gitignore b/project.inlang/.gitignore index 5e465967..04df3303 100644 --- a/project.inlang/.gitignore +++ b/project.inlang/.gitignore @@ -1 +1,19 @@ -cache \ No newline at end of file +# IF GIT SHOWED THAT THIS FILE CHANGED +# +# 1. RUN THE FOLLOWING COMMAND +# +# --- +# git rm --cached '**/*.inlang/.gitignore' +# --- +# +# 2. COMMIT THE CHANGE +# +# --- +# git commit -m "fix: remove tracked .gitignore from inlang project" +# --- +# +# Inlang handles the gitignore itself starting with version ^2.5. +# +# everything is ignored except settings.json +* +!settings.json \ No newline at end of file diff --git a/project.inlang/settings.json b/project.inlang/settings.json index 40922c58..c51d7514 100644 --- a/project.inlang/settings.json +++ b/project.inlang/settings.json @@ -1,7 +1,7 @@ { "$schema": "https://inlang.com/schema/project-settings", - "baseLocale": "de", - "locales": ["de", "en"], + "baseLocale": "en", + "locales": ["en", "de", "pt"], "modules": [ "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-empty-pattern@latest/dist/index.js", "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-missing-translation@latest/dist/index.js", diff --git a/schema.graphql b/schema.graphql index ff54687a..82162523 100644 --- a/schema.graphql +++ b/schema.graphql @@ -115,6 +115,7 @@ type Committee { conference(where: ConferenceWhereInputArgument): Conference conferenceId: ID! createdAt: DateTime! + currentOperativeClauseId: ID currentOperativeIndex: Int customPaperSupportThreshold: Int customSimpleMajority: Int @@ -197,6 +198,7 @@ input CommitteeWhereInputArgument { conference: ConferenceWhereInputArgument conferenceId: ID createdAt: DateTime + currentOperativeClauseId: ID currentOperativeIndex: Int customPaperSupportThreshold: Int customSimpleMajority: Int @@ -226,6 +228,7 @@ type Conference { members(limit: Int, offset: Int, where: ConferenceMemberWhereInputArgument): [ConferenceMember!]! pressWebsite: String representations(limit: Int, offset: Int, where: RepresentationWhereInputArgument): [Representation!]! + resolutionFeatureEnabled: Boolean! title: String! """ @@ -312,6 +315,7 @@ input ConferenceWhereInputArgument { members: ConferenceMemberWhereInputArgument pressWebsite: String representations: RepresentationWhereInputArgument + resolutionFeatureEnabled: Boolean title: String updatedAt: DateTime users: ConferenceUserWhereInputArgument @@ -436,8 +440,8 @@ type Mutation { startVotingPhase(paperId: ID!): ResolutionPaper submitPaper(paperId: ID!): ResolutionPaper updateComment(commentId: ID!, content: String!): ResolutionComment - updateCommittee(abbreviation: String, activeAgendaItemId: ID, activeAmendmentId: ID, activeDraftResolutionId: ID, allowDelegationsToAddThemselvesToSpeakersList: Boolean, amendmentSponsoringOpen: Boolean, amendmentSubmissionOpen: Boolean, clearActiveAmendment: Boolean, clearActiveDraftResolution: Boolean, currentOperativeIndex: Int, id: ID!, lastResolutionAdoptionDate: DateTime, maxDraftResolutions: Int, name: String, showWhiteboard: Boolean, stateOfDebate: String, status: CommitteeStatusEnum, statusHeadline: String, statusUntil: DateTime, supportReEvaluationOpen: Boolean, whiteboardContent: String): Committee - updateConference(hasModeratedCaucus: Boolean, id: ID!, pressWebsite: String, title: String): Conference + updateCommittee(abbreviation: String, activeAgendaItemId: ID, activeAmendmentId: ID, activeDraftResolutionId: ID, allowDelegationsToAddThemselvesToSpeakersList: Boolean, amendmentSponsoringOpen: Boolean, amendmentSubmissionOpen: Boolean, clearActiveAmendment: Boolean, clearActiveDraftResolution: Boolean, currentOperativeClauseId: String, currentOperativeIndex: Int, id: ID!, lastResolutionAdoptionDate: DateTime, maxDraftResolutions: Int, name: String, showWhiteboard: Boolean, stateOfDebate: String, status: CommitteeStatusEnum, statusHeadline: String, statusUntil: DateTime, supportReEvaluationOpen: Boolean, whiteboardContent: String): Committee + updateConference(hasModeratedCaucus: Boolean, id: ID!, pressWebsite: String, resolutionFeatureEnabled: Boolean, title: String): Conference updateConferenceUser(committeeMemberId: ID, conferenceMemberId: ID, conferenceUserType: ConferenceUserTypeEnum!, id: ID!): ConferenceUser updatePaperContent(content: JSON!, paperId: ID!): ResolutionPaper updatePaperTitle(paperId: ID!, title: String!): ResolutionPaper diff --git a/src/api/db/schema.ts b/src/api/db/schema.ts index c1f3ed62..aa32d1b0 100644 --- a/src/api/db/schema.ts +++ b/src/api/db/schema.ts @@ -42,7 +42,8 @@ export const conference = pgTable('conference', { ...defaultIdAndTimestamps, title: text().notNull(), pressWebsite: text(), - hasModeratedCaucus: boolean().notNull().default(false) + hasModeratedCaucus: boolean().notNull().default(false), + resolutionFeatureEnabled: boolean().notNull().default(true) }); export const committeeStatus = pgEnum('committee_status', [ @@ -78,6 +79,7 @@ export const committee = pgTable( maxDraftResolutions: smallint().notNull().default(3), activeDraftResolutionId: text().references((): AnyPgColumn => resolutionPaper.id), currentOperativeIndex: smallint(), + currentOperativeClauseId: text(), supportReEvaluationOpen: boolean().notNull().default(false), amendmentSubmissionOpen: boolean().notNull().default(true), amendmentSponsoringOpen: boolean().notNull().default(true), diff --git a/src/api/handlers/amendment.ts b/src/api/handlers/amendment.ts index 69884374..53af8257 100644 --- a/src/api/handlers/amendment.ts +++ b/src/api/handlers/amendment.ts @@ -3,7 +3,7 @@ import { abilityBuilder, enum_, schemaBuilder, pubsub as rumblePubsub } from '$a import { basics } from './basics'; import { isWhitelistedEmail } from '$api/services/isDMUNEmail'; import { assertFindFirstExists, assertFirstEntryExists } from '@m1212e/rumble'; -import { and, eq, count as drizzleCount, not, inArray } from 'drizzle-orm'; +import { and, eq, count as drizzleCount, not, inArray, gt, gte, sql } from 'drizzle-orm'; import { GraphQLError } from 'graphql'; import { assertCommitteeChairOrAdmin } from './resolutionPaper'; import { @@ -83,6 +83,52 @@ function validateAmendmentArgs( type Resolution = { committeeName: string; preamble: unknown[]; operative: unknown[] }; +/** + * Find the current index of a clause by its stable ID. + * Throws if the clause is not found (e.g. already deleted by a prior amendment). + */ +function findClauseIndex(operative: { id: string }[], clauseId: string): number { + const idx = operative.findIndex((c) => c.id === clauseId); + if (idx === -1) { + throw new GraphQLError( + `Clause "${clauseId}" not found in resolution — it may have been deleted by a prior amendment` + ); + } + return idx; +} + +/** + * Auto-adjust targetPosition on remaining PENDING/SUBMITTED ADD/ALTER_POSITION amendments + * after a structural change (deletion or insertion) shifts operative clause indices. + */ +async function adjustPendingPositions( + tx: Parameters[0]>[0], + paperId: string, + excludeAmendmentId: string, + direction: 'decrement' | 'increment', + thresholdIndex: number, + comparison: 'gt' | 'gte' +) { + const delta = direction === 'decrement' ? -1 : 1; + const cmp = + comparison === 'gt' + ? gt(schema.amendment.targetPosition, thresholdIndex) + : gte(schema.amendment.targetPosition, thresholdIndex); + + await tx + .update(schema.amendment) + .set({ targetPosition: sql`${schema.amendment.targetPosition} + ${delta}` }) + .where( + and( + eq(schema.amendment.paperId, paperId), + inArray(schema.amendment.status, ['PENDING', 'SUBMITTED']), + inArray(schema.amendment.type, ['ADD', 'ALTER_POSITION']), + not(eq(schema.amendment.id, excludeAmendmentId)), + cmp + ) + ); +} + async function applyAmendmentToResolution( tx: Parameters[0]>[0], amendment: typeof schema.amendment.$inferSelect, @@ -103,13 +149,8 @@ async function applyAmendmentToResolution( switch (amendment.type) { case 'DELETE': { - const idx = amendment.targetOperativeIndex!; - if (idx < 0 || idx >= resolution.operative.length) { - throw new GraphQLError('Target operative index out of range'); - } - if (resolution.operative[idx].id !== amendment.targetClauseId) { - throw new GraphQLError('Clause ID mismatch at target index'); - } + // Resolve current index from stable clause ID (not stored index) + const idx = findClauseIndex(resolution.operative, amendment.targetClauseId!); resolution.operative.splice(idx, 1); // Auto-withdraw other PENDING/SUBMITTED amendments targeting the deleted clause await tx @@ -123,6 +164,8 @@ async function applyAmendmentToResolution( not(eq(schema.amendment.id, amendment.id)) ) ); + // Adjust targetPosition on remaining ADD/ALTER_POSITION amendments + await adjustPendingPositions(tx, paper.id, amendment.id, 'decrement', idx, 'gt'); break; } case 'ADD': { @@ -132,13 +175,13 @@ async function applyAmendmentToResolution( } const insertAfter = amendment.targetPosition!; resolution.operative.splice(insertAfter + 1, 0, parsedClause.data); + // Adjust targetPosition on remaining ADD/ALTER_POSITION amendments + await adjustPendingPositions(tx, paper.id, amendment.id, 'increment', insertAfter, 'gte'); break; } case 'ALTER_TEXT': { - const idx = amendment.targetOperativeIndex!; - if (idx < 0 || idx >= resolution.operative.length) { - throw new GraphQLError('Target operative index out of range'); - } + // Resolve current index from stable clause ID + const idx = findClauseIndex(resolution.operative, amendment.targetClauseId!); const parsedClause = OperativeClauseSchema.safeParse(amendment.newContent); if (!parsedClause.success) { throw new GraphQLError('Invalid newContent for ALTER_TEXT amendment'); @@ -151,11 +194,9 @@ async function applyAmendmentToResolution( break; } case 'ALTER_POSITION': { - const sourceIdx = amendment.targetOperativeIndex!; + // Resolve current index from stable clause ID + const sourceIdx = findClauseIndex(resolution.operative, amendment.targetClauseId!); const destIdx = amendment.targetPosition!; - if (sourceIdx < 0 || sourceIdx >= resolution.operative.length) { - throw new GraphQLError('Source operative index out of range'); - } if (destIdx < 0 || destIdx > resolution.operative.length) { throw new GraphQLError('Destination index out of range'); } @@ -163,6 +204,11 @@ async function applyAmendmentToResolution( // After removing from source, the target index might shift const adjustedDest = destIdx > sourceIdx ? destIdx - 1 : destIdx; resolution.operative.splice(adjustedDest, 0, clause); + // Adjust other pending amendments' targetPosition for the structural shift + // First: source removal shifts indices down + await adjustPendingPositions(tx, paper.id, amendment.id, 'decrement', sourceIdx, 'gt'); + // Then: destination insertion shifts indices up + await adjustPendingPositions(tx, paper.id, amendment.id, 'increment', adjustedDest, 'gte'); break; } } @@ -233,6 +279,21 @@ schemaBuilder.mutationFields((t) => ({ // Validate type-specific args validateAmendmentArgs(args.type, args); + // If targetClauseId is provided, resolve and auto-correct the operative index + if (args.targetClauseId) { + const parsed = ResolutionSchema.safeParse(paper.content); + if (parsed.success) { + const actualIdx = parsed.data.operative.findIndex((c) => c.id === args.targetClauseId); + if (actualIdx === -1) { + throw new GraphQLError('Target clause no longer exists in the resolution'); + } + // Auto-correct stale index from client + if (args.targetOperativeIndex !== actualIdx) { + args.targetOperativeIndex = actualIdx; + } + } + } + // For DELETE and ALTER_TEXT, validate targetOperativeIndex >= currentOperativeIndex if ( (args.type === 'DELETE' || args.type === 'ALTER_TEXT') && @@ -244,21 +305,6 @@ schemaBuilder.mutationFields((t) => ({ throw new GraphQLError('Cannot amend a clause that has already been passed'); } - // Validate clauseId exists if provided - if ( - args.targetClauseId && - args.targetOperativeIndex !== undefined && - args.targetOperativeIndex !== null - ) { - const parsed = ResolutionSchema.safeParse(paper.content); - if (parsed.success) { - const clause = parsed.data.operative[args.targetOperativeIndex]; - if (!clause || clause.id !== args.targetClauseId) { - throw new GraphQLError('Clause ID does not match at the given index'); - } - } - } - // Validate newContent if provided if (args.newContent) { const parsedContent = OperativeClauseSchema.safeParse(args.newContent); @@ -276,10 +322,9 @@ schemaBuilder.mutationFields((t) => ({ inArray(schema.amendment.status, ['PENDING', 'SUBMITTED']) ]; - if (args.targetOperativeIndex !== undefined && args.targetOperativeIndex !== null) { - duplicateConditions.push( - eq(schema.amendment.targetOperativeIndex, args.targetOperativeIndex) - ); + // Use targetClauseId for duplicate detection (stable, not affected by index drift) + if (args.targetClauseId) { + duplicateConditions.push(eq(schema.amendment.targetClauseId, args.targetClauseId)); } const [{ count: duplicateCount }] = await db @@ -394,17 +439,17 @@ schemaBuilder.mutationFields((t) => ({ // Validate type-specific args validateAmendmentArgs(args.type, args); - // Validate clauseId exists if provided - if ( - args.targetClauseId && - args.targetOperativeIndex !== undefined && - args.targetOperativeIndex !== null - ) { + // If targetClauseId is provided, resolve and auto-correct the operative index + if (args.targetClauseId) { const parsed = ResolutionSchema.safeParse(paper.content); if (parsed.success) { - const clause = parsed.data.operative[args.targetOperativeIndex]; - if (!clause || clause.id !== args.targetClauseId) { - throw new GraphQLError('Clause ID does not match at the given index'); + const actualIdx = parsed.data.operative.findIndex((c) => c.id === args.targetClauseId); + if (actualIdx === -1) { + throw new GraphQLError('Target clause no longer exists in the resolution'); + } + // Auto-correct stale index from client + if (args.targetOperativeIndex !== actualIdx) { + args.targetOperativeIndex = actualIdx; } } } @@ -426,10 +471,9 @@ schemaBuilder.mutationFields((t) => ({ inArray(schema.amendment.status, ['PENDING', 'SUBMITTED']) ]; - if (args.targetOperativeIndex !== undefined && args.targetOperativeIndex !== null) { - duplicateConditions.push( - eq(schema.amendment.targetOperativeIndex, args.targetOperativeIndex) - ); + // Use targetClauseId for duplicate detection (stable, not affected by index drift) + if (args.targetClauseId) { + duplicateConditions.push(eq(schema.amendment.targetClauseId, args.targetClauseId)); } const [{ count: duplicateCount }] = await db @@ -714,17 +758,18 @@ schemaBuilder.mutationFields((t) => ({ // Re-validate with merged values validateAmendmentArgs(amendment.type, merged); - // Validate clauseId matches paper content if provided - if ( - merged.targetClauseId && - merged.targetOperativeIndex !== undefined && - merged.targetOperativeIndex !== null - ) { + // If targetClauseId is provided, resolve and auto-correct the operative index + if (merged.targetClauseId) { const parsed = ResolutionSchema.safeParse(paper.content); if (parsed.success) { - const clause = parsed.data.operative[merged.targetOperativeIndex]; - if (!clause || clause.id !== merged.targetClauseId) { - throw new GraphQLError('Clause ID does not match at the given index'); + const actualIdx = parsed.data.operative.findIndex((c) => c.id === merged.targetClauseId); + if (actualIdx === -1) { + throw new GraphQLError('Target clause no longer exists in the resolution'); + } + // Auto-correct stale index + if (merged.targetOperativeIndex !== actualIdx) { + merged.targetOperativeIndex = actualIdx; + args.targetOperativeIndex = actualIdx; } } } diff --git a/src/api/handlers/committee.ts b/src/api/handlers/committee.ts index 7d28dc41..4b29dd28 100644 --- a/src/api/handlers/committee.ts +++ b/src/api/handlers/committee.ts @@ -198,6 +198,7 @@ schemaBuilder.mutationFields((t) => { activeDraftResolutionId: t.arg.id(), clearActiveDraftResolution: t.arg.boolean(), currentOperativeIndex: t.arg.int(), + currentOperativeClauseId: t.arg.string(), supportReEvaluationOpen: t.arg.boolean(), amendmentSubmissionOpen: t.arg.boolean(), amendmentSponsoringOpen: t.arg.boolean(), @@ -251,6 +252,7 @@ schemaBuilder.mutationFields((t) => { ? null : (args.activeDraftResolutionId ?? undefined), currentOperativeIndex: args.currentOperativeIndex ?? undefined, + currentOperativeClauseId: args.currentOperativeClauseId ?? undefined, supportReEvaluationOpen, amendmentSubmissionOpen: args.amendmentSubmissionOpen ?? undefined, amendmentSponsoringOpen: args.amendmentSponsoringOpen ?? undefined, diff --git a/src/api/handlers/conference.ts b/src/api/handlers/conference.ts index 40d8d646..cb77e06a 100644 --- a/src/api/handlers/conference.ts +++ b/src/api/handlers/conference.ts @@ -79,7 +79,8 @@ schemaBuilder.mutationFields((t) => ({ id: t.arg.id({ required: true }), title: t.arg.string(), pressWebsite: t.arg.string(), - hasModeratedCaucus: t.arg.boolean() + hasModeratedCaucus: t.arg.boolean(), + resolutionFeatureEnabled: t.arg.boolean() }, resolve: async (query, root, args, ctx, info) => { await assertConferenceAdmin(ctx, args.id); @@ -89,7 +90,8 @@ schemaBuilder.mutationFields((t) => ({ .set({ title: args.title ?? undefined, pressWebsite: args.pressWebsite ?? undefined, - hasModeratedCaucus: args.hasModeratedCaucus ?? undefined + hasModeratedCaucus: args.hasModeratedCaucus ?? undefined, + resolutionFeatureEnabled: args.resolutionFeatureEnabled ?? undefined }) .where(eq(schema.conference.id, args.id)); diff --git a/src/api/handlers/resolutionPaper.ts b/src/api/handlers/resolutionPaper.ts index f44fd1a0..a1802bfc 100644 --- a/src/api/handlers/resolutionPaper.ts +++ b/src/api/handlers/resolutionPaper.ts @@ -620,9 +620,11 @@ schemaBuilder.mutationFields((t) => ({ }); // Reset currentOperativeIndex to 0 for voting navigation + const parsed = ResolutionSchema.safeParse(paper.content); + const firstClauseId = parsed.success ? (parsed.data.operative[0]?.id ?? null) : null; await tx .update(schema.committee) - .set({ currentOperativeIndex: 0 }) + .set({ currentOperativeIndex: 0, currentOperativeClauseId: firstClauseId }) .where(eq(schema.committee.id, paper.committeeId)); }); @@ -709,7 +711,8 @@ schemaBuilder.mutationFields((t) => ({ // Always clear activeDraftResolutionId and currentOperativeIndex const updateSet: Record = { activeDraftResolutionId: null, - currentOperativeIndex: null + currentOperativeIndex: null, + currentOperativeClauseId: null }; if (args.outcome === 'ADOPTED') { @@ -819,11 +822,14 @@ schemaBuilder.mutationFields((t) => ({ .findFirst({ where: { id: paper.committeeId } }) .then(assertFindFirstExists); if (!committee.activeDraftResolutionId) { + const parsed = ResolutionSchema.safeParse(paper.content); + const firstClauseId = parsed.success ? (parsed.data.operative[0]?.id ?? null) : null; await tx .update(schema.committee) .set({ activeDraftResolutionId: args.paperId, - currentOperativeIndex: 0 + currentOperativeIndex: 0, + currentOperativeClauseId: firstClauseId }) .where(eq(schema.committee.id, paper.committeeId)); } @@ -836,7 +842,7 @@ schemaBuilder.mutationFields((t) => ({ // Clear currentOperativeIndex on committee await tx .update(schema.committee) - .set({ currentOperativeIndex: null }) + .set({ currentOperativeIndex: null, currentOperativeClauseId: null }) .where(eq(schema.committee.id, paper.committeeId)); if (args.restoreSnapshot) { // Restore content from latest AMENDMENT_PHASE snapshot @@ -876,7 +882,8 @@ schemaBuilder.mutationFields((t) => ({ .update(schema.committee) .set({ activeDraftResolutionId: null, - currentOperativeIndex: null + currentOperativeIndex: null, + currentOperativeClauseId: null }) .where(eq(schema.committee.id, paper.committeeId)); } diff --git a/src/app.css b/src/app.css index 293b5513..9cd5ab03 100644 --- a/src/app.css +++ b/src/app.css @@ -2,6 +2,7 @@ @import '@deutschemodelunitednations/corporate-identity/css/shades/dmun'; @import 'tailwindcss'; +@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)); @import '@deutschemodelunitednations/munify-resolution-editor/tailwind.css'; @plugin '@tailwindcss/typography'; @plugin "daisyui"; diff --git a/src/hooks.server.ts b/src/hooks.server.ts index c4fed324..5abe1549 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,9 +1,32 @@ -import type { Handle } from '@sveltejs/kit'; +import { type Handle, redirect } from '@sveltejs/kit'; import { paraglideMiddleware } from '$lib/paraglide/server'; import { sequence } from '@sveltejs/kit/hooks'; import { OIDC } from '$api/services/OIDC'; +import { locales, baseLocale, cookieName, cookieMaxAge } from '$lib/paraglide/runtime'; -export const handle: Handle = sequence(OIDC.handle, ({ event, resolve }) => +const nonBaseLocales = locales.filter((l) => l !== baseLocale); + +/** Redirect locale-prefixed URLs to bare paths, setting the cookie instead. */ +const localeRedirect: Handle = ({ event, resolve }) => { + const { pathname } = event.url; + for (const locale of nonBaseLocales) { + if (pathname === `/${locale}` || pathname.startsWith(`/${locale}/`)) { + const bare = pathname.slice(`/${locale}`.length) || '/'; + const domain = event.url.hostname; + event.cookies.set(cookieName, locale, { + path: '/', + maxAge: cookieMaxAge, + domain, + httpOnly: false, + sameSite: 'lax' + }); + redirect(302, bare + event.url.search); + } + } + return resolve(event); +}; + +export const handle: Handle = sequence(OIDC.handle, localeRedirect, ({ event, resolve }) => paraglideMiddleware(event.request, ({ request: localizedRequest, locale }) => { event.request = localizedRequest; diff --git a/src/hooks.ts b/src/hooks.ts index f088616d..446fd89c 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1,6 +1,2 @@ -import type { Reroute } from '@sveltejs/kit'; -import { deLocalizeUrl } from '$lib/paraglide/runtime'; - -export const reroute: Reroute = (request) => { - return deLocalizeUrl(request.url).pathname; -}; +// Reroute hook removed — locale is now cookie-based, not URL-based. +// Locale-prefixed URLs are redirected by the server hook. diff --git a/src/lib/components/AbbreviationInfoBox.svelte b/src/lib/components/AbbreviationInfoBox.svelte index 95dbeafb..b4a07dd6 100644 --- a/src/lib/components/AbbreviationInfoBox.svelte +++ b/src/lib/components/AbbreviationInfoBox.svelte @@ -24,8 +24,10 @@ }); -
-
+
+
{#if abbreviation}
{abbreviation}
{/if} diff --git a/src/lib/components/IconInfoBox.svelte b/src/lib/components/IconInfoBox.svelte index 9d8c5d9e..93123f4e 100644 --- a/src/lib/components/IconInfoBox.svelte +++ b/src/lib/components/IconInfoBox.svelte @@ -71,11 +71,11 @@
-
+
{#if iconText}
{iconText}
{:else if faIcon} diff --git a/src/lib/components/LanguageSwitcher.svelte b/src/lib/components/LanguageSwitcher.svelte index 7f2dcdba..5677230a 100644 --- a/src/lib/components/LanguageSwitcher.svelte +++ b/src/lib/components/LanguageSwitcher.svelte @@ -1,44 +1,40 @@ - + + +

+ {m.language()} +

+
+ {#each locales as l} + + {/each} +
+
diff --git a/src/lib/utils/nationTranslationHelper.svelte.ts b/src/lib/utils/nationTranslationHelper.svelte.ts index 609b1d97..156c2ed2 100644 --- a/src/lib/utils/nationTranslationHelper.svelte.ts +++ b/src/lib/utils/nationTranslationHelper.svelte.ts @@ -251,6 +251,8 @@ function nationCodeToLocalName(code: string, locale = getLocale(), official = fa return 'deu'; case 'en': return 'eng'; + case 'pt': + return 'por'; default: return 'eng'; } diff --git a/src/lib/utils/paperNameGenerator.ts b/src/lib/utils/paperNameGenerator.ts index a0cc2613..16e4ab8c 100644 --- a/src/lib/utils/paperNameGenerator.ts +++ b/src/lib/utils/paperNameGenerator.ts @@ -34,6 +34,23 @@ const adverbs = { 'Enorm', 'Riesig', 'Fantastisch' + ], + pt: [ + 'Muito', + 'Super', + 'Ultra', + 'Bastante', + 'Totalmente', + 'Absolutamente', + 'Razoavelmente', + 'Realmente', + 'Extremamente', + 'Incrivelmente', + 'Notavelmente', + 'Excepcionalmente', + 'Tremendamente', + 'Imensamente', + 'Fantasticamente' ] }; @@ -79,6 +96,27 @@ const adjectives = { 'Edler', 'Würdevoller', 'Optimistischer' + ], + pt: [ + 'Feliz', + 'Calmo', + 'Entusiasmado', + 'Energético', + 'Esperançoso', + 'Contente', + 'Curioso', + 'Motivado', + 'Alegre', + 'Determinado', + 'Confiante', + 'Magnífico', + 'Grandioso', + 'Majestoso', + 'Esplêndido', + 'Glorioso', + 'Nobre', + 'Digno', + 'Otimista' ] }; @@ -99,7 +137,7 @@ function pick(arr: T[]): T { } export function generatePaperName(): string { - const locale = getLocale() as 'en' | 'de'; + const locale = getLocale() as 'en' | 'de' | 'pt'; const adverbList = adverbs[locale] ?? adverbs.en; const adjectiveList = adjectives[locale] ?? adjectives.en; diff --git a/src/routes/(pages)/Card.svelte b/src/routes/(pages)/Card.svelte index 906315da..ab2e9aae 100644 --- a/src/routes/(pages)/Card.svelte +++ b/src/routes/(pages)/Card.svelte @@ -12,9 +12,11 @@ let { src, alt, header, text, comingSoonRibbon = false }: Props = $props(); -
+
- +

diff --git a/src/routes/(pages)/ContactSection.svelte b/src/routes/(pages)/ContactSection.svelte index 01bd4060..536f8050 100644 --- a/src/routes/(pages)/ContactSection.svelte +++ b/src/routes/(pages)/ContactSection.svelte @@ -8,13 +8,11 @@ class="flex flex-col items-center gap-6 mx-4 p-4 py-20 lg:p-20 bg-base-100 rounded-box border-base-300 border shadow-lg" >

{m.homeContactTitle()}

-

+

{m.homeContactText()}

diff --git a/src/routes/(pages)/LandingHero.svelte b/src/routes/(pages)/LandingHero.svelte index 9aa77f11..b7cd3737 100644 --- a/src/routes/(pages)/LandingHero.svelte +++ b/src/routes/(pages)/LandingHero.svelte @@ -39,7 +39,7 @@ class="mb-4 text-center font-serif text-5xl leading-tight font-bold lg:text-right lg:text-6xl" > MUN @@ -47,9 +47,7 @@
-

+

{m.homeHeroText()}

diff --git a/src/routes/(pages)/TextSection.svelte b/src/routes/(pages)/TextSection.svelte index cc590d75..cbbcb718 100644 --- a/src/routes/(pages)/TextSection.svelte +++ b/src/routes/(pages)/TextSection.svelte @@ -11,14 +11,12 @@

{title}

-

+

{text}

{#if children} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 80600bf5..478c9e1e 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,7 +1,5 @@ @@ -1640,9 +1667,11 @@ onclick={async () => { if (!committee) return; try { + const newIndex = currentOpIndex - 1; await UpdateCommitteeMutation.mutate({ id: committee.id, - currentOperativeIndex: currentOpIndex - 1 + currentOperativeIndex: newIndex, + currentOperativeClauseId: operativeClauses[newIndex]?.id ?? null }); } catch { toast.error(m.saveError()); diff --git a/src/routes/app/[conferenceId]/[committeeId]/(presentation)/PresentationResolutionPreview.svelte b/src/routes/app/[conferenceId]/[committeeId]/(presentation)/PresentationResolutionPreview.svelte index 27301b12..d0fe3360 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(presentation)/PresentationResolutionPreview.svelte +++ b/src/routes/app/[conferenceId]/[committeeId]/(presentation)/PresentationResolutionPreview.svelte @@ -19,6 +19,7 @@ name: string; resolutionHeadline?: string | null; currentOperativeIndex?: number | null; + currentOperativeClauseId?: string | null; activeAmendment?: { id: string; type: string; @@ -103,7 +104,14 @@ let dr = $derived(committee.activeDraftResolution); let activeAmendment = $derived(committee.activeAmendment); - let currentOpIndex = $derived(committee.currentOperativeIndex ?? 0); + let currentOpIndex = $derived.by(() => { + const clauseId = committee.currentOperativeClauseId; + if (clauseId && resolution) { + const idx = resolution.operative.findIndex((c) => c.id === clauseId); + if (idx !== -1) return idx; + } + return committee.currentOperativeIndex ?? 0; + }); let resolution = $derived.by(() => { if (!dr?.content) return null; @@ -182,17 +190,34 @@ } let pendingAmendmentCounts = $derived.by(() => { - if (!dr?.amendments) return new SvelteMap(); + if (!dr?.amendments || !resolution) return new SvelteMap(); const counts = new SvelteMap(); for (const a of dr.amendments) { if (a.status !== 'SUBMITTED') continue; if (a.type !== 'ALTER_TEXT' && a.type !== 'DELETE') continue; - if (a.targetOperativeIndex == null) continue; - counts.set(a.targetOperativeIndex, (counts.get(a.targetOperativeIndex) ?? 0) + 1); + let idx: number | null = null; + if (a.targetClauseId) { + const found = resolution.operative.findIndex((c) => c.id === a.targetClauseId); + if (found !== -1) idx = found; + } else if (a.targetOperativeIndex != null) { + idx = a.targetOperativeIndex; + } + if (idx == null) continue; + counts.set(idx, (counts.get(idx) ?? 0) + 1); } return counts; }); + // Resolve active amendment's target index from stable clause ID + let resolvedActiveAmendIdx = $derived.by(() => { + if (!activeAmendment || !resolution) return -1; + if (activeAmendment.targetClauseId) { + const idx = resolution.operative.findIndex((c) => c.id === activeAmendment.targetClauseId); + if (idx !== -1) return idx; + } + return activeAmendment.targetOperativeIndex ?? -1; + }); + function getProposerName( proposer: | { @@ -246,12 +271,12 @@ {/if}
- {#if activeAmendment.type === 'DELETE' && activeAmendment.targetOperativeIndex != null} + {#if activeAmendment.type === 'DELETE' && resolvedActiveAmendIdx >= 0} - {@const targetClause = resolution.operative[activeAmendment.targetOperativeIndex]} + {@const targetClause = resolution.operative[resolvedActiveAmendIdx]}
{m.operativeClausePresentation()} - {activeAmendment.targetOperativeIndex + 1} + {resolvedActiveAmendIdx + 1}
{#if targetClause}
{/if} - {:else if activeAmendment.type === 'ALTER_TEXT' && activeAmendment.targetOperativeIndex != null} + {:else if activeAmendment.type === 'ALTER_TEXT' && resolvedActiveAmendIdx >= 0} - {@const targetClause = resolution.operative[activeAmendment.targetOperativeIndex]} + {@const targetClause = resolution.operative[resolvedActiveAmendIdx]}
{m.operativeClausePresentation()} - {activeAmendment.targetOperativeIndex + 1} + {resolvedActiveAmendIdx + 1}
@@ -321,9 +346,9 @@
{/if}
- {:else if activeAmendment.type === 'ALTER_POSITION' && activeAmendment.targetOperativeIndex != null} + {:else if activeAmendment.type === 'ALTER_POSITION' && resolvedActiveAmendIdx >= 0} - {@const targetClause = resolution.operative[activeAmendment.targetOperativeIndex]} + {@const targetClause = resolution.operative[resolvedActiveAmendIdx]}
{#if targetClause}
diff --git a/src/routes/app/[conferenceId]/[committeeId]/(presentation)/committeeSubscription.ts b/src/routes/app/[conferenceId]/[committeeId]/(presentation)/committeeSubscription.ts index 02af4463..5544689f 100644 --- a/src/routes/app/[conferenceId]/[committeeId]/(presentation)/committeeSubscription.ts +++ b/src/routes/app/[conferenceId]/[committeeId]/(presentation)/committeeSubscription.ts @@ -59,6 +59,7 @@ export const PresentationSubscription = graphql(` } activeDraftResolutionId currentOperativeIndex + currentOperativeClauseId activeAmendmentId activeAmendment { id @@ -150,6 +151,7 @@ export const PresentationSubscription = graphql(` } conference { title + resolutionFeatureEnabled uniqueConferenceMembers { id representation { diff --git a/src/routes/app/[conferenceId]/mission-control/config/+page.ts b/src/routes/app/[conferenceId]/mission-control/config/+page.ts index fa7eefc7..eadafe89 100644 --- a/src/routes/app/[conferenceId]/mission-control/config/+page.ts +++ b/src/routes/app/[conferenceId]/mission-control/config/+page.ts @@ -9,6 +9,7 @@ export const _houdini_load = graphql(` title pressWebsite hasModeratedCaucus + resolutionFeatureEnabled users { id userEmail diff --git a/src/routes/app/[conferenceId]/mission-control/config/GeneralTab.svelte b/src/routes/app/[conferenceId]/mission-control/config/GeneralTab.svelte index 6f640dd5..23a2fe24 100644 --- a/src/routes/app/[conferenceId]/mission-control/config/GeneralTab.svelte +++ b/src/routes/app/[conferenceId]/mission-control/config/GeneralTab.svelte @@ -12,6 +12,7 @@ title: string; pressWebsite: string | null; hasModeratedCaucus: boolean; + resolutionFeatureEnabled: boolean; }; } @@ -20,12 +21,14 @@ let title = $state(''); let pressWebsite = $state(''); let hasModeratedCaucus = $state(false); + let resolutionFeatureEnabled = $state(true); let isSaving = $state(false); $effect(() => { title = conference.title; pressWebsite = conference.pressWebsite ?? ''; hasModeratedCaucus = conference.hasModeratedCaucus; + resolutionFeatureEnabled = conference.resolutionFeatureEnabled; }); const UpdateConferenceMutation = graphql(` @@ -34,17 +37,20 @@ $title: String $pressWebsite: String $hasModeratedCaucus: Boolean + $resolutionFeatureEnabled: Boolean ) { updateConference( id: $id title: $title pressWebsite: $pressWebsite hasModeratedCaucus: $hasModeratedCaucus + resolutionFeatureEnabled: $resolutionFeatureEnabled ) { id title pressWebsite hasModeratedCaucus + resolutionFeatureEnabled } } `); @@ -57,7 +63,8 @@ id: conference.id, title, pressWebsite: pressWebsite || null, - hasModeratedCaucus + hasModeratedCaucus, + resolutionFeatureEnabled }), promiseToastStrings(m.configuration(), 'update') ); @@ -111,6 +118,21 @@
+
+ +
+
{/if} diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/+layout.ts b/src/routes/app/[conferenceId]/participant/[committeeId]/+layout.ts index 2760a159..ddf19dc5 100644 --- a/src/routes/app/[conferenceId]/participant/[committeeId]/+layout.ts +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/+layout.ts @@ -12,6 +12,7 @@ export const _houdini_load = graphql(` activeDraftResolutionId conference { title + resolutionFeatureEnabled } activeAgendaItem { id diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/committeeSubscription.ts b/src/routes/app/[conferenceId]/participant/[committeeId]/committeeSubscription.ts index 632aa0bd..ef928ddc 100644 --- a/src/routes/app/[conferenceId]/participant/[committeeId]/committeeSubscription.ts +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/committeeSubscription.ts @@ -22,6 +22,7 @@ export const ParticipantCommitteeSubscription = graphql(` twoThirdsMajority paperSupportThreshold currentOperativeIndex + currentOperativeClauseId activeAmendmentId activeAgendaItem { id diff --git a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte index fa64a6f5..f048a799 100644 --- a/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte +++ b/src/routes/app/[conferenceId]/participant/[committeeId]/papers/[paperId]/+page.svelte @@ -592,7 +592,14 @@ let committeeSubscriptionData = $derived( $ParticipantCommitteeSubscription.data?.findFirstCommittee ); - let currentOpIndex = $derived(committeeSubscriptionData?.currentOperativeIndex ?? 0); + let currentOpIndex = $derived.by(() => { + const clauseId = committeeSubscriptionData?.currentOperativeClauseId; + if (clauseId && resolution) { + const idx = resolution.operative.findIndex((c: { id: string }) => c.id === clauseId); + if (idx !== -1) return idx; + } + return committeeSubscriptionData?.currentOperativeIndex ?? 0; + }); let activeAmendmentId = $derived(committeeSubscriptionData?.activeAmendmentId ?? null); let isActiveDr = $derived(paper?.id === committee?.activeDraftResolutionId); @@ -1301,10 +1308,15 @@ {getAmendmentStatusLabel(amendment.status)} - {#if amendment.targetOperativeIndex != null} - - OP {amendment.targetOperativeIndex + 1} - + {#if amendment.targetClauseId || amendment.targetOperativeIndex != null} + {@const resolvedOpIdx = amendment.targetClauseId + ? operativeClauses.findIndex((c) => c.id === amendment.targetClauseId) + : (amendment.targetOperativeIndex ?? -1)} + {#if resolvedOpIdx >= 0} + + OP {resolvedOpIdx + 1} + + {/if} {/if} {#if isActive} {m.activeAmendment()} @@ -1343,10 +1355,15 @@ {amendment.documentNumber ?? getAmendmentTypeLabel(amendment.type)} - {#if amendment.targetOperativeIndex != null} - - OP {amendment.targetOperativeIndex + 1} - + {#if amendment.targetClauseId || amendment.targetOperativeIndex != null} + {@const resolvedOpIdx = amendment.targetClauseId + ? operativeClauses.findIndex((c) => c.id === amendment.targetClauseId) + : (amendment.targetOperativeIndex ?? -1)} + {#if resolvedOpIdx >= 0} + + OP {resolvedOpIdx + 1} + + {/if} {/if} {#if isActive} {m.activeAmendment()} @@ -1391,10 +1408,15 @@ {amendment.documentNumber ?? getAmendmentTypeLabel(amendment.type)} - {#if amendment.targetOperativeIndex != null} - - OP {amendment.targetOperativeIndex + 1} - + {#if amendment.targetClauseId || amendment.targetOperativeIndex != null} + {@const resolvedOpIdx = amendment.targetClauseId + ? operativeClauses.findIndex((c) => c.id === amendment.targetClauseId) + : (amendment.targetOperativeIndex ?? -1)} + {#if resolvedOpIdx >= 0} + + OP {resolvedOpIdx + 1} + + {/if} {/if} {#if amendment.proposer?.representation}
diff --git a/vite.config.ts b/vite.config.ts index 14ac629d..55de23af 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -54,7 +54,7 @@ export default defineConfig({ paraglideVitePlugin({ project: './project.inlang', outdir: './src/lib/paraglide', - strategy: ['url', 'baseLocale'] + strategy: ['cookie', 'preferredLanguage', 'baseLocale'] }), houdini(), sveltekit() From 69c2ecb8179b4b53785899a877b77fb2af0b3d7d Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Fri, 3 Apr 2026 17:34:02 +0200 Subject: [PATCH 81/89] chore: ignore kysely and picomatch CVEs in Trivy kysely (CVE-2026-32763, CVE-2026-33468): transitive dep of @inlang/sdk for internal SQLite ops, not used in app code. picomatch (CVE-2026-33671): build-time only dep of rollup plugins. Co-Authored-By: Claude Opus 4.6 (1M context) --- .trivyignore | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.trivyignore b/.trivyignore index 983589fc..e516dde3 100644 --- a/.trivyignore +++ b/.trivyignore @@ -124,4 +124,12 @@ CVE-2026-22774 CVE-2026-22775 CVE-2026-31802 CVE-2026-25679 -CVE-2026-27142 \ No newline at end of file +CVE-2026-27142 + +# kysely: SQL injection via JSON path keys / backslash escaping in sql.lit() +# Transitive dep of @inlang/sdk (i18n tooling), not used in app code. Uses internal SQLite only. +CVE-2026-32763 +CVE-2026-33468 + +# picomatch: ReDoS via crafted extglob patterns - transitive dep of rollup/micromatch, build-time only +CVE-2026-33671 \ No newline at end of file From 92b142c289f1db5ffeb297e1b69e111a763b09be Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Fri, 3 Apr 2026 19:09:08 +0200 Subject: [PATCH 82/89] fix: filter empty strings from admin whitelist env vars When ADMIN_DOMAIN_WHITELIST or ADMIN_EMAIL_WHITELIST is empty, split(',') produces [''] which could interfere with matching. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/api/services/isDMUNEmail.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/services/isDMUNEmail.ts b/src/api/services/isDMUNEmail.ts index f8440232..93ce35d8 100644 --- a/src/api/services/isDMUNEmail.ts +++ b/src/api/services/isDMUNEmail.ts @@ -1,8 +1,8 @@ import { configPrivate } from '$config/private'; export function isWhitelistedEmail(email: string) { - const whitelistEmails = configPrivate.ADMIN_EMAIL_WHITELIST.split(','); - const whitelistDomains = configPrivate.ADMIN_DOMAIN_WHITELIST.split(','); + const whitelistEmails = configPrivate.ADMIN_EMAIL_WHITELIST.split(',').filter(Boolean); + const whitelistDomains = configPrivate.ADMIN_DOMAIN_WHITELIST.split(',').filter(Boolean); const domain = email.split('@')[1]; return whitelistEmails.includes(email) || whitelistDomains.includes(domain); From 21355dec86b98fa62f04301b2a44af86157e23d6 Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Fri, 3 Apr 2026 19:35:17 +0200 Subject: [PATCH 83/89] feat: add conference deletion for global admins with confirmation modal Adds a deleteConference mutation (global admin only) and an isGlobalAdmin query. The launcher page shows a "Manage Conferences" toggle at the bottom for whitelisted admins, listing all conferences with delete buttons. A modal requires typing the exact conference name to confirm deletion. Co-Authored-By: Claude Opus 4.6 (1M context) --- messages/de.json | 4 ++ messages/en.json | 4 ++ schema.graphql | 2 + src/api/handlers/conference.ts | 27 +++++++ src/api/handlers/user.ts | 8 +++ .../components/DeleteConferenceModal.svelte | 71 +++++++++++++++++++ src/routes/app/(launcher)/+page.svelte | 51 +++++++++++++ src/routes/app/(launcher)/+page.ts | 5 ++ 8 files changed, 172 insertions(+) create mode 100644 src/lib/components/DeleteConferenceModal.svelte diff --git a/messages/de.json b/messages/de.json index 1e0e4890..304f7022 100644 --- a/messages/de.json +++ b/messages/de.json @@ -173,6 +173,9 @@ "deleteClausePresentation": "Absatz streichen", "deleteCode": "Code löschen", "deleteComment": "Löschen", + "deleteConference": "Konferenz löschen", + "deleteConferenceConfirmation": "Geben Sie den Konferenznamen ein, um das Löschen zu bestätigen:", + "deleteConferenceWarning": "Diese Aktion ist unwiderruflich. Alle Daten dieser Konferenz werden dauerhaft gelöscht.", "deletePaper": "Papier löschen", "deleteRepresentation": "Delegation entfernen", "displayRegionalGroups": "Regionalgruppenanzeige", @@ -269,6 +272,7 @@ "majorities": "Mehrheiten", "majoritySettings": "Mehrheitseinstellungen", "majoritySettingsDescriptions": "Die Einstellung der Mehrheitsverhältnisse dient der richtigen Darstellung, ob eine Mehrheit erreicht wurde.", + "manageConferences": "Konferenzen verwalten", "maroon_bland_ray_renew": "Gremien-Abkürzung", "matching": "übereinstimmend", "maxDraftResolutions": "Max. Resolutionsentwürfe", diff --git a/messages/en.json b/messages/en.json index cf756585..d4c7011a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -173,6 +173,9 @@ "deleteClausePresentation": "Delete Clause", "deleteCode": "Delete Code", "deleteComment": "Delete", + "deleteConference": "Delete Conference", + "deleteConferenceConfirmation": "Type the conference name to confirm deletion:", + "deleteConferenceWarning": "This action is irreversible. All data associated with this conference will be permanently deleted.", "deletePaper": "Delete Paper", "deleteRepresentation": "Remove Delegation", "displayRegionalGroups": "Display Regional Blocs", @@ -269,6 +272,7 @@ "majorities": "Majorities", "majoritySettings": "Majority Settings", "majoritySettingsDescriptions": "Majority settings help visualize whether a motion has passed.", + "manageConferences": "Manage Conferences", "maroon_bland_ray_renew": "Committee abbreviation", "matching": "matching", "maxDraftResolutions": "Max Draft Resolutions", diff --git a/schema.graphql b/schema.graphql index 82162523..6c1a7c7b 100644 --- a/schema.graphql +++ b/schema.graphql @@ -414,6 +414,7 @@ type Mutation { deleteComment(commentId: ID!): Boolean deleteCommittee(id: ID!): Boolean deleteCommitteeMember(id: ID!): Boolean + deleteConference(id: ID!): Boolean deleteConferenceMember(id: ID!): Boolean deleteConferenceUser(id: ID!): Boolean deleteRepresentation(id: ID!): Boolean @@ -654,6 +655,7 @@ type Query { findManySpeakerOnList(limit: Int, offset: Int, where: SpeakerOnListWhereInputArgument): [SpeakerOnList!]! findManySpeakersList(limit: Int, offset: Int, where: SpeakersListWhereInputArgument): [SpeakersList!]! findManyUser(limit: Int, offset: Int, where: UserWhereInputArgument): [User!]! + isGlobalAdmin: Boolean serverTime: DateTime! } diff --git a/src/api/handlers/conference.ts b/src/api/handlers/conference.ts index cb77e06a..2c2ee340 100644 --- a/src/api/handlers/conference.ts +++ b/src/api/handlers/conference.ts @@ -12,6 +12,7 @@ import { ConferenceMemberRef, ConferenceMemberWhereInput } from './conferenceMem import { assertConferenceAdmin } from './conferenceUser'; import { eq } from 'drizzle-orm'; import { assertFindFirstExists } from '@m1212e/rumble'; +import { GraphQLError } from 'graphql'; abilityBuilder.conference.allow(['read', 'update']).when(({ mustBeLoggedIn }) => { const user = mustBeLoggedIn(); @@ -112,4 +113,30 @@ schemaBuilder.mutationFields((t) => ({ }) })); +schemaBuilder.mutationFields((t) => ({ + deleteConference: t.field({ + type: 'Boolean', + args: { + id: t.arg.id({ required: true }) + }, + resolve: async (root, args, ctx) => { + if (!ctx.oidc?.user?.email || !isWhitelistedEmail(ctx.oidc.user.email)) { + throw new GraphQLError('Only global admins can delete conferences'); + } + + const conf = await db.query.conference.findFirst({ + where: { id: args.id } + }); + + if (!conf) { + throw new GraphQLError('Conference not found'); + } + + await db.delete(schema.conference).where(eq(schema.conference.id, args.id)); + + return true; + } + }) +})); + export const ConferenceRef = ref; diff --git a/src/api/handlers/user.ts b/src/api/handlers/user.ts index 337767bd..4ce7ccf5 100644 --- a/src/api/handlers/user.ts +++ b/src/api/handlers/user.ts @@ -22,3 +22,11 @@ abilityBuilder.user.allow('read'); // return 'allow'; // } // }); + +schemaBuilder.queryFields((t) => ({ + isGlobalAdmin: t.boolean({ + resolve: (root, args, ctx) => { + return !!ctx.oidc?.user?.email && isWhitelistedEmail(ctx.oidc.user.email); + } + }) +})); diff --git a/src/lib/components/DeleteConferenceModal.svelte b/src/lib/components/DeleteConferenceModal.svelte new file mode 100644 index 00000000..b29c1e99 --- /dev/null +++ b/src/lib/components/DeleteConferenceModal.svelte @@ -0,0 +1,71 @@ + + + +

{m.deleteConference()}

+

{m.deleteConferenceWarning()}

+

{m.deleteConferenceConfirmation()}

+

{conferenceName}

+ + +
diff --git a/src/routes/app/(launcher)/+page.svelte b/src/routes/app/(launcher)/+page.svelte index 88c2ee8d..eb1e0456 100644 --- a/src/routes/app/(launcher)/+page.svelte +++ b/src/routes/app/(launcher)/+page.svelte @@ -1,6 +1,7 @@