Skip to content

Commit cb1cf1c

Browse files
committed
feat: add Diavgeia decisions integration
Add support for linking meeting subjects to decisions from the Greek Government Transparency portal (Diavgeia). Schema changes: - Add Decision model with ada, protocolNumber, title, pdfUrl, issueDate - Add City.diavgeiaUid for organization identifier - Add AdministrativeBody.diavgeiaUnitId for unit filtering UI additions: - Add DecisionsPanel for managing decision links in meeting admin - Add diavgeiaUid field to CityForm - Add diavgeiaUnitId field to AdministrativeBodiesList - Add export functionality for manual matching Task integration: - Add pollDecisions task type and handler - Register in task registry for automatic callback handling - Support filtering by diavgeiaUnitId for more accurate matching Also update CLAUDE.md with guidance on checking for existing types before creating new ones.
1 parent 1827363 commit cb1cf1c

File tree

27 files changed

+1143
-20
lines changed

27 files changed

+1143
-20
lines changed

CLAUDE.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
2323
- `npm run prisma:migrate:reset` - Reset database and re-run migrations
2424
- `npx prisma db seed` - Seed database with sample data
2525

26+
**IMPORTANT**: When making schema changes, always use `--create-only` to generate the migration file without applying it:
27+
```
28+
npx prisma migrate dev --name <migration_name> --create-only
29+
```
30+
This allows testing the migration against a local database first before applying to production. Never run `npx prisma migrate dev` directly, as it both creates and applies the migration to whatever database `DATABASE_URL` points to.
31+
2632
### Utility Scripts
2733
- `npm run lint` - Run ESLint
2834
- `npm run email` - Test municipality email sending
@@ -78,6 +84,12 @@ src/
7884
- Re-export from `src/lib/db/types/index.ts`
7985
- Import from `@/lib/db/types` to prevent circular dependencies
8086

87+
**CRITICAL - Before Creating New Types**:
88+
- **Always check if the type already exists** before defining a new one
89+
- When a function returns a type you need, follow the import chain to find its definition
90+
- If the type exists but isn't exported, export it rather than duplicating
91+
- Example: If `getCouncilMeeting()` returns `CouncilMeetingWithAdminBody`, check `src/lib/db/meetings.ts` for that type definition before creating your own
92+
8193
### Authentication Patterns
8294

8395
**Always use methods from `src/lib/auth.ts`**:
@@ -181,6 +193,13 @@ Multi-channel delivery in `src/lib/notifications/`:
181193
3. Extract duplicates to shared utilities
182194
4. Ensure all imports are at the top of files
183195

196+
**Build Verification**:
197+
- **Always run `npm run build` after completing changes** to verify TypeScript compiles and catch errors early
198+
- This closes the feedback loop quickly - don't wait for the user to discover build failures
199+
- For schema changes: run `npm run prisma:generate` before building
200+
- Quick TypeScript check without full build: `npx tsc --noEmit`
201+
- **Full stack verification**: Run `nix run .#dev` to verify the app starts with a fresh local DB (includes running migrations)
202+
184203
### TypeScript
185204
- Strict mode is enabled
186205
- Use interfaces/types for data structures

messages/el.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@
8787
"supportsNotificationsDescription": "Ενεργοποίηση εγγραφής και διαχείρισης ειδοποιήσεων για τους πολίτες.",
8888
"consultationsEnabled": "Ενεργοποίηση Διαβουλεύσεων",
8989
"consultationsEnabledDescription": "Ενεργοποίηση της καρτέλας δημόσιων διαβουλεύσεων για αυτήν την πόλη",
90+
"diavgeiaUid": "Diavgeia UID",
91+
"diavgeiaUidPlaceholder": "π.χ. 6253",
92+
"diavgeiaUidDescription": "Το UID του οργανισμού στη Διαύγεια (diavgeia.gov.gr). Απαιτείται για αυτόματη ανάκτηση αποφάσεων.",
9093
"administrativeBodies": "Διοικητικά Όργανα",
9194
"municipality": "Δήμος",
9295
"region": "Περιφέρεια",
@@ -545,6 +548,9 @@
545548
"auto": "Αυτόματες",
546549
"approval": "Με Έγκριση"
547550
},
551+
"diavgeiaUnitId": "ID Μονάδας Διαύγειας",
552+
"diavgeiaUnitIdPlaceholder": "π.χ. 81689",
553+
"diavgeiaUnitIdDescription": "Το ID της μονάδας στη Διαύγεια (για αυτόματη ανάκτηση αποφάσεων).",
548554
"submitting": "Αποθήκευση...",
549555
"update": "Ενημέρωση",
550556
"create": "Δημιουργία",

messages/el/admin.json

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@
7272
"addTopic": "Προσθήκη Θέματος",
7373
"starting": "Ξεκινάει...",
7474
"syncing": "Συγχρονισμός...",
75-
"process": "Επεξεργασία"
75+
"process": "Επεξεργασία",
76+
"manageDecisions": "Διαχείριση Αποφάσεων"
7677
},
7778
"forms": {
7879
"enterMediaUrl": "Εισάγετε URL Μέσου",
@@ -157,7 +158,57 @@
157158
"title": "Σφάλμα",
158159
"failedToInvalidateMeetingCache": "Αποτυχία ακύρωσης cache συνεδρίασης",
159160
"failedToInvalidateCityCache": "Αποτυχία ακύρωσης cache πόλης"
161+
},
162+
"pollDecisionsRequested": {
163+
"title": "Αίτημα ανάκτησης αποφάσεων",
164+
"description": "Η ανάκτηση αποφάσεων από τη Διαύγεια ξεκίνησε."
165+
},
166+
"errorPollingDecisions": {
167+
"title": "Σφάλμα ανάκτησης αποφάσεων"
168+
},
169+
"decisionLinked": {
170+
"title": "Η απόφαση συνδέθηκε"
171+
},
172+
"decisionUnlinked": {
173+
"title": "Η απόφαση αποσυνδέθηκε"
174+
},
175+
"errorSavingDecision": {
176+
"title": "Σφάλμα αποθήκευσης απόφασης"
177+
},
178+
"errorRemovingDecision": {
179+
"title": "Σφάλμα αφαίρεσης απόφασης"
160180
}
181+
},
182+
"decisions": {
183+
"title": "Διαχείριση Αποφάσεων (Διαύγεια)",
184+
"description": "Σύνδεση αποφάσεων Διαύγειας με θέματα συνεδρίασης. Χρησιμοποιήστε αυτόματη ανάκτηση ή προσθέστε χειροκίνητα.",
185+
"noSubjects": "Δεν βρέθηκαν θέματα για αυτή τη συνεδρίαση.",
186+
"noDecision": "Χωρίς απόφαση",
187+
"adaLabel": "ΑΔΑ",
188+
"adaPlaceholder": "π.χ. 91ΥΔΩΡΦ-ΓΥ6",
189+
"protocolNumberLabel": "Αριθμός Πρωτοκόλλου",
190+
"protocolNumberPlaceholder": "π.χ. 261/2024",
191+
"titleLabel": "Τίτλος Απόφασης",
192+
"titlePlaceholder": "Τίτλος Απόφασης",
193+
"pdfUrlLabel": "URL PDF",
194+
"pdfUrlPlaceholder": "https://diavgeia.gov.gr/...",
195+
"viewPdf": "Προβολή PDF",
196+
"linked": "συνδεδεμένα",
197+
"poll": "Ανάκτηση Διαύγεια",
198+
"pollUnlinked": "Ανάκτηση μη συνδεδεμένων ({count})",
199+
"pollAll": "Ανάκτηση όλων των θεμάτων",
200+
"save": "Αποθήκευση",
201+
"validation": {
202+
"pdfUrlRequired": "Το URL PDF είναι υποχρεωτικό",
203+
"pdfUrlInvalid": "Παρακαλώ εισάγετε ένα έγκυρο URL"
204+
},
205+
"sourceTask": "Προστέθηκε αυτόματα",
206+
"addManually": "Χειροκίνητη προσθήκη",
207+
"remove": "Αφαίρεση",
208+
"tabAll": "Όλα",
209+
"tabUnlinked": "Χωρίς σύνδεση",
210+
"allLinked": "Όλα τα θέματα έχουν συνδεδεμένες αποφάσεις.",
211+
"pollDisabledNoConfig": "Ρυθμίστε το UID Διαύγειας στις ρυθμίσεις πόλης για ενεργοποίηση"
161212
}
162213
}
163214
}

messages/en.json

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@
8585
"supportsNotificationsDescription": "Enable email and SMS notifications for meetings in this city",
8686
"consultationsEnabled": "Enable Consultations",
8787
"consultationsEnabledDescription": "Enable the public consultations tab for this city",
88+
"diavgeiaUid": "Diavgeia UID",
89+
"diavgeiaUidPlaceholder": "e.g. 6253",
90+
"diavgeiaUidDescription": "The organization UID on Diavgeia (diavgeia.gov.gr). Required for automatic decision polling.",
8891
"administrativeBodies": "Administrative Bodies",
8992
"CityMessageForm": {
9093
"sectionTitle": "City Message",
@@ -367,6 +370,43 @@
367370
"humanReview": "Human reviewed",
368371
"summarize": "Summarized"
369372
},
373+
"AdministrativeBodiesList": {
374+
"addNew": "Add New",
375+
"editBody": "Edit Body",
376+
"addBody": "Add Body",
377+
"namePlaceholder": "Body Name",
378+
"nameDescription": "Enter the name of the administrative body.",
379+
"nameEnPlaceholder": "Body Name (English)",
380+
"nameEnDescription": "Enter the name of the administrative body in English.",
381+
"type": "Type",
382+
"selectType": "Select type",
383+
"typeDescription": "Select the type of administrative body.",
384+
"types": {
385+
"council": "City Council",
386+
"committee": "Municipal Committee",
387+
"community": "Municipal Community"
388+
},
389+
"youtubeChannelUrl": "YouTube Channel URL",
390+
"youtubeChannelUrlPlaceholder": "https://youtube.com/@channel",
391+
"youtubeChannelUrlDescription": "Enter the YouTube channel URL for this administrative body.",
392+
"notificationBehavior": "Notification Behavior",
393+
"notificationBehaviorDescription": "Choose the notification behavior for meetings of this body.",
394+
"notificationBehaviorOptions": {
395+
"disabled": "Disabled",
396+
"auto": "Automatic",
397+
"approval": "With Approval"
398+
},
399+
"diavgeiaUnitId": "Diavgeia Unit ID",
400+
"diavgeiaUnitIdPlaceholder": "e.g. 81689",
401+
"diavgeiaUnitIdDescription": "The unit ID on Diavgeia (for automatic decision retrieval).",
402+
"submitting": "Submitting...",
403+
"update": "Update",
404+
"create": "Create",
405+
"failedToSave": "Failed to save",
406+
"unexpectedError": "An unexpected error occurred",
407+
"confirmDelete": "Are you sure you want to delete this administrative body?",
408+
"failedToDelete": "Failed to delete"
409+
},
370410
"PartyMemberRankingSheet": {
371411
"title": "Change Member Ordering",
372412
"description": "Drag and drop members to reorder them. The order will be saved as rankings.",

messages/en/admin.json

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@
7272
"addTopic": "Add Topic",
7373
"starting": "Starting...",
7474
"syncing": "Syncing...",
75-
"process": "Process"
75+
"process": "Process",
76+
"manageDecisions": "Manage Decisions"
7677
},
7778
"forms": {
7879
"enterMediaUrl": "Enter Media URL",
@@ -157,7 +158,57 @@
157158
"title": "Error",
158159
"failedToInvalidateMeetingCache": "Failed to invalidate meeting cache",
159160
"failedToInvalidateCityCache": "Failed to invalidate city cache"
161+
},
162+
"pollDecisionsRequested": {
163+
"title": "Poll decisions requested",
164+
"description": "Diavgeia decision polling has been started."
165+
},
166+
"errorPollingDecisions": {
167+
"title": "Error polling decisions"
168+
},
169+
"decisionLinked": {
170+
"title": "Decision linked"
171+
},
172+
"decisionUnlinked": {
173+
"title": "Decision unlinked"
174+
},
175+
"errorSavingDecision": {
176+
"title": "Error saving decision"
177+
},
178+
"errorRemovingDecision": {
179+
"title": "Error removing decision"
160180
}
181+
},
182+
"decisions": {
183+
"title": "Manage Decisions (Diavgeia)",
184+
"description": "Link Diavgeia decisions to meeting subjects. Use automatic polling or add manually.",
185+
"noSubjects": "No subjects found for this meeting.",
186+
"noDecision": "No decision",
187+
"adaLabel": "ADA",
188+
"adaPlaceholder": "e.g. 91ΥΔΩΡΦ-ΓΥ6",
189+
"protocolNumberLabel": "Protocol Number",
190+
"protocolNumberPlaceholder": "e.g. 261/2024",
191+
"titleLabel": "Decision Title",
192+
"titlePlaceholder": "Decision Title",
193+
"pdfUrlLabel": "PDF URL",
194+
"pdfUrlPlaceholder": "https://diavgeia.gov.gr/...",
195+
"viewPdf": "View PDF",
196+
"linked": "linked",
197+
"poll": "Poll Diavgeia",
198+
"pollUnlinked": "Poll unlinked ({count})",
199+
"pollAll": "Poll all subjects",
200+
"save": "Save",
201+
"validation": {
202+
"pdfUrlRequired": "PDF URL is required",
203+
"pdfUrlInvalid": "Please enter a valid URL"
204+
},
205+
"sourceTask": "Added automatically",
206+
"addManually": "Add manually",
207+
"remove": "Remove",
208+
"tabAll": "All",
209+
"tabUnlinked": "Unlinked",
210+
"allLinked": "All subjects have linked decisions.",
211+
"pollDisabledNoConfig": "Configure Diavgeia UID in city settings to enable polling"
161212
}
162213
}
163214
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
-- AlterTable
2+
ALTER TABLE "City" ADD COLUMN "diavgeiaUid" TEXT;
3+
4+
-- AlterTable
5+
ALTER TABLE "AdministrativeBody" ADD COLUMN "diavgeiaUnitId" TEXT;
6+
7+
-- CreateTable
8+
CREATE TABLE "Decision" (
9+
"id" TEXT NOT NULL,
10+
"subjectId" TEXT NOT NULL,
11+
"ada" TEXT,
12+
"protocolNumber" TEXT,
13+
"title" TEXT,
14+
"pdfUrl" TEXT NOT NULL,
15+
"issueDate" TIMESTAMP(3),
16+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
17+
"updatedAt" TIMESTAMP(3) NOT NULL,
18+
"taskId" TEXT,
19+
"createdById" TEXT,
20+
21+
CONSTRAINT "Decision_pkey" PRIMARY KEY ("id")
22+
);
23+
24+
-- CreateIndex
25+
CREATE UNIQUE INDEX "Decision_subjectId_key" ON "Decision"("subjectId");
26+
27+
-- CreateIndex
28+
CREATE UNIQUE INDEX "Decision_ada_key" ON "Decision"("ada");
29+
30+
-- CreateIndex
31+
CREATE UNIQUE INDEX "AdministrativeBody_diavgeiaUnitId_key" ON "AdministrativeBody"("diavgeiaUnitId");
32+
33+
-- AddForeignKey
34+
ALTER TABLE "Decision" ADD CONSTRAINT "Decision_subjectId_fkey" FOREIGN KEY ("subjectId") REFERENCES "Subject"("id") ON DELETE CASCADE ON UPDATE CASCADE;
35+
36+
-- AddForeignKey
37+
ALTER TABLE "Decision" ADD CONSTRAINT "Decision_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "TaskStatus"("id") ON DELETE SET NULL ON UPDATE CASCADE;
38+
39+
-- AddForeignKey
40+
ALTER TABLE "Decision" ADD CONSTRAINT "Decision_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

prisma/schema.prisma

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ model City {
3232
consultationsEnabled Boolean @default(false)
3333
peopleOrdering PeopleOrdering @default(default)
3434
highlightCreationPermission HighlightCreationPermission @default(ADMINS_ONLY)
35+
diavgeiaUid String?
3536
3637
parties Party[]
3738
persons Person[]
@@ -95,6 +96,7 @@ model AdministrativeBody {
9596
type AdministrativeBodyType
9697
notificationBehavior NotificationBehavior @default(NOTIFICATIONS_APPROVAL)
9798
youtubeChannelUrl String?
99+
diavgeiaUnitId String? @unique // Diavgeia unit ID (e.g., "81689")
98100
createdAt DateTime @default(now())
99101
updatedAt DateTime @updatedAt
100102
@@ -210,7 +212,9 @@ model TaskStatus {
210212
211213
councilMeeting CouncilMeeting @relation(fields: [councilMeetingId, cityId], references: [id, cityId], onDelete: Cascade)
212214
councilMeetingId String
213-
cityId String
215+
cityId String
216+
217+
decisions Decision[]
214218
215219
@@index([councilMeetingId, cityId])
216220
}
@@ -431,10 +435,32 @@ model Subject {
431435
discussedIn Subject? @relation("GroupedSubjects", fields: [discussedInId], references: [id], onDelete: SetNull)
432436
groupedSubjects Subject[] @relation("GroupedSubjects")
433437
438+
decision Decision?
439+
434440
@@index([cityId, councilMeetingId])
435441
@@index([discussedInId])
436442
}
437443

444+
model Decision {
445+
id String @id @default(cuid())
446+
subjectId String @unique
447+
ada String? @unique // Diavgeia unique identifier (e.g., "91ΥΔΩΡΦ-ΓΥ6")
448+
protocolNumber String? // Protocol number (e.g., "261/2024")
449+
title String? // Decision title from Diavgeia
450+
pdfUrl String
451+
issueDate DateTime? // Date published on Diavgeia
452+
createdAt DateTime @default(now())
453+
updatedAt DateTime @updatedAt
454+
455+
subject Subject @relation(fields: [subjectId], references: [id], onDelete: Cascade)
456+
457+
// Track whether decision was added by task or manually by user
458+
task TaskStatus? @relation(fields: [taskId], references: [id], onDelete: SetNull)
459+
taskId String?
460+
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
461+
createdById String?
462+
}
463+
438464
enum NonAgendaReason {
439465
beforeAgenda
440466
outOfAgenda
@@ -636,6 +662,7 @@ model User {
636662
updatedAt DateTime @updatedAt
637663
utteranceEdits UtteranceEdit[]
638664
highlights Highlight[]
665+
decisions Decision[]
639666
}
640667

641668
model Account {

src/app/api/cities/[cityId]/administrative-bodies/[bodyId]/route.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ const bodySchema = z.object({
1818
}),
1919
z.literal('')
2020
]).optional().transform(val => val === '' ? undefined : val),
21-
notificationBehavior: z.enum(['NOTIFICATIONS_DISABLED', 'NOTIFICATIONS_AUTO', 'NOTIFICATIONS_APPROVAL']).optional()
21+
notificationBehavior: z.enum(['NOTIFICATIONS_DISABLED', 'NOTIFICATIONS_AUTO', 'NOTIFICATIONS_APPROVAL']).optional(),
22+
diavgeiaUnitId: z.string().optional().transform(val => val === '' ? undefined : val),
2223
});
2324

2425
export async function PUT(
@@ -29,14 +30,15 @@ export async function PUT(
2930
await withUserAuthorizedToEdit({ cityId: params.cityId });
3031
const body = await request.json();
3132
const parsed = bodySchema.parse(body);
32-
const { name, name_en, type, youtubeChannelUrl, notificationBehavior } = parsed;
33+
const { name, name_en, type, youtubeChannelUrl, notificationBehavior, diavgeiaUnitId } = parsed;
3334

3435
const updatedBody = await editAdministrativeBody(params.bodyId, {
3536
name,
3637
name_en,
3738
type,
3839
youtubeChannelUrl: youtubeChannelUrl && youtubeChannelUrl.trim() !== '' ? youtubeChannelUrl : null,
3940
notificationBehavior: notificationBehavior,
41+
diavgeiaUnitId: diavgeiaUnitId || null,
4042
});
4143

4244
revalidateTag(`city:${params.cityId}:administrativeBodies`);

0 commit comments

Comments
 (0)