From ff274f0c6cb7ce94653c587290adaba5e0f14d6a Mon Sep 17 00:00:00 2001 From: Muhammad Ahsan Farooq Date: Sun, 11 Jan 2026 01:43:16 +0000 Subject: [PATCH 01/31] Refactor conference supervisor seeding to use supervisedDelegationMembers and improve data handling --- prisma/seed/dev/conferenceSupervisor.ts | 4 +- prisma/seed/dev/seed.ts | 1078 +++++++++++------------ 2 files changed, 538 insertions(+), 544 deletions(-) diff --git a/prisma/seed/dev/conferenceSupervisor.ts b/prisma/seed/dev/conferenceSupervisor.ts index fbb648f6..a61b37da 100644 --- a/prisma/seed/dev/conferenceSupervisor.ts +++ b/prisma/seed/dev/conferenceSupervisor.ts @@ -4,14 +4,14 @@ import { faker } from '@faker-js/faker'; export function makeSeedConferenceSupervisor( options: Pick & Partial<{ - delegations: { connect: { id: string }[] }; - postAssignmentDelegeationMembers: { connect: { id: string }[] }; + supervisedDelegationMembers: { connect: { id: string }[] }; }> ): ConferenceSupervisor { return { ...options, id: faker.database.mongodbObjectId(), plansOwnAttendenceAtConference: faker.datatype.boolean(), + connectionCode: faker.string.numeric(6), createdAt: faker.date.past(), updatedAt: faker.date.past() }; diff --git a/prisma/seed/dev/seed.ts b/prisma/seed/dev/seed.ts index b0930dff..513e5777 100644 --- a/prisma/seed/dev/seed.ts +++ b/prisma/seed/dev/seed.ts @@ -24,562 +24,556 @@ faker.seed(123); const _db = new PrismaClient(); -await _db.$transaction(async (db) => { - // creating default data - const { nations } = await createDefaultData(db); - - const users: User[] = []; - for (let i = 0; i < 1000; i++) { - users.push(makeSeedUser()); - } - - function takeXUsers(x: number) { - return users.splice(0, x); - } - - await Promise.all( - users.map(async (user) => - db.user.upsert({ - where: { - id: user.id - }, - update: user, - create: user - }) - ) - ); - - await Promise.all( - [ - makeSeedConference({ state: 'PRE' }), - makeSeedConference({ state: 'PARTICIPANT_REGISTRATION' }), - makeSeedConference({ state: 'PREPARATION' }), - makeSeedConference({ state: 'ACTIVE' }), - makeSeedConference({ state: 'POST' }) - ].map(async (conference) => { - await db.conference.upsert({ - where: { - id: conference.id - }, - update: conference, - create: conference - }); - - const conferenceIsInAssignedState = !( - [ConferenceState.PRE, ConferenceState.PARTICIPANT_REGISTRATION] as ConferenceState[] - ).includes(conference.state); - - const committees = [ - makeSeedCommittee({ - conferenceId: conference.id, - nations: { - connect: faker.helpers - .arrayElements(nations, { min: 6, max: 36 }) - .map((nation) => ({ alpha3Code: nation.alpha3Code })) - } - }), - makeSeedCommittee({ - conferenceId: conference.id, - nations: { - connect: faker.helpers - .arrayElements(nations, { min: 6, max: 36 }) - .map((nation) => ({ alpha3Code: nation.alpha3Code })) - } - }), - makeSeedCommittee({ - conferenceId: conference.id, - nations: { - connect: faker.helpers - .arrayElements(nations, { min: 6, max: 36 }) - .map((nation) => ({ alpha3Code: nation.alpha3Code })) - } - }), - makeSeedCommittee({ - conferenceId: conference.id, - nations: { - connect: faker.helpers - .arrayElements(nations, { min: 6, max: 36 }) - .map((nation) => ({ alpha3Code: nation.alpha3Code })) - } - }), - makeSeedCommittee({ - conferenceId: conference.id, - nations: { - connect: faker.helpers - .arrayElements(nations, { min: 6, max: 36 }) - .map((nation) => ({ alpha3Code: nation.alpha3Code })) - } - }), - makeSeedCommittee({ - conferenceId: conference.id, - nations: { - connect: faker.helpers - .arrayElements(nations, { min: 6, max: 36 }) - .map((nation) => ({ alpha3Code: nation.alpha3Code })) - } - }), - makeSeedCommittee({ - conferenceId: conference.id, - nations: { - connect: faker.helpers - .arrayElements(nations, { min: 6, max: 36 }) - .map((nation) => ({ alpha3Code: nation.alpha3Code })) - } - }), - makeSeedCommittee({ - conferenceId: conference.id, - nations: { - connect: faker.helpers - .arrayElements(nations, { min: 6, max: 36 }) - .map((nation) => ({ alpha3Code: nation.alpha3Code })) - } - }), - makeSeedCommittee({ - conferenceId: conference.id, - nations: { - connect: faker.helpers - .arrayElements(nations, { min: 6, max: 36 }) - .map((nation) => ({ alpha3Code: nation.alpha3Code })) - } - }), - makeSeedCommittee({ - conferenceId: conference.id, - nations: { - connect: faker.helpers - .arrayElements(nations, { min: 6, max: 36 }) - .map((nation) => ({ alpha3Code: nation.alpha3Code })) - } +await _db.$transaction( + async (db) => { + // creating default data + const { nations } = await createDefaultData(db); + + const users: User[] = []; + for (let i = 0; i < 1000; i++) { + users.push(makeSeedUser()); + } + + function takeXUsers(x: number) { + return users.splice(0, x); + } + + await Promise.all( + users.map(async (user) => + db.user.upsert({ + where: { + id: user.id + }, + update: user, + create: user }) - ]; - - const dbcommittees = await Promise.all( - committees.map((committee) => { - return db.committee.upsert({ - where: { - id: committee.id - }, - update: committee, - create: committee, - include: { - nations: true + ) + ); + + await Promise.all( + [ + makeSeedConference({ state: 'PRE' }), + makeSeedConference({ state: 'PARTICIPANT_REGISTRATION' }), + makeSeedConference({ state: 'PREPARATION' }), + makeSeedConference({ state: 'ACTIVE' }), + makeSeedConference({ state: 'POST' }) + ].map(async (conference) => { + await db.conference.upsert({ + where: { + id: conference.id + }, + update: conference, + create: conference + }); + + const conferenceIsInAssignedState = !( + [ConferenceState.PRE, ConferenceState.PARTICIPANT_REGISTRATION] as ConferenceState[] + ).includes(conference.state); + + const committees = [ + makeSeedCommittee({ + conferenceId: conference.id, + nations: { + connect: faker.helpers + .arrayElements(nations, { min: 6, max: 36 }) + .map((nation) => ({ alpha3Code: nation.alpha3Code })) } - }); - }) - ); - - const allNationsRepresentedInCommittees = dbcommittees.flatMap((c) => c.nations); - - const nonStateActors = [ - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }), - makeSeedNSA({ conferenceId: conference.id }) - ]; - - await Promise.all( - nonStateActors.map(async (nonStateActor) => { - await db.nonStateActor.upsert({ - where: { - id: nonStateActor.id - }, - update: nonStateActor, - create: nonStateActor - }); - }) - ); - - const customConferenceRoles = [ - makeSeedCustomConferenceRole({ conferenceId: conference.id }), - makeSeedCustomConferenceRole({ conferenceId: conference.id }), - makeSeedCustomConferenceRole({ conferenceId: conference.id }), - makeSeedCustomConferenceRole({ conferenceId: conference.id }), - makeSeedCustomConferenceRole({ conferenceId: conference.id }), - makeSeedCustomConferenceRole({ conferenceId: conference.id }) - ]; - - await Promise.all( - customConferenceRoles.map(async (customConferenceRole) => { - await db.customConferenceRole.upsert({ - where: { - id: customConferenceRole.id - }, - update: customConferenceRole, - create: customConferenceRole - }); - }) - ); - - const delegations = [ - makeSeedDelegation({ conferenceId: conference.id }), - makeSeedDelegation({ conferenceId: conference.id }), - makeSeedDelegation({ conferenceId: conference.id }), - makeSeedDelegation({ conferenceId: conference.id }), - makeSeedDelegation({ conferenceId: conference.id }), - makeSeedDelegation({ conferenceId: conference.id }) - ]; - - const dbdelegations = await Promise.all( - delegations.map(async (delegation) => { - await db.delegation.upsert({ - where: { - id: delegation.id - }, - update: delegation, - create: delegation - }); - - const delegationMembers: DelegationMember[] = []; - - for (let i = 0; i < faker.number.int({ min: 1, max: 6 }); i++) { - delegationMembers.push( - makeSeedDelegationMember({ + }), + makeSeedCommittee({ + conferenceId: conference.id, + nations: { + connect: faker.helpers + .arrayElements(nations, { min: 6, max: 36 }) + .map((nation) => ({ alpha3Code: nation.alpha3Code })) + } + }), + makeSeedCommittee({ + conferenceId: conference.id, + nations: { + connect: faker.helpers + .arrayElements(nations, { min: 6, max: 36 }) + .map((nation) => ({ alpha3Code: nation.alpha3Code })) + } + }), + makeSeedCommittee({ + conferenceId: conference.id, + nations: { + connect: faker.helpers + .arrayElements(nations, { min: 6, max: 36 }) + .map((nation) => ({ alpha3Code: nation.alpha3Code })) + } + }), + makeSeedCommittee({ + conferenceId: conference.id, + nations: { + connect: faker.helpers + .arrayElements(nations, { min: 6, max: 36 }) + .map((nation) => ({ alpha3Code: nation.alpha3Code })) + } + }), + makeSeedCommittee({ + conferenceId: conference.id, + nations: { + connect: faker.helpers + .arrayElements(nations, { min: 6, max: 36 }) + .map((nation) => ({ alpha3Code: nation.alpha3Code })) + } + }), + makeSeedCommittee({ + conferenceId: conference.id, + nations: { + connect: faker.helpers + .arrayElements(nations, { min: 6, max: 36 }) + .map((nation) => ({ alpha3Code: nation.alpha3Code })) + } + }), + makeSeedCommittee({ + conferenceId: conference.id, + nations: { + connect: faker.helpers + .arrayElements(nations, { min: 6, max: 36 }) + .map((nation) => ({ alpha3Code: nation.alpha3Code })) + } + }), + makeSeedCommittee({ + conferenceId: conference.id, + nations: { + connect: faker.helpers + .arrayElements(nations, { min: 6, max: 36 }) + .map((nation) => ({ alpha3Code: nation.alpha3Code })) + } + }), + makeSeedCommittee({ + conferenceId: conference.id, + nations: { + connect: faker.helpers + .arrayElements(nations, { min: 6, max: 36 }) + .map((nation) => ({ alpha3Code: nation.alpha3Code })) + } + }) + ]; + + const dbcommittees = await Promise.all( + committees.map((committee) => { + return db.committee.upsert({ + where: { + id: committee.id + }, + update: committee, + create: committee, + include: { + nations: true + } + }); + }) + ); + + const allNationsRepresentedInCommittees = dbcommittees.flatMap((c) => c.nations); + + const nonStateActors = [ + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }), + makeSeedNSA({ conferenceId: conference.id }) + ]; + + await Promise.all( + nonStateActors.map(async (nonStateActor) => { + await db.nonStateActor.upsert({ + where: { + id: nonStateActor.id + }, + update: nonStateActor, + create: nonStateActor + }); + }) + ); + + const customConferenceRoles = [ + makeSeedCustomConferenceRole({ conferenceId: conference.id }), + makeSeedCustomConferenceRole({ conferenceId: conference.id }), + makeSeedCustomConferenceRole({ conferenceId: conference.id }), + makeSeedCustomConferenceRole({ conferenceId: conference.id }), + makeSeedCustomConferenceRole({ conferenceId: conference.id }), + makeSeedCustomConferenceRole({ conferenceId: conference.id }) + ]; + + await Promise.all( + customConferenceRoles.map(async (customConferenceRole) => { + await db.customConferenceRole.upsert({ + where: { + id: customConferenceRole.id + }, + update: customConferenceRole, + create: customConferenceRole + }); + }) + ); + + const delegations = [ + makeSeedDelegation({ conferenceId: conference.id }), + makeSeedDelegation({ conferenceId: conference.id }), + makeSeedDelegation({ conferenceId: conference.id }), + makeSeedDelegation({ conferenceId: conference.id }), + makeSeedDelegation({ conferenceId: conference.id }), + makeSeedDelegation({ conferenceId: conference.id }) + ]; + + const dbdelegations = await Promise.all( + delegations.map(async (delegation) => { + await db.delegation.upsert({ + where: { + id: delegation.id + }, + update: delegation, + create: delegation + }); + + const delegationMembers: DelegationMember[] = []; + + for (let i = 0; i < faker.number.int({ min: 1, max: 6 }); i++) { + delegationMembers.push( + makeSeedDelegationMember({ + delegationId: delegation.id, + conferenceId: conference.id, + userId: takeXUsers(1)[0].id, + isHeadDelegate: false, + assignedCommitteeId: conferenceIsInAssignedState ? committees[4].id : undefined + }) + ); + } + + await Promise.all( + delegationMembers.map(async (delegationMember) => { + await db.delegationMember.upsert({ + where: { + id: delegationMember.id + }, + update: delegationMember, + create: delegationMember + }); + }) + ); + + const delegationRoleApplications: RoleApplication[] = [ + { + id: faker.database.mongodbObjectId(), + delegationId: delegation.id, + rank: 0, + nationId: allNationsRepresentedInCommittees[0].alpha3Code, + nonStateActorId: null, + createdAt: faker.date.past(), + updatedAt: faker.date.past() + }, + { + id: faker.database.mongodbObjectId(), + delegationId: delegation.id, + rank: 1, + nationId: allNationsRepresentedInCommittees[1].alpha3Code, + nonStateActorId: null, + createdAt: faker.date.past(), + updatedAt: faker.date.past() + }, + { + id: faker.database.mongodbObjectId(), delegationId: delegation.id, - conferenceId: conference.id, - userId: takeXUsers(1)[0].id, - isHeadDelegate: false, - assignedCommitteeId: conferenceIsInAssignedState ? committees[4].id : undefined + rank: 2, + nationId: null, + nonStateActorId: nonStateActors[0].id, + createdAt: faker.date.past(), + updatedAt: faker.date.past() + } + ]; + + await Promise.all( + delegationRoleApplications.map(async (delegationRoleApplication) => { + await db.roleApplication.upsert({ + where: { + id: delegationRoleApplication.id + }, + update: delegationRoleApplication, + create: delegationRoleApplication + }); }) ); - } - - await Promise.all( - delegationMembers.map(async (delegationMember) => { - await db.delegationMember.upsert({ - where: { - id: delegationMember.id - }, - update: delegationMember, - create: delegationMember - }); - }) - ); - - const delegationRoleApplications: RoleApplication[] = [ - { - id: faker.database.mongodbObjectId(), - delegationId: delegation.id, - rank: 0, - nationId: allNationsRepresentedInCommittees[0].alpha3Code, - nonStateActorId: null, - createdAt: faker.date.past(), - updatedAt: faker.date.past() - }, - { - id: faker.database.mongodbObjectId(), - delegationId: delegation.id, - rank: 1, - nationId: allNationsRepresentedInCommittees[1].alpha3Code, - nonStateActorId: null, - createdAt: faker.date.past(), - updatedAt: faker.date.past() - }, - { - id: faker.database.mongodbObjectId(), - delegationId: delegation.id, - rank: 2, - nationId: null, - nonStateActorId: nonStateActors[0].id, - createdAt: faker.date.past(), - updatedAt: faker.date.past() - } - ]; - - await Promise.all( - delegationRoleApplications.map(async (delegationRoleApplication) => { - await db.roleApplication.upsert({ - where: { - id: delegationRoleApplication.id - }, - update: delegationRoleApplication, - create: delegationRoleApplication - }); - }) - ); - - if (conferenceIsInAssignedState) { - if (faker.datatype.boolean()) { - await db.delegation.update({ - where: { - id: delegation.id - }, - data: { - assignedNationAlpha3Code: faker.helpers.arrayElement( - allNationsRepresentedInCommittees - ).alpha3Code - } - }); - } else { - await db.delegation.update({ - where: { - id: delegation.id - }, - data: { - assignedNonStateActorId: faker.helpers.arrayElement(nonStateActors).id - } - }); + + if (conferenceIsInAssignedState) { + if (faker.datatype.boolean()) { + await db.delegation.update({ + where: { + id: delegation.id + }, + data: { + assignedNationAlpha3Code: faker.helpers.arrayElement( + allNationsRepresentedInCommittees + ).alpha3Code + } + }); + } else { + await db.delegation.update({ + where: { + id: delegation.id + }, + data: { + assignedNonStateActorId: faker.helpers.arrayElement(nonStateActors).id + } + }); + } } - } - return { - ...delegation, - members: delegationMembers, - appliedForRoles: delegationRoleApplications - }; - }) - ); - - const conferenceSupervisors = [ - makeSeedConferenceSupervisor({ - conferenceId: conference.id, - userId: takeXUsers(1)[0].id, - delegations: conferenceIsInAssignedState - ? undefined - : { - connect: [{ id: delegations[0].id }, { id: delegations[1].id }] + return { + ...delegation, + members: delegationMembers, + appliedForRoles: delegationRoleApplications + }; + }) + ); + + const conferenceSupervisors = [ + makeSeedConferenceSupervisor({ + conferenceId: conference.id, + userId: takeXUsers(1)[0].id, + supervisedDelegationMembers: { + connect: conferenceIsInAssignedState + ? dbdelegations[1].members.slice(0, 2).map((member) => ({ id: member.id })) + : [ + ...dbdelegations[0].members.slice(0, 2).map((member) => ({ id: member.id })), + ...dbdelegations[1].members.slice(0, 2).map((member) => ({ id: member.id })) + ] + } + }), + makeSeedConferenceSupervisor({ + conferenceId: conference.id, + userId: takeXUsers(1)[0].id, + supervisedDelegationMembers: { + connect: conferenceIsInAssignedState + ? dbdelegations[1].members.slice(0, 2).map((member) => ({ id: member.id })) + : [ + ...dbdelegations[0].members.slice(0, 2).map((member) => ({ id: member.id })), + ...dbdelegations[1].members.slice(0, 2).map((member) => ({ id: member.id })) + ] + } + }), + makeSeedConferenceSupervisor({ + conferenceId: conference.id, + userId: takeXUsers(1)[0].id, + supervisedDelegationMembers: { + connect: conferenceIsInAssignedState + ? dbdelegations[1].members.slice(0, 2).map((member) => ({ id: member.id })) + : [ + ...dbdelegations[0].members.slice(0, 2).map((member) => ({ id: member.id })), + ...dbdelegations[1].members.slice(0, 2).map((member) => ({ id: member.id })) + ] + } + }), + makeSeedConferenceSupervisor({ + conferenceId: conference.id, + userId: takeXUsers(1)[0].id + }), + makeSeedConferenceSupervisor({ + conferenceId: conference.id, + userId: takeXUsers(1)[0].id + }), + makeSeedConferenceSupervisor({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }) + ]; + + await Promise.all( + conferenceSupervisors.map(async (conferenceSupervisor) => { + await db.conferenceSupervisor.upsert({ + where: { + id: conferenceSupervisor.id }, - postAssignmentDelegeationMembers: conferenceIsInAssignedState - ? { - connect: [ - { id: dbdelegations[1].members[0].id }, - { id: dbdelegations[1].members[1].id } - ] - } - : undefined - }), - makeSeedConferenceSupervisor({ - conferenceId: conference.id, - userId: takeXUsers(1)[0].id, - delegations: conferenceIsInAssignedState - ? undefined - : { - connect: [{ id: delegations[0].id }, { id: delegations[1].id }] + update: conferenceSupervisor, + create: conferenceSupervisor + }); + }) + ); + + const singleApplications = [ + makeSeedSingleParticipant({ + conferenceId: conference.id, + userId: takeXUsers(1)[0].id, + assignedRoleId: conferenceIsInAssignedState + ? faker.helpers.arrayElement(customConferenceRoles).id + : null + }), + makeSeedSingleParticipant({ + conferenceId: conference.id, + userId: takeXUsers(1)[0].id, + assignedRoleId: conferenceIsInAssignedState + ? faker.helpers.arrayElement(customConferenceRoles).id + : null + }), + makeSeedSingleParticipant({ + conferenceId: conference.id, + userId: takeXUsers(1)[0].id, + assignedRoleId: conferenceIsInAssignedState + ? faker.helpers.arrayElement(customConferenceRoles).id + : null + }), + makeSeedSingleParticipant({ + conferenceId: conference.id, + userId: takeXUsers(1)[0].id, + assignedRoleId: conferenceIsInAssignedState + ? faker.helpers.arrayElement(customConferenceRoles).id + : null + }), + makeSeedSingleParticipant({ + conferenceId: conference.id, + userId: takeXUsers(1)[0].id, + assignedRoleId: conferenceIsInAssignedState + ? faker.helpers.arrayElement(customConferenceRoles).id + : null + }), + makeSeedSingleParticipant({ + conferenceId: conference.id, + userId: takeXUsers(1)[0].id, + assignedRoleId: conferenceIsInAssignedState + ? faker.helpers.arrayElement(customConferenceRoles).id + : null + }), + makeSeedSingleParticipant({ + conferenceId: conference.id, + userId: takeXUsers(1)[0].id, + assignedRoleId: conferenceIsInAssignedState + ? faker.helpers.arrayElement(customConferenceRoles).id + : null + }), + makeSeedSingleParticipant({ + conferenceId: conference.id, + userId: takeXUsers(1)[0].id, + assignedRoleId: conferenceIsInAssignedState + ? faker.helpers.arrayElement(customConferenceRoles).id + : null + }), + makeSeedSingleParticipant({ + conferenceId: conference.id, + userId: takeXUsers(1)[0].id, + assignedRoleId: conferenceIsInAssignedState + ? faker.helpers.arrayElement(customConferenceRoles).id + : null + }), + makeSeedSingleParticipant({ + conferenceId: conference.id, + userId: takeXUsers(1)[0].id, + assignedRoleId: conferenceIsInAssignedState + ? faker.helpers.arrayElement(customConferenceRoles).id + : null + }), + makeSeedSingleParticipant({ + conferenceId: conference.id, + userId: takeXUsers(1)[0].id, + assignedRoleId: conferenceIsInAssignedState + ? faker.helpers.arrayElement(customConferenceRoles).id + : null + }) + ]; + + await Promise.all( + singleApplications.map(async (singleApplication) => { + await db.singleParticipant.upsert({ + where: { + id: singleApplication.id }, - postAssignmentDelegeationMembers: conferenceIsInAssignedState - ? { - connect: [ - { id: dbdelegations[1].members[0].id }, - { id: dbdelegations[1].members[1].id } - ] - } - : undefined - }), - makeSeedConferenceSupervisor({ - conferenceId: conference.id, - userId: takeXUsers(1)[0].id, - delegations: conferenceIsInAssignedState - ? undefined - : { - connect: [{ id: delegations[0].id }, { id: delegations[1].id }] + update: singleApplication, + create: singleApplication + }); + }) + ); + + const teamMembers = [ + makeSeedTeamMember({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }), + makeSeedTeamMember({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }), + makeSeedTeamMember({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }), + makeSeedTeamMember({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }), + makeSeedTeamMember({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }), + makeSeedTeamMember({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }), + makeSeedTeamMember({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }), + makeSeedTeamMember({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }) + ]; + + await Promise.all( + teamMembers.map(async (teamMember) => { + await db.teamMember.upsert({ + where: { + id: teamMember.id }, - postAssignmentDelegeationMembers: conferenceIsInAssignedState - ? { - connect: [ - { id: dbdelegations[1].members[0].id }, - { id: dbdelegations[1].members[1].id } - ] + update: teamMember, + create: teamMember + }); + }) + ); + + const paymentTransaction = [ + makeSeedPaymentTransaction({ + conferenceId: conference.id, + userId: dbdelegations[0].members[0].userId, + paymentFor: { + createMany: { + data: dbdelegations[0].members.slice(1).map((m) => ({ userId: m.userId })) } - : undefined - }), - makeSeedConferenceSupervisor({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }), - makeSeedConferenceSupervisor({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }), - makeSeedConferenceSupervisor({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }) - ]; - - await Promise.all( - conferenceSupervisors.map(async (conferenceSupervisor) => { - await db.conferenceSupervisor.upsert({ - where: { - id: conferenceSupervisor.id - }, - update: conferenceSupervisor, - create: conferenceSupervisor - }); - }) - ); - - const singleApplications = [ - makeSeedSingleParticipant({ - conferenceId: conference.id, - userId: takeXUsers(1)[0].id, - assignedRoleId: conferenceIsInAssignedState - ? faker.helpers.arrayElement(customConferenceRoles).id - : null - }), - makeSeedSingleParticipant({ - conferenceId: conference.id, - userId: takeXUsers(1)[0].id, - assignedRoleId: conferenceIsInAssignedState - ? faker.helpers.arrayElement(customConferenceRoles).id - : null - }), - makeSeedSingleParticipant({ - conferenceId: conference.id, - userId: takeXUsers(1)[0].id, - assignedRoleId: conferenceIsInAssignedState - ? faker.helpers.arrayElement(customConferenceRoles).id - : null - }), - makeSeedSingleParticipant({ - conferenceId: conference.id, - userId: takeXUsers(1)[0].id, - assignedRoleId: conferenceIsInAssignedState - ? faker.helpers.arrayElement(customConferenceRoles).id - : null - }), - makeSeedSingleParticipant({ - conferenceId: conference.id, - userId: takeXUsers(1)[0].id, - assignedRoleId: conferenceIsInAssignedState - ? faker.helpers.arrayElement(customConferenceRoles).id - : null - }), - makeSeedSingleParticipant({ - conferenceId: conference.id, - userId: takeXUsers(1)[0].id, - assignedRoleId: conferenceIsInAssignedState - ? faker.helpers.arrayElement(customConferenceRoles).id - : null - }), - makeSeedSingleParticipant({ - conferenceId: conference.id, - userId: takeXUsers(1)[0].id, - assignedRoleId: conferenceIsInAssignedState - ? faker.helpers.arrayElement(customConferenceRoles).id - : null - }), - makeSeedSingleParticipant({ - conferenceId: conference.id, - userId: takeXUsers(1)[0].id, - assignedRoleId: conferenceIsInAssignedState - ? faker.helpers.arrayElement(customConferenceRoles).id - : null - }), - makeSeedSingleParticipant({ - conferenceId: conference.id, - userId: takeXUsers(1)[0].id, - assignedRoleId: conferenceIsInAssignedState - ? faker.helpers.arrayElement(customConferenceRoles).id - : null - }), - makeSeedSingleParticipant({ - conferenceId: conference.id, - userId: takeXUsers(1)[0].id, - assignedRoleId: conferenceIsInAssignedState - ? faker.helpers.arrayElement(customConferenceRoles).id - : null - }), - makeSeedSingleParticipant({ - conferenceId: conference.id, - userId: takeXUsers(1)[0].id, - assignedRoleId: conferenceIsInAssignedState - ? faker.helpers.arrayElement(customConferenceRoles).id - : null - }) - ]; - - await Promise.all( - singleApplications.map(async (singleApplication) => { - await db.singleParticipant.upsert({ - where: { - id: singleApplication.id - }, - update: singleApplication, - create: singleApplication - }); - }) - ); - - const teamMembers = [ - makeSeedTeamMember({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }), - makeSeedTeamMember({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }), - makeSeedTeamMember({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }), - makeSeedTeamMember({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }), - makeSeedTeamMember({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }), - makeSeedTeamMember({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }), - makeSeedTeamMember({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }), - makeSeedTeamMember({ conferenceId: conference.id, userId: takeXUsers(1)[0].id }) - ]; - - await Promise.all( - teamMembers.map(async (teamMember) => { - await db.teamMember.upsert({ - where: { - id: teamMember.id - }, - update: teamMember, - create: teamMember - }); - }) - ); - - const paymentTransaction = [ - makeSeedPaymentTransaction({ - conferenceId: conference.id, - userId: dbdelegations[0].members[0].userId, - paymentFor: { - createMany: { - data: dbdelegations[0].members.slice(1).map((m) => ({ userId: m.userId })) } - } - }), - makeSeedPaymentTransaction({ - conferenceId: conference.id, - userId: dbdelegations[1].members[0].userId, - paymentFor: { - createMany: { - data: dbdelegations[1].members.slice(1).map((m) => ({ userId: m.userId })) + }), + makeSeedPaymentTransaction({ + conferenceId: conference.id, + userId: dbdelegations[1].members[0].userId, + paymentFor: { + createMany: { + data: dbdelegations[1].members.slice(1).map((m) => ({ userId: m.userId })) + } } - } - }), - makeSeedPaymentTransaction({ - conferenceId: conference.id, - userId: dbdelegations[2].members[0].userId, - paymentFor: { - createMany: { - data: dbdelegations[2].members.slice(1).map((m) => ({ userId: m.userId })) + }), + makeSeedPaymentTransaction({ + conferenceId: conference.id, + userId: dbdelegations[2].members[0].userId, + paymentFor: { + createMany: { + data: dbdelegations[2].members.slice(1).map((m) => ({ userId: m.userId })) + } } - } - }), - makeSeedPaymentTransaction({ - conferenceId: conference.id, - userId: dbdelegations[3].members[0].userId, - paymentFor: { - createMany: { - data: dbdelegations[3].members.slice(1).map((m) => ({ userId: m.userId })) + }), + makeSeedPaymentTransaction({ + conferenceId: conference.id, + userId: dbdelegations[3].members[0].userId, + paymentFor: { + createMany: { + data: dbdelegations[3].members.slice(1).map((m) => ({ userId: m.userId })) + } } - } - }) - ]; - - await Promise.all( - paymentTransaction.map(async (paymentTransaction) => { - await db.paymentTransaction.upsert({ - where: { - id: paymentTransaction.id - }, - update: paymentTransaction, - create: paymentTransaction - }); - }) - ); - }) - ); -}); + }) + ]; + + await Promise.all( + paymentTransaction.map(async (paymentTransaction) => { + await db.paymentTransaction.upsert({ + where: { + id: paymentTransaction.id + }, + update: paymentTransaction, + create: paymentTransaction + }); + }) + ); + }) + ); + }, + { timeout: 50_0000 } +); console.info('Done!'); From 7ea37490485c83b9a293f47ee1d9b39be59f8935 Mon Sep 17 00:00:00 2001 From: Muhammad Ahsan Farooq Date: Sun, 11 Jan 2026 13:06:33 +0000 Subject: [PATCH 02/31] added schema for messages in conference --- .../migration.sql | 30 +++ prisma/schema.prisma | 39 +++- schema.graphql | 209 ++++++++++++++++++ 3 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20260111130406_schema_for_messages_between_delegates_in_conference/migration.sql diff --git a/prisma/migrations/20260111130406_schema_for_messages_between_delegates_in_conference/migration.sql b/prisma/migrations/20260111130406_schema_for_messages_between_delegates_in_conference/migration.sql new file mode 100644 index 00000000..c3366bcc --- /dev/null +++ b/prisma/migrations/20260111130406_schema_for_messages_between_delegates_in_conference/migration.sql @@ -0,0 +1,30 @@ +-- CreateEnum +CREATE TYPE "MessageStatus" AS ENUM ('SENT', 'FAILED', 'BLOCKED'); + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "canReceiveDelegationMail" BOOLEAN NOT NULL DEFAULT false; + +-- CreateTable +CREATE TABLE "MessageAudit" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "subject" TEXT NOT NULL, + "body" TEXT NOT NULL, + "senderUserId" TEXT NOT NULL, + "recipientUserId" TEXT NOT NULL, + "conferenceId" TEXT NOT NULL, + "messageId" TEXT, + "status" "MessageStatus" NOT NULL DEFAULT 'SENT', + + CONSTRAINT "MessageAudit_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "MessageAudit" ADD CONSTRAINT "MessageAudit_senderUserId_fkey" FOREIGN KEY ("senderUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MessageAudit" ADD CONSTRAINT "MessageAudit_recipientUserId_fkey" FOREIGN KEY ("recipientUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MessageAudit" ADD CONSTRAINT "MessageAudit_conferenceId_fkey" FOREIGN KEY ("conferenceId") REFERENCES "Conference"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 685f9999..7fc96e85 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -86,8 +86,9 @@ model Conference { WaitingListEntry WaitingListEntry[] papers Paper[] - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + messageAudits MessageAudit[] } /// A committee in a conference. E.g. the human rights council @@ -178,6 +179,9 @@ model User { waitingListEntry WaitingListEntry[] papers Paper[] paperReviews PaperReview[] + canReceiveDelegationMail Boolean @default(false) + sentDelegationMessages MessageAudit[] @relation("SentMessages") + receivedDelegationMessages MessageAudit[] @relation("ReceivedMessages") createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt @@ -599,3 +603,34 @@ model TeamMember { @@unique([conferenceId, userId]) } + +model MessageAudit { + id String @id @default(nanoid()) + + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + subject String + body String + + //tracibility + senderUserId String + senderUser User @relation("SentMessages", fields: [senderUserId], references: [id], onDelete: Cascade) + + recipientUserId String + recipientUser User @relation("ReceivedMessages", fields: [recipientUserId], references: [id], onDelete: Cascade) + + conferenceId String + conference Conference @relation(fields: [conferenceId], references: [id], onDelete: Cascade) + + // Technical meta-data + messageId String? // The ID from the email provider + status MessageStatus @default(SENT) +} + +/// Status for message audit entries +enum MessageStatus { + SENT + FAILED + BLOCKED +} diff --git a/schema.graphql b/schema.graphql index ec0019b6..5ec25704 100644 --- a/schema.graphql +++ b/schema.graphql @@ -388,6 +388,7 @@ input ConferenceCreateInput { location: String longTitle: String mediaConsentContent: String + messageAudits: MessageAuditCreateNestedManyWithoutConferenceInput nonStateActors: NonStateActorCreateNestedManyWithoutConferenceInput papers: PaperCreateNestedManyWithoutConferenceInput paymentTransactions: PaymentTransactionCreateNestedManyWithoutConferenceInput @@ -441,6 +442,7 @@ input ConferenceOrderByWithRelationInput { location: SortOrder longTitle: SortOrder mediaConsentContent: SortOrder + messageAudits: MessageAuditOrderByRelationAggregateInput nonStateActors: NonStateActorOrderByRelationAggregateInput papers: PaperOrderByRelationAggregateInput paymentTransactions: PaymentTransactionOrderByRelationAggregateInput @@ -939,6 +941,7 @@ input ConferenceUpdateInput { location: NullableStringFieldUpdateOperationsInput longTitle: NullableStringFieldUpdateOperationsInput mediaConsentContent: NullableStringFieldUpdateOperationsInput + messageAudits: MessageAuditUpdateManyWithoutConferenceNestedInput nonStateActors: NonStateActorUpdateManyWithoutConferenceNestedInput papers: PaperUpdateManyWithoutConferenceNestedInput paymentTransactions: PaymentTransactionUpdateManyWithoutConferenceNestedInput @@ -1035,6 +1038,7 @@ input ConferenceWhereInput { location: StringNullableFilter longTitle: StringNullableFilter mediaConsentContent: StringNullableFilter + messageAudits: MessageAuditListRelationFilter nonStateActors: NonStateActorListRelationFilter papers: PaperListRelationFilter paymentTransactions: PaymentTransactionListRelationFilter @@ -1091,6 +1095,7 @@ input ConferenceWhereUniqueInput { location: StringNullableFilter longTitle: StringNullableFilter mediaConsentContent: StringNullableFilter + messageAudits: MessageAuditListRelationFilter nonStateActors: NonStateActorListRelationFilter papers: PaperListRelationFilter paymentTransactions: PaymentTransactionListRelationFilter @@ -1822,6 +1827,27 @@ input EnumMediaConsentStatusWithAggregatesFilter { notIn: [MediaConsentStatus!] } +input EnumMessageStatusFieldUpdateOperationsInput { + set: MessageStatus +} + +input EnumMessageStatusFilter { + equals: MessageStatus + in: [MessageStatus!] + not: MessageStatus + notIn: [MessageStatus!] +} + +input EnumMessageStatusWithAggregatesFilter { + _count: NestedIntFilter + _max: NestedEnumMessageStatusFilter + _min: NestedEnumMessageStatusFilter + equals: MessageStatus + in: [MessageStatus!] + not: MessageStatus + notIn: [MessageStatus!] +} + input EnumPaperStatusFieldUpdateOperationsInput { set: PaperStatus } @@ -2142,6 +2168,155 @@ enum MediaConsentStatus { PARTIALLY_ALLOWED } +input MessageAuditCreateInput { + body: String! + conferenceId: String! + createdAt: DateTime + id: String + messageId: String + recipientUserId: String! + senderUserId: String! + status: MessageStatus + subject: String! + updatedAt: DateTime +} + +input MessageAuditCreateNestedManyWithoutConferenceInput { + connect: [MessageAuditWhereUniqueInput!] +} + +input MessageAuditCreateNestedManyWithoutRecipientUserInput { + connect: [MessageAuditWhereUniqueInput!] +} + +input MessageAuditCreateNestedManyWithoutSenderUserInput { + connect: [MessageAuditWhereUniqueInput!] +} + +input MessageAuditListRelationFilter { + every: MessageAuditWhereInput + none: MessageAuditWhereInput + some: MessageAuditWhereInput +} + +input MessageAuditOrderByRelationAggregateInput { + _count: SortOrder +} + +input MessageAuditOrderByWithRelationInput { + body: SortOrder + conference: ConferenceOrderByWithRelationInput + conferenceId: SortOrder + createdAt: SortOrder + id: SortOrder + messageId: SortOrder + recipientUser: UserOrderByWithRelationInput + recipientUserId: SortOrder + senderUser: UserOrderByWithRelationInput + senderUserId: SortOrder + status: SortOrder + subject: SortOrder + updatedAt: SortOrder +} + +enum MessageAuditScalarFieldEnum { + body + conferenceId + createdAt + id + messageId + recipientUserId + senderUserId + status + subject + updatedAt +} + +input MessageAuditUpdateInput { + body: StringFieldUpdateOperationsInput + conferenceId: StringFieldUpdateOperationsInput + createdAt: DateTimeFieldUpdateOperationsInput + id: StringFieldUpdateOperationsInput + messageId: NullableStringFieldUpdateOperationsInput + recipientUserId: StringFieldUpdateOperationsInput + senderUserId: StringFieldUpdateOperationsInput + status: EnumMessageStatusFieldUpdateOperationsInput + subject: StringFieldUpdateOperationsInput + updatedAt: DateTimeFieldUpdateOperationsInput +} + +input MessageAuditUpdateManyMutationInput { + body: StringFieldUpdateOperationsInput + createdAt: DateTimeFieldUpdateOperationsInput + id: StringFieldUpdateOperationsInput + messageId: NullableStringFieldUpdateOperationsInput + status: EnumMessageStatusFieldUpdateOperationsInput + subject: StringFieldUpdateOperationsInput + updatedAt: DateTimeFieldUpdateOperationsInput +} + +input MessageAuditUpdateManyWithoutConferenceNestedInput { + connect: [MessageAuditWhereUniqueInput!] + disconnect: [MessageAuditWhereUniqueInput!] + set: [MessageAuditWhereUniqueInput!] +} + +input MessageAuditUpdateManyWithoutRecipientUserNestedInput { + connect: [MessageAuditWhereUniqueInput!] + disconnect: [MessageAuditWhereUniqueInput!] + set: [MessageAuditWhereUniqueInput!] +} + +input MessageAuditUpdateManyWithoutSenderUserNestedInput { + connect: [MessageAuditWhereUniqueInput!] + disconnect: [MessageAuditWhereUniqueInput!] + set: [MessageAuditWhereUniqueInput!] +} + +input MessageAuditWhereInput { + AND: [MessageAuditWhereInput!] + NOT: [MessageAuditWhereInput!] + OR: [MessageAuditWhereInput!] + body: StringFilter + conference: ConferenceWhereInput + conferenceId: StringFilter + createdAt: DateTimeFilter + id: StringFilter + messageId: StringNullableFilter + recipientUser: UserWhereInput + recipientUserId: StringFilter + senderUser: UserWhereInput + senderUserId: StringFilter + status: EnumMessageStatusFilter + subject: StringFilter + updatedAt: DateTimeFilter +} + +input MessageAuditWhereUniqueInput { + AND: [MessageAuditWhereInput!] + NOT: [MessageAuditWhereInput!] + OR: [MessageAuditWhereInput!] + body: StringFilter + conference: ConferenceWhereInput + conferenceId: StringFilter + createdAt: DateTimeFilter + id: String + messageId: StringNullableFilter + recipientUser: UserWhereInput + recipientUserId: StringFilter + senderUser: UserWhereInput + senderUserId: StringFilter + status: EnumMessageStatusFilter + subject: StringFilter + updatedAt: DateTimeFilter +} + +enum MessageStatus { + BLOCKED + FAILED + SENT +} + type Mutation { assignCommitteesToDelegationMembers(assignments: [updateManyDelegationMemberInputTypeArrayValue!]!): [DelegationMember!]! connectToConferenceSupervisor(conferenceId: ID!, connectionCode: String!, userId: ID): ConferenceSupervisor @@ -2457,6 +2632,23 @@ input NestedEnumMediaConsentStatusWithAggregatesFilter { notIn: [MediaConsentStatus!] } +input NestedEnumMessageStatusFilter { + equals: MessageStatus + in: [MessageStatus!] + not: MessageStatus + notIn: [MessageStatus!] +} + +input NestedEnumMessageStatusWithAggregatesFilter { + _count: NestedIntFilter + _max: NestedEnumMessageStatusFilter + _min: NestedEnumMessageStatusFilter + equals: MessageStatus + in: [MessageStatus!] + not: MessageStatus + notIn: [MessageStatus!] +} + input NestedEnumPaperStatusFilter { equals: PaperStatus in: [PaperStatus!] @@ -4729,6 +4921,7 @@ type User { input UserCreateInput { apartment: String birthday: DateTime + canReceiveDelegationMail: Boolean city: String conferenceParticipantStatus: ConferenceParticipantStatusCreateNestedManyWithoutUserInput conferenceSupervisor: ConferenceSupervisorCreateNestedManyWithoutUserInput @@ -4751,6 +4944,8 @@ input UserCreateInput { phone: String preferred_username: String! pronouns: String + receivedDelegationMessages: MessageAuditCreateNestedManyWithoutRecipientUserInput + sentDelegationMessages: MessageAuditCreateNestedManyWithoutSenderUserInput singleParticipant: SingleParticipantCreateNestedManyWithoutUserInput street: String surveyAnswers: SurveyAnswerCreateNestedManyWithoutUserInput @@ -4765,6 +4960,7 @@ input UserCreateInput { input UserOrderByWithRelationInput { apartment: SortOrder birthday: SortOrder + canReceiveDelegationMail: SortOrder city: SortOrder conferenceParticipantStatus: ConferenceParticipantStatusOrderByRelationAggregateInput conferenceSupervisor: ConferenceSupervisorOrderByRelationAggregateInput @@ -4787,6 +4983,8 @@ input UserOrderByWithRelationInput { phone: SortOrder preferred_username: SortOrder pronouns: SortOrder + receivedDelegationMessages: MessageAuditOrderByRelationAggregateInput + sentDelegationMessages: MessageAuditOrderByRelationAggregateInput singleParticipant: SingleParticipantOrderByRelationAggregateInput street: SortOrder surveyAnswers: SurveyAnswerOrderByRelationAggregateInput @@ -4904,6 +5102,7 @@ input UserReferenceInPaymentTransactionWhereUniqueInput { enum UserScalarFieldEnum { apartment birthday + canReceiveDelegationMail city country createdAt @@ -4950,6 +5149,7 @@ input UserUpdateDataInput { input UserUpdateInput { apartment: NullableStringFieldUpdateOperationsInput birthday: NullableDateTimeFieldUpdateOperationsInput + canReceiveDelegationMail: BoolFieldUpdateOperationsInput city: NullableStringFieldUpdateOperationsInput conferenceParticipantStatus: ConferenceParticipantStatusUpdateManyWithoutUserNestedInput conferenceSupervisor: ConferenceSupervisorUpdateManyWithoutUserNestedInput @@ -4972,6 +5172,8 @@ input UserUpdateInput { phone: NullableStringFieldUpdateOperationsInput preferred_username: StringFieldUpdateOperationsInput pronouns: NullableStringFieldUpdateOperationsInput + receivedDelegationMessages: MessageAuditUpdateManyWithoutRecipientUserNestedInput + sentDelegationMessages: MessageAuditUpdateManyWithoutSenderUserNestedInput singleParticipant: SingleParticipantUpdateManyWithoutUserNestedInput street: NullableStringFieldUpdateOperationsInput surveyAnswers: SurveyAnswerUpdateManyWithoutUserNestedInput @@ -4986,6 +5188,7 @@ input UserUpdateInput { input UserUpdateManyMutationInput { apartment: NullableStringFieldUpdateOperationsInput birthday: NullableDateTimeFieldUpdateOperationsInput + canReceiveDelegationMail: BoolFieldUpdateOperationsInput city: NullableStringFieldUpdateOperationsInput country: NullableStringFieldUpdateOperationsInput createdAt: DateTimeFieldUpdateOperationsInput @@ -5014,6 +5217,7 @@ input UserWhereInput { OR: [UserWhereInput!] apartment: StringNullableFilter birthday: DateTimeNullableFilter + canReceiveDelegationMail: BoolFilter city: StringNullableFilter conferenceParticipantStatus: ConferenceParticipantStatusListRelationFilter conferenceSupervisor: ConferenceSupervisorListRelationFilter @@ -5036,6 +5240,8 @@ input UserWhereInput { phone: StringNullableFilter preferred_username: StringFilter pronouns: StringNullableFilter + receivedDelegationMessages: MessageAuditListRelationFilter + sentDelegationMessages: MessageAuditListRelationFilter singleParticipant: SingleParticipantListRelationFilter street: StringNullableFilter surveyAnswers: SurveyAnswerListRelationFilter @@ -5053,6 +5259,7 @@ input UserWhereUniqueInput { OR: [UserWhereInput!] apartment: StringNullableFilter birthday: DateTimeNullableFilter + canReceiveDelegationMail: BoolFilter city: StringNullableFilter conferenceParticipantStatus: ConferenceParticipantStatusListRelationFilter conferenceSupervisor: ConferenceSupervisorListRelationFilter @@ -5075,6 +5282,8 @@ input UserWhereUniqueInput { phone: StringNullableFilter preferred_username: StringFilter pronouns: StringNullableFilter + receivedDelegationMessages: MessageAuditListRelationFilter + sentDelegationMessages: MessageAuditListRelationFilter singleParticipant: SingleParticipantListRelationFilter street: StringNullableFilter surveyAnswers: SurveyAnswerListRelationFilter From 72ae8f0a671bfffb0128da5c17301dbd77ab7d03 Mon Sep 17 00:00:00 2001 From: Muhammad Ahsan Farooq Date: Sun, 11 Jan 2026 15:15:17 +0000 Subject: [PATCH 03/31] Add communication preferences and delegation email settings to user account --- messages/de.json | 3 ++ messages/en.json | 3 ++ .../(authenticated)/my-account/+page.svelte | 34 +++++++++++++------ .../(authenticated)/my-account/form-schema.ts | 3 +- 4 files changed, 31 insertions(+), 12 deletions(-) diff --git a/messages/de.json b/messages/de.json index 0086416c..2ffbee33 100644 --- a/messages/de.json +++ b/messages/de.json @@ -45,6 +45,7 @@ "allConferences": "Alle Konferenzen", "allNations": "Alle Nationen", "allRightsReservedby": "Alle Rechte vorbehalten von", + "allowDelegationMailer": "Delegations-E-Mails erlauben", "alpha3Code": "ISO Alpha 3 Code", "alphabetical": "Alphabetisch", "alreadRegistered": "Bereits angemeldet", @@ -219,6 +220,8 @@ "committeesAndAgendaItems": "Gremien und Themen", "committeesSucessfullyAssigned": "Gremien wurden erfolgreich verteilt.", "communication": "Kommunikation", + "communicationPreferences": "Kommunikationseinstellungen", + "communicationPreferencesDescription": "Wähle, welche Updates du zu deiner Delegation erhalten möchtest.", "compareVersion": "Mit einer anderen Version vergleichen", "compareVersions": "Versionen vergleichen", "completeAddressAndBirthdayForPostalRegistration": "Dein Profil ist leider nicht vollständig. Bitte ergänze deine Adresse und dein Geburtsdatum, damit wir die postalische Anmeldung generieren können.", diff --git a/messages/en.json b/messages/en.json index af8e9c72..9da48abd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -45,6 +45,7 @@ "allConferences": "All Conferences", "allNations": "All Nations", "allRightsReservedby": "All rights reserved by", + "allowDelegationMailer": "Allow delegation emails", "alpha3Code": "ISO Alpha 3 Code", "alphabetical": "alphabetical", "alreadRegistered": "Already registered", @@ -219,6 +220,8 @@ "committeesAndAgendaItems": "Committees and Agenda Items", "committeesSucessfullyAssigned": "Committees successfully assigned", "communication": "Communication", + "communicationPreferences": "Communication preferences", + "communicationPreferencesDescription": "Choose which updates you want to receive about your delegation.", "compareVersion": "Compare with another version", "compareVersions": "Compare Versions", "completeAddressAndBirthdayForPostalRegistration": "Unfortunately, we cannot generate the postal registration documents for you because your address or date of birth is incomplete. Please complete your profile with the necessary information and try again.", diff --git a/src/routes/(authenticated)/my-account/+page.svelte b/src/routes/(authenticated)/my-account/+page.svelte index c9dd42b4..f0732938 100644 --- a/src/routes/(authenticated)/my-account/+page.svelte +++ b/src/routes/(authenticated)/my-account/+page.svelte @@ -16,8 +16,8 @@ import { dev } from '$app/environment'; import FormFieldset from '$lib/components/Form/FormFieldset.svelte'; - let { data }: { data: PageData } = $props(); - let form = superForm(data.form, { + const props = $props<{ data: PageData }>(); + const form = superForm(props.data.form, { resetForm: false, validationMethod: 'oninput', validators: zod4Client(userFormSchema), @@ -29,7 +29,7 @@ //TODO pronoun prefill -{#if data.redirectUrl} +{#if props.data.redirectUrl}
{/if}
@@ -38,7 +38,7 @@ - {#if data.redirectUrl} + {#if props.data.redirectUrl}
@@ -52,8 +52,8 @@
{#if dev} @@ -134,6 +134,17 @@ label={m.receiveJoinTeamInformation()} /> + +

+ {m.communicationPreferencesDescription()} +

+ + +
@@ -146,7 +157,7 @@ {m.email()} - {data.user.email} + {props.data.user.email} @@ -162,22 +173,22 @@ {m.firstName()} - {data.user.given_name} + {props.data.user.given_name} {m.lastName()} - {data.user.family_name} + {props.data.user.family_name} {m.userId()} - {data.user.sub} + {props.data.user.sub} {m.rights()} - {data.user.myOIDCRoles.map((x) => x.toUpperCase()).join(', ')} + {props.data.user.myOIDCRoles.map((x) => x.toUpperCase()).join(', ')} @@ -185,6 +196,7 @@ {m.edit()} +

{@html m.deleteAccountGPDR()}

diff --git a/src/routes/(authenticated)/my-account/form-schema.ts b/src/routes/(authenticated)/my-account/form-schema.ts index cd47a484..f78d29e0 100644 --- a/src/routes/(authenticated)/my-account/form-schema.ts +++ b/src/routes/(authenticated)/my-account/form-schema.ts @@ -62,5 +62,6 @@ export const userFormSchema = z.object({ return res || s; }), wantsToReceiveGeneralInformation: z.boolean().default(false), - wantsJoinTeamInformation: z.boolean().default(false) + wantsJoinTeamInformation: z.boolean().default(false), + canReceiveDelegationMail: z.boolean().default(false).optional() }); From 338561fd03d4927864ba7887885fdc976d995596 Mon Sep 17 00:00:00 2001 From: Muhammad Ahsan Farooq Date: Sun, 11 Jan 2026 16:15:23 +0000 Subject: [PATCH 04/31] Add canReceiveDelegationMail field to user input and update my-account page data handling --- schema.graphql | 1 + src/api/resolvers/modules/user.ts | 3 ++- .../(authenticated)/my-account/+page.svelte | 22 +++++++++---------- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/schema.graphql b/schema.graphql index 5ec25704..8e158161 100644 --- a/schema.graphql +++ b/schema.graphql @@ -5133,6 +5133,7 @@ input UserScalarRelationFilter { input UserUpdateDataInput { apartment: String birthday: DateTime! + canReceiveDelegationMail: Boolean city: String! country: String! emergencyContacts: String! diff --git a/src/api/resolvers/modules/user.ts b/src/api/resolvers/modules/user.ts index 13d6b083..d9d5672d 100644 --- a/src/api/resolvers/modules/user.ts +++ b/src/api/resolvers/modules/user.ts @@ -248,7 +248,8 @@ builder.mutationFields((t) => { wantsToReceiveGeneralInformation: t.boolean({ required: false }), - wantsJoinTeamInformation: t.boolean({ required: false }) + wantsJoinTeamInformation: t.boolean({ required: false }), + canReceiveDelegationMail: t.boolean({ required: false }) }) }) }) diff --git a/src/routes/(authenticated)/my-account/+page.svelte b/src/routes/(authenticated)/my-account/+page.svelte index f0732938..33a7aa37 100644 --- a/src/routes/(authenticated)/my-account/+page.svelte +++ b/src/routes/(authenticated)/my-account/+page.svelte @@ -16,8 +16,8 @@ import { dev } from '$app/environment'; import FormFieldset from '$lib/components/Form/FormFieldset.svelte'; - const props = $props<{ data: PageData }>(); - const form = superForm(props.data.form, { + let { data }: { data: PageData } = $props(); + let form = superForm(data.form, { resetForm: false, validationMethod: 'oninput', validators: zod4Client(userFormSchema), @@ -29,7 +29,7 @@ //TODO pronoun prefill -{#if props.data.redirectUrl} +{#if data.redirectUrl}
{/if}
@@ -38,7 +38,7 @@ - {#if props.data.redirectUrl} + {#if data.redirectUrl}
@@ -52,8 +52,8 @@
{#if dev} @@ -157,7 +157,7 @@ {m.email()} - {props.data.user.email} + {data.user.email} @@ -173,22 +173,22 @@ {m.firstName()} - {props.data.user.given_name} + {data.user.given_name} {m.lastName()} - {props.data.user.family_name} + {data.user.family_name} {m.userId()} - {props.data.user.sub} + {data.user.sub} {m.rights()} - {props.data.user.myOIDCRoles.map((x) => x.toUpperCase()).join(', ')} + {data.user.myOIDCRoles.map((x) => x.toUpperCase()).join(', ')} From 31e54fe67d95df08819c1bbaf83810232e0b9b43 Mon Sep 17 00:00:00 2001 From: Muhammad Ahsan Farooq Date: Tue, 13 Jan 2026 17:03:13 +0000 Subject: [PATCH 05/31] Implement messaging feature with compose and history views, including recipient selection and message sending functionality --- .../dashboard/[conferenceId]/+page.svelte | 14 +++++ .../[conferenceId]/messaging/+page.svelte | 12 ++++ .../messaging/compose/+page.server.ts | 19 ++++++ .../messaging/compose/+page.svelte | 58 +++++++++++++++++++ .../messaging/compose/send/+server.ts | 16 +++++ .../messaging/history/+page.svelte | 25 ++++++++ .../messaging/history/list/+server.ts | 20 +++++++ .../messaging/search/+server.ts | 13 +++++ 8 files changed, 177 insertions(+) create mode 100644 src/routes/(authenticated)/dashboard/[conferenceId]/messaging/+page.svelte create mode 100644 src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.server.ts create mode 100644 src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte create mode 100644 src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/send/+server.ts create mode 100644 src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/+page.svelte create mode 100644 src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/list/+server.ts create mode 100644 src/routes/(authenticated)/dashboard/[conferenceId]/messaging/search/+server.ts diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/+page.svelte b/src/routes/(authenticated)/dashboard/[conferenceId]/+page.svelte index 5a0409c9..6230a1ac 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/+page.svelte +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/+page.svelte @@ -47,6 +47,13 @@ unlockPayment={conference?.unlockPayments} unlockPostals={conference?.unlockPostals} /> + + + + + import { page } from '$app/stores'; + $: conferenceId = $page.params.conferenceId; + + +

Messaging

+ + + diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.server.ts b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.server.ts new file mode 100644 index 00000000..8f339613 --- /dev/null +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.server.ts @@ -0,0 +1,19 @@ +import type { RequestEvent } from '@sveltejs/kit'; +import { json } from '@sveltejs/kit'; + +export async function POST(event: RequestEvent) { + const conferenceId = event.params.conferenceId; + const user = event.locals.user; + if (!user) return json({ error: 'Unauthorized' }, { status: 401 }); + + const body = await event.request.json(); + const { recipientId, subject, body: messageBody } = body; + if (!recipientId || !subject || !messageBody) + return json({ error: 'Missing fields' }, { status: 400 }); + + // TODO: Rate limit check via MessageAudit counts + // TODO: Verify recipient opt-in and eligibility + // TODO: Insert MessageAudit record and trigger transactional email + + return json({ status: 'ok' }); +} diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte new file mode 100644 index 00000000..9ed85a5d --- /dev/null +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte @@ -0,0 +1,58 @@ + + +

Compose Message

+{#if error}
{error}
{/if} +
+ + + + +
diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/send/+server.ts b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/send/+server.ts new file mode 100644 index 00000000..40f55a64 --- /dev/null +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/send/+server.ts @@ -0,0 +1,16 @@ +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ request, locals, params }) => { + const user = locals.user; + if (!user) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }); + const body = await request.json(); + const { recipientId, subject, body: messageBody } = body; + if (!recipientId || !subject || !messageBody) + return new Response(JSON.stringify({ error: 'Missing fields' }), { status: 400 }); + + // TODO: Rate limiting, opt-in check, MessageAudit insertion, email send + + return new Response(JSON.stringify({ status: 'ok' }), { + headers: { 'content-type': 'application/json' } + }); +}; diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/+page.svelte b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/+page.svelte new file mode 100644 index 00000000..ed3dc7da --- /dev/null +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/+page.svelte @@ -0,0 +1,25 @@ + + +

Sent Messages

+ + + + {#each messages as m} + + + + + + + {/each} + +
RecipientSubjectSentStatus
{m.recipientLabel}{m.subject}{m.sentAt}{m.status}
diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/list/+server.ts b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/list/+server.ts new file mode 100644 index 00000000..20ecb7dd --- /dev/null +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/list/+server.ts @@ -0,0 +1,20 @@ +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params, locals }) => { + // TODO: Query MessageAudit where senderId = locals.user.id and conferenceId + const items = [ + { + recipientLabel: 'Germany', + subject: 'Merging working paper', + sentAt: '2026-01-12T10:00:00Z', + status: 'Sent' + }, + { + recipientLabel: 'ICJ Judge', + subject: 'Question about draft', + sentAt: '2026-01-11T09:00:00Z', + status: 'Sent' + } + ]; + return new Response(JSON.stringify(items), { headers: { 'content-type': 'application/json' } }); +}; diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/search/+server.ts b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/search/+server.ts new file mode 100644 index 00000000..0651e42e --- /dev/null +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/search/+server.ts @@ -0,0 +1,13 @@ +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params }) => { + // TODO: Query DB for active DelegationMember and SingleParticipant in conference with opt-in + // For now return placeholder + const conferenceId = params.conferenceId; + const items = [ + { id: 'dm-1', label: 'Germany' }, + { id: 'dm-2', label: 'Amnesty International (J.D.)' }, + { id: 'sp-1', label: 'ICJ Judge' } + ]; + return new Response(JSON.stringify(items), { headers: { 'content-type': 'application/json' } }); +}; From 01a507299062235ab60eea3bee5a39979c2f1d5b Mon Sep 17 00:00:00 2001 From: Muhammad Ahsan Farooq Date: Tue, 13 Jan 2026 23:00:57 +0500 Subject: [PATCH 06/31] added basic UI for messaging --- schema.graphql | 209 ------------------ .../dashboard/[conferenceId]/+page.svelte | 8 + .../[conferenceId]/messaging/+page.svelte | 120 +++++++++- .../messaging/compose/+page.svelte | 162 ++++++++++++-- .../compose/{+page.server.ts => +server.ts} | 3 +- .../messaging/history/+page.svelte | 109 +++++++-- 6 files changed, 356 insertions(+), 255 deletions(-) rename src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/{+page.server.ts => +server.ts} (92%) diff --git a/schema.graphql b/schema.graphql index 8e158161..028ecc3c 100644 --- a/schema.graphql +++ b/schema.graphql @@ -388,7 +388,6 @@ input ConferenceCreateInput { location: String longTitle: String mediaConsentContent: String - messageAudits: MessageAuditCreateNestedManyWithoutConferenceInput nonStateActors: NonStateActorCreateNestedManyWithoutConferenceInput papers: PaperCreateNestedManyWithoutConferenceInput paymentTransactions: PaymentTransactionCreateNestedManyWithoutConferenceInput @@ -442,7 +441,6 @@ input ConferenceOrderByWithRelationInput { location: SortOrder longTitle: SortOrder mediaConsentContent: SortOrder - messageAudits: MessageAuditOrderByRelationAggregateInput nonStateActors: NonStateActorOrderByRelationAggregateInput papers: PaperOrderByRelationAggregateInput paymentTransactions: PaymentTransactionOrderByRelationAggregateInput @@ -941,7 +939,6 @@ input ConferenceUpdateInput { location: NullableStringFieldUpdateOperationsInput longTitle: NullableStringFieldUpdateOperationsInput mediaConsentContent: NullableStringFieldUpdateOperationsInput - messageAudits: MessageAuditUpdateManyWithoutConferenceNestedInput nonStateActors: NonStateActorUpdateManyWithoutConferenceNestedInput papers: PaperUpdateManyWithoutConferenceNestedInput paymentTransactions: PaymentTransactionUpdateManyWithoutConferenceNestedInput @@ -1038,7 +1035,6 @@ input ConferenceWhereInput { location: StringNullableFilter longTitle: StringNullableFilter mediaConsentContent: StringNullableFilter - messageAudits: MessageAuditListRelationFilter nonStateActors: NonStateActorListRelationFilter papers: PaperListRelationFilter paymentTransactions: PaymentTransactionListRelationFilter @@ -1095,7 +1091,6 @@ input ConferenceWhereUniqueInput { location: StringNullableFilter longTitle: StringNullableFilter mediaConsentContent: StringNullableFilter - messageAudits: MessageAuditListRelationFilter nonStateActors: NonStateActorListRelationFilter papers: PaperListRelationFilter paymentTransactions: PaymentTransactionListRelationFilter @@ -1827,27 +1822,6 @@ input EnumMediaConsentStatusWithAggregatesFilter { notIn: [MediaConsentStatus!] } -input EnumMessageStatusFieldUpdateOperationsInput { - set: MessageStatus -} - -input EnumMessageStatusFilter { - equals: MessageStatus - in: [MessageStatus!] - not: MessageStatus - notIn: [MessageStatus!] -} - -input EnumMessageStatusWithAggregatesFilter { - _count: NestedIntFilter - _max: NestedEnumMessageStatusFilter - _min: NestedEnumMessageStatusFilter - equals: MessageStatus - in: [MessageStatus!] - not: MessageStatus - notIn: [MessageStatus!] -} - input EnumPaperStatusFieldUpdateOperationsInput { set: PaperStatus } @@ -2168,155 +2142,6 @@ enum MediaConsentStatus { PARTIALLY_ALLOWED } -input MessageAuditCreateInput { - body: String! - conferenceId: String! - createdAt: DateTime - id: String - messageId: String - recipientUserId: String! - senderUserId: String! - status: MessageStatus - subject: String! - updatedAt: DateTime -} - -input MessageAuditCreateNestedManyWithoutConferenceInput { - connect: [MessageAuditWhereUniqueInput!] -} - -input MessageAuditCreateNestedManyWithoutRecipientUserInput { - connect: [MessageAuditWhereUniqueInput!] -} - -input MessageAuditCreateNestedManyWithoutSenderUserInput { - connect: [MessageAuditWhereUniqueInput!] -} - -input MessageAuditListRelationFilter { - every: MessageAuditWhereInput - none: MessageAuditWhereInput - some: MessageAuditWhereInput -} - -input MessageAuditOrderByRelationAggregateInput { - _count: SortOrder -} - -input MessageAuditOrderByWithRelationInput { - body: SortOrder - conference: ConferenceOrderByWithRelationInput - conferenceId: SortOrder - createdAt: SortOrder - id: SortOrder - messageId: SortOrder - recipientUser: UserOrderByWithRelationInput - recipientUserId: SortOrder - senderUser: UserOrderByWithRelationInput - senderUserId: SortOrder - status: SortOrder - subject: SortOrder - updatedAt: SortOrder -} - -enum MessageAuditScalarFieldEnum { - body - conferenceId - createdAt - id - messageId - recipientUserId - senderUserId - status - subject - updatedAt -} - -input MessageAuditUpdateInput { - body: StringFieldUpdateOperationsInput - conferenceId: StringFieldUpdateOperationsInput - createdAt: DateTimeFieldUpdateOperationsInput - id: StringFieldUpdateOperationsInput - messageId: NullableStringFieldUpdateOperationsInput - recipientUserId: StringFieldUpdateOperationsInput - senderUserId: StringFieldUpdateOperationsInput - status: EnumMessageStatusFieldUpdateOperationsInput - subject: StringFieldUpdateOperationsInput - updatedAt: DateTimeFieldUpdateOperationsInput -} - -input MessageAuditUpdateManyMutationInput { - body: StringFieldUpdateOperationsInput - createdAt: DateTimeFieldUpdateOperationsInput - id: StringFieldUpdateOperationsInput - messageId: NullableStringFieldUpdateOperationsInput - status: EnumMessageStatusFieldUpdateOperationsInput - subject: StringFieldUpdateOperationsInput - updatedAt: DateTimeFieldUpdateOperationsInput -} - -input MessageAuditUpdateManyWithoutConferenceNestedInput { - connect: [MessageAuditWhereUniqueInput!] - disconnect: [MessageAuditWhereUniqueInput!] - set: [MessageAuditWhereUniqueInput!] -} - -input MessageAuditUpdateManyWithoutRecipientUserNestedInput { - connect: [MessageAuditWhereUniqueInput!] - disconnect: [MessageAuditWhereUniqueInput!] - set: [MessageAuditWhereUniqueInput!] -} - -input MessageAuditUpdateManyWithoutSenderUserNestedInput { - connect: [MessageAuditWhereUniqueInput!] - disconnect: [MessageAuditWhereUniqueInput!] - set: [MessageAuditWhereUniqueInput!] -} - -input MessageAuditWhereInput { - AND: [MessageAuditWhereInput!] - NOT: [MessageAuditWhereInput!] - OR: [MessageAuditWhereInput!] - body: StringFilter - conference: ConferenceWhereInput - conferenceId: StringFilter - createdAt: DateTimeFilter - id: StringFilter - messageId: StringNullableFilter - recipientUser: UserWhereInput - recipientUserId: StringFilter - senderUser: UserWhereInput - senderUserId: StringFilter - status: EnumMessageStatusFilter - subject: StringFilter - updatedAt: DateTimeFilter -} - -input MessageAuditWhereUniqueInput { - AND: [MessageAuditWhereInput!] - NOT: [MessageAuditWhereInput!] - OR: [MessageAuditWhereInput!] - body: StringFilter - conference: ConferenceWhereInput - conferenceId: StringFilter - createdAt: DateTimeFilter - id: String - messageId: StringNullableFilter - recipientUser: UserWhereInput - recipientUserId: StringFilter - senderUser: UserWhereInput - senderUserId: StringFilter - status: EnumMessageStatusFilter - subject: StringFilter - updatedAt: DateTimeFilter -} - -enum MessageStatus { - BLOCKED - FAILED - SENT -} - type Mutation { assignCommitteesToDelegationMembers(assignments: [updateManyDelegationMemberInputTypeArrayValue!]!): [DelegationMember!]! connectToConferenceSupervisor(conferenceId: ID!, connectionCode: String!, userId: ID): ConferenceSupervisor @@ -2632,23 +2457,6 @@ input NestedEnumMediaConsentStatusWithAggregatesFilter { notIn: [MediaConsentStatus!] } -input NestedEnumMessageStatusFilter { - equals: MessageStatus - in: [MessageStatus!] - not: MessageStatus - notIn: [MessageStatus!] -} - -input NestedEnumMessageStatusWithAggregatesFilter { - _count: NestedIntFilter - _max: NestedEnumMessageStatusFilter - _min: NestedEnumMessageStatusFilter - equals: MessageStatus - in: [MessageStatus!] - not: MessageStatus - notIn: [MessageStatus!] -} - input NestedEnumPaperStatusFilter { equals: PaperStatus in: [PaperStatus!] @@ -4921,7 +4729,6 @@ type User { input UserCreateInput { apartment: String birthday: DateTime - canReceiveDelegationMail: Boolean city: String conferenceParticipantStatus: ConferenceParticipantStatusCreateNestedManyWithoutUserInput conferenceSupervisor: ConferenceSupervisorCreateNestedManyWithoutUserInput @@ -4944,8 +4751,6 @@ input UserCreateInput { phone: String preferred_username: String! pronouns: String - receivedDelegationMessages: MessageAuditCreateNestedManyWithoutRecipientUserInput - sentDelegationMessages: MessageAuditCreateNestedManyWithoutSenderUserInput singleParticipant: SingleParticipantCreateNestedManyWithoutUserInput street: String surveyAnswers: SurveyAnswerCreateNestedManyWithoutUserInput @@ -4960,7 +4765,6 @@ input UserCreateInput { input UserOrderByWithRelationInput { apartment: SortOrder birthday: SortOrder - canReceiveDelegationMail: SortOrder city: SortOrder conferenceParticipantStatus: ConferenceParticipantStatusOrderByRelationAggregateInput conferenceSupervisor: ConferenceSupervisorOrderByRelationAggregateInput @@ -4983,8 +4787,6 @@ input UserOrderByWithRelationInput { phone: SortOrder preferred_username: SortOrder pronouns: SortOrder - receivedDelegationMessages: MessageAuditOrderByRelationAggregateInput - sentDelegationMessages: MessageAuditOrderByRelationAggregateInput singleParticipant: SingleParticipantOrderByRelationAggregateInput street: SortOrder surveyAnswers: SurveyAnswerOrderByRelationAggregateInput @@ -5102,7 +4904,6 @@ input UserReferenceInPaymentTransactionWhereUniqueInput { enum UserScalarFieldEnum { apartment birthday - canReceiveDelegationMail city country createdAt @@ -5150,7 +4951,6 @@ input UserUpdateDataInput { input UserUpdateInput { apartment: NullableStringFieldUpdateOperationsInput birthday: NullableDateTimeFieldUpdateOperationsInput - canReceiveDelegationMail: BoolFieldUpdateOperationsInput city: NullableStringFieldUpdateOperationsInput conferenceParticipantStatus: ConferenceParticipantStatusUpdateManyWithoutUserNestedInput conferenceSupervisor: ConferenceSupervisorUpdateManyWithoutUserNestedInput @@ -5173,8 +4973,6 @@ input UserUpdateInput { phone: NullableStringFieldUpdateOperationsInput preferred_username: StringFieldUpdateOperationsInput pronouns: NullableStringFieldUpdateOperationsInput - receivedDelegationMessages: MessageAuditUpdateManyWithoutRecipientUserNestedInput - sentDelegationMessages: MessageAuditUpdateManyWithoutSenderUserNestedInput singleParticipant: SingleParticipantUpdateManyWithoutUserNestedInput street: NullableStringFieldUpdateOperationsInput surveyAnswers: SurveyAnswerUpdateManyWithoutUserNestedInput @@ -5189,7 +4987,6 @@ input UserUpdateInput { input UserUpdateManyMutationInput { apartment: NullableStringFieldUpdateOperationsInput birthday: NullableDateTimeFieldUpdateOperationsInput - canReceiveDelegationMail: BoolFieldUpdateOperationsInput city: NullableStringFieldUpdateOperationsInput country: NullableStringFieldUpdateOperationsInput createdAt: DateTimeFieldUpdateOperationsInput @@ -5218,7 +5015,6 @@ input UserWhereInput { OR: [UserWhereInput!] apartment: StringNullableFilter birthday: DateTimeNullableFilter - canReceiveDelegationMail: BoolFilter city: StringNullableFilter conferenceParticipantStatus: ConferenceParticipantStatusListRelationFilter conferenceSupervisor: ConferenceSupervisorListRelationFilter @@ -5241,8 +5037,6 @@ input UserWhereInput { phone: StringNullableFilter preferred_username: StringFilter pronouns: StringNullableFilter - receivedDelegationMessages: MessageAuditListRelationFilter - sentDelegationMessages: MessageAuditListRelationFilter singleParticipant: SingleParticipantListRelationFilter street: StringNullableFilter surveyAnswers: SurveyAnswerListRelationFilter @@ -5260,7 +5054,6 @@ input UserWhereUniqueInput { OR: [UserWhereInput!] apartment: StringNullableFilter birthday: DateTimeNullableFilter - canReceiveDelegationMail: BoolFilter city: StringNullableFilter conferenceParticipantStatus: ConferenceParticipantStatusListRelationFilter conferenceSupervisor: ConferenceSupervisorListRelationFilter @@ -5283,8 +5076,6 @@ input UserWhereUniqueInput { phone: StringNullableFilter preferred_username: StringFilter pronouns: StringNullableFilter - receivedDelegationMessages: MessageAuditListRelationFilter - sentDelegationMessages: MessageAuditListRelationFilter singleParticipant: SingleParticipantListRelationFilter street: StringNullableFilter surveyAnswers: SurveyAnswerListRelationFilter diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/+page.svelte b/src/routes/(authenticated)/dashboard/[conferenceId]/+page.svelte index 6230a1ac..fa05e414 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/+page.svelte +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/+page.svelte @@ -29,6 +29,14 @@
+ {#if conference?.id} + + {/if} {#if singleParticipant?.id} {#if conference!.state === 'PARTICIPANT_REGISTRATION'} diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/+page.svelte b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/+page.svelte index 53dc2826..3a9738aa 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/+page.svelte +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/+page.svelte @@ -1,12 +1,120 @@ -

Messaging

- +
+ + - +
+
+

Conference Messaging

+

Messaging Center

+

+ Reach delegations and participants quickly. Compose announcements, track delivery, and keep + the conference aligned. +

+
+ + +
+
+ +
+
+
+

Quick actions

+

+ Start a targeted message to delegations, committees, or individual participants. +

+ +
+
+ + +
diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte index 9ed85a5d..52a4de4a 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte @@ -6,10 +6,23 @@ let subject = ''; let body = ''; let error = ''; + let loadingRecipients = true; + let loadError = ''; + $: conferenceId = $page.params.conferenceId; + $: basePath = `/dashboard/${conferenceId}/messaging`; + $: subjectCount = subject.length; + $: bodyCount = body.length; async function loadRecipients() { - const res = await fetch('./search'); - if (res.ok) recipients = await res.json(); + loadError = ''; + loadingRecipients = true; + const res = await fetch('../search'); + if (res.ok) { + recipients = await res.json(); + } else { + loadError = 'Unable to load recipients'; + } + loadingRecipients = false; } onMount(() => { @@ -34,25 +47,126 @@ } -

Compose Message

-{#if error}
{error}
{/if} -
- - - - -
+
+
+
+

Messaging

+

Compose message

+

+ Deliver clear, actionable updates to conference participants. +

+
+ +
+
+ +
+
+
+
+

Message details

+ Draft +
+ + {#if error} +
+ + {error} +
+ {/if} + +
+ + + + + + +
+

+ This message will be logged in the delivery history. +

+ +
+
+
+
+ + +
diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.server.ts b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+server.ts similarity index 92% rename from src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.server.ts rename to src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+server.ts index 8f339613..193c7f7a 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.server.ts +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+server.ts @@ -8,8 +8,9 @@ export async function POST(event: RequestEvent) { const body = await event.request.json(); const { recipientId, subject, body: messageBody } = body; - if (!recipientId || !subject || !messageBody) + if (!recipientId || !subject || !messageBody) { return json({ error: 'Missing fields' }, { status: 400 }); + } // TODO: Rate limit check via MessageAudit counts // TODO: Verify recipient opt-in and eligibility diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/+page.svelte b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/+page.svelte index ed3dc7da..43cd7da7 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/+page.svelte +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/+page.svelte @@ -1,25 +1,104 @@ -

Sent Messages

- - - - {#each messages as m} - - - - - - - {/each} - -
RecipientSubjectSentStatus
{m.recipientLabel}{m.subject}{m.sentAt}{m.status}
+
+
+
+

Messaging

+

Sent history

+

+ Review delivery status and message activity for this conference. +

+
+ +
+
+ +
+
+
+

Delivery log

+ + + New message + +
+ + {#if loadError} +
+ + {loadError} +
+ {/if} + +
+ + + + + + + + + + + {#if loading} + + + + {:else if messages.length === 0} + + + + {:else} + {#each messages as m} + + + + + + + {/each} + {/if} + +
RecipientSubjectSentStatus
+
+ + Loading sent messages... +
+
+
+ No messages sent yet. Send your first update from the compose page. +
+
{m.recipientLabel}{m.subject} + {new Date(m.sentAt).toLocaleString()} + + {m.status} +
+
+
+
From a5b8632c1a97734721ea2fe70d1ae8c5f39f9074 Mon Sep 17 00:00:00 2001 From: Muhammad Ahsan Farooq Date: Tue, 13 Jan 2026 23:33:14 +0500 Subject: [PATCH 07/31] added instruction to run project on windows --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index 0a967771..dee7f972 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,21 @@ bun run dev:server # starts the actual dev server (vite&sveltekit) bunx lefthook install ``` +## Running on Windows + +Some `package.json` scripts in this repository assume a Unix-like shell (bash) and may not work out of the box on Windows `cmd.exe` or PowerShell. If you develop on Windows, consider one of the options below: + +- **Use WSL or Git Bash:** Install Windows Subsystem for Linux (WSL) or Git for Windows and run the development commands from there. This is the easiest option when scripts use shell operators (like `&&`, `rm`, or `sed`). +- **Modify `package.json` scripts for PowerShell / cmd:** Replace Unix-only commands with cross-platform equivalents or use Node-based packages. + +if you are running on windows then change copy the following script tags and replace the cooresponding script tags in package.json + +```bash +"dev": "concurrently \"bun run dev:server\" \"bun run dev:docker\"", +"dev:docker": "docker compose -f ./dev.docker-compose.yml up", +"dev:server": "bunx tsx -e \"const {spawn}=require('child_process'); const sleep=(ms)=>new Promise(r=>setTimeout(r,ms)); const run=(cmd,args)=>new Promise(res=>{const p=spawn(cmd,args,{stdio:'inherit',shell:process.platform==='win32'}); p.on('exit',c=>res(c??0));}); (async()=>{for(;;){let c=await run('bunx',['svelte-kit','sync']); if(c!==0){console.log('🔄 sync failed, retrying...'); await sleep(1000); continue;} c=await run('bunx',['vite']); console.log('🔄 Server exited, restarting...'); await sleep(1000);}})();\"", +``` + ## Deployment The easiest way to deploy delegator on your own hardware is to use our provided [docker images](https://hub.docker.com/r/deutschemodelunitednations/delegator). You can find an example docker compose file in the [example](./example/) directoy. Please note that delegator relies on an [OIDC](https://auth0.com/intro-to-iam/what-is-openid-connect-oidc) issuer to be connected and properly configured. We recommend [ZITADEL](https://zitadel.com/) but any issuer of your choice will work. There are some additional instructions on this topic to be found in the example compose file. From 669105bafe0137585ae2bc75aa9db9ff72775d7e Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:55:03 +0000 Subject: [PATCH 08/31] feat: complete messaging feature with opt-in and privacy controls - Implement `search` endpoint to list available recipients by role (Nation/NSA/Role) with opt-in filtering. - Implement `send` endpoint to validate opt-in status and send privacy-masked emails. - Add `canReceiveDelegationMail` boolean to User schema in GraphQL. - Add opt-in toggle to "My Account" page. - Update Compose page to handle "Reply" parameters and show opt-in warning. - Add utility for consistent delegate label generation. --- src/api/resolvers/modules/user.ts | 1 + .../queries/myConferenceparticipationQuery.ts | 1 + .../messaging/compose/+page.svelte | 20 +++ .../messaging/compose/send/+server.ts | 141 +++++++++++++++++- .../messaging/search/+server.ts | 83 ++++++++++- .../[conferenceId]/messaging/utils.ts | 37 +++++ .../my-account/+page.server.ts | 1 + .../(authenticated)/my-account/+page.svelte | 13 +- 8 files changed, 281 insertions(+), 16 deletions(-) create mode 100644 src/routes/(authenticated)/dashboard/[conferenceId]/messaging/utils.ts diff --git a/src/api/resolvers/modules/user.ts b/src/api/resolvers/modules/user.ts index d9d5672d..877ffc29 100644 --- a/src/api/resolvers/modules/user.ts +++ b/src/api/resolvers/modules/user.ts @@ -55,6 +55,7 @@ export const GQLUser = builder.prismaObject('User', { emergencyContacts: t.field(UserEmergencyContactsFieldObject), wantsToReceiveGeneralInformation: t.field(UserWantsToReceiveGeneralInformationFieldObject), wantsJoinTeamInformation: t.field(UserWantsJoinTeamInformationFieldObject), + canReceiveDelegationMail: t.exposeBoolean('canReceiveDelegationMail', { nullable: false }), globalNotes: t.field(UserGlobalNotesFieldObject), papers: t.relation('papers', { query: (_args, ctx) => ({ diff --git a/src/lib/queries/myConferenceparticipationQuery.ts b/src/lib/queries/myConferenceparticipationQuery.ts index 0950f948..7f18c53d 100644 --- a/src/lib/queries/myConferenceparticipationQuery.ts +++ b/src/lib/queries/myConferenceparticipationQuery.ts @@ -4,6 +4,7 @@ export const myConferenceparticipationQuery = graphql(` query MyConferenceparticipationQuery($userId: String!, $conferenceId: String!) { findUniqueUser(where: { id: $userId }) { birthday + canReceiveDelegationMail } findUniqueConferenceParticipantStatus( where: { userId_conferenceId: { userId: $userId, conferenceId: $conferenceId } } diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte index 52a4de4a..a20228e5 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte @@ -1,6 +1,9 @@ + + + + Neue Nachricht von Delegierten + + + +
+ + Neue Nachricht + + + Hallo, + + + du hast eine neue Nachricht von {senderLabel} erhalten. + + + + Betreff: {subject} + + +
+ {#each messageLines as line} + + {line || ' '} + + {/each} +
+ +
+ + Jetzt antworten + +
+ +
+ + + Viele Gruesse,
Das {conferenceTitle} Team +
+ + + Diese Nachricht wurde ueber das Messaging-System von {conferenceTitle} gesendet. Deine E-Mail-Adresse + wurde dem Absender nicht offengelegt. + +
+
+ + diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte index a20228e5..63129ba8 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte @@ -21,7 +21,7 @@ async function loadRecipients() { loadError = ''; loadingRecipients = true; - const res = await fetch('../search'); + const res = await fetch(`/dashboard/${conferenceId}/messaging/search`); if (res.ok) { recipients = await res.json(); } else { diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/send/+server.ts b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/send/+server.ts index 1aed2d13..9fe16577 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/send/+server.ts +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/send/+server.ts @@ -1,160 +1,200 @@ import type { RequestHandler } from './$types'; import { db } from '$db/db'; import { emailService } from '$api/services/email/emailService'; +import { renderDelegationMessageEmail } from '$api/services/email/delegationMessageTemplates'; import { getDelegateLabel } from '../../utils'; export const POST: RequestHandler = async ({ request, locals, params, url }) => { - const user = locals.user; - if (!user) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }); + const authUser = (locals as { user?: { sub?: string } }).user; + if (!authUser?.sub) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }); + } - const conferenceId = params.conferenceId; + const conferenceId = params.conferenceId; + if (!conferenceId) { + return new Response(JSON.stringify({ error: 'Missing conference id' }), { status: 400 }); + } const bodyData = await request.json(); const { recipientId, subject, body: messageBody } = bodyData; - if (!recipientId || !subject || !messageBody) + if (!recipientId || !subject || !messageBody) { return new Response(JSON.stringify({ error: 'Missing fields' }), { status: 400 }); + } + + if (recipientId === authUser.sub) { + return new Response(JSON.stringify({ error: 'Cannot send to yourself' }), { status: 400 }); + } + + // 1. Validate Sender Opt-in + const sender = await db.user.findUnique({ + where: { id: authUser.sub }, + select: { + // canReceiveDelegationMail: true, // TODO: Re-enable after prisma generate + id: true, + given_name: true, + family_name: true + } + }); + + if (!sender) { + return new Response(JSON.stringify({ error: 'Sender not found' }), { status: 404 }); + } + + // TODO: Re-enable check + // if (!sender?.canReceiveDelegationMail) { + // return new Response(JSON.stringify({ error: 'You must enable messaging in your account settings.' }), { status: 403 }); + // } + + // 2. Validate Recipient Opt-in + const recipient = await db.user.findUnique({ + where: { id: recipientId }, + select: { + // canReceiveDelegationMail: true, // TODO: Re-enable after prisma generate + email: true, + id: true + } + }); + + if (!recipient) { + return new Response(JSON.stringify({ error: 'Recipient not found' }), { status: 404 }); + } + + // TODO: Re-enable check + // if (!recipient?.canReceiveDelegationMail) { + // return new Response(JSON.stringify({ error: 'Recipient has not enabled messaging.' }), { status: 400 }); + // } - // 1. Validate Sender Opt-in - const sender = await db.user.findUnique({ - where: { id: user.sub }, - select: { - // canReceiveDelegationMail: true, // TODO: Re-enable after prisma generate - id: true, - given_name: true, - family_name: true - } - }); - - // TODO: Re-enable check - // if (!sender?.canReceiveDelegationMail) { - // return new Response(JSON.stringify({ error: 'You must enable messaging in your account settings.' }), { status: 403 }); - // } - - // 2. Validate Recipient Opt-in - const recipient = await db.user.findUnique({ - where: { id: recipientId }, - select: { - // canReceiveDelegationMail: true, // TODO: Re-enable after prisma generate - email: true, - id: true - } - }); - - // TODO: Re-enable check - // if (!recipient?.canReceiveDelegationMail) { - // return new Response(JSON.stringify({ error: 'Recipient has not enabled messaging.' }), { status: 400 }); - // } - - // 3. Get Conference Details - const conference = await db.conference.findUnique({ - where: { id: conferenceId }, - select: { title: true } - }); - - if (!conference) { - return new Response(JSON.stringify({ error: 'Conference not found' }), { status: 404 }); - } - - // 4. Determine Sender Label (Role) - const dm = await db.delegationMember.findUnique({ - where: { - conferenceId_userId: { - conferenceId: conferenceId, - userId: sender.id - } - }, - include: { - delegation: { - include: { - assignedNation: true, - assignedNonStateActor: true - } - }, - assignedCommittee: true - } - }); - - let sp = null; - if (!dm) { - sp = await db.singleParticipant.findUnique({ - where: { - conferenceId_userId: { - conferenceId: conferenceId, - userId: sender.id - } - }, - include: { - assignedRole: true - } - }); - } - - const senderLabel = getDelegateLabel(sender, dm, sp); - - // 5. Create MessageAudit (Optimistic) - const audit = await db.messageAudit.create({ - data: { - subject, - body: messageBody, - senderUserId: sender.id, - recipientUserId: recipient.id, - conferenceId, - status: 'SENT' - } - }); - - // 6. Send Email - const replyLink = `${url.origin}/dashboard/${conferenceId}/messaging/compose?recipientId=${sender.id}&subject=Re: ${encodeURIComponent(subject)}`; - const safeBody = escapeHtml(messageBody).replace(/\n/g, '
'); - - const html = ` -

Hallo,

-

Du hast eine neue Nachricht von ${escapeHtml(senderLabel)} erhalten.

-

Betreff: ${escapeHtml(subject)}

-

Nachricht:

-
- ${safeBody} -
-

Hier klicken, um zu antworten

-

Viele Grüße,
Das ${escapeHtml(conference.title)} Team

-

Diese Nachricht wurde über das Messaging-System von ${escapeHtml(conference.title)} gesendet. Deine E-Mail-Adresse wurde dem Absender nicht offengelegt.

- `; - - const result = await emailService.sendEmail({ - to: recipient.email, - subject: `[${conference.title}] Neue Nachricht: ${subject}`, - html: html, - }); - - if (!result.success) { - console.error("Email sending failed", result.error); - - await db.messageAudit.update({ - where: { id: audit.id }, - data: { status: 'FAILED' } - }); - - return new Response(JSON.stringify({ error: 'Failed to send email' }), { status: 500 }); - } else { - // Update with messageId if available - if (result.messageId) { - await db.messageAudit.update({ - where: { id: audit.id }, - data: { messageId: result.messageId } - }); - } - } + // 3. Get Conference Details + const conference = await db.conference.findUnique({ + where: { id: conferenceId }, + select: { title: true } + }); + + if (!conference) { + return new Response(JSON.stringify({ error: 'Conference not found' }), { status: 404 }); + } + + // 4. Determine Sender Label (Role) + const senderDelegationMember = await db.delegationMember.findUnique({ + where: { + conferenceId_userId: { + conferenceId: conferenceId, + userId: sender.id + } + }, + include: { + delegation: { + include: { + assignedNation: true, + assignedNonStateActor: true + } + }, + assignedCommittee: true + } + }); + + let senderSingleParticipant: Awaited> = null; + if (!senderDelegationMember) { + senderSingleParticipant = await db.singleParticipant.findUnique({ + where: { + conferenceId_userId: { + conferenceId: conferenceId, + userId: sender.id + } + }, + include: { + assignedRole: true + } + }); + } + + if (!senderDelegationMember && !senderSingleParticipant) { + return new Response(JSON.stringify({ error: 'Sender is not part of this conference' }), { + status: 403 + }); + } + + const senderLabel = getDelegateLabel(sender, senderDelegationMember, senderSingleParticipant); + + const recipientDelegationMember = await db.delegationMember.findUnique({ + where: { + conferenceId_userId: { + conferenceId: conferenceId, + userId: recipient.id + } + } + }); + + let recipientSingleParticipant: Awaited> = + null; + if (!recipientDelegationMember) { + recipientSingleParticipant = await db.singleParticipant.findUnique({ + where: { + conferenceId_userId: { + conferenceId: conferenceId, + userId: recipient.id + } + } + }); + } + + if (!recipientDelegationMember && !recipientSingleParticipant) { + return new Response(JSON.stringify({ error: 'Recipient is not part of this conference' }), { + status: 400 + }); + } + + // 5. Create MessageAudit (Optimistic) + const audit = await db.messageAudit.create({ + data: { + subject, + body: messageBody, + senderUserId: sender.id, + recipientUserId: recipient.id, + conferenceId, + status: 'SENT' + } + }); + + // 6. Send Email + const replySubject = `Re: ${subject}`; + const replyLink = `${url.origin}/dashboard/${conferenceId}/messaging/compose?recipientId=${encodeURIComponent(sender.id)}&subject=${encodeURIComponent(replySubject)}`; + + const { html, text } = await renderDelegationMessageEmail({ + senderLabel, + subject, + messageBody, + conferenceTitle: conference.title, + replyUrl: replyLink + }); + + const result = await emailService.sendEmail({ + to: recipient.email, + subject: `[${conference.title}] Neue Nachricht: ${subject}`, + html, + text + }); + + if (!result.success) { + console.error('Email sending failed', result.error); + + await db.messageAudit.update({ + where: { id: audit.id }, + data: { status: 'FAILED' } + }); + + return new Response(JSON.stringify({ error: 'Failed to send email' }), { status: 500 }); + } else { + // Update with messageId if available + if (result.messageId) { + await db.messageAudit.update({ + where: { id: audit.id }, + data: { messageId: result.messageId } + }); + } + } return new Response(JSON.stringify({ status: 'ok' }), { headers: { 'content-type': 'application/json' } }); }; - -function escapeHtml(text: string) { - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/search/+server.ts b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/search/+server.ts index 8cf80671..25a07ea5 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/search/+server.ts +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/search/+server.ts @@ -2,88 +2,95 @@ import type { RequestHandler } from './$types'; import { db } from '$db/db'; import { getDelegateLabel } from '../utils'; -export const GET: RequestHandler = async ({ params }) => { - try { - const conferenceId = params.conferenceId; +export const GET: RequestHandler = async ({ params, locals }) => { + try { + const conferenceId = params.conferenceId; + if (!conferenceId) { + return new Response(JSON.stringify({ error: 'Missing conference id' }), { status: 400 }); + } - // Fetch DelegationMembers - // TODO: Re-enable canReceiveDelegationMail filter after prisma generate is run - const delegationMembers = await db.delegationMember.findMany({ - where: { - conferenceId: conferenceId, - // user: { - // canReceiveDelegationMail: true - // } - }, - include: { - user: { - select: { - id: true, - family_name: true, - given_name: true - } - }, - delegation: { - include: { - assignedNation: true, - assignedNonStateActor: true - } - }, - assignedCommittee: true - } - }); + const currentUserId = (locals as { user?: { sub?: string } }).user?.sub ?? null; - // Fetch SingleParticipants - // TODO: Re-enable canReceiveDelegationMail filter after prisma generate is run - const singleParticipants = await db.singleParticipant.findMany({ - where: { - conferenceId: conferenceId, - // user: { - // canReceiveDelegationMail: true - // } - }, - include: { - user: { - select: { - id: true, - family_name: true, - given_name: true - } - }, - assignedRole: true - } - }); + // Fetch DelegationMembers + // TODO: Re-enable canReceiveDelegationMail filter after prisma generate is run + const delegationMembers = await db.delegationMember.findMany({ + where: { + conferenceId: conferenceId, + ...(currentUserId ? { userId: { not: currentUserId } } : {}) + // user: { + // canReceiveDelegationMail: true + // } + }, + include: { + user: { + select: { + id: true, + family_name: true, + given_name: true + } + }, + delegation: { + include: { + assignedNation: true, + assignedNonStateActor: true + } + }, + assignedCommittee: true + } + }); - const items = []; + // Fetch SingleParticipants + // TODO: Re-enable canReceiveDelegationMail filter after prisma generate is run + const singleParticipants = await db.singleParticipant.findMany({ + where: { + conferenceId: conferenceId, + ...(currentUserId ? { userId: { not: currentUserId } } : {}) + // user: { + // canReceiveDelegationMail: true + // } + }, + include: { + user: { + select: { + id: true, + family_name: true, + given_name: true + } + }, + assignedRole: true + } + }); - // Process DelegationMembers - for (const dm of delegationMembers) { - const label = getDelegateLabel(dm.user, dm, null); - if (label) { - items.push({ - id: dm.user.id, - label: label - }); - } - } + const items: Array<{ id: string; label: string }> = []; - // Process SingleParticipants - for (const sp of singleParticipants) { - if (sp.assignedRole) { - const label = getDelegateLabel(sp.user, null, sp); - items.push({ - id: sp.user.id, - label: label - }); - } - } + // Process DelegationMembers + for (const dm of delegationMembers) { + const label = getDelegateLabel(dm.user, dm, null); + if (label) { + items.push({ + id: dm.user.id, + label: label + }); + } + } - // Sort items by label - items.sort((a, b) => a.label.localeCompare(b.label)); + // Process SingleParticipants + for (const sp of singleParticipants) { + if (sp.assignedRole) { + const label = getDelegateLabel(sp.user, null, sp); + items.push({ + id: sp.user.id, + label: label + }); + } + } - return new Response(JSON.stringify(items), { headers: { 'content-type': 'application/json' } }); - } catch (error) { - console.error('Search endpoint error:', error); - return new Response(JSON.stringify({ error: 'Internal Server Error' }), { status: 500 }); - } + // Sort items by label + items.sort((a, b) => a.label.localeCompare(b.label)); + + return new Response(JSON.stringify(items), { headers: { 'content-type': 'application/json' } }); + } catch (error) { + console.error('Search endpoint error:', error); + return new Response(JSON.stringify({ error: 'Internal Server Error' }), { status: 500 }); + } }; From ba287b0c851b6494441c8b1bd41680e62edde86d Mon Sep 17 00:00:00 2001 From: Muhammad Ahsan Farooq Date: Sat, 17 Jan 2026 08:05:24 +0000 Subject: [PATCH 12/31] feat: Enhance messaging functionality with MessageAudit inputs and update delegation member retrieval logic --- .../messaging/search/+server.ts | 78 ++++++++----------- 1 file changed, 33 insertions(+), 45 deletions(-) diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/search/+server.ts b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/search/+server.ts index 25a07ea5..942bedee 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/search/+server.ts +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/search/+server.ts @@ -1,22 +1,43 @@ import type { RequestHandler } from './$types'; import { db } from '$db/db'; import { getDelegateLabel } from '../utils'; +import { oidc } from '$api/context/oidc'; -export const GET: RequestHandler = async ({ params, locals }) => { +export const GET: RequestHandler = async ({ params, cookies }) => { try { const conferenceId = params.conferenceId; if (!conferenceId) { return new Response(JSON.stringify({ error: 'Missing conference id' }), { status: 400 }); } - const currentUserId = (locals as { user?: { sub?: string } }).user?.sub ?? null; + const { user } = await oidc(cookies); + const currentUserId = user?.sub ?? null; + if (!currentUserId) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }); + } + + const currentDelegationMember = await db.delegationMember.findUnique({ + where: { + conferenceId_userId: { + conferenceId: conferenceId, + userId: currentUserId + } + }, + select: { + delegationId: true + } + }); + + if (!currentDelegationMember?.delegationId) { + return new Response(JSON.stringify([]), { headers: { 'content-type': 'application/json' } }); + } - // Fetch DelegationMembers + // Fetch DelegationMembers in the same delegation // TODO: Re-enable canReceiveDelegationMail filter after prisma generate is run const delegationMembers = await db.delegationMember.findMany({ where: { - conferenceId: conferenceId, - ...(currentUserId ? { userId: { not: currentUserId } } : {}) + delegationId: currentDelegationMember.delegationId, + userId: { not: currentUserId } // user: { // canReceiveDelegationMail: true // } @@ -39,50 +60,17 @@ export const GET: RequestHandler = async ({ params, locals }) => { } }); - // Fetch SingleParticipants - // TODO: Re-enable canReceiveDelegationMail filter after prisma generate is run - const singleParticipants = await db.singleParticipant.findMany({ - where: { - conferenceId: conferenceId, - ...(currentUserId ? { userId: { not: currentUserId } } : {}) - // user: { - // canReceiveDelegationMail: true - // } - }, - include: { - user: { - select: { - id: true, - family_name: true, - given_name: true - } - }, - assignedRole: true - } - }); - const items: Array<{ id: string; label: string }> = []; // Process DelegationMembers for (const dm of delegationMembers) { - const label = getDelegateLabel(dm.user, dm, null); - if (label) { - items.push({ - id: dm.user.id, - label: label - }); - } - } - - // Process SingleParticipants - for (const sp of singleParticipants) { - if (sp.assignedRole) { - const label = getDelegateLabel(sp.user, null, sp); - items.push({ - id: sp.user.id, - label: label - }); - } + const name = `${dm.user.given_name} ${dm.user.family_name}`; + const roleLabel = getDelegateLabel(dm.user, dm, null); + const label = roleLabel ? `${name} - ${roleLabel}` : name; + items.push({ + id: dm.user.id, + label: label + }); } // Sort items by label From b2db8cb0b37c249c482c4af54e788d6581ff5b56 Mon Sep 17 00:00:00 2001 From: Muhammad Ahsan Farooq Date: Sat, 17 Jan 2026 08:07:18 +0000 Subject: [PATCH 13/31] feat: Update user retrieval logic in messaging compose handler to use OIDC context --- .../dashboard/[conferenceId]/messaging/compose/+server.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+server.ts b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+server.ts index 193c7f7a..82cea5f5 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+server.ts +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+server.ts @@ -1,9 +1,10 @@ import type { RequestEvent } from '@sveltejs/kit'; import { json } from '@sveltejs/kit'; +import { oidc } from '$api/context/oidc'; export async function POST(event: RequestEvent) { const conferenceId = event.params.conferenceId; - const user = event.locals.user; + const { user } = await oidc(event.cookies); if (!user) return json({ error: 'Unauthorized' }, { status: 401 }); const body = await event.request.json(); From 723bb081510a15c31386a3a4d050fadf04b06196 Mon Sep 17 00:00:00 2001 From: Muhammad Ahsan Farooq Date: Sat, 17 Jan 2026 08:19:39 +0000 Subject: [PATCH 14/31] feat: Improve messaging compose functionality with recipient mail validation and error handling --- .../messaging/compose/+page.svelte | 9 ++++----- .../messaging/compose/send/+server.ts | 17 ++++++++++------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte index 63129ba8..1fa2904f 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte @@ -17,6 +17,7 @@ $: bodyCount = body.length; $: userCanReceiveMail = data.conferenceQueryData?.findUniqueUser?.canReceiveDelegationMail; + $: showReceiveMailWarning = userCanReceiveMail === false; async function loadRecipients() { loadError = ''; @@ -42,15 +43,13 @@ async function send(e: Event) { e.preventDefault(); error = ''; - const res = await fetch('./send', { + const res = await fetch(`${basePath}/compose/send`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ recipientId: selectedRecipient, subject, body }) }); if (res.ok) { - const data = await res.json(); - // redirect to history - location.href = '../history'; + await res.json(); } else { error = 'Failed to send message'; } @@ -89,7 +88,7 @@
{/if} - {#if !userCanReceiveMail} + {#if showReceiveMailWarning}
diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/send/+server.ts b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/send/+server.ts index 9fe16577..64d0a112 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/send/+server.ts +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/send/+server.ts @@ -3,9 +3,10 @@ import { db } from '$db/db'; import { emailService } from '$api/services/email/emailService'; import { renderDelegationMessageEmail } from '$api/services/email/delegationMessageTemplates'; import { getDelegateLabel } from '../../utils'; +import { oidc } from '$api/context/oidc'; -export const POST: RequestHandler = async ({ request, locals, params, url }) => { - const authUser = (locals as { user?: { sub?: string } }).user; +export const POST: RequestHandler = async ({ request, cookies, params, url }) => { + const { user: authUser } = await oidc(cookies); if (!authUser?.sub) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }); } @@ -29,7 +30,7 @@ export const POST: RequestHandler = async ({ request, locals, params, url }) => const sender = await db.user.findUnique({ where: { id: authUser.sub }, select: { - // canReceiveDelegationMail: true, // TODO: Re-enable after prisma generate + canReceiveDelegationMail: true, id: true, given_name: true, family_name: true @@ -40,10 +41,12 @@ export const POST: RequestHandler = async ({ request, locals, params, url }) => return new Response(JSON.stringify({ error: 'Sender not found' }), { status: 404 }); } - // TODO: Re-enable check - // if (!sender?.canReceiveDelegationMail) { - // return new Response(JSON.stringify({ error: 'You must enable messaging in your account settings.' }), { status: 403 }); - // } + if (!sender?.canReceiveDelegationMail) { + return new Response( + JSON.stringify({ error: 'You must enable messaging in your account settings.' }), + { status: 403 } + ); + } // 2. Validate Recipient Opt-in const recipient = await db.user.findUnique({ From 150bdadce196aadb6b439272a7fe4218882972fb Mon Sep 17 00:00:00 2001 From: Muhammad Ahsan Farooq Date: Sat, 17 Jan 2026 08:27:37 +0000 Subject: [PATCH 15/31] feat: Re-enable recipient mail validation in messaging compose handler --- .../[conferenceId]/messaging/compose/send/+server.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/send/+server.ts b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/send/+server.ts index 64d0a112..ecf2be91 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/send/+server.ts +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/send/+server.ts @@ -52,7 +52,7 @@ export const POST: RequestHandler = async ({ request, cookies, params, url }) => const recipient = await db.user.findUnique({ where: { id: recipientId }, select: { - // canReceiveDelegationMail: true, // TODO: Re-enable after prisma generate + canReceiveDelegationMail: true, email: true, id: true } @@ -62,10 +62,11 @@ export const POST: RequestHandler = async ({ request, cookies, params, url }) => return new Response(JSON.stringify({ error: 'Recipient not found' }), { status: 404 }); } - // TODO: Re-enable check - // if (!recipient?.canReceiveDelegationMail) { - // return new Response(JSON.stringify({ error: 'Recipient has not enabled messaging.' }), { status: 400 }); - // } + if (!recipient?.canReceiveDelegationMail) { + return new Response(JSON.stringify({ error: 'Recipient has not enabled messaging.' }), { + status: 400 + }); + } // 3. Get Conference Details const conference = await db.conference.findUnique({ From 0d85571d2113c75012de25b3c6d88424c68f6de2 Mon Sep 17 00:00:00 2001 From: Muhammad Ahsan Farooq Date: Sat, 17 Jan 2026 08:46:41 +0000 Subject: [PATCH 16/31] feat: Enhance messaging compose UI with error handling and improved user feedback --- .../messaging/compose/+page.svelte | 248 +++++++++++------- 1 file changed, 156 insertions(+), 92 deletions(-) diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte index 1fa2904f..bbd2aa4d 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte @@ -1,6 +1,7 @@ -
-
+
+ +
-

Messaging

-

Compose message

-

- Deliver clear, actionable updates to conference participants. -

+
+
+ +
+
+

Compose Message

+

Send a message to conference participants

+
+
-
-
- -
-
-
-
-

Message details

- Draft + + + {#if error} +
+ + {error} +
+ {/if} + + {#if showReceiveMailWarning} +
+ +
+ Messaging is disabled for your account. +

+ You cannot receive replies. Enable it in settings +

+
+
+ {/if} + + +
+
+
+ + Verify recipient and conference +
+
+ + Include deadlines when applicable +
+
+ + Keep it concise and actionable
+
+
- {#if error} -
- - {error} -
- {/if} - - {#if showReceiveMailWarning} -
- - - Messaging is disabled. You cannot receive replies. - Enable it in settings. - + +
+
+ +
+
+

Message Details

+
+ + Draft +
- {/if} +
- - + +
-
-
-

- This message will be logged in the delivery history. + +

+
+

+ + This message will be saved in delivery history

- +
+ + + Cancel + + +
- -
-
- - +
From 06dcf93f20dfdb5c5a99ba34a61b1a7ff927fd7d Mon Sep 17 00:00:00 2001 From: Muhammad Ahsan Farooq Date: Sat, 17 Jan 2026 19:19:21 +0000 Subject: [PATCH 17/31] feat: Implement messaging history and compose functionality with error handling and recipient validation --- src/api/resolvers/api.ts | 1 + src/api/resolvers/modules/messageAudit.ts | 519 ++++++++++++++++++ .../messaging/compose/+page.server.ts | 134 +++++ .../messaging/compose/+page.svelte | 100 ++-- .../messaging/compose/+server.ts | 21 - .../messaging/compose/send/+server.ts | 204 ------- .../messaging/history/+page.server.ts | 47 ++ .../messaging/history/+page.svelte | 46 +- .../messaging/history/list/+server.ts | 20 - .../messaging/search/+server.ts | 84 --- 10 files changed, 775 insertions(+), 401 deletions(-) create mode 100644 src/api/resolvers/modules/messageAudit.ts create mode 100644 src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.server.ts delete mode 100644 src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+server.ts delete mode 100644 src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/send/+server.ts create mode 100644 src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/+page.server.ts delete mode 100644 src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/list/+server.ts delete mode 100644 src/routes/(authenticated)/dashboard/[conferenceId]/messaging/search/+server.ts diff --git a/src/api/resolvers/api.ts b/src/api/resolvers/api.ts index 184467f2..ba368e0a 100644 --- a/src/api/resolvers/api.ts +++ b/src/api/resolvers/api.ts @@ -22,6 +22,7 @@ import './modules/customConferenceRole'; import './modules/delegation'; import './modules/delegationMember'; import './modules/impersonation'; +import './modules/messageAudit'; import './modules/nation'; import './modules/nonStateActor'; import './modules/paymentTransaction'; diff --git a/src/api/resolvers/modules/messageAudit.ts b/src/api/resolvers/modules/messageAudit.ts new file mode 100644 index 00000000..714fc5c6 --- /dev/null +++ b/src/api/resolvers/modules/messageAudit.ts @@ -0,0 +1,519 @@ +import { builder } from '../builder'; +import { + findManyMessageAuditQueryObject, + findUniqueMessageAuditQueryObject, + MessageAuditIdFieldObject, + MessageAuditCreatedAtFieldObject, + MessageAuditUpdatedAtFieldObject, + MessageAuditSubjectFieldObject, + MessageAuditBodyFieldObject, + MessageAuditSenderUserIdFieldObject, + MessageAuditSenderUserFieldObject, + MessageAuditRecipientUserIdFieldObject, + MessageAuditRecipientUserFieldObject, + MessageAuditConferenceIdFieldObject, + MessageAuditConferenceFieldObject, + MessageAuditMessageIdFieldObject, + MessageAuditStatusFieldObject +} from '$db/generated/graphql/MessageAudit'; +import { db } from '$db/db'; +import { GraphQLError } from 'graphql'; +import { renderDelegationMessageEmail } from '$api/services/email/delegationMessageTemplates'; +import { emailService } from '$api/services/email/emailService'; + +export const GQLMessageAudit = builder.prismaObject('MessageAudit', { + fields: (t) => ({ + id: t.field(MessageAuditIdFieldObject), + createdAt: t.field(MessageAuditCreatedAtFieldObject), + updatedAt: t.field(MessageAuditUpdatedAtFieldObject), + subject: t.field(MessageAuditSubjectFieldObject), + body: t.field(MessageAuditBodyFieldObject), + senderUserId: t.field(MessageAuditSenderUserIdFieldObject), + senderUser: t.relation('senderUser', MessageAuditSenderUserFieldObject), + recipientUserId: t.field(MessageAuditRecipientUserIdFieldObject), + recipientUser: t.relation('recipientUser', MessageAuditRecipientUserFieldObject), + conferenceId: t.field(MessageAuditConferenceIdFieldObject), + conference: t.relation('conference', MessageAuditConferenceFieldObject), + messageId: t.field(MessageAuditMessageIdFieldObject), + status: t.field(MessageAuditStatusFieldObject) + }) +}); + +// Simple type for recipient info +const RecipientInfo = builder.simpleObject('RecipientInfo', { + fields: (t) => ({ + id: t.string(), + label: t.string() + }) +}); + +// Type for history items +const MessageHistoryItem = builder.simpleObject('MessageHistoryItem', { + fields: (t) => ({ + recipientLabel: t.string(), + subject: t.string(), + sentAt: t.string(), + status: t.string() + }) +}); + +builder.queryFields((t) => { + const field = findManyMessageAuditQueryObject(t); + return { + findManyMessageAudits: t.prismaField({ + ...field, + resolve: (query, root, args, ctx, info) => { + args.where = { + ...args.where, + AND: [ctx.permissions.allowDatabaseAccessTo('list').MessageAudit] + }; + + return field.resolve(query, root, args, ctx, info); + } + }) + }; +}); + +builder.queryFields((t) => { + const field = findUniqueMessageAuditQueryObject(t); + return { + findUniqueMessageAudit: t.prismaField({ + ...field, + resolve: (query, root, args, ctx, info) => { + args.where = { + ...args.where, + AND: [ctx.permissions.allowDatabaseAccessTo('read').MessageAudit] + }; + + return field.resolve(query, root, args, ctx, info); + } + }) + }; +}); + +// Custom query to get message recipients for a conference +builder.queryField('getMessageRecipients', (t) => + t.field({ + type: [RecipientInfo], + args: { + conferenceId: t.arg.string({ required: true }) + }, + resolve: async (_root, args, ctx) => { + const user = ctx.permissions.getLoggedInUserOrThrow(); + const userId = user.sub; + + console.log('[getMessageRecipients] User:', userId, 'Conference:', args.conferenceId); + + // Get current user's delegation member to find their delegationId + const currentDelegationMember = await db.delegationMember.findUnique({ + where: { + conferenceId_userId: { + conferenceId: args.conferenceId, + userId: userId + } + }, + select: { + delegationId: true + } + }); + + if (!currentDelegationMember?.delegationId) { + console.log('[getMessageRecipients] User is not part of a delegation'); + return []; + } + + console.log( + '[getMessageRecipients] User delegation ID:', + currentDelegationMember.delegationId + ); + + // Fetch delegation members in the SAME delegation (excluding current user) + const delegationMembers = await db.delegationMember.findMany({ + where: { + delegationId: currentDelegationMember.delegationId, + userId: { not: userId } + }, + include: { + user: { + select: { + id: true, + given_name: true, + family_name: true + } + }, + delegation: { + include: { + assignedNation: true, + assignedNonStateActor: true + } + }, + assignedCommittee: true + } + }); + + console.log('[getMessageRecipients] Delegation members found:', delegationMembers.length); + + // Build recipient list + const recipients: Array<{ id: string; label: string }> = []; + + for (const member of delegationMembers) { + if (!member.user) continue; + const userObj = member.user; + const name = `${userObj.given_name} ${userObj.family_name}`; + const roleLabel = getDelegateLabel(userObj, member, null); + const label = roleLabel ? `${name} - ${roleLabel}` : name; + recipients.push({ id: userObj.id, label }); + } + + // Sort by label + recipients.sort((a, b) => a.label.localeCompare(b.label)); + + console.log('[getMessageRecipients] Final recipients count:', recipients.length); + + return recipients; + } + }) +); + +// Custom query to get message history +builder.queryField('getMessageHistory', (t) => + t.field({ + type: [MessageHistoryItem], + args: { + conferenceId: t.arg.string({ required: true }) + }, + resolve: async (_root, args, ctx) => { + const user = ctx.permissions.getLoggedInUserOrThrow(); + const userId = user.sub; + + const audits = await db.messageAudit.findMany({ + where: { + senderUserId: userId, + conferenceId: args.conferenceId + }, + orderBy: { createdAt: 'desc' }, + select: { + subject: true, + createdAt: true, + status: true, + recipientUserId: true, + recipientUser: { + select: { + id: true, + given_name: true, + family_name: true + } + } + } + }); + + if (audits.length === 0) { + return []; + } + + const recipientIds = Array.from(new Set(audits.map((audit) => audit.recipientUserId))); + + const delegationMembers = await db.delegationMember.findMany({ + where: { + conferenceId: args.conferenceId, + userId: { in: recipientIds } + }, + include: { + delegation: { + include: { + assignedNation: true, + assignedNonStateActor: true + } + }, + assignedCommittee: true + } + }); + + const delegationByUserId = new Map( + delegationMembers.map((member) => [member.userId, member]) + ); + + const singleParticipants = await db.singleParticipant.findMany({ + where: { + conferenceId: args.conferenceId, + userId: { in: recipientIds } + }, + include: { + assignedRole: true + } + }); + + const singleByUserId = new Map( + singleParticipants.map((participant) => [participant.userId, participant]) + ); + + const items = audits.map((audit) => { + const recipient = audit.recipientUser; + const delegationMember = delegationByUserId.get(audit.recipientUserId) ?? null; + const singleParticipant = !delegationMember + ? (singleByUserId.get(audit.recipientUserId) ?? null) + : null; + const roleLabel = recipient + ? getDelegateLabel(recipient, delegationMember, singleParticipant) + : 'Participant'; + const recipientLabel = + roleLabel !== 'Participant' && roleLabel + ? roleLabel + : recipient + ? `${recipient.given_name} ${recipient.family_name}` + : 'Participant'; + const status = audit.status.charAt(0) + audit.status.slice(1).toLowerCase(); + + return { + recipientLabel, + subject: audit.subject, + sentAt: audit.createdAt.toISOString(), + status + }; + }); + + return items; + } + }) +); + +// Mutation to send a delegation message +builder.mutationField('sendDelegationMessage', (t) => + t.field({ + type: 'String', + args: { + conferenceId: t.arg.string({ required: true }), + recipientId: t.arg.string({ required: true }), + subject: t.arg.string({ required: true }), + body: t.arg.string({ required: true }), + replyUrl: t.arg.string({ required: true }) + }, + resolve: async (_root, args, ctx) => { + const user = ctx.permissions.getLoggedInUserOrThrow(); + const senderId = user.sub; + + // Validate inputs + if (!args.recipientId.trim() || !args.subject.trim() || !args.body.trim()) { + throw new GraphQLError('Missing required fields'); + } + + if (args.recipientId === senderId) { + throw new GraphQLError('Cannot send message to yourself'); + } + + // Get sender info + const sender = await db.user.findUnique({ + where: { id: senderId }, + select: { + canReceiveDelegationMail: true, + id: true, + given_name: true, + family_name: true + } + }); + + if (!sender) { + throw new GraphQLError('Sender not found'); + } + + if (!sender.canReceiveDelegationMail) { + throw new GraphQLError('You must enable messaging in your account settings.'); + } + + // Get recipient info + const recipient = await db.user.findUnique({ + where: { id: args.recipientId }, + select: { + canReceiveDelegationMail: true, + email: true, + id: true + } + }); + + if (!recipient) { + throw new GraphQLError('Recipient not found'); + } + + if (!recipient.canReceiveDelegationMail) { + throw new GraphQLError('Recipient has not enabled messaging.'); + } + + // Get conference info + const conference = await db.conference.findUnique({ + where: { id: args.conferenceId }, + select: { title: true } + }); + + if (!conference) { + throw new GraphQLError('Conference not found'); + } + + // Verify sender is part of conference + const senderDelegationMember = await db.delegationMember.findUnique({ + where: { + conferenceId_userId: { + conferenceId: args.conferenceId, + userId: sender.id + } + }, + include: { + delegation: { + include: { + assignedNation: true, + assignedNonStateActor: true + } + }, + assignedCommittee: true + } + }); + + let senderSingleParticipant = null as { + assignedRole: { name: string } | null; + } | null; + if (!senderDelegationMember) { + senderSingleParticipant = await db.singleParticipant.findUnique({ + where: { + conferenceId_userId: { + conferenceId: args.conferenceId, + userId: sender.id + } + }, + include: { + assignedRole: true + } + }); + } + + if (!senderDelegationMember && !senderSingleParticipant) { + throw new GraphQLError('Sender is not part of this conference'); + } + + const senderLabel = getDelegateLabel(sender, senderDelegationMember, senderSingleParticipant); + + // Verify recipient is part of conference + const recipientDelegationMember = await db.delegationMember.findUnique({ + where: { + conferenceId_userId: { + conferenceId: args.conferenceId, + userId: recipient.id + } + } + }); + + let recipientSingleParticipant = null as { + assignedRole: { name: string } | null; + } | null; + if (!recipientDelegationMember) { + recipientSingleParticipant = await db.singleParticipant.findUnique({ + where: { + conferenceId_userId: { + conferenceId: args.conferenceId, + userId: recipient.id + } + }, + include: { + assignedRole: true + } + }); + } + + if (!recipientDelegationMember && !recipientSingleParticipant) { + throw new GraphQLError('Recipient is not part of this conference'); + } + + // Create audit record + const audit = await db.messageAudit.create({ + data: { + subject: args.subject, + body: args.body, + senderUserId: sender.id, + recipientUserId: recipient.id, + conferenceId: args.conferenceId, + status: 'SENT' + } + }); + + // Render email + const { html, text } = await renderDelegationMessageEmail({ + senderLabel, + subject: args.subject, + messageBody: args.body, + conferenceTitle: conference.title, + replyUrl: args.replyUrl + }); + + // Send email + const result = await emailService.sendEmail({ + to: recipient.email, + subject: `[${conference.title}] Neue Nachricht: ${args.subject}`, + html, + text + }); + + if (!result.success) { + console.error('Email sending failed', result.error); + + await db.messageAudit.update({ + where: { id: audit.id }, + data: { status: 'FAILED' } + }); + + throw new GraphQLError('Failed to send email'); + } + + // Update audit with message ID + if (result.messageId) { + await db.messageAudit.update({ + where: { id: audit.id }, + data: { messageId: result.messageId } + }); + } + + return 'ok'; + } + }) +); + +// Helper function to generate delegate label +function getInitials(firstName: string, lastName: string) { + return `${firstName.charAt(0)}.${lastName.charAt(0)}.`; +} + +function getDelegateLabel( + user: { given_name: string; family_name: string }, + delegationMember: { + delegation: { + assignedNation: { alpha3Code: string } | null; + assignedNonStateActor: { name: string } | null; + }; + assignedCommittee: { abbreviation: string | null; name: string } | null; + } | null, + singleParticipant: { + assignedRole: { name: string } | null; + } | null +): string { + let label = 'Participant'; + + if (delegationMember) { + if (delegationMember.delegation.assignedNation) { + // Import countries here to avoid issues + // For now, use simple alpha3Code + const alpha3Code = delegationMember.delegation.assignedNation.alpha3Code; + label = alpha3Code; + + if (delegationMember.assignedCommittee) { + label += ` (${delegationMember.assignedCommittee.abbreviation || delegationMember.assignedCommittee.name})`; + } + } else if (delegationMember.delegation.assignedNonStateActor) { + label = delegationMember.delegation.assignedNonStateActor.name; + const initials = getInitials(user.given_name, user.family_name); + label += ` (${initials})`; + } + } else if (singleParticipant) { + if (singleParticipant.assignedRole) { + const initials = getInitials(user.given_name, user.family_name); + label = `${singleParticipant.assignedRole.name} (${initials})`; + } + } + return label; +} diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.server.ts b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.server.ts new file mode 100644 index 00000000..d91f27de --- /dev/null +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.server.ts @@ -0,0 +1,134 @@ +import type { Actions, PageServerLoad } from './$types'; +import { error, fail } from '@sveltejs/kit'; +import { graphql } from '$houdini'; +import { fastUserQuery } from '$lib/queries/fastUserQuery'; + +const getMessageRecipientsQuery = graphql(` + query GetMessageRecipientsQuery($conferenceId: String!) { + getMessageRecipients(conferenceId: $conferenceId) { + id + label + } + } +`); + +const sendDelegationMessageMutation = graphql(` + mutation SendDelegationMessageMutation( + $conferenceId: String! + $recipientId: String! + $subject: String! + $body: String! + $replyUrl: String! + ) { + sendDelegationMessage( + conferenceId: $conferenceId + recipientId: $recipientId + subject: $subject + body: $body + replyUrl: $replyUrl + ) + } +`); + +export const load: PageServerLoad = async (event) => { + const parent = await event.parent(); + const userId = parent.user?.sub; + if (!userId) { + throw error(401, 'Unauthorized'); + } + + const conferenceId = event.params.conferenceId; + if (!conferenceId) { + throw error(400, 'Missing conference id'); + } + + try { + console.log('[Messaging] Fetching recipients for conference:', conferenceId); + const result = await getMessageRecipientsQuery.fetch({ + event, + variables: { conferenceId }, + blocking: true + }); + + console.log('[Messaging] Query result:', result); + const recipients = result.data?.getMessageRecipients ?? []; + console.log('[Messaging] Recipients found:', recipients.length); + + return { + recipients + }; + } catch (loadError) { + console.error('Messaging recipients load error:', loadError); + return { + recipients: [], + recipientLoadError: 'Unable to load recipients' + }; + } +}; + +export const actions = { + send: async (event) => { + const conferenceId = event.params.conferenceId; + if (!conferenceId) { + return fail(400, { error: 'Missing conference id' }); + } + + const formData = await event.request.formData(); + const recipientIdValue = formData.get('recipientId'); + const subjectValue = formData.get('subject'); + const messageBodyValue = formData.get('body'); + + if ( + typeof recipientIdValue !== 'string' || + typeof subjectValue !== 'string' || + typeof messageBodyValue !== 'string' + ) { + return fail(400, { error: 'Missing fields' }); + } + + const recipientId = recipientIdValue.trim(); + const subject = subjectValue.trim(); + const messageBody = messageBodyValue; + + if (!recipientId || !subject || !messageBody.trim()) { + return fail(400, { error: 'Missing fields' }); + } + + const { data } = await fastUserQuery.fetch({ event, blocking: true }); + const authUser = data?.offlineUserRefresh.user; + if (!authUser?.sub) { + return fail(401, { error: 'Unauthorized' }); + } + + if (recipientId === authUser.sub) { + return fail(400, { error: 'Cannot send to yourself' }); + } + + const replySubject = `Re: ${subject}`; + const replyUrl = `${event.url.origin}/dashboard/${conferenceId}/messaging/compose?recipientId=${encodeURIComponent(authUser.sub)}&subject=${encodeURIComponent(replySubject)}`; + + try { + await sendDelegationMessageMutation.mutate( + { + conferenceId, + recipientId, + subject, + body: messageBody, + replyUrl + }, + { event } + ); + + return { + status: 'ok' + }; + } catch (sendError: unknown) { + console.error('Message sending error:', sendError); + const errorMessage = + sendError && typeof sendError === 'object' && 'message' in sendError + ? String(sendError.message) + : 'Failed to send message'; + return fail(500, { error: errorMessage }); + } + } +} satisfies Actions; diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte index bbd2aa4d..0ff529fb 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte @@ -1,17 +1,38 @@
@@ -84,10 +89,10 @@
- {#if error} + {#if actionError}
- {error} + {actionError}
{/if} @@ -124,7 +129,7 @@
-
+
@@ -150,13 +155,14 @@ +
+
+ + +
+
+

+ + This message will be saved in delivery history +

+
+ + + Cancel + + +
+
+
+
+ +
From adda4708478b0c98001d944536bfee0b45606056 Mon Sep 17 00:00:00 2001 From: Muhammad Ahsan Farooq Date: Fri, 23 Jan 2026 19:01:50 +0500 Subject: [PATCH 19/31] added translations --- messages/de.json | 54 +++++++++++++++++ messages/en.json | 54 +++++++++++++++++ .../[conferenceId]/messaging/+page.svelte | 42 +++++++------- .../messaging/compose/+page.svelte | 58 ++++++++++--------- .../messaging/history/+page.svelte | 31 +++++----- 5 files changed, 178 insertions(+), 61 deletions(-) diff --git a/messages/de.json b/messages/de.json index 03885c8b..1813656e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -559,6 +559,60 @@ "mediaConsentStatus": "Fotostatus", "members": "Mitglieder", "membersPerDelegation": "Plätze pro Delegation", + "messageCancelButton": "Abbrechen", + "messageCompose": "Verfassen", + "messageComposeAnnouncement": "Ankündigung verfassen", + "messageComposeMessage": "Nachricht verfassen", + "messageDraft": "Entwurf", + "messageDraftAMessage": "Eine neue Nachricht entwerfen", + "messageHistory": "Verlauf", + "messageMessageBody": "Nachricht", + "messageMessagePlaceholder": "Teile wichtige Details, Aufgaben und Fristen hier...\n\nHalte es klar und prägnant. Du kannst Links für weitere Informationen hinzufügen.", + "messageNoMessagesSent": "Noch keine Nachrichten versendet. Sende deine erste Nachricht von der Verfassen-Seite.", + "messageRecipient": "Empfänger*in", + "messageRecipientNotEnabled": "Empfänger*in hat Messaging nicht aktiviert.", + "messageReviewDeliveryStatus": "Überprüfe den Zustellungsstatus und die Nachrichtenaktivität für diese Konferenz.", + "messageReviewSent": "Gesendete überprüfen", + "messageSendButton": "Nachricht senden", + "messageSendToParticipants": "Eine Nachricht an Konferenzteilnehmende senden", + "messageSent": "Nachricht gesendet.", + "messageSentLog": "Sendelog", + "messageStatusDelivered": "Zugestellt", + "messageSubject": "Betreff", + "messageSubjectPlaceholder": "z.B. Gremienagenda-Sperrung - 18:00 CET", + "messagingCenter": "Nachrichtenzentrale", + "messagingCheckDeliveryStatus": "Zustellungsstatus prüfen", + "messagingClearActionDeadlines": "Fristen bei Bedarf angeben", + "messagingConferenceMessaging": "Konferenznachrichten", + "messagingDeliveryLog": "Zustellungsprotokoll", + "messagingDescription": "Erreiche Delegationen und Teilnehmende schnell. Verfasse Ankündigungen, verfolge die Zustellung und halte die Konferenz auf dem Laufenden.", + "messagingDisabledForAccount": "Messaging ist für dein Konto deaktiviert.", + "messagingEnableInSettings": "In den Einstellungen aktivieren", + "messagingGuidelineDeadlines": "Verwende klare Handlungsaufforderungen und Fristen, um Hin und Her zu reduzieren.", + "messagingGuidelineOptIn": "Sende nur an Empfänger*innen, die zugestimmt haben, und vermeide Duplikate.", + "messagingGuidelineShort": "Halte Betreffzeilen kurz und spezifisch, um die Sichtbarkeit zu verbessern.", + "messagingGuidelines": "Richtlinien", + "messagingKeepConcise": "Halte es prägnant und handlungsorientiert", + "messagingLogsStored": "Nachrichtenprotokolle werden zur Prüfung und Compliance gespeichert.", + "messagingMessageDetails": "Nachrichtendetails", + "messagingMessaging": "Nachrichten", + "messagingNewMessage": "Neue Nachricht", + "messagingNoEligibleRecipients": "Keine geeigneten Empfänger*innen gefunden", + "messagingNoReplyWarning": "Du kannst keine Antworten erhalten.", + "messagingOnlyEnabledUsers": "Nur Nutzer*innen, die Messaging aktiviert haben, werden in dieser Liste angezeigt", + "messagingOverview": "Übersicht", + "messagingQuickActions": "Schnellaktionen", + "messagingRecipientLabel": "Empfänger*in", + "messagingRecipientRequired": "Empfänger*in auswählen...", + "messagingSavedInHistory": "Diese Nachricht wird im Zustellungsverlauf gespeichert", + "messagingSelectRecipient": "Empfänger*in auswählen...", + "messagingSent": "Gesendet", + "messagingSentHistory": "Sendverlauf", + "messagingStartTargetedMessage": "Starte eine gezielte Nachricht an Delegationen, Gremien oder einzelne Teilnehmende.", + "messagingStatus": "Status", + "messagingTipSubjectLine": "Tipp: Verwende klare Betreffzeilen wie \"Gremienagenda-Sperrung - 18:00\", um die Rücklaufquote zu verbessern", + "messagingUnableToLoadRecipients": "Empfänger*in konnte nicht geladen werden", + "messagingVerifyRecipient": "Empfänger*in und Konferenz überprüfen", "missingInformation": "Fehlende Informationen", "motivation": "Motivation", "myAccount": "Mein Konto", diff --git a/messages/en.json b/messages/en.json index 3ff12a12..e35585ac 100644 --- a/messages/en.json +++ b/messages/en.json @@ -559,6 +559,60 @@ "mediaConsentStatus": "Media Status", "members": "Members", "membersPerDelegation": "Seats per Delegation", + "messageCancelButton": "Cancel", + "messageCompose": "Compose", + "messageComposeAnnouncement": "Compose announcement", + "messageComposeMessage": "Compose Message", + "messageDraft": "Draft", + "messageDraftAMessage": "Draft a new message", + "messageHistory": "History", + "messageMessageBody": "Message", + "messageMessagePlaceholder": "Share key details, action items, and deadlines here...\n\nKeep it clear and concise. You can include links for additional information.", + "messageNoMessagesSent": "No messages sent yet. Send your first update from the compose page.", + "messageRecipient": "Recipient", + "messageRecipientNotEnabled": "Recipient has not enabled messaging.", + "messageReviewDeliveryStatus": "Review delivery status and message activity for this conference.", + "messageReviewSent": "Review sent", + "messageSendButton": "Send Message", + "messageSendToParticipants": "Send a message to conference participants", + "messageSent": "Message sent.", + "messageSentLog": "Sent log", + "messageStatusDelivered": "Delivered", + "messageSubject": "Subject", + "messageSubjectPlaceholder": "e.g., Committee agenda lock - 18:00 CET", + "messagingCenter": "Messaging Center", + "messagingCheckDeliveryStatus": "Check delivery status", + "messagingClearActionDeadlines": "Include deadlines when applicable", + "messagingConferenceMessaging": "Conference Messaging", + "messagingDeliveryLog": "Delivery log", + "messagingDescription": "Reach delegations and participants quickly. Compose announcements, track delivery, and keep the conference aligned.", + "messagingDisabledForAccount": "Messaging is disabled for your account.", + "messagingEnableInSettings": "Enable it in settings", + "messagingGuidelineDeadlines": "Use clear actions and deadlines to reduce back-and-forth.", + "messagingGuidelineOptIn": "Send only to opt-in recipients and avoid duplicates.", + "messagingGuidelineShort": "Keep subject lines short and specific to improve visibility.", + "messagingGuidelines": "Guidelines", + "messagingKeepConcise": "Keep it concise and actionable", + "messagingLogsStored": "Messaging logs are stored for auditing and compliance.", + "messagingMessageDetails": "Message Details", + "messagingMessaging": "Messaging", + "messagingNewMessage": "New message", + "messagingNoEligibleRecipients": "No eligible recipients found", + "messagingNoReplyWarning": "You cannot receive replies.", + "messagingOnlyEnabledUsers": "Only users who have enabled messaging will appear in this list", + "messagingOverview": "Overview", + "messagingQuickActions": "Quick actions", + "messagingRecipientLabel": "Recipient", + "messagingRecipientRequired": "Select a recipient...", + "messagingSavedInHistory": "This message will be saved in delivery history", + "messagingSelectRecipient": "Select a recipient...", + "messagingSent": "Sent", + "messagingSentHistory": "Sent history", + "messagingStartTargetedMessage": "Start a targeted message to delegations, committees, or individual participants.", + "messagingStatus": "Status", + "messagingTipSubjectLine": "Tip: Use clear subject lines like \"Committee agenda lock - 18:00\" to improve response rates", + "messagingUnableToLoadRecipients": "Unable to load recipient", + "messagingVerifyRecipient": "Verify recipient and conference", "missingInformation": "Missing information", "motivation": "Motivation", "myAccount": "My Account", diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/+page.svelte b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/+page.svelte index 3a9738aa..82949c53 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/+page.svelte +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/+page.svelte @@ -1,4 +1,5 @@
- {#if conference?.id} + {#if conference?.id && isDelegatee} {/if} @@ -56,12 +59,6 @@ unlockPostals={conference?.unlockPostals} /> - - messaging + {m.messagingMessaging()}
Date: Fri, 23 Jan 2026 21:01:54 +0500 Subject: [PATCH 22/31] improved the UI --- .../dashboard/[conferenceId]/+page.svelte | 29 +- .../[conferenceId]/messaging/+page.svelte | 390 +++++++++++++++--- .../messaging/compose/+page.svelte | 347 ++++++++++++---- .../messaging/history/+page.svelte | 304 +++++++++++--- .../messaging/reply/+page.svelte | 316 ++++++++++---- 5 files changed, 1111 insertions(+), 275 deletions(-) diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/+page.svelte b/src/routes/(authenticated)/dashboard/[conferenceId]/+page.svelte index fb44bad2..98deb584 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/+page.svelte +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/+page.svelte @@ -11,6 +11,7 @@ import Supervisor from './stages/Supervisor/Supervisor.svelte'; import { m } from '$lib/paraglide/messages'; import { translateTeamRole } from '$lib/services/enumTranslations'; + import { onMount } from 'svelte'; // the app needs some proper loading states! //TODO https://houdinigraphql.com/guides/loading-states @@ -28,6 +29,28 @@ let isDelegatee = $derived( !!delegationMember?.id && !singleParticipant?.id && !supervisor?.id && !teamMember?.id ); + let showNewBadge = $state(false); + + const getContentSignature = () => + JSON.stringify({ + conference, + delegationMember, + singleParticipant, + supervisor, + teamMember, + status, + surveyQuestions, + surveyAnswers + }); + + onMount(() => { + if (!conference?.id || !data.user?.sub) return; + const storageKey = `dashboard-content:${conference.id}:${data.user.sub}`; + const signature = getContentSignature(); + const previousSignature = localStorage.getItem(storageKey); + showNewBadge = !!previousSignature && previousSignature !== signature; + localStorage.setItem(storageKey, signature); + });
@@ -40,7 +63,11 @@
{/if} - + {#if showNewBadge} +
+ new +
+ {/if} {#if singleParticipant?.id} {#if conference!.state === 'PARTICIPANT_REGISTRATION'} -
+ +
+ + -
-
-

- {m.messagingConferenceMessaging()} -

-

{m.messagingCenter()}

-

+

-
-
-
-

{m.messagingQuickActions()}

-

- {m.messagingStartTargetedMessage()} -

-
- -
-
- - - + +
+ +
+
-
{#if actionError} -
- - {actionError} +
+
+
+ +
+ {actionError} +
{/if} {#if showReceiveMailWarning} -
- -
- {m.messagingDisabledForAccount()} -

- {m.messagingNoReplyWarning()} - {m.messagingEnableInSettings()} -

+
+
+
+ +
+
+ + {m.messagingDisabledForAccount()} + +

+ {m.messagingNoReplyWarning()} + + {m.messagingEnableInSettings()} + +

+
{/if} -
-
-
- - {m.messagingVerifyRecipient()} +
+
+
+
-
- - {m.messagingClearActionDeadlines()} +

Quick Tips

+
+
+
+ + {m.messagingVerifyRecipient()}
-
- - {m.messagingKeepConcise()} +
+ + {m.messagingClearActionDeadlines()} +
+
+ + {m.messagingKeepConcise()}
-
-
+ +
-
+
-

{m.messagingMessageDetails()}

-
- - {m.messageDraft()} +
+
+ +
+

+ {m.messagingMessageDetails()} +

+
+
+
+ {m.messageDraft()}
-
+
-
-
+ +
+
+
+
+
+ + + +
+
-
-
+
-

- - {m.messagingSavedInHistory()} -

+
+ + {m.messagingSavedInHistory()} +
- + {m.messageCancelButton()} - @@ -266,3 +401,59 @@
+ + diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/+page.svelte b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/+page.svelte index 94539652..f65a6786 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/+page.svelte +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/+page.svelte @@ -25,79 +25,263 @@ $: loadError = data.historyLoadError ?? ''; -
-
-
-

- {m.messagingMessaging()} -

-

{m.messagingSentHistory()}

-

- {m.messageReviewDeliveryStatus()} -

+
+ +
+ + + + +
+
+ +
+
+ +
+
+

+ {m.messagingMessaging()} +

+

+ {m.messagingSentHistory()} +

+

+ {m.messageReviewDeliveryStatus()} +

+
+
+ + + +
- -
-
+
-
-
-
-

{m.messagingDeliveryLog()}

- - - {m.messagingNewMessage()} - + +
+ +
+
+
+
+ +
+

+ {m.messagingDeliveryLog()} +

+
+ + + {m.messagingNewMessage()} + +
+ {#if loadError} -
- - {loadError} +
+
+
+ +
+ {loadError} +
{/if} -
- - - - - - - - - - - {#if messages.length === 0} - - +
{m.messageRecipient()}{m.messageSubject()}{m.messagingSent()}{m.messagingStatus()}
-
- {m.messageNoMessagesSent()} + +
+ {#if messages.length === 0} +
+
+ +
+

No Messages Yet

+

+ {m.messageNoMessagesSent()} +

+ + + Send Your First Message + +
+ {:else} + + + + + + + - {:else} - {#each messages as m} - - - - + + {#each messages as msg} + + + + {/each} - {/if} - -
+
+ + {m.messageRecipient()} +
+
+
+ + {m.messageSubject()} +
+
+
+ + {m.messagingSent()}
- +
+
+ + {m.messagingStatus()} +
+
{m.recipientLabel}{m.subject} - {new Date(m.sentAt).toLocaleString()} +
+
+
+ +
+ {msg.recipientLabel} +
+
{msg.subject} +
+ + {new Date(msg.sentAt).toLocaleString()} +
- {m.status} + {#if msg.status.toLowerCase() === 'delivered' || msg.status.toLowerCase() === 'sent'} + + + {msg.status} + + {:else if msg.status.toLowerCase() === 'pending'} + + + {msg.status} + + {:else if msg.status.toLowerCase() === 'failed'} + + + {msg.status} + + {:else} + + + {msg.status} + + {/if}
+
+ {/if}
-
-
+
+
+ + diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/reply/+page.svelte b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/reply/+page.svelte index 7b1aa6d2..c4001417 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/reply/+page.svelte +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/reply/+page.svelte @@ -40,13 +40,13 @@ return async ({ result, update }) => { if (result.type === 'success') { toast.success('Reply sent.'); - // Redirect or clear? Usually reply sends you back or clears. - // Since we are on a specific reply page, maybe redirecting to history or overview would be better, - // but SvelteKit actions usually invalidate. - // For now, clear body. + // Redirect or clear? Usually reply sends you back or clears. + // Since we are on a specific reply page, maybe redirecting to history or overview would be better, + // but SvelteKit actions usually invalidate. + // For now, clear body. body = ''; - // Optional: go back to history - // window.location.href = `${basePath}/history`; + // Optional: go back to history + // window.location.href = `${basePath}/history`; } else if (result.type === 'failure') { const errorMessage = getActionError(result.data); if (errorMessage === 'Recipient has not enabled messaging.') { @@ -58,98 +58,197 @@ }; -
- -
-
-
-
- -
-
-

Reply to Message

-

Responding to {recipient.label}

+
+ +
+ + + + +
+
+ +
+
+ +
+
+

+ Reply to Message +

+

+ Responding to {recipient.label} +

+
+ + +
- -
+ {#if actionError} -
- - {actionError} +
+
+
+ +
+ {actionError} +
{/if} {#if showReceiveMailWarning} -
- -
- Messaging is disabled for your account. -

- You cannot receive further replies. Enable it in settings -

+
+
+
+ +
+
+ + Messaging is disabled for your account. + +

+ You cannot receive further replies. + + Enable it in settings + +

+
{/if} -
- -
+ + +
-
+
-

Message Details

-
- - Reply +
+
+ +
+

+ Message Details +

+
+
+ + Reply
-
+
-
-
+ +
+
+
+
+
+ + + +
+
-
-
+ +
+
+
+
+
+ + + +
+
-
-
+ -
-
-
- - {m.messagingSavedInHistory()} -
-
- - - {m.messageCancelButton()} - - -
+
+ + + {m.messagingSavedInHistory()} + +
+ + + {m.messageCancelButton()} + +
- - diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/+page.svelte b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/+page.svelte index f65a6786..a21c46f2 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/+page.svelte +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/+page.svelte @@ -1,6 +1,6 @@ -
- -
- - - - -
-
- -
-
- -
-
-

- {m.messagingMessaging()} -

-

- {m.messagingSentHistory()} -

-

- {m.messageReviewDeliveryStatus()} -

-
-
- - - -
+
+ +
+
+

{m.messagingSentHistory()}

+

{m.messageReviewDeliveryStatus()}

-
+ + + {m.messagingNewMessage()} + +
- -
- -
-
-
-
- -
-

- {m.messagingDeliveryLog()} -

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

No Messages Yet

+ +

+ {m.messagingNoMessagesYet()} +

{m.messageNoMessagesSent()}

- + - Send Your First Message + {m.messagingSendFirstMessage()}
{:else} - - - - - - - - - - - {#each messages as msg} - - - - - +
+
-
- - {m.messageRecipient()} -
-
-
- - {m.messageSubject()} -
-
-
- - {m.messagingSent()} -
-
-
- - {m.messagingStatus()} -
-
-
-
- -
- {msg.recipientLabel} -
-
{msg.subject} -
- - {new Date(msg.sentAt).toLocaleString()} -
-
- {#if msg.status.toLowerCase() === 'delivered' || msg.status.toLowerCase() === 'sent'} - - - {msg.status} - - {:else if msg.status.toLowerCase() === 'pending'} - - - {msg.status} - - {:else if msg.status.toLowerCase() === 'failed'} - - - {msg.status} - - {:else} - - - {msg.status} - - {/if} -
+ + + + + + - {/each} - -
{m.messageRecipient()}{m.messageSubject()}{m.messagingSent()}{m.messagingStatus()}
+ + + {#each messages as msg} + + {msg.recipientLabel} + {msg.subject} + + {new Date(msg.sentAt).toLocaleString()} + + + {#if msg.status.toLowerCase() === 'delivered' || msg.status.toLowerCase() === 'sent'} + + + {msg.status} + + {:else if msg.status.toLowerCase() === 'pending'} + + + {msg.status} + + {:else if msg.status.toLowerCase() === 'failed'} + + + {msg.status} + + {:else} + + + {msg.status} + + {/if} + + + {/each} + + +
{/if}
-
+
- - diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/reply/+page.svelte b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/reply/+page.svelte index f4f04265..24a19437 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/reply/+page.svelte +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/reply/+page.svelte @@ -1,9 +1,11 @@ -
- -
- - - - -
-
- -
-
- -
-
-

- Reply to Message -

-

- Responding to {recipient.label} -

-
-
- - - -
-
-
+
+ +
+

{m.messagingReplyToMessage()}

+

+ {m.messagingRespondingTo({ recipientName: recipient.label })} +

+
{#if actionError} -
-
-
- -
- {actionError} -
+ {/if} {#if showReceiveMailWarning} -
-
-
- -
-
- - Messaging is disabled for your account. - -

- You cannot receive further replies. - - Enable it in settings - -

-
+ {/if} @@ -165,90 +88,35 @@
-
- -
-
-
-
- -
-

- Message Details -

-
-
- - Reply -
-
-
- - -
- +
+ +
-
- - -
-
-
-
-
- - - + + {recipient.label}
+
- + +
-
- -
-
-
-
-
- - - -
-
- -
-
-
+ -
-
-
- - This message will be saved in delivery history -
-
- - - Cancel - - -
+
+ + + {m.messagingSavedInHistory()} + +
+ + + {m.messageCancelButton()} + +
- - From 816c2fcdc08b843cb73b47806e85beb82571d1c6 Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Thu, 19 Feb 2026 21:04:09 +0100 Subject: [PATCH 26/31] wip --- messages/de.json | 10 + messages/en.json | 10 + schema.graphql | 26 +- src/api/resolvers/modules/messageAudit.ts | 60 +++- src/api/resolvers/modules/user.ts | 27 ++ src/api/services/email/types.ts | 6 + src/composers/messagingComposer.ts | 253 ++++++++++++++-- .../Messaging/RecipientPickerDrawer.svelte | 274 ++++++++++++++++++ .../components/Messaging/recipientUtils.ts | 27 ++ .../templates/DelegationMessageEmail.svelte | 28 +- .../[conferenceId]/messaging/+page.server.ts | 47 +++ .../[conferenceId]/messaging/+page.svelte | 49 +++- .../messaging/compose/+page.server.ts | 100 ++++++- .../messaging/compose/+page.svelte | 265 ++++++++++------- .../messaging/reply/+page.server.ts | 153 ---------- .../messaging/reply/+page.svelte | 166 ----------- 16 files changed, 1047 insertions(+), 454 deletions(-) create mode 100644 src/lib/components/Messaging/RecipientPickerDrawer.svelte create mode 100644 src/lib/components/Messaging/recipientUtils.ts create mode 100644 src/routes/(authenticated)/dashboard/[conferenceId]/messaging/+page.server.ts delete mode 100644 src/routes/(authenticated)/dashboard/[conferenceId]/messaging/reply/+page.server.ts delete mode 100644 src/routes/(authenticated)/dashboard/[conferenceId]/messaging/reply/+page.svelte diff --git a/messages/de.json b/messages/de.json index 59921925..c45373b4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -785,7 +785,11 @@ "messagingAboutRecipientNotice": "Empfangende erhalten deine Nachricht als E-Mail-Benachrichtigung.", "messagingAboutTitle": "Konferenznachrichten", "messagingActivationNotice": "Du musst E-Mail-Nachrichten in deinen Kontoeinstellungen aktivieren, um Nachrichten empfangen und senden zu können.", + "messagingBackToGroups": "Zurück zu Gruppen", "messagingCannotReceiveReplies": "Du kannst keine weiteren Antworten empfangen.", + "messagingCategoryCommittee": "Gremium", + "messagingCategoryCustomRole": "Sonderrolle", + "messagingCategoryNSA": "Nichtstaatlicher Akteur", "messagingCenter": "Nachrichtenzentrale", "messagingCheckDeliveryStatus": "Zustellungsstatus prüfen", "messagingClearActionDeadlines": "Fristen bei Bedarf angeben", @@ -815,8 +819,10 @@ "messagingNoRecipientsInGroup": "Keine berechtigten Empfangenden in dieser Gruppe", "messagingNoReplyWarning": "Du kannst keine Antworten erhalten.", "messagingOnlyEnabledUsers": "Nur Nutzer*innen, die Messaging aktiviert haben, werden in dieser Liste angezeigt", + "messagingOriginalMessage": "Ursprüngliche Nachricht", "messagingOverview": "Übersicht", "messagingQuickActions": "Schnellaktionen", + "messagingRecipientCount": "{count} Empfangende", "messagingRecipientLabel": "Empfänger*in", "messagingRecipientNotEnabledToast": "Empfänger*in hat Messaging nicht aktiviert.", "messagingRecipientRequired": "Empfänger*in auswählen...", @@ -827,14 +833,18 @@ "messagingSavedInHistory": "Diese Nachricht wird im Zustellungsverlauf gespeichert", "messagingSelectGroup": "Gruppe auswählen...", "messagingSelectRecipient": "Empfänger*in auswählen...", + "messagingSelectRecipientDrawer": "Empfänger*in auswählen", "messagingSendFirstMessage": "Sende deine erste Nachricht", "messagingSendReply": "Antwort senden", + "messagingSenderNotEnabled": "Du musst Messaging in deinen Kontoeinstellungen aktivieren, bevor du Nachrichten senden kannst.", "messagingSent": "Gesendet", "messagingSentHistory": "Sendverlauf", "messagingStartTargetedMessage": "Starte eine gezielte Nachricht an Delegationen, Gremien oder einzelne Teilnehmende.", "messagingStatus": "Status", "messagingTipSubjectLine": "Tipp: Verwende klare Betreffzeilen wie \"Gremienagenda-Sperrung - 18:00\", um die Rücklaufquote zu verbessern", "messagingTo": "An", + "messagingToggleDisabled": "E-Mail-Nachrichten sind deaktiviert — aktiviere sie, um Nachrichten senden und empfangen zu können.", + "messagingToggleEnabled": "E-Mail-Nachrichten sind aktiviert — du kannst Nachrichten senden und empfangen.", "messagingUnableToLoadRecipients": "Empfänger*in konnte nicht geladen werden", "messagingVerifyRecipient": "Empfänger*in und Konferenz überprüfen", "messagingViewSentHistory": "Sendverlauf anzeigen", diff --git a/messages/en.json b/messages/en.json index b6bfd41b..ed8abf17 100644 --- a/messages/en.json +++ b/messages/en.json @@ -785,7 +785,11 @@ "messagingAboutRecipientNotice": "Recipients will receive your message as an email notification.", "messagingAboutTitle": "About Conference Messaging", "messagingActivationNotice": "You need to activate Email-Messaging in your account settings to receive and send messages.", + "messagingBackToGroups": "Back to groups", "messagingCannotReceiveReplies": "You cannot receive further replies.", + "messagingCategoryCommittee": "Committee", + "messagingCategoryCustomRole": "Role", + "messagingCategoryNSA": "Non-State Actor", "messagingCenter": "Messaging Center", "messagingCheckDeliveryStatus": "Check delivery status", "messagingClearActionDeadlines": "Include deadlines when applicable", @@ -815,8 +819,10 @@ "messagingNoRecipientsInGroup": "No eligible recipients in this group", "messagingNoReplyWarning": "You cannot receive replies.", "messagingOnlyEnabledUsers": "Only users who have enabled messaging will appear in this list", + "messagingOriginalMessage": "Original Message", "messagingOverview": "Overview", "messagingQuickActions": "Quick actions", + "messagingRecipientCount": "{count} recipients", "messagingRecipientLabel": "Recipient", "messagingRecipientNotEnabledToast": "Recipient has not enabled messaging.", "messagingRecipientRequired": "Select a recipient...", @@ -827,14 +833,18 @@ "messagingSavedInHistory": "This message will be saved in delivery history", "messagingSelectGroup": "Select a group...", "messagingSelectRecipient": "Select a recipient...", + "messagingSelectRecipientDrawer": "Select Recipient", "messagingSendFirstMessage": "Send Your First Message", "messagingSendReply": "Send Reply", + "messagingSenderNotEnabled": "You must enable messaging in your account settings before sending messages.", "messagingSent": "Sent", "messagingSentHistory": "Sent history", "messagingStartTargetedMessage": "Start a targeted message to delegations, committees, or individual participants.", "messagingStatus": "Status", "messagingTipSubjectLine": "Tip: Use clear subject lines like \"Committee agenda lock - 18:00\" to improve response rates", "messagingTo": "To", + "messagingToggleDisabled": "Email messaging is disabled — enable it to send and receive messages.", + "messagingToggleEnabled": "Email messaging is enabled — you can send and receive messages.", "messagingUnableToLoadRecipients": "Unable to load recipient", "messagingVerifyRecipient": "Verify recipient and conference", "messagingViewSentHistory": "View Sent History", diff --git a/schema.graphql b/schema.graphql index 2c16e175..6f28b597 100644 --- a/schema.graphql +++ b/schema.graphql @@ -3099,7 +3099,7 @@ type Mutation { rotateSupervisorConnectionCode(id: ID!): ConferenceSupervisor! seedNewConference(data: JSONObject!): SeedNewConferenceResult! sendAssignmentData(data: JSONObject!, where: ConferenceWhereUniqueInput!): SetAssignmentDataResult! - sendDelegationMessage(body: String!, conferenceId: String!, recipientId: String!, replyUrl: String!, subject: String!): String! + sendDelegationMessage(body: String!, conferenceId: String!, origin: String!, recipientId: String!, replyToMessageId: String, subject: String!): String! setAgendaItemReviewHelpStatus(agendaItemId: String!, status: ReviewHelpStatus!): CommitteeAgendaItem! startImpersonation(scope: String, targetUserId: String!): Boolean! stopImpersonation: Boolean! @@ -3131,6 +3131,7 @@ type Mutation { updateOneUsersNewsletterPreferences(email: String!, wantsJoinTeamInformation: Boolean, wantsToReceiveGeneralInformation: Boolean): User updateOneWaitingListEntry(data: WaitingListEntryUpdateInput!, where: WaitingListEntryWhereUniqueInput!): WaitingListEntry updateReviewerSnippet(content: Json!, id: String!, name: String!): ReviewerSnippet! + updateUserMessagingPreference(canReceiveDelegationMail: Boolean!, where: UserWhereUniqueInput!): User upsertSelf: UpsertSelfResult! } @@ -4717,6 +4718,7 @@ type Query { getCertificateJWT(where: ConferenceParticipantStatusWhereUniqueInput!): CertificateJWT! getCertificateJWTPublicKeyObject: JWK! getConferenceStatistics(conferenceId: ID!, filter: StatsFilter! = ALL): StatisticsResult! + getMessageForReply(conferenceId: String!, messageAuditId: String!): ReplyMessageInfo getMessageHistory(conferenceId: String!): [MessageHistoryItem!]! getMessageRecipients(conferenceId: String!): [RecipientGroup!]! impersonatableUsers: [User!]! @@ -4739,14 +4741,21 @@ enum QueryMode { type RecipientGroup { category: String! + fontAwesomeIcon: String groupId: String! groupLabel: String! recipients: [RecipientInfo!]! } type RecipientInfo { + alpha2Code: String + alpha3Code: String + firstName: String + fontAwesomeIcon: String id: String! label: String! + lastName: String + roleName: String } type RegenerateInvitationResult { @@ -4756,6 +4765,21 @@ type RegenerateInvitationResult { success: Boolean! } +type ReplyMessageInfo { + body: String! + id: String! + senderAlpha2Code: String + senderAlpha3Code: String + senderFirstName: String! + senderFontAwesomeIcon: String + senderLabel: String! + senderLastName: String! + senderRoleName: String + senderUserId: String! + sentAt: String! + subject: String! +} + enum ReviewHelpStatus { HELP_NEEDED NO_HELP_WANTED diff --git a/src/api/resolvers/modules/messageAudit.ts b/src/api/resolvers/modules/messageAudit.ts index 5b52f4ff..7ac1d4d6 100644 --- a/src/api/resolvers/modules/messageAudit.ts +++ b/src/api/resolvers/modules/messageAudit.ts @@ -20,7 +20,8 @@ import { GraphQLError } from 'graphql'; import { sendDelegationMessage, getMessageRecipients, - getMessageHistory + getMessageHistory, + getMessageForReply } from '../../../composers/messagingComposer'; export const GQLMessageAudit = builder.prismaObject('MessageAudit', { @@ -45,7 +46,13 @@ export const GQLMessageAudit = builder.prismaObject('MessageAudit', { const RecipientInfo = builder.simpleObject('RecipientInfo', { fields: (t) => ({ id: t.string(), - label: t.string() + label: t.string(), + firstName: t.string({ nullable: true }), + lastName: t.string({ nullable: true }), + alpha2Code: t.string({ nullable: true }), + alpha3Code: t.string({ nullable: true }), + fontAwesomeIcon: t.string({ nullable: true }), + roleName: t.string({ nullable: true }) }) }); @@ -55,6 +62,7 @@ const RecipientGroup = builder.simpleObject('RecipientGroup', { groupId: t.string(), groupLabel: t.string(), category: t.string(), + fontAwesomeIcon: t.string({ nullable: true }), recipients: t.field({ type: [RecipientInfo] }) }) }); @@ -69,6 +77,24 @@ const MessageHistoryItem = builder.simpleObject('MessageHistoryItem', { }) }); +// Type for reply message info +const ReplyMessageInfo = builder.simpleObject('ReplyMessageInfo', { + fields: (t) => ({ + id: t.string(), + subject: t.string(), + body: t.string(), + senderLabel: t.string(), + senderUserId: t.string(), + senderFirstName: t.string(), + senderLastName: t.string(), + senderAlpha2Code: t.string({ nullable: true }), + senderAlpha3Code: t.string({ nullable: true }), + senderFontAwesomeIcon: t.string({ nullable: true }), + senderRoleName: t.string({ nullable: true }), + sentAt: t.string() + }) +}); + builder.queryFields((t) => { const field = findManyMessageAuditQueryObject(t); return { @@ -143,6 +169,28 @@ builder.queryField('getMessageHistory', (t) => }) ); +// Query to get a message for reply context +builder.queryField('getMessageForReply', (t) => + t.field({ + type: ReplyMessageInfo, + nullable: true, + args: { + messageAuditId: t.arg.string({ required: true }), + conferenceId: t.arg.string({ required: true }) + }, + resolve: async (_root, args, ctx) => { + const user = ctx.permissions.getLoggedInUserOrThrow(); + try { + return await getMessageForReply(args.messageAuditId, args.conferenceId, user.sub); + } catch (e: unknown) { + console.error(e); + const message = e instanceof Error ? e.message : 'Error fetching reply message'; + throw new GraphQLError(message); + } + } + }) +); + // Mutation to send a delegation message builder.mutationField('sendDelegationMessage', (t) => t.field({ @@ -152,7 +200,8 @@ builder.mutationField('sendDelegationMessage', (t) => recipientId: t.arg.string({ required: true }), subject: t.arg.string({ required: true }), body: t.arg.string({ required: true }), - replyUrl: t.arg.string({ required: true }) + origin: t.arg.string({ required: true }), + replyToMessageId: t.arg.string({ required: false }) }, resolve: async (_root, args, ctx) => { const user = ctx.permissions.getLoggedInUserOrThrow(); @@ -162,8 +211,9 @@ builder.mutationField('sendDelegationMessage', (t) => recipientId: args.recipientId, subject: args.subject, body: args.body, - replyUrl: args.replyUrl, - senderId: user.sub + origin: args.origin, + senderId: user.sub, + replyToMessageId: args.replyToMessageId ?? undefined }); } catch (e: unknown) { console.error(e); diff --git a/src/api/resolvers/modules/user.ts b/src/api/resolvers/modules/user.ts index 5f46a566..94c531a1 100644 --- a/src/api/resolvers/modules/user.ts +++ b/src/api/resolvers/modules/user.ts @@ -338,6 +338,33 @@ builder.mutationFields((t) => { }; }); +builder.mutationFields((t) => { + const field = updateOneUserMutationObject(t); + return { + updateUserMessagingPreference: t.prismaField({ + ...field, + args: { + where: field.args.where, + canReceiveDelegationMail: t.arg.boolean() + }, + resolve: async (query, root, args, ctx) => { + args.where = { + ...args.where, + AND: [ctx.permissions.allowDatabaseAccessTo('update').User] + }; + + return await db.user.update({ + ...query, + where: args.where, + data: { + canReceiveDelegationMail: args.canReceiveDelegationMail + } + }); + } + }) + }; +}); + builder.mutationFields((t) => { const field = updateOneUserMutationObject(t); return { diff --git a/src/api/services/email/types.ts b/src/api/services/email/types.ts index d88b116c..413ecd8d 100644 --- a/src/api/services/email/types.ts +++ b/src/api/services/email/types.ts @@ -41,4 +41,10 @@ export interface DelegationMessageEmailProps { messageBody: string; conferenceTitle: string; replyUrl: string; + quotedMessage?: { + senderLabel: string; + subject: string; + body: string; + sentAt: string; + }; } diff --git a/src/composers/messagingComposer.ts b/src/composers/messagingComposer.ts index 404f36d4..08f7d5d8 100644 --- a/src/composers/messagingComposer.ts +++ b/src/composers/messagingComposer.ts @@ -47,11 +47,23 @@ export function getDelegateLabel( return label; } +export type RecipientInfo = { + id: string; + label: string; + firstName: string | null; + lastName: string | null; + alpha2Code: string | null; + alpha3Code: string | null; + fontAwesomeIcon: string | null; + roleName: string | null; +}; + export type RecipientGroup = { groupId: string; groupLabel: string; category: 'COMMITTEE' | 'NSA' | 'CUSTOM_ROLE'; - recipients: Array<{ id: string; label: string }>; + fontAwesomeIcon: string | null; + recipients: RecipientInfo[]; }; export async function getMessageRecipients( @@ -105,15 +117,12 @@ export async function getMessageRecipients( }); // Group DelegationMembers by committee - const committeeGroups = new Map< - string, - { label: string; recipients: Array<{ id: string; label: string }> } - >(); + const committeeGroups = new Map(); // Group DelegationMembers by NSA (those with no assignedCommittee but with assignedNonStateActor) const nsaGroups = new Map< string, - { label: string; recipients: Array<{ id: string; label: string }> } + { label: string; fontAwesomeIcon: string | null; recipients: RecipientInfo[] } >(); for (const member of delegationMembers) { @@ -127,26 +136,52 @@ export async function getMessageRecipients( if (member.assignedCommittee) { const key = member.assignedCommittee.id; if (!committeeGroups.has(key)) { + const committeeLabel = member.assignedCommittee.abbreviation + ? `${member.assignedCommittee.name} (${member.assignedCommittee.abbreviation})` + : member.assignedCommittee.name; committeeGroups.set(key, { - label: member.assignedCommittee.abbreviation || member.assignedCommittee.name, + label: committeeLabel, recipients: [] }); } - committeeGroups.get(key)!.recipients.push({ id: userObj.id, label }); + const committeeName = member.assignedCommittee.abbreviation || member.assignedCommittee.name; + committeeGroups.get(key)!.recipients.push({ + id: userObj.id, + label, + firstName: userObj.given_name, + lastName: userObj.family_name, + alpha2Code: member.delegation.assignedNation?.alpha2Code ?? null, + alpha3Code: member.delegation.assignedNation?.alpha3Code ?? null, + fontAwesomeIcon: null, + roleName: committeeName + }); } else if (member.delegation.assignedNonStateActor) { const nsa = member.delegation.assignedNonStateActor; const key = nsa.id; if (!nsaGroups.has(key)) { - nsaGroups.set(key, { label: nsa.name, recipients: [] }); + nsaGroups.set(key, { + label: nsa.name, + fontAwesomeIcon: nsa.fontAwesomeIcon, + recipients: [] + }); } - nsaGroups.get(key)!.recipients.push({ id: userObj.id, label }); + nsaGroups.get(key)!.recipients.push({ + id: userObj.id, + label, + firstName: userObj.given_name, + lastName: userObj.family_name, + alpha2Code: null, + alpha3Code: null, + fontAwesomeIcon: nsa.fontAwesomeIcon, + roleName: nsa.name + }); } } // Group SingleParticipants by assignedRole const roleGroups = new Map< string, - { label: string; recipients: Array<{ id: string; label: string }> } + { label: string; fontAwesomeIcon: string | null; recipients: RecipientInfo[] } >(); for (const participant of singleParticipants) { @@ -161,9 +196,22 @@ export async function getMessageRecipients( const key = participant.assignedRole.id; if (!roleGroups.has(key)) { - roleGroups.set(key, { label: participant.assignedRole.name, recipients: [] }); + roleGroups.set(key, { + label: participant.assignedRole.name, + fontAwesomeIcon: participant.assignedRole.fontAwesomeIcon, + recipients: [] + }); } - roleGroups.get(key)!.recipients.push({ id: userObj.id, label }); + roleGroups.get(key)!.recipients.push({ + id: userObj.id, + label, + firstName: userObj.given_name, + lastName: userObj.family_name, + alpha2Code: null, + alpha3Code: null, + fontAwesomeIcon: participant.assignedRole.fontAwesomeIcon, + roleName: participant.assignedRole.name + }); } // Build result, sorting recipients within each group @@ -175,6 +223,7 @@ export async function getMessageRecipients( groupId, groupLabel: group.label, category: 'COMMITTEE', + fontAwesomeIcon: null, recipients: group.recipients }); } @@ -185,6 +234,7 @@ export async function getMessageRecipients( groupId, groupLabel: group.label, category: 'NSA', + fontAwesomeIcon: group.fontAwesomeIcon, recipients: group.recipients }); } @@ -195,6 +245,7 @@ export async function getMessageRecipients( groupId, groupLabel: group.label, category: 'CUSTOM_ROLE', + fontAwesomeIcon: group.fontAwesomeIcon, recipients: group.recipients }); } @@ -205,6 +256,122 @@ export async function getMessageRecipients( return groups; } +export type ReplyMessageData = { + id: string; + subject: string; + body: string; + senderLabel: string; + senderUserId: string; + senderFirstName: string; + senderLastName: string; + senderAlpha2Code: string | null; + senderAlpha3Code: string | null; + senderFontAwesomeIcon: string | null; + senderRoleName: string | null; + sentAt: string; +}; + +export async function getMessageForReply( + messageAuditId: string, + conferenceId: string, + userId: string +): Promise { + const audit = await db.messageAudit.findUnique({ + where: { id: messageAuditId }, + select: { + id: true, + subject: true, + body: true, + createdAt: true, + senderUserId: true, + recipientUserId: true, + conferenceId: true, + senderUser: { + select: { + id: true, + given_name: true, + family_name: true + } + } + } + }); + + if (!audit || audit.conferenceId !== conferenceId || audit.recipientUserId !== userId) { + return null; + } + + // Look up sender's delegation/role info for the label + const senderDelegationMember = await db.delegationMember.findUnique({ + where: { + conferenceId_userId: { + conferenceId, + userId: audit.senderUserId + } + }, + include: { + delegation: { + include: { + assignedNation: true, + assignedNonStateActor: true + } + }, + assignedCommittee: true + } + }); + + let senderSingleParticipant = null as { + assignedRole: { name: string; fontAwesomeIcon: string | null } | null; + } | null; + if (!senderDelegationMember) { + senderSingleParticipant = await db.singleParticipant.findUnique({ + where: { + conferenceId_userId: { + conferenceId, + userId: audit.senderUserId + } + }, + include: { + assignedRole: true + } + }); + } + + const senderLabel = getDelegateLabel( + audit.senderUser, + senderDelegationMember, + senderSingleParticipant + ); + + // Derive recipient-info fields for display + const senderAlpha2Code = senderDelegationMember?.delegation.assignedNation?.alpha2Code ?? null; + const senderAlpha3Code = senderDelegationMember?.delegation.assignedNation?.alpha3Code ?? null; + const senderFontAwesomeIcon = + senderDelegationMember?.delegation.assignedNonStateActor?.fontAwesomeIcon ?? + senderSingleParticipant?.assignedRole?.fontAwesomeIcon ?? + null; + const senderRoleName = + senderDelegationMember?.assignedCommittee?.abbreviation ?? + senderDelegationMember?.assignedCommittee?.name ?? + senderDelegationMember?.delegation.assignedNonStateActor?.name ?? + senderSingleParticipant?.assignedRole?.name ?? + null; + + return { + id: audit.id, + subject: audit.subject, + body: audit.body, + senderLabel, + senderUserId: audit.senderUserId, + senderFirstName: audit.senderUser.given_name, + senderLastName: audit.senderUser.family_name, + senderAlpha2Code, + senderAlpha3Code, + senderFontAwesomeIcon, + senderRoleName, + sentAt: audit.createdAt.toISOString() + }; +} + export async function getMessageHistory(conferenceId: string, userId: string) { const audits = await db.messageAudit.findMany({ where: { @@ -298,15 +465,17 @@ export async function sendDelegationMessage({ recipientId, subject, body, - replyUrl, - senderId + origin, + senderId, + replyToMessageId }: { conferenceId: string; recipientId: string; subject: string; body: string; - replyUrl: string; + origin: string; senderId: string; + replyToMessageId?: string; }) { // Validate inputs if (!recipientId.trim() || !subject.trim() || !body.trim()) { @@ -449,13 +618,63 @@ export async function sendDelegationMessage({ } }); + // Build reply URL from the new audit ID + const replyUrl = `${origin}/dashboard/${conferenceId}/messaging/compose?replyTo=${audit.id}`; + + // Build quoted message if this is a reply + let quotedMessage: + | { senderLabel: string; subject: string; body: string; sentAt: string } + | undefined; + if (replyToMessageId) { + const originalAudit = await db.messageAudit.findUnique({ + where: { id: replyToMessageId }, + select: { + subject: true, + body: true, + createdAt: true, + senderUserId: true, + senderUser: { + select: { given_name: true, family_name: true } + } + } + }); + if (originalAudit) { + const origSenderDM = await db.delegationMember.findUnique({ + where: { + conferenceId_userId: { conferenceId, userId: originalAudit.senderUserId } + }, + include: { + delegation: { include: { assignedNation: true, assignedNonStateActor: true } }, + assignedCommittee: true + } + }); + let origSenderSP = null as { assignedRole: { name: string } | null } | null; + if (!origSenderDM) { + origSenderSP = await db.singleParticipant.findUnique({ + where: { + conferenceId_userId: { conferenceId, userId: originalAudit.senderUserId } + }, + include: { assignedRole: true } + }); + } + const origLabel = getDelegateLabel(originalAudit.senderUser, origSenderDM, origSenderSP); + quotedMessage = { + senderLabel: origLabel, + subject: originalAudit.subject, + body: originalAudit.body, + sentAt: originalAudit.createdAt.toISOString() + }; + } + } + // Render email const { html, text } = await renderDelegationMessageEmail({ senderLabel, subject: subject, messageBody: body, conferenceTitle: conference.title, - replyUrl: replyUrl + replyUrl, + quotedMessage }); // Send email diff --git a/src/lib/components/Messaging/RecipientPickerDrawer.svelte b/src/lib/components/Messaging/RecipientPickerDrawer.svelte new file mode 100644 index 00000000..f13fa3b6 --- /dev/null +++ b/src/lib/components/Messaging/RecipientPickerDrawer.svelte @@ -0,0 +1,274 @@ + + + +{#if selected} +
+
+ {#if selected.alpha2Code} + + {:else if selected.fontAwesomeIcon} + + {/if} +
+
+ {getRecipientDisplayName(selected)} +
+ {#if selected.firstName && selected.lastName} +
+ {formatNames(selected.firstName, selected.lastName)} +
+ {/if} +
+
+ +
+{:else} + +{/if} + + +{#key direction} + + + + + +
+ {#if direction === 'bottom'} +
+
+
+ {/if} +
+ {#if drawerStep === 'recipient'} + + {/if} + + {#if drawerStep === 'group'} + {m.messagingSelectRecipientDrawer()} + {:else if selectedGroup} + {selectedGroup.groupLabel} + {/if} + + +
+
+ + +
+ {#if drawerStep === 'group'} + {#if loadError} +
+ +
+ {:else if groups.length === 0} +
+ +
+ {:else} + + {/if} + {:else if drawerStep === 'recipient' && selectedGroup} + {#if selectedGroup.recipients.length === 0} +
+ +
+ {:else} + + {/if} + {/if} +
+
+
+
+{/key} diff --git a/src/lib/components/Messaging/recipientUtils.ts b/src/lib/components/Messaging/recipientUtils.ts new file mode 100644 index 00000000..f88b9026 --- /dev/null +++ b/src/lib/components/Messaging/recipientUtils.ts @@ -0,0 +1,27 @@ +import { getFullTranslatedCountryNameFromISO3Code } from '$lib/services/nationTranslationHelper.svelte'; + +export type Recipient = { + id: string; + label: string; + firstName: string | null; + lastName: string | null; + alpha2Code: string | null; + alpha3Code: string | null; + fontAwesomeIcon: string | null; + roleName: string | null; +}; + +export type RecipientGroup = { + groupId: string; + groupLabel: string; + category: string; + fontAwesomeIcon: string | null; + recipients: Recipient[]; +}; + +export function getRecipientDisplayName(r: Recipient): string { + if (r.alpha3Code) { + return getFullTranslatedCountryNameFromISO3Code(r.alpha3Code); + } + return r.roleName ?? r.label; +} diff --git a/src/lib/emails/templates/DelegationMessageEmail.svelte b/src/lib/emails/templates/DelegationMessageEmail.svelte index 4289222f..39db6e96 100644 --- a/src/lib/emails/templates/DelegationMessageEmail.svelte +++ b/src/lib/emails/templates/DelegationMessageEmail.svelte @@ -7,10 +7,18 @@ messageBody: string; conferenceTitle: string; replyUrl: string; + quotedMessage?: { + senderLabel: string; + subject: string; + body: string; + sentAt: string; + }; } - let { senderLabel, subject, messageBody, conferenceTitle, replyUrl }: Props = $props(); + let { senderLabel, subject, messageBody, conferenceTitle, replyUrl, quotedMessage }: Props = + $props(); const messageLines = messageBody.split(/\r?\n/); + const quotedLines = quotedMessage?.body.split(/\r?\n/) ?? []; @@ -55,6 +63,24 @@ + {#if quotedMessage} +
+ + + --- Urspruengliche Nachricht --- + + + Von: {quotedMessage.senderLabel} | Betreff: {quotedMessage.subject} | {quotedMessage.sentAt} + +
+ {#each quotedLines as line} + + {line || ' '} + + {/each} +
+ {/if} +
diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/+page.server.ts b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/+page.server.ts new file mode 100644 index 00000000..f18908b8 --- /dev/null +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/+page.server.ts @@ -0,0 +1,47 @@ +import { fail } from '@sveltejs/kit'; +import { graphql } from '$houdini'; +import { fastUserQuery } from '$lib/queries/fastUserQuery'; +import type { Actions } from './$types'; + +const messagingPreferenceMutation = graphql(` + mutation UpdateUserMessagingPreference( + $where: UserWhereUniqueInput! + $canReceiveDelegationMail: Boolean! + ) { + updateUserMessagingPreference( + where: $where + canReceiveDelegationMail: $canReceiveDelegationMail + ) { + id + } + } +`); + +export const actions = { + toggleMessaging: async (event) => { + const formData = await event.request.formData(); + const enabledValue = formData.get('enabled'); + + if (typeof enabledValue !== 'string') { + return fail(400); + } + + const enabled = enabledValue === 'true'; + + const { data } = await fastUserQuery.fetch({ event, blocking: true }); + const userId = data?.offlineUserRefresh.user?.sub; + if (!userId) { + return fail(401); + } + + await messagingPreferenceMutation.mutate( + { + where: { id: userId }, + canReceiveDelegationMail: enabled + }, + { event } + ); + + return { success: true }; + } +} satisfies Actions; diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/+page.svelte b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/+page.svelte index 7b1b793c..ea1bd2c1 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/+page.svelte +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/+page.svelte @@ -1,10 +1,21 @@
@@ -50,10 +61,38 @@ {m.messagingAboutRecipientNotice()}

- +
{ + submitting = true; + return async ({ update }) => { + await update(); + await invalidateAll(); + submitting = false; + }; + }} + > + + +
@@ -85,7 +124,7 @@ diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.server.ts b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.server.ts index d4a2bda4..f36fdd21 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.server.ts +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.server.ts @@ -9,28 +9,70 @@ const getMessageRecipientsQuery = graphql(` groupId groupLabel category + fontAwesomeIcon recipients { id label + firstName + lastName + alpha2Code + alpha3Code + fontAwesomeIcon + roleName } } } `); +const messagingPreferenceMutation = graphql(` + mutation ComposeToggleMessagingPreference( + $where: UserWhereUniqueInput! + $canReceiveDelegationMail: Boolean! + ) { + updateUserMessagingPreference( + where: $where + canReceiveDelegationMail: $canReceiveDelegationMail + ) { + id + } + } +`); + +const getMessageForReplyQuery = graphql(` + query GetMessageForReplyQuery($messageAuditId: String!, $conferenceId: String!) { + getMessageForReply(messageAuditId: $messageAuditId, conferenceId: $conferenceId) { + id + subject + body + senderLabel + senderUserId + senderFirstName + senderLastName + senderAlpha2Code + senderAlpha3Code + senderFontAwesomeIcon + senderRoleName + sentAt + } + } +`); + const sendDelegationMessageMutation = graphql(` mutation SendDelegationMessageMutation( $conferenceId: String! $recipientId: String! $subject: String! $body: String! - $replyUrl: String! + $origin: String! + $replyToMessageId: String ) { sendDelegationMessage( conferenceId: $conferenceId recipientId: $recipientId subject: $subject body: $body - replyUrl: $replyUrl + origin: $origin + replyToMessageId: $replyToMessageId ) } `); @@ -47,6 +89,8 @@ export const load: PageServerLoad = async (event) => { throw error(400, 'Missing conference id'); } + const replyToId = event.url.searchParams.get('replyTo'); + try { const result = await getMessageRecipientsQuery.fetch({ event, @@ -56,19 +100,59 @@ export const load: PageServerLoad = async (event) => { const recipientGroups = result.data?.getMessageRecipients ?? []; + if (replyToId) { + const replyResult = await getMessageForReplyQuery.fetch({ + event, + variables: { messageAuditId: replyToId, conferenceId }, + blocking: true + }); + return { + recipientGroups, + replyToMessage: replyResult.data?.getMessageForReply ?? null + }; + } + return { - recipientGroups + recipientGroups, + replyToMessage: null as null }; } catch (loadError) { console.error('Messaging recipients load error:', loadError); return { recipientGroups: [], + replyToMessage: null, recipientLoadError: 'Unable to load recipients' }; } }; export const actions = { + toggleMessaging: async (event) => { + const formData = await event.request.formData(); + const enabledValue = formData.get('enabled'); + + if (typeof enabledValue !== 'string') { + return fail(400, { error: 'Invalid request' }); + } + + const enabled = enabledValue === 'true'; + + const { data } = await fastUserQuery.fetch({ event, blocking: true }); + const userId = data?.offlineUserRefresh.user?.sub; + if (!userId) { + return fail(401, { error: 'Unauthorized' }); + } + + await messagingPreferenceMutation.mutate( + { + where: { id: userId }, + canReceiveDelegationMail: enabled + }, + { event } + ); + + return { status: 'ok' }; + }, send: async (event) => { const conferenceId = event.params.conferenceId; if (!conferenceId) { @@ -106,8 +190,11 @@ export const actions = { return fail(400, { error: 'Cannot send to yourself' }); } - const replySubject = `Re: ${subject}`; - const replyUrl = `${event.url.origin}/dashboard/${conferenceId}/messaging/reply?recipientId=${encodeURIComponent(authUser.sub)}&subject=${encodeURIComponent(replySubject)}`; + const replyToMessageIdValue = formData.get('replyToMessageId'); + const replyToMessageId = + typeof replyToMessageIdValue === 'string' && replyToMessageIdValue.trim() + ? replyToMessageIdValue.trim() + : null; try { await sendDelegationMessageMutation.mutate( @@ -116,7 +203,8 @@ export const actions = { recipientId, subject, body: messageBody, - replyUrl + origin: event.url.origin, + replyToMessageId }, { event } ); diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte index 3e67ed72..024c86c8 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/compose/+page.svelte @@ -1,32 +1,24 @@ -
+
-

{m.messageComposeMessage()}

-

{m.messageSendToParticipants()}

+

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

+

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

@@ -108,75 +136,89 @@
{/if} - {#if showReceiveMailWarning} - + + {#if replyToMessage} + +
+
+ {replyToMessage.senderLabel} + · + {formattedSentAt} +
+
+ {replyToMessage.subject} +
+
+ {replyToMessage.body} +
+
+
+ {/if} +
@@ -231,9 +296,9 @@ {m.messageCancelButton()} -
diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/reply/+page.server.ts b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/reply/+page.server.ts deleted file mode 100644 index 73c7e55d..00000000 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/reply/+page.server.ts +++ /dev/null @@ -1,153 +0,0 @@ -import type { Actions, PageServerLoad } from './$types'; -import { error, fail } from '@sveltejs/kit'; -import { graphql } from '$houdini'; -import { fastUserQuery } from '$lib/queries/fastUserQuery'; - -const getMessageRecipientsQuery = graphql(` - query GetReplyMessageRecipientsQuery($conferenceId: String!) { - getMessageRecipients(conferenceId: $conferenceId) { - groupId - groupLabel - category - recipients { - id - label - } - } - } -`); - -const sendDelegationMessageMutation = graphql(` - mutation SendReplyMessageMutation( - $conferenceId: String! - $recipientId: String! - $subject: String! - $body: String! - $replyUrl: String! - ) { - sendDelegationMessage( - conferenceId: $conferenceId - recipientId: $recipientId - subject: $subject - body: $body - replyUrl: $replyUrl - ) - } -`); - -export const load: PageServerLoad = async (event) => { - const parent = await event.parent(); - const userId = parent.user?.sub; - if (!userId) { - throw error(401, 'Unauthorized'); - } - - const conferenceId = event.params.conferenceId; - if (!conferenceId) { - throw error(400, 'Missing conference id'); - } - - const recipientId = event.url.searchParams.get('recipientId'); - const subject = event.url.searchParams.get('subject') || ''; - - if (!recipientId) { - throw error(400, 'Missing recipient'); - } - - try { - const result = await getMessageRecipientsQuery.fetch({ - event, - variables: { conferenceId }, - blocking: true - }); - - const groups = result.data?.getMessageRecipients ?? []; - const recipient = groups.flatMap((g) => g.recipients).find((r) => r.id === recipientId); - - if (!recipient) { - throw error(404, 'Recipient not found or not eligible'); - } - - return { - recipient, - prefilledSubject: subject - }; - } catch (loadError) { - console.error('Messaging recipients load error:', loadError); - // If we can't load recipients, we can't verify the recipient. - if ( - loadError instanceof Error && - (loadError.message.includes('404') || loadError.message.includes('Missing recipient')) - ) { - throw loadError; - } - throw error(500, 'Unable to load recipient details'); - } -}; - -export const actions = { - send: async (event) => { - const conferenceId = event.params.conferenceId; - if (!conferenceId) { - return fail(400, { error: 'Missing conference id' }); - } - - const formData = await event.request.formData(); - const recipientIdValue = formData.get('recipientId'); - const subjectValue = formData.get('subject'); - const messageBodyValue = formData.get('body'); - - if ( - typeof recipientIdValue !== 'string' || - typeof subjectValue !== 'string' || - typeof messageBodyValue !== 'string' - ) { - return fail(400, { error: 'Missing fields' }); - } - - const recipientId = recipientIdValue.trim(); - const subject = subjectValue.trim(); - const messageBody = messageBodyValue; - - if (!recipientId || !subject || !messageBody.trim()) { - return fail(400, { error: 'Missing fields' }); - } - - const { data } = await fastUserQuery.fetch({ event, blocking: true }); - const authUser = data?.offlineUserRefresh.user; - if (!authUser?.sub) { - return fail(401, { error: 'Unauthorized' }); - } - - if (recipientId === authUser.sub) { - return fail(400, { error: 'Cannot send to yourself' }); - } - - const replySubject = subject.startsWith('Re:') ? subject : `Re: ${subject}`; - const replyUrl = `${event.url.origin}/dashboard/${conferenceId}/messaging/reply?recipientId=${encodeURIComponent(authUser.sub)}&subject=${encodeURIComponent(replySubject)}`; - - try { - await sendDelegationMessageMutation.mutate( - { - conferenceId, - recipientId, - subject, - body: messageBody, - replyUrl - }, - { event } - ); - - return { - status: 'ok' - }; - } catch (sendError: unknown) { - console.error('Message sending error:', sendError); - const errorMessage = - sendError && typeof sendError === 'object' && 'message' in sendError - ? String(sendError.message) - : 'Failed to send message'; - return fail(500, { error: errorMessage }); - } - } -} satisfies Actions; diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/reply/+page.svelte b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/reply/+page.svelte deleted file mode 100644 index 24a19437..00000000 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/reply/+page.svelte +++ /dev/null @@ -1,166 +0,0 @@ - - -
- -
-

{m.messagingReplyToMessage()}

-

- {m.messagingRespondingTo({ recipientName: recipient.label })} -

-
- - - {#if actionError} - - {/if} - - {#if showReceiveMailWarning} - - {/if} - - - - -
- - -
- -
- - {recipient.label} -
-
-
- - - -
- - -
- -
- - -
-
- - -
- - - {m.messagingSavedInHistory()} - -
- - - {m.messageCancelButton()} - - -
-
-
- -
From bd4499fcc589a73a5cc6f3d2eab2065d57cd43be Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Thu, 19 Feb 2026 21:27:26 +0100 Subject: [PATCH 27/31] improve email --- messages/de.json | 4 + messages/en.json | 4 + src/api/services/email/types.ts | 2 + src/composers/messagingComposer.ts | 29 ++-- .../templates/DelegationMessageEmail.svelte | 151 +++++++++++------- .../[conferenceId]/messaging/+page.svelte | 26 ++- 6 files changed, 146 insertions(+), 70 deletions(-) diff --git a/messages/de.json b/messages/de.json index c45373b4..d136c7be 100644 --- a/messages/de.json +++ b/messages/de.json @@ -781,8 +781,12 @@ "messageSubject": "Betreff", "messageSubjectPlaceholder": "z.B. Vorschlag für bilaterales Treffen zu Resolution A/RES/1", "messaging": "Nachrichten senden", + "messagingAboutDeliveryLog": "Jede Nachricht wird protokolliert — überprüfe deinen Sendverlauf jederzeit.", "messagingAboutDescription": "Nutze die Konferenznachrichten, um mit anderen Delegierten, Nichtstaatlichen Akteuren und der Presse zu kommunizieren — vor und während der Konferenz.", + "messagingAboutGrouped": "Empfangende sind nach Gremium, Nichtstaatlichem Akteur und Rolle sortiert.", + "messagingAboutPrivacy": "Deine E-Mail-Adresse wird niemals weitergegeben — Empfangende sehen nur deine Konferenzrolle.", "messagingAboutRecipientNotice": "Empfangende erhalten deine Nachricht als E-Mail-Benachrichtigung.", + "messagingAboutThreading": "Antworten enthalten die ursprüngliche Nachricht für einen nahtlosen Gesprächsverlauf.", "messagingAboutTitle": "Konferenznachrichten", "messagingActivationNotice": "Du musst E-Mail-Nachrichten in deinen Kontoeinstellungen aktivieren, um Nachrichten empfangen und senden zu können.", "messagingBackToGroups": "Zurück zu Gruppen", diff --git a/messages/en.json b/messages/en.json index ed8abf17..cb5b758d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -781,8 +781,12 @@ "messageSubject": "Subject", "messageSubjectPlaceholder": "e.g., Proposal for bilateral meeting on Resolution A/RES/1", "messaging": "Send messages", + "messagingAboutDeliveryLog": "Every message is logged — check your delivery history anytime.", "messagingAboutDescription": "Use conference messaging to communicate with other delegates, Non-State Actors, and the press — before and during the conference.", + "messagingAboutGrouped": "Recipients are organized by committee, Non-State Actor, and role for quick discovery.", + "messagingAboutPrivacy": "Your email address is never shared — recipients only see your conference role.", "messagingAboutRecipientNotice": "Recipients will receive your message as an email notification.", + "messagingAboutThreading": "Replies include the original message for seamless conversation threading.", "messagingAboutTitle": "About Conference Messaging", "messagingActivationNotice": "You need to activate Email-Messaging in your account settings to receive and send messages.", "messagingBackToGroups": "Back to groups", diff --git a/src/api/services/email/types.ts b/src/api/services/email/types.ts index 413ecd8d..96d947eb 100644 --- a/src/api/services/email/types.ts +++ b/src/api/services/email/types.ts @@ -37,12 +37,14 @@ export interface NewReviewEmailProps { */ export interface DelegationMessageEmailProps { senderLabel: string; + senderInitials: string; subject: string; messageBody: string; conferenceTitle: string; replyUrl: string; quotedMessage?: { senderLabel: string; + senderInitials: string; subject: string; body: string; sentAt: string; diff --git a/src/composers/messagingComposer.ts b/src/composers/messagingComposer.ts index 08f7d5d8..03d5e2cf 100644 --- a/src/composers/messagingComposer.ts +++ b/src/composers/messagingComposer.ts @@ -1,7 +1,7 @@ import { db } from '$db/db'; import { renderDelegationMessageEmail } from '$api/services/email/delegationMessageTemplates'; import { emailService } from '$api/services/email/emailService'; -import countries from 'world-countries'; +import { getFullTranslatedCountryNameFromISO3Code } from '$lib/services/nationTranslationHelper.svelte'; // Helper function to generate delegate label export function getInitials(firstName: string, lastName: string) { @@ -26,8 +26,7 @@ export function getDelegateLabel( if (delegationMember) { if (delegationMember.delegation.assignedNation) { const alpha3Code = delegationMember.delegation.assignedNation.alpha3Code; - const nation = countries.find((c) => c.cca3 === alpha3Code); - const nationName = nation ? nation.name.common : alpha3Code; + const nationName = getFullTranslatedCountryNameFromISO3Code(alpha3Code); label = nationName; if (delegationMember.assignedCommittee) { @@ -35,13 +34,13 @@ export function getDelegateLabel( } } else if (delegationMember.delegation.assignedNonStateActor) { label = delegationMember.delegation.assignedNonStateActor.name; - const initials = getInitials(user.given_name, user.family_name); - label += ` (${initials})`; + if (delegationMember.assignedCommittee) { + label += ` (${delegationMember.assignedCommittee.abbreviation || delegationMember.assignedCommittee.name})`; + } } } else if (singleParticipant) { if (singleParticipant.assignedRole) { - const initials = getInitials(user.given_name, user.family_name); - label = `${singleParticipant.assignedRole.name} (${initials})`; + label = singleParticipant.assignedRole.name; } } return label; @@ -574,6 +573,8 @@ export async function sendDelegationMessage({ } const senderLabel = getDelegateLabel(sender, senderDelegationMember, senderSingleParticipant); + const senderInitials = + `${sender.given_name.charAt(0)}${sender.family_name.charAt(0)}`.toUpperCase(); // Verify recipient is part of conference const recipientDelegationMember = await db.delegationMember.findUnique({ @@ -623,7 +624,7 @@ export async function sendDelegationMessage({ // Build quoted message if this is a reply let quotedMessage: - | { senderLabel: string; subject: string; body: string; sentAt: string } + | { senderLabel: string; senderInitials: string; subject: string; body: string; sentAt: string } | undefined; if (replyToMessageId) { const originalAudit = await db.messageAudit.findUnique({ @@ -658,11 +659,20 @@ export async function sendDelegationMessage({ }); } const origLabel = getDelegateLabel(originalAudit.senderUser, origSenderDM, origSenderSP); + const origInitials = + `${originalAudit.senderUser.given_name.charAt(0)}${originalAudit.senderUser.family_name.charAt(0)}`.toUpperCase(); quotedMessage = { senderLabel: origLabel, + senderInitials: origInitials, subject: originalAudit.subject, body: originalAudit.body, - sentAt: originalAudit.createdAt.toISOString() + sentAt: originalAudit.createdAt.toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) }; } } @@ -670,6 +680,7 @@ export async function sendDelegationMessage({ // Render email const { html, text } = await renderDelegationMessageEmail({ senderLabel, + senderInitials, subject: subject, messageBody: body, conferenceTitle: conference.title, diff --git a/src/lib/emails/templates/DelegationMessageEmail.svelte b/src/lib/emails/templates/DelegationMessageEmail.svelte index 39db6e96..f2a0433a 100644 --- a/src/lib/emails/templates/DelegationMessageEmail.svelte +++ b/src/lib/emails/templates/DelegationMessageEmail.svelte @@ -3,95 +3,132 @@ interface Props { senderLabel: string; + senderInitials: string; subject: string; messageBody: string; conferenceTitle: string; replyUrl: string; quotedMessage?: { senderLabel: string; + senderInitials: string; subject: string; body: string; sentAt: string; }; } - let { senderLabel, subject, messageBody, conferenceTitle, replyUrl, quotedMessage }: Props = - $props(); + let { + senderLabel, + senderInitials, + subject, + messageBody, + conferenceTitle, + replyUrl, + quotedMessage + }: Props = $props(); const messageLines = messageBody.split(/\r?\n/); const quotedLines = quotedMessage?.body.split(/\r?\n/) ?? []; - Neue Nachricht von Delegierten + {subject} -
- - Neue Nachricht - + + + {conferenceTitle} + - Hallo, + + + + + + + + +
+ {senderInitials} + + + {senderLabel} + +
+ + + + {subject} + - - du hast eine neue Nachricht von {senderLabel} erhalten. - + +
+ {#each messageLines as line} + + {line || '\u00A0'} + + {/each} +
- - Betreff: {subject} - + +
+ + Antworten + +
-
- {#each messageLines as line} - - {line || ' '} + + {#if quotedMessage} +
+ + + + + + + +
+ {quotedMessage.senderInitials} + + + {quotedMessage.senderLabel} · {quotedMessage.sentAt} + +
+ + {quotedMessage.subject} + + {#each quotedLines as line} + + {line || '\u00A0'} {/each}
+ {/if} -
- - Jetzt antworten - -
- - {#if quotedMessage} -
- - - --- Urspruengliche Nachricht --- - - - Von: {quotedMessage.senderLabel} | Betreff: {quotedMessage.subject} | {quotedMessage.sentAt} - -
- {#each quotedLines as line} - - {line || ' '} - - {/each} -
- {/if} - -
- - - Viele Gruesse,
Das {conferenceTitle} Team -
- - - Diese Nachricht wurde ueber das Messaging-System von {conferenceTitle} gesendet. Deine E-Mail-Adresse - wurde dem Absender nicht offengelegt. - -
+ +
+ + Gesendet über {conferenceTitle}. Deine E-Mail-Adresse wurde nicht offengelegt. Bitte + antworte nicht direkt auf diese E-Mail, sondern nutze den Button oben. + diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/+page.svelte b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/+page.svelte index ea1bd2c1..32d70bab 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/+page.svelte +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/+page.svelte @@ -57,10 +57,28 @@ description={m.messagingAboutDescription()} >
-

- - {m.messagingAboutRecipientNotice()} -

+
    +
  • + + {m.messagingAboutRecipientNotice()} +
  • +
  • + + {m.messagingAboutPrivacy()} +
  • +
  • + + {m.messagingAboutThreading()} +
  • +
  • + + {m.messagingAboutGrouped()} +
  • +
  • + + {m.messagingAboutDeliveryLog()} +
  • +
Date: Thu, 19 Feb 2026 21:34:57 +0100 Subject: [PATCH 28/31] Add modal preview for archive --- messages/de.json | 1 + messages/en.json | 1 + schema.graphql | 1 + src/api/resolvers/modules/messageAudit.ts | 1 + src/composers/messagingComposer.ts | 2 + .../messaging/history/+page.server.ts | 1 + .../messaging/history/+page.svelte | 85 ++++++++++++++++++- 7 files changed, 91 insertions(+), 1 deletion(-) diff --git a/messages/de.json b/messages/de.json index d136c7be..24377596 100644 --- a/messages/de.json +++ b/messages/de.json @@ -825,6 +825,7 @@ "messagingOnlyEnabledUsers": "Nur Nutzer*innen, die Messaging aktiviert haben, werden in dieser Liste angezeigt", "messagingOriginalMessage": "Ursprüngliche Nachricht", "messagingOverview": "Übersicht", + "messagingPreview": "Vorschau", "messagingQuickActions": "Schnellaktionen", "messagingRecipientCount": "{count} Empfangende", "messagingRecipientLabel": "Empfänger*in", diff --git a/messages/en.json b/messages/en.json index cb5b758d..081cbc0e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -825,6 +825,7 @@ "messagingOnlyEnabledUsers": "Only users who have enabled messaging will appear in this list", "messagingOriginalMessage": "Original Message", "messagingOverview": "Overview", + "messagingPreview": "Preview", "messagingQuickActions": "Quick actions", "messagingRecipientCount": "{count} recipients", "messagingRecipientLabel": "Recipient", diff --git a/schema.graphql b/schema.graphql index 6f28b597..e232dea6 100644 --- a/schema.graphql +++ b/schema.graphql @@ -3029,6 +3029,7 @@ input MessageAuditWhereUniqueInput { } type MessageHistoryItem { + body: String! recipientLabel: String! sentAt: String! status: String! diff --git a/src/api/resolvers/modules/messageAudit.ts b/src/api/resolvers/modules/messageAudit.ts index 7ac1d4d6..529d49df 100644 --- a/src/api/resolvers/modules/messageAudit.ts +++ b/src/api/resolvers/modules/messageAudit.ts @@ -72,6 +72,7 @@ const MessageHistoryItem = builder.simpleObject('MessageHistoryItem', { fields: (t) => ({ recipientLabel: t.string(), subject: t.string(), + body: t.string(), sentAt: t.string(), status: t.string() }) diff --git a/src/composers/messagingComposer.ts b/src/composers/messagingComposer.ts index 03d5e2cf..ba4c658a 100644 --- a/src/composers/messagingComposer.ts +++ b/src/composers/messagingComposer.ts @@ -380,6 +380,7 @@ export async function getMessageHistory(conferenceId: string, userId: string) { orderBy: { createdAt: 'desc' }, select: { subject: true, + body: true, createdAt: true, status: true, recipientUserId: true, @@ -451,6 +452,7 @@ export async function getMessageHistory(conferenceId: string, userId: string) { return { recipientLabel, subject: audit.subject, + body: audit.body, sentAt: audit.createdAt.toISOString(), status }; diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/+page.server.ts b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/+page.server.ts index e0ce2698..0bb37ad3 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/+page.server.ts +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/+page.server.ts @@ -7,6 +7,7 @@ const getMessageHistoryQuery = graphql(` getMessageHistory(conferenceId: $conferenceId) { recipientLabel subject + body sentAt status } diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/+page.svelte b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/+page.svelte index a21c46f2..aabbf265 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/+page.svelte +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/history/+page.svelte @@ -2,10 +2,12 @@ import { m } from '$lib/paraglide/messages'; import { page } from '$app/state'; import type { PageData } from './$types'; + import Modal from '$lib/components/Modal.svelte'; type HistoryItem = { recipientLabel: string; subject: string; + body: string; sentAt: string; status: string; }; @@ -20,6 +22,19 @@ const basePath = $derived(`/dashboard/${conferenceId}/messaging`); const messages = $derived(data.items ?? []); const loadError = $derived(data.historyLoadError ?? ''); + + let selectedMessage = $state(null); + let previewOpen = $state(false); + + function openPreview(msg: HistoryItem) { + selectedMessage = msg; + previewOpen = true; + } + + function closePreview() { + selectedMessage = null; + previewOpen = false; + }
@@ -69,6 +84,7 @@ {m.messageSubject()} {m.messagingSent()} {m.messagingStatus()} + @@ -97,11 +113,20 @@ {:else} - + {msg.status} {/if} + + + {/each} @@ -111,3 +136,61 @@
+ + + + {#if selectedMessage} +
+
+ {m.messageRecipient()} +

{selectedMessage.recipientLabel}

+
+
+ {m.messageSubject()} +

{selectedMessage.subject}

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

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

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

{selectedMessage.body}

+
+
+ {/if} + + {#snippet action()} + + {/snippet} +
From 5b756648897b3761ab96a398055d003fe52fab63 Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Fri, 20 Feb 2026 00:21:09 +0100 Subject: [PATCH 29/31] Refactor --- messages/de.json | 1 + messages/en.json | 1 + .../migration.sql | 2 + prisma/schema.prisma | 1 + schema.graphql | 9 + .../modules/conference/conference.ts | 7 + src/composers/messagingComposer.ts | 6 +- src/lib/config/dashboardLinks.ts | 5 +- src/lib/emails/render.ts | 8 +- .../queries/myConferenceparticipationQuery.ts | 1 + .../dashboard/[conferenceId]/+page.svelte | 17 -- .../[conferenceId]/messaging/+page.server.ts | 47 ----- .../[conferenceId]/messaging/+page.svelte | 106 +++++----- .../messaging/compose/+page.server.ts | 136 +------------ .../messaging/compose/+page.svelte | 189 +++++++++++------- .../messaging/history/+page.svelte | 10 +- .../DelegationPreparationStage.svelte | 1 + .../SingleParticipantPreparationStage.svelte | 1 + .../stages/Supervisor/Supervisor.svelte | 1 + .../configuration/+page.server.ts | 1 + .../[conferenceId]/configuration/+page.svelte | 9 + .../configuration/form-schema.ts | 1 + 22 files changed, 225 insertions(+), 335 deletions(-) create mode 100644 prisma/migrations/20260219225324_add_allow_messaging_to_conference/migration.sql delete mode 100644 src/routes/(authenticated)/dashboard/[conferenceId]/messaging/+page.server.ts diff --git a/messages/de.json b/messages/de.json index 24377596..3bd88442 100644 --- a/messages/de.json +++ b/messages/de.json @@ -58,6 +58,7 @@ "allStudentsAcceptedMessage": "Alle deine Schüler*innen wurden angenommen!", "allowDelegationMailer": "Nachrichten von anderen Delegierten empfangen", "allowDelegationMailerDescription": "Wenn du dies aktivierst, können andere Delegierte dir Nachrichten per E-Mail senden. Deine E-Mail-Adresse wird dabei nicht weitergegeben. Du kannst direkt über die E-Mail-Benachrichtigung antworten.", + "allowMessaging": "Nachrichten für Teilnehmende erlauben", "alpha3Code": "ISO Alpha 3 Code", "alphabetical": "Alphabetisch", "alreadRegistered": "Bereits angemeldet", diff --git a/messages/en.json b/messages/en.json index 081cbc0e..50e87321 100644 --- a/messages/en.json +++ b/messages/en.json @@ -58,6 +58,7 @@ "allStudentsAcceptedMessage": "All your students were accepted!", "allowDelegationMailer": "Receive messages from other delegates", "allowDelegationMailerDescription": "By enabling this, other delegates can send you messages via email. Your email address will not be shared with them. You can reply directly from the email notification.", + "allowMessaging": "Allow messaging for participants", "alpha3Code": "ISO Alpha 3 Code", "alphabetical": "alphabetical", "alreadRegistered": "Already registered", diff --git a/prisma/migrations/20260219225324_add_allow_messaging_to_conference/migration.sql b/prisma/migrations/20260219225324_add_allow_messaging_to_conference/migration.sql new file mode 100644 index 00000000..18e28d18 --- /dev/null +++ b/prisma/migrations/20260219225324_add_allow_messaging_to_conference/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Conference" ADD COLUMN "allowMessaging" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7762f634..24581ed9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -62,6 +62,7 @@ model Conference { linkToPaperInbox String? isOpenPaperSubmission Boolean @default(false) showCalendar Boolean @default(false) + allowMessaging Boolean @default(false) timezone String @default("Europe/Berlin") state ConferenceState @default(PRE) startAssignment DateTime diff --git a/schema.graphql b/schema.graphql index e232dea6..4afae8b7 100644 --- a/schema.graphql +++ b/schema.graphql @@ -775,6 +775,7 @@ input CommitteeWhereUniqueInput { type Conference { accountHolder: String + allowMessaging: Boolean! bankName: String bic: String @@ -856,6 +857,7 @@ type Conference { input ConferenceCreateInput { WaitingListEntry: WaitingListEntryCreateNestedManyWithoutConferenceInput accountHolder: String + allowMessaging: Boolean bankName: String bic: String calendarDays: CalendarDayCreateNestedManyWithoutConferenceInput @@ -920,6 +922,7 @@ input ConferenceCreateInput { input ConferenceOrderByWithRelationInput { WaitingListEntry: WaitingListEntryOrderByRelationAggregateInput accountHolder: SortOrder + allowMessaging: SortOrder bankName: SortOrder bic: SortOrder calendarDays: CalendarDayOrderByRelationAggregateInput @@ -1160,6 +1163,7 @@ input ConferenceParticipantStatusWhereUniqueInputNotRequired { enum ConferenceScalarFieldEnum { accountHolder + allowMessaging bankName bic certificateContent @@ -1398,6 +1402,7 @@ input ConferenceSupervisorWhereUniqueInput { input ConferenceUpdateDataInput { accountHolder: String + allowMessaging: Boolean bankName: String bic: String certificateBasePDF: File @@ -1443,6 +1448,7 @@ input ConferenceUpdateDataInput { input ConferenceUpdateInput { WaitingListEntry: WaitingListEntryUpdateManyWithoutConferenceNestedInput accountHolder: NullableStringFieldUpdateOperationsInput + allowMessaging: BoolFieldUpdateOperationsInput bankName: NullableStringFieldUpdateOperationsInput bic: NullableStringFieldUpdateOperationsInput calendarDays: CalendarDayUpdateManyWithoutConferenceNestedInput @@ -1506,6 +1512,7 @@ input ConferenceUpdateInput { input ConferenceUpdateManyMutationInput { accountHolder: NullableStringFieldUpdateOperationsInput + allowMessaging: BoolFieldUpdateOperationsInput bankName: NullableStringFieldUpdateOperationsInput bic: NullableStringFieldUpdateOperationsInput certificateContent: NullableStringFieldUpdateOperationsInput @@ -1557,6 +1564,7 @@ input ConferenceWhereInput { OR: [ConferenceWhereInput!] WaitingListEntry: WaitingListEntryListRelationFilter accountHolder: StringNullableFilter + allowMessaging: BoolFilter bankName: StringNullableFilter bic: StringNullableFilter calendarDays: CalendarDayListRelationFilter @@ -1624,6 +1632,7 @@ input ConferenceWhereUniqueInput { OR: [ConferenceWhereInput!] WaitingListEntry: WaitingListEntryListRelationFilter accountHolder: StringNullableFilter + allowMessaging: BoolFilter bankName: StringNullableFilter bic: StringNullableFilter calendarDays: CalendarDayListRelationFilter diff --git a/src/api/resolvers/modules/conference/conference.ts b/src/api/resolvers/modules/conference/conference.ts index ab76a32e..c7c778f4 100644 --- a/src/api/resolvers/modules/conference/conference.ts +++ b/src/api/resolvers/modules/conference/conference.ts @@ -38,6 +38,7 @@ import { ConferenceIsOpenPaperSubmissionFieldObject, ConferenceShowInfoExpandedFieldObject, ConferenceShowCalendarFieldObject, + ConferenceAllowMessagingFieldObject, ConferenceTimezoneFieldObject, deleteOneConferenceMutationObject, findManyConferenceQueryObject, @@ -65,6 +66,7 @@ builder.prismaObject('Conference', { linkToPaperInbox: t.field(ConferenceLinkToPaperInboxFieldObject), isOpenPaperSubmission: t.field(ConferenceIsOpenPaperSubmissionFieldObject), showCalendar: t.field(ConferenceShowCalendarFieldObject), + allowMessaging: t.field(ConferenceAllowMessagingFieldObject), timezone: t.field(ConferenceTimezoneFieldObject), longTitle: t.field(ConferenceLongTitleFieldObject), location: t.field(ConferenceLocationFieldObject), @@ -481,6 +483,9 @@ builder.mutationFields((t) => { showCalendar: t.boolean({ required: false }), + allowMessaging: t.boolean({ + required: false + }), timezone: t.string({ required: false }), @@ -648,6 +653,8 @@ builder.mutationFields((t) => { ? undefined : args.data.isOpenPaperSubmission, showCalendar: args.data.showCalendar === null ? undefined : args.data.showCalendar, + allowMessaging: + args.data.allowMessaging === null ? undefined : args.data.allowMessaging, timezone: args.data.timezone ?? undefined, showInfoExpanded: args.data.showInfoExpanded === null ? undefined : args.data.showInfoExpanded, diff --git a/src/composers/messagingComposer.ts b/src/composers/messagingComposer.ts index ba4c658a..6f722011 100644 --- a/src/composers/messagingComposer.ts +++ b/src/composers/messagingComposer.ts @@ -527,13 +527,17 @@ export async function sendDelegationMessage({ // Get conference info const conference = await db.conference.findUnique({ where: { id: conferenceId }, - select: { title: true } + select: { title: true, allowMessaging: true } }); if (!conference) { throw new Error('Conference not found'); } + if (!conference.allowMessaging) { + throw new Error('Messaging is not enabled for this conference.'); + } + // Verify sender is part of conference const senderDelegationMember = await db.delegationMember.findUnique({ where: { diff --git a/src/lib/config/dashboardLinks.ts b/src/lib/config/dashboardLinks.ts index 4e6ad49f..0974a58f 100644 --- a/src/lib/config/dashboardLinks.ts +++ b/src/lib/config/dashboardLinks.ts @@ -26,6 +26,7 @@ export interface DashboardLinkContext { hasNationAssigned?: boolean; membersLackCommittees?: boolean; postalRegistrationComplete?: boolean; + allowMessaging?: boolean; user?: { sub: string; email: string }; } @@ -106,11 +107,11 @@ export const dashboardLinks: DashboardLink[] = [ { id: 'messaging', icon: 'envelope', - getTitle: () => m.messaging(), + getTitle: () => m.messagingCenter(), getDescription: () => m.messagingDescription(), getHref: (ctx) => `/dashboard/${ctx.conferenceId}/messaging`, showFor: ['delegation', 'singleParticipant', 'supervisor'], - isVisible: () => true, + isVisible: (ctx) => !!ctx.allowMessaging, isDisabled: () => false }, { diff --git a/src/lib/emails/render.ts b/src/lib/emails/render.ts index fb80b9db..a006a3c3 100644 --- a/src/lib/emails/render.ts +++ b/src/lib/emails/render.ts @@ -1,6 +1,8 @@ import { Renderer, toPlainText } from 'better-svelte-email'; import type { Component } from 'svelte'; +type ExtractProps = C extends Component ? P : never; + /** * Render a Svelte email component to HTML and plain text * @@ -8,9 +10,9 @@ import type { Component } from 'svelte'; * @param props - Props to pass to the component * @returns Object containing rendered HTML and plain text versions */ -export async function renderEmail>( - component: Component, - props: Props +export async function renderEmail( + component: C, + props: ExtractProps ): Promise<{ html: string; text: string }> { const { render } = new Renderer(); const html = await render(component, { props }); diff --git a/src/lib/queries/myConferenceparticipationQuery.ts b/src/lib/queries/myConferenceparticipationQuery.ts index 0b7e0e80..6adbc308 100644 --- a/src/lib/queries/myConferenceparticipationQuery.ts +++ b/src/lib/queries/myConferenceparticipationQuery.ts @@ -28,6 +28,7 @@ export const myConferenceparticipationQuery = graphql(` linkToPaperInbox isOpenPaperSubmission showCalendar + allowMessaging timezone state startConference diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/+page.svelte b/src/routes/(authenticated)/dashboard/[conferenceId]/+page.svelte index fc418638..b21ecfa6 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/+page.svelte +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/+page.svelte @@ -32,9 +32,6 @@ let status = $derived(conferenceQueryData?.findUniqueConferenceParticipantStatus); let surveyQuestions = $derived(conferenceQueryData?.findManySurveyQuestions); let surveyAnswers = $derived(conferenceQueryData?.findManySurveyAnswers); - let isDelegatee = $derived( - !!delegationMember?.id && !singleParticipant?.id && !supervisor?.id && !teamMember?.id - ); let showNewBadge = $state(false); const getContentSignature = () => @@ -61,14 +58,6 @@
- {#if conference?.id && isDelegatee} - - {/if} {#if showNewBadge}
new @@ -153,12 +142,6 @@ unlockPostals={conference?.unlockPostals} /> - { - const formData = await event.request.formData(); - const enabledValue = formData.get('enabled'); - - if (typeof enabledValue !== 'string') { - return fail(400); - } - - const enabled = enabledValue === 'true'; - - const { data } = await fastUserQuery.fetch({ event, blocking: true }); - const userId = data?.offlineUserRefresh.user?.sub; - if (!userId) { - return fail(401); - } - - await messagingPreferenceMutation.mutate( - { - where: { id: userId }, - canReceiveDelegationMail: enabled - }, - { event } - ); - - return { success: true }; - } -} satisfies Actions; diff --git a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/+page.svelte b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/+page.svelte index 32d70bab..6dca6dac 100644 --- a/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/+page.svelte +++ b/src/routes/(authenticated)/dashboard/[conferenceId]/messaging/+page.svelte @@ -1,7 +1,7 @@ -
+
@@ -79,38 +106,22 @@ {m.messagingAboutDeliveryLog()} - { - submitting = true; - return async ({ update }) => { - await update(); - await invalidateAll(); - submitting = false; - }; - }} - > - - - +
@@ -120,7 +131,7 @@ title={m.messagingGuidelines()} description={m.messagingGuidelinesDescription()} > -