diff --git a/.trivyignore b/.trivyignore index c1a6e671..c9142d5e 100644 --- a/.trivyignore +++ b/.trivyignore @@ -3,3 +3,9 @@ CVE-2025-47907 CVE-2025-58754 CVE-2025-47906 AVD-DS-0029 +CVE-2025-61724 +CVE-2025-58188 +CVE-2025-58187 +CVE-2025-58186 +CVE-2025-58183 +CVE-2025-47912 diff --git a/Dockerfile b/Dockerfile index e083387b..4f81d5cc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM oven/bun:1.2-slim AS base +FROM oven/bun:1.3-slim AS base FROM base AS dual WORKDIR /temp diff --git a/WARP.md b/WARP.md new file mode 100644 index 00000000..54b1356e --- /dev/null +++ b/WARP.md @@ -0,0 +1,225 @@ +# WARP.md + +This file provides guidance to WARP (warp.dev) when working with code in this repository. + +## Project Overview + +MUNify DELEGATOR is a registration and organization management system for Model United Nations conferences. Built with SvelteKit 2, it handles delegation registration, assignment, and management workflows for MUN conferences. + +**Tech Stack**: SvelteKit 2 (Svelte 5 runes mode), TypeScript, Prisma ORM (PostgreSQL), GraphQL (Pothos + Yoga), Tailwind CSS 4 + DaisyUI, Houdini (GraphQL client), Paraglide (i18n) + +## Development Commands + +### Setup & Running + +```bash +# Install dependencies +bun install + +# Start full dev environment (Docker services + dev server) +bun run dev + +# OR run separately: +bun run dev:docker # Start PostgreSQL and other services +bun run dev:server # Start SvelteKit dev server with Vite + +# Install git hooks for automated linting +bunx lefthook install +``` + +### Database Management + +```bash +# Migrate database to latest schema +bunx prisma migrate dev + +# Reset database (WARNING: deletes all data) +bunx prisma migrate reset + +# Seed database with development data +bun prisma/seed/dev/seed.ts + +# Open Prisma Studio (database GUI) +bun run studio +``` + +### Code Quality + +```bash +# Format code +bun run format + +# Lint (runs both prettier check and eslint) +bun run lint + +# Type check +bun run typecheck + +# Svelte type checking +bun run check + +# Continuous type checking +bun run check:watch +``` + +### Building + +```bash +# Build for production +bun run build +``` + +### Translations + +```bash +# Add new translation key +bun run add-translation + +# Machine translate missing keys +bun run machine-translate +``` + +## Architecture Overview + +### Directory Structure + +- **`src/routes/`** - SvelteKit file-based routing + - `(authenticated)/` - Protected routes requiring authentication + - `dashboard/[conferenceId]/` - Participant-facing conference dashboard + - `management/[conferenceId]/` - Admin conference management UI + - `registration/[conferenceId]/` - Registration flows (delegation, individual, supervisor) + - `assignment-assistant/` - Committee assignment tooling + - `api/graphql/` - GraphQL API endpoint + - `auth/` - OIDC authentication flows + +- **`src/api/`** - GraphQL API implementation + - `resolvers/` - Pothos GraphQL resolvers organized by entity + - `abilities/` - CASL permission definitions per entity + - `context/` - Request context (OIDC, permissions) + - `services/` - Business logic services + +- **`src/lib/`** - Shared utilities + - `components/` - Reusable Svelte components + - `queries/` - Houdini GraphQL queries/mutations + - `services/` - Frontend services + - `schemata/` - Zod validation schemas + - `db/` - Database utilities + - `paraglide/` - Generated i18n code + +- **`src/tasks/`** - Background tasks (email sync, conference status updates) + +- **`prisma/`** - Database schema, migrations, and seed scripts + - `schema.prisma` - Main database schema + - `migrations/` - Database migration history + - `seed/dev/` - Development seed scripts + +### Key Architectural Patterns + +#### GraphQL API Layer + +- **Schema Generation**: Pothos schema builder with Prisma plugin generates GraphQL schema from Prisma models +- **Resolvers**: Organized by entity in `src/api/resolvers/modules/` (e.g., `conference.ts`, `delegation.ts`) +- **Auto-generated CRUD**: `prisma-generator-pothos-codegen` creates base CRUD operations +- **Server**: GraphQL Yoga serves the API at `/api/graphql` + +#### Frontend Data Flow + +- **Houdini Client**: Type-safe GraphQL client with automatic cache management +- **Code Generation**: `houdini.config.js` watches schema and generates TypeScript types +- **Queries**: Store queries in `src/lib/queries/` for reuse across components +- **Load Functions**: SvelteKit `+page.ts` files use Houdini queries for SSR/CSR data loading + +#### Authentication & Authorization + +- **OIDC Integration**: Uses OpenID Connect (designed for ZITADEL but supports any OIDC provider) +- **Context Building**: `src/api/context/context.ts` constructs request context with OIDC data +- **Permission System**: CASL ability-based authorization + - Definitions in `src/api/abilities/entities/` + - Admins get full access, team members get scoped access based on roles + - Roles: `admin`, `PROJECT_MANAGEMENT`, `PARTICIPANT_CARE`, etc. + +#### Database & ORM + +- **Prisma Models**: Single source of truth in `prisma/schema.prisma` +- **Generators**: Three generators run on schema changes: + 1. `@prisma/client` - TypeScript client + 2. `prisma-pothos-types` - Pothos integration types + 3. `prisma-generator-pothos-codegen` - Auto-generated resolvers +- **Migrations**: All schema changes must create migrations (`bunx prisma migrate dev`) + +#### Internationalization + +- **Paraglide.js**: Compile-time i18n with URL-based locale switching +- **Message Files**: JSON files in `messages/` directory per locale +- **Middleware**: `hooks.server.ts` uses `paraglideMiddleware` to set locale from URL + +#### UI Components + +- **Tailwind CSS 4**: Utility-first styling with DaisyUI component library +- **Svelte 5 Runes**: Uses modern runes mode (`$state`, `$derived`, `$effect`) +- **Houdini Integration**: `houdini-svelte` plugin provides reactive stores + +### State Management Concepts + +- **Conference States**: `PRE` → `PARTICIPANT_REGISTRATION` → `PREPARATION` → `ACTIVE` → `POST` +- **Delegations**: Groups of participants representing countries +- **Single Participants**: Individuals applying for custom roles +- **Committee Assignment**: Matching delegations to nations in committees +- **Background Tasks**: `src/tasks/` contains scheduled jobs (mail sync, status updates) + +### Testing Database Changes + +After modifying `prisma/schema.prisma`: + +1. Run `bunx prisma migrate dev` to create migration +2. Run `bun prisma/seed/dev/seed.ts` to seed test data +3. Use Prisma Studio (`bun run studio`) to verify data structure + +### Configuration + +- **Environment Variables**: Copy `.env.example` to `.env` and configure: + - `DATABASE_URL` - PostgreSQL connection string + - `PUBLIC_OIDC_AUTHORITY` - OIDC provider URL + - `PUBLIC_OIDC_CLIENT_ID` - OIDC client identifier + - `SECRET` - Session encryption key + - `CERTIFICATE_SECRET` - Certificate signing key (generate with `openssl rand -base64 32`) + +- **Aliases**: Configured in `svelte.config.js`: + - `$api` → `src/api` + - `$assets` → `src/assets` + - `$db` → `prisma` + - `$config` → `src/config` + - `$houdini` → `.houdini` + +## Development Workflow + +1. **Schema Changes**: Edit `prisma/schema.prisma` → run migrations → regenerate GraphQL schema +2. **API Changes**: Modify resolvers in `src/api/resolvers/modules/` → schema regenerates automatically +3. **Frontend Changes**: Edit Svelte components → Vite hot-reloads +4. **Adding GraphQL Operations**: Create `.gql` files or use `graphql()` function → Houdini generates types +5. **Permission Changes**: Edit ability files in `src/api/abilities/entities/` + +## Commit Convention + +Follow [Conventional Commits](https://www.conventionalcommits.org/): + +- `feat:` - New feature +- `fix:` - Bug fix +- `refactor:` - Code restructure without behavior change +- `style:` - UI/UX changes +- `docs:` - Documentation +- `test:` - Tests +- `chore:` - Maintenance +- `ci:` - CI/CD changes +- `build:` - Build system changes +- `perf:` - Performance improvements + +Example: `feat(delegation): add nation preference selection` + +## Docker Deployment + +Use provided Docker images: `deutschemodelunitednations/delegator` + +- Example compose file in `example/` directory +- Requires external OIDC provider (ZITADEL recommended) +- Environment variables must be configured (see `.env.example`) diff --git a/messages/de.json b/messages/de.json index 809b4eb6..1d79ede2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -168,6 +168,22 @@ "cleanupIndividualParticipantsButton": "Einzelteilnehmende aufräumen", "cleanupIndividualParticipantsDescription": "Mit einem Klick auf den unteren Button werden alle Einzelteilnehmende, die keine zugewiesenen Rollen haben, aus der Konferenz entfernt.", "cleanupIndividualParticipantsSuccess": "Einzelteilnehmende wurden erfolgreich aufgeräumt.", + "cleanupNormalizeSchoolsClearSelected": "Auswahl löschen: {count} Schule(n)", + "cleanupNormalizeSchoolsColumnDelegationMembers": "Delegationsmitglieder", + "cleanupNormalizeSchoolsColumnDelegations": "Delegationen", + "cleanupNormalizeSchoolsColumnSchool": "Schulname", + "cleanupNormalizeSchoolsColumnTotalParticipants": "Gesamt", + "cleanupNormalizeSchoolsDescription": "Da Menschen unterschiedliche Varianten ihres Schulnamens eintragen, ist es oft schwer zu überblicken, wie viele Personen von derselben Schule kommen. Mit der folgenden Tabelle kannst du Schulnamen zusammenführen und anpassen, um sie zu vereinheitlichen.", + "cleanupNormalizeSchoolsEnterNewName": "Bitte gib einen neuen Schulnamen ein", + "cleanupNormalizeSchoolsFailed": "Vereinheitlichen der Schulen fehlgeschlagen", + "cleanupNormalizeSchoolsNewName": "Neuer Schulname", + "cleanupNormalizeSchoolsNewNamePlaceholder": "Gib den vereinheitlichten Schulnamen ein", + "cleanupNormalizeSchoolsNoSchools": "Keine Schulen für diese Konferenz gefunden.", + "cleanupNormalizeSchoolsNormalizeMany": "Ausgewählte Schulen vereinheitlichen", + "cleanupNormalizeSchoolsRenameOne": "Ausgewählte Schule umbenennen", + "cleanupNormalizeSchoolsSelectAtLeastOne": "Bitte wähle mindestens 1 Schule aus", + "cleanupNormalizeSchoolsSuccess": "Erfolgreich {count} Schule(n) vereinheitlicht", + "cleanupNormalizeSchoolsTitle": "Schulnamen vereinheitlichen", "cleanupSupervisors": "Betreuende aufräumen", "cleanupSupervisorsButton": "Betreuende aufräumen", "cleanupSupervisorsDescription": "Mit einem Klick auf den unteren Button werden alle Betreuende, die nicht mindestens eine Delegation mit einer zugewiesenen Rolle betreuen, aus der Konferenz entfernt.", @@ -318,6 +334,7 @@ "end": "Ende", "enterCode": "Code eingeben", "enterDateOfdateReceipt": "Empfangsdatum eingeben", + "enterNewSchoolName": "Neuen Schulnamen eingeben", "entryCode": "Eintrittscode", "experience": "Erfahrung", "exportFrom": "Export aus {appName}", diff --git a/messages/en.json b/messages/en.json index 111cf8d2..077eaa1e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -168,6 +168,22 @@ "cleanupIndividualParticipantsButton": "Clean up single participants", "cleanupIndividualParticipantsDescription": "By clicking on the button below, all single participants who do not have assigned roles will be removed from the conference.", "cleanupIndividualParticipantsSuccess": "Single participants have been cleaned up successfully.", + "cleanupNormalizeSchoolsClearSelected": "Clear selected {count} school(s)", + "cleanupNormalizeSchoolsColumnDelegationMembers": "Delegation Members", + "cleanupNormalizeSchoolsColumnDelegations": "Delegations", + "cleanupNormalizeSchoolsColumnSchool": "School Name", + "cleanupNormalizeSchoolsColumnTotalParticipants": "Total Participants", + "cleanupNormalizeSchoolsDescription": "Because people enter varying versions of their school names, it can be hard to see how many people come from one school. Use the table below to merge and adapt school names to a unified, normalized name.", + "cleanupNormalizeSchoolsEnterNewName": "Please enter a new school name", + "cleanupNormalizeSchoolsFailed": "Failed to normalize schools", + "cleanupNormalizeSchoolsNewName": "New School Name", + "cleanupNormalizeSchoolsNewNamePlaceholder": "Enter the normalized school name", + "cleanupNormalizeSchoolsNoSchools": "No schools found for this conference.", + "cleanupNormalizeSchoolsNormalizeMany": "Normalize Selected Schools", + "cleanupNormalizeSchoolsRenameOne": "Rename Selected School", + "cleanupNormalizeSchoolsSelectAtLeastOne": "Please select at least 1 school", + "cleanupNormalizeSchoolsSuccess": "Successfully normalized {count} school(s)", + "cleanupNormalizeSchoolsTitle": "Normalize Schools", "cleanupSupervisors": "Clean up Supervisors", "cleanupSupervisorsButton": "Clean up supervisors", "cleanupSupervisorsDescription": "By clicking on the button below, all supervisors who do not supervise at least one delegation with an assigned role will be removed from the conference.", diff --git a/schema.graphql b/schema.graphql index 669c17b3..cae404da 100644 --- a/schema.graphql +++ b/schema.graphql @@ -320,6 +320,7 @@ type Conference { postalStreet: String postalZip: String registrationDeadlineGracePeriodMinutes: Int! + schools: [ConferenceSchools!]! singleParticipants: [SingleParticipant!]! startAssignment: DateTime! startConference: DateTime! @@ -648,6 +649,14 @@ input ConferenceScalarRelationFilter { isNot: ConferenceWhereInput } +type ConferenceSchools { + delegationCount: Int! + delegationMembers: Int! + school: String! + singleParticipants: Int! + sumParticipants: Int! +} + enum ConferenceState { ACTIVE PARTICIPANT_REGISTRATION @@ -1974,6 +1983,7 @@ type Mutation { deleteOneTeamMember(where: TeamMemberWhereUniqueInput!): TeamMember deleteOneUser(where: UserWhereUniqueInput!): User deleteOneWaitingListEntry(where: WaitingListEntryWhereUniqueInput!): WaitingListEntry + normalizeSchoolsInConference(conferenceId: String!, newSchoolName: String!, schoolsToMerge: [String!]!): Conference rotateSupervisorConnectionCode(id: ID!): ConferenceSupervisor! seedNewConference(data: JSONObject!): SeedNewConferenceResult! sendAssignmentData(data: JSONObject!, where: ConferenceWhereUniqueInput!): SetAssignmentDataResult! diff --git a/src/api/resolvers/modules/conference/conference.ts b/src/api/resolvers/modules/conference/conference.ts index 47ef286b..0d7b57ed 100644 --- a/src/api/resolvers/modules/conference/conference.ts +++ b/src/api/resolvers/modules/conference/conference.ts @@ -43,7 +43,6 @@ import { toDataURL } from '$api/services/fileToDataURL'; import { db } from '$db/db'; import { conferenceSettingsFormSchema } from '../../../../routes/(authenticated)/management/[conferenceId]/configuration/form-schema'; import { ConferenceState } from '$db/generated/graphql/inputs'; -import { conference } from '$lib/paraglide/messages'; import { findManyNationQueryObject } from '$db/generated/graphql/Nation'; builder.prismaObject('Conference', { @@ -215,6 +214,111 @@ builder.prismaObject('Conference', { conferenceId: conference.id } }) + }), + schools: t.field({ + type: [ + builder.simpleObject('ConferenceSchools', { + fields: (t) => ({ + school: t.string(), + delegationCount: t.int(), + delegationMembers: t.int(), + singleParticipants: t.int(), + sumParticipants: t.int() + }) + }) + ], + resolve: async (conference, _, ctx) => { + const res: { + school: string; + delegationCount: number; + delegationMembers: number; + singleParticipants: number; + sumParticipants: number; + }[] = []; + + const delegations = await db.delegation.findMany({ + where: { + conferenceId: conference.id, + school: { + not: null + }, + applied: { + equals: true + }, + AND: [ctx.permissions.allowDatabaseAccessTo('list').Delegation] + } + }); + + const members = await db.delegationMember.groupBy({ + where: { + delegation: { + conferenceId: conference.id, + applied: true + }, + AND: [ctx.permissions.allowDatabaseAccessTo('list').DelegationMember] + }, + by: 'delegationId', + _count: { + delegationId: true + } + }); + + for (const delegation of delegations) { + if (!delegation.school) continue; + const existing = res.find((r) => r.school === delegation.school); + const memberCount = + members.find((m) => m.delegationId === delegation.id)?._count.delegationId || 0; + if (existing) { + existing.delegationCount += 1; + existing.delegationMembers += memberCount; + existing.sumParticipants += memberCount; + } else { + res.push({ + school: delegation.school, + delegationCount: 1, + delegationMembers: memberCount, + singleParticipants: 0, + sumParticipants: memberCount + }); + } + } + const singleParticipants = await db.singleParticipant.groupBy({ + where: { + conferenceId: conference.id, + school: { + not: null + }, + applied: { + equals: true + }, + AND: [ctx.permissions.allowDatabaseAccessTo('list').SingleParticipant] + }, + by: 'school', + _count: { + school: true + } + }); + + for (const singleParticipant of singleParticipants) { + if (!singleParticipant.school) continue; + const existing = res.find((r) => r.school === singleParticipant.school); + const singleParticipantCount = singleParticipant._count.school; + if (existing) { + existing.singleParticipants += singleParticipantCount; + existing.sumParticipants += singleParticipantCount; + } else { + res.push({ + school: singleParticipant.school, + delegationCount: 0, + delegationMembers: 0, + singleParticipants: singleParticipantCount, + sumParticipants: singleParticipantCount + }); + } + } + + return res; + } }) }) }); @@ -500,3 +604,55 @@ builder.mutationFields((t) => { }) }; }); + +builder.mutationFields((t) => { + const field = updateOneConferenceMutationObject(t); + return { + normalizeSchoolsInConference: t.prismaField({ + ...field, + args: { + conferenceId: t.arg.string({ required: true }), + schoolsToMerge: t.arg.stringList(), + newSchoolName: t.arg.string({ required: true }) + }, + resolve: async (query, root, args, ctx, info) => { + return await db.$transaction(async (tx) => { + const { conferenceId, schoolsToMerge } = args; + + await tx.delegation.updateMany({ + where: { + conferenceId, + school: { + in: schoolsToMerge + }, + AND: [ctx.permissions.allowDatabaseAccessTo('update').Delegation] + }, + data: { + school: args.newSchoolName + } + }); + + await tx.singleParticipant.updateMany({ + where: { + conferenceId, + school: { + in: schoolsToMerge + }, + AND: [ctx.permissions.allowDatabaseAccessTo('update').SingleParticipant] + }, + data: { + school: args.newSchoolName + } + }); + + return await tx.conference.findUniqueOrThrow({ + where: { + id: args.conferenceId, + AND: [ctx.permissions.allowDatabaseAccessTo('read').Conference] + } + }); + }); + } + }) + }; +}); diff --git a/src/lib/components/DataTable/DataTable.svelte b/src/lib/components/DataTable/DataTable.svelte index 72c7e01e..d5bd366f 100644 --- a/src/lib/components/DataTable/DataTable.svelte +++ b/src/lib/components/DataTable/DataTable.svelte @@ -19,6 +19,9 @@ title?: string; showExpandIcon?: boolean; expandSingle?: boolean; + selectOnClick?: boolean; + rowKey?: string; + selected?: string[]; expandedRowContent?: Snippet<[RowData]>; filterOptions?: TableColumns[number]['key'][]; additionallyIndexedKeys?: string[]; @@ -34,6 +37,9 @@ queryParamKey, showExpandIcon = false, expandSingle = false, + selectOnClick = false, + rowKey = 'id', + selected = $bindable([]), expandedRowContent, filterOptions, additionallyIndexedKeys = [], @@ -168,7 +174,6 @@ ? rowSelected(e.detail.row) : undefined} on:clickExpand={(e) => toggleExpanded(e.detail.row)} - rowKey="id" classNameTable="table {getZebra() && !expandedRowContent && 'table-zebra'} table-{getTableSize()} table-pin-rows" @@ -181,6 +186,10 @@ iconExpand="" iconExpanded="" iconFilterable="" + classNameRowSelected="bg-accent" + bind:selected + {rowKey} + {selectOnClick} {sortBy} {expandSingle} {showExpandIcon} diff --git a/src/routes/(authenticated)/assignment-assistant/+page.svelte b/src/routes/(authenticated)/assignment-assistant/+page.svelte index 8c5f12c2..512fb47f 100644 --- a/src/routes/(authenticated)/assignment-assistant/+page.svelte +++ b/src/routes/(authenticated)/assignment-assistant/+page.svelte @@ -2,6 +2,7 @@ import { goto } from '$app/navigation'; import { onMount } from 'svelte'; import { RAW_DATA_KEY } from './local_storage_keys'; + import { graphql } from '$houdini'; let projects: { id: string; diff --git a/src/routes/(authenticated)/assignment-assistant/[projectId]/DelegationCard.svelte b/src/routes/(authenticated)/assignment-assistant/[projectId]/DelegationCard.svelte index 980145f3..867a426b 100644 --- a/src/routes/(authenticated)/assignment-assistant/[projectId]/DelegationCard.svelte +++ b/src/routes/(authenticated)/assignment-assistant/[projectId]/DelegationCard.svelte @@ -1,10 +1,11 @@ + +
+ + {#each getSchools() as school (school.school)} + + {/each} +
diff --git a/src/routes/(authenticated)/management/[conferenceId]/assignment/+page.ts b/src/routes/(authenticated)/management/[conferenceId]/assignment/+page.ts index 17ccd1c1..c98f99a7 100644 --- a/src/routes/(authenticated)/management/[conferenceId]/assignment/+page.ts +++ b/src/routes/(authenticated)/management/[conferenceId]/assignment/+page.ts @@ -6,6 +6,7 @@ export const _houdini_load = graphql(` where: { conferenceId: { equals: $conferenceId }, applied: { equals: true } } ) { id + school appliedForRoles { id rank @@ -40,6 +41,7 @@ export const _houdini_load = graphql(` where: { conferenceId: { equals: $conferenceId }, applied: { equals: true } } ) { id + school supervisors { id user { diff --git a/src/routes/(authenticated)/management/[conferenceId]/cleanup/+page.svelte b/src/routes/(authenticated)/management/[conferenceId]/cleanup/+page.svelte index 764e4bfd..cf821511 100644 --- a/src/routes/(authenticated)/management/[conferenceId]/cleanup/+page.svelte +++ b/src/routes/(authenticated)/management/[conferenceId]/cleanup/+page.svelte @@ -3,6 +3,7 @@ import { m } from '$lib/paraglide/messages'; import formatNames from '$lib/services/formatNames'; import Modal from './Modal.svelte'; + import NormalizeSchools from './NormalizeSchools.svelte'; import Section from './Section.svelte'; import type { ModalData } from './types'; @@ -72,7 +73,7 @@ } -
+

{m.cleanup()}

{@html m.cleanupDescription()}

@@ -125,6 +126,11 @@ ); }} /> +
+

{m.cleanupNormalizeSchoolsTitle()}

+

{@html m.cleanupNormalizeSchoolsDescription()}

+ +
(modalData = undefined)} /> diff --git a/src/routes/(authenticated)/management/[conferenceId]/cleanup/CheckboxForTable.svelte b/src/routes/(authenticated)/management/[conferenceId]/cleanup/CheckboxForTable.svelte new file mode 100644 index 00000000..4bec4d46 --- /dev/null +++ b/src/routes/(authenticated)/management/[conferenceId]/cleanup/CheckboxForTable.svelte @@ -0,0 +1,11 @@ + + + diff --git a/src/routes/(authenticated)/management/[conferenceId]/cleanup/NormalizeSchools.svelte b/src/routes/(authenticated)/management/[conferenceId]/cleanup/NormalizeSchools.svelte new file mode 100644 index 00000000..cce07dc1 --- /dev/null +++ b/src/routes/(authenticated)/management/[conferenceId]/cleanup/NormalizeSchools.svelte @@ -0,0 +1,232 @@ + + +{#if $conferenceSchoolsQuery.fetching} +
+ +
+{:else if schools.length > 0} +
+ + +
+ +
+ {#each selectedSchools as school (school)} + {school} + {/each} +
+
+ +
+
+ + +
+ +
+
+{:else} +
+ + {m.cleanupNormalizeSchoolsNoSchools()} +
+{/if} diff --git a/src/routes/(authenticated)/management/[conferenceId]/delegations/DelegationDrawer.svelte b/src/routes/(authenticated)/management/[conferenceId]/delegations/DelegationDrawer.svelte index fe10a76f..a5d3d240 100644 --- a/src/routes/(authenticated)/management/[conferenceId]/delegations/DelegationDrawer.svelte +++ b/src/routes/(authenticated)/management/[conferenceId]/delegations/DelegationDrawer.svelte @@ -145,6 +145,34 @@ selectedMember = null; } } + + const changeDelegationSchoolMutation = graphql(` + mutation ChangeDelegationSchool($delegationId: String!, $newSchool: String!) { + updateOneDelegation(where: { id: $delegationId }, school: $newSchool) { + id + school + } + } + `); + + const changeSchool = async () => { + const newSchool = prompt(m.enterNewSchoolName()); + if (!newSchool) return; + + try { + await toast.promise( + changeDelegationSchoolMutation.mutate({ + delegationId, + newSchool + }), + genericPromiseToastMessages + ); + cache.markStale(); + await invalidateAll(); + } catch (error) { + console.error('Failed to change school name:', error); + } + }; - {delegation?.school} +
+
+ {delegation?.school} +
+ +