From 03a2f6ad29dd60dff35a3261f138b3215695f91a Mon Sep 17 00:00:00 2001 From: Bianca Date: Mon, 19 Oct 2026 23:59:58 +0200 Subject: [PATCH 1/3] feat: add freshman content management with cards and FAQs --- README.md | 9 + drizzle/0022_freshman_content.sql | 96 + drizzle/meta/0022_snapshot.json | 2857 ++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + scripts/seed-db.ts | 17 + src/db/schema/web/freshman.ts | 58 + src/db/schema/web/index.ts | 11 +- src/routers/web/faqs.ts | 39 +- src/routers/web/freshman.ts | 140 ++ src/routers/web/freshman/constants.ts | 35 + src/routers/web/freshman/seed.ts | 130 ++ src/routers/web/freshman/validation.ts | 70 + src/routers/web/index.ts | 3 +- tests/fixtures/freshman-integration.ts | 224 ++ tests/freshman.integration.test.ts | 16 + tests/freshman.test.ts | 140 ++ tests/seed-db.test.ts | 38 +- 17 files changed, 3862 insertions(+), 28 deletions(-) create mode 100644 drizzle/0022_freshman_content.sql create mode 100644 drizzle/meta/0022_snapshot.json create mode 100644 src/db/schema/web/freshman.ts create mode 100644 src/routers/web/freshman.ts create mode 100644 src/routers/web/freshman/constants.ts create mode 100644 src/routers/web/freshman/seed.ts create mode 100644 src/routers/web/freshman/validation.ts create mode 100644 tests/fixtures/freshman-integration.ts create mode 100644 tests/freshman.integration.test.ts create mode 100644 tests/freshman.test.ts diff --git a/README.md b/README.md index 71ab57c..afebdd1 100644 --- a/README.md +++ b/README.md @@ -61,3 +61,12 @@ Actor fields such as `createdBy`, `adderId` or `performerId` are read only from The bot's socket.io client authenticates with its service token in `auth.token` (scope `backend:tg:events`) and is disconnected when the token expires. Set `OAUTH_BOT_CLIENT_ID` on the backend to the same registered OAuth client ID used by the bot. This is the actual client ID, not its display name. Token sockets are rejected if this setting is absent, the client ID differs, the token is not a service token or the scope is missing. Without a token the socket is identified by its `type` query, while `LEGACY_ANONYMOUS=allow`. The IdP notifies `POST /internal/events` when access changes; the backend also polls the snapshot every 30 s. The last good signing keys and snapshot are stored in `common_idp_state`, so a restart during an IdP outage resumes from them. Permission checks deny once the snapshot is more than an hour old. Configuration is in `.env.example`. + +## Freshman content + +`web.freshman` exposes fixed-slot content for the website and authorized admin editors. Its schema, permissions, +rollout and test procedure are described in [the API contract](docs/freshman-api.md). Migration +`0022_freshman_content` initializes 10 resources, 8 Telegram links and 5 existing/new FAQs, preserving matching +catalog entries. Selected FAQs/categories must be replaced in Freshman before deleting them from the catalog. +The development seed also initializes the card slots and selections; `--force` clears those selections before +replacing the sample FAQ catalog. diff --git a/drizzle/0022_freshman_content.sql b/drizzle/0022_freshman_content.sql new file mode 100644 index 0000000..410cb0b --- /dev/null +++ b/drizzle/0022_freshman_content.sql @@ -0,0 +1,96 @@ +CREATE TABLE "web_freshman_cards" ( + "id" text PRIMARY KEY NOT NULL, + "title" text NOT NULL, + "description" text NOT NULL, + "icon" text NOT NULL, + "href" text NOT NULL, + "created_by_id" bigint, + "modified_by_id" bigint, + "created_by_sub" text, + "modified_by_sub" text, + "updated_at" timestamp (0) with time zone, + "created_at" timestamp (0) with time zone DEFAULT now() NOT NULL, + CONSTRAINT "web_freshman_cards_creator_check" CHECK ("web_freshman_cards"."created_by_id" IS NOT NULL OR "web_freshman_cards"."created_by_sub" IS NOT NULL), + CONSTRAINT "web_freshman_cards_slot_check" CHECK ("web_freshman_cards"."id" in ('featured-1', 'featured-2', 'guides-1', 'guides-2', 'guides-3', 'guides-4', 'guides-5', 'materials-1', 'tools-1', 'tools-2', 'telegram-1', 'telegram-2', 'telegram-3', 'telegram-4', 'telegram-5', 'telegram-6', 'telegram-7', 'telegram-8')), + CONSTRAINT "web_freshman_cards_icon_check" CHECK ("web_freshman_cards"."icon" in ('chart', 'monitor', 'book', 'map', 'news', 'money', 'folder', 'repeat', 'refresh', 'bell', 'user', 'users', 'cart', 'home')) +); +--> statement-breakpoint +CREATE TABLE "web_freshman_faqs" ( + "position" integer PRIMARY KEY NOT NULL, + "faq_id" integer NOT NULL, + "created_by_id" bigint, + "modified_by_id" bigint, + "created_by_sub" text, + "modified_by_sub" text, + "updated_at" timestamp (0) with time zone, + "created_at" timestamp (0) with time zone DEFAULT now() NOT NULL, + CONSTRAINT "web_freshman_faqs_faq_id_unique" UNIQUE("faq_id"), + CONSTRAINT "web_freshman_faqs_creator_check" CHECK ("web_freshman_faqs"."created_by_id" IS NOT NULL OR "web_freshman_faqs"."created_by_sub" IS NOT NULL), + CONSTRAINT "web_freshman_faqs_position_check" CHECK ("web_freshman_faqs"."position" between 0 and 4) +); +--> statement-breakpoint +ALTER TABLE "web_freshman_cards" ADD CONSTRAINT "web_freshman_cards_created_by_id_tg_permissions_user_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."tg_permissions"("user_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "web_freshman_cards" ADD CONSTRAINT "web_freshman_cards_modified_by_id_tg_permissions_user_id_fk" FOREIGN KEY ("modified_by_id") REFERENCES "public"."tg_permissions"("user_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "web_freshman_faqs" ADD CONSTRAINT "web_freshman_faqs_faq_id_web_faqs_id_fk" FOREIGN KEY ("faq_id") REFERENCES "public"."web_faqs"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "web_freshman_faqs" ADD CONSTRAINT "web_freshman_faqs_created_by_id_tg_permissions_user_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."tg_permissions"("user_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "web_freshman_faqs" ADD CONSTRAINT "web_freshman_faqs_modified_by_id_tg_permissions_user_id_fk" FOREIGN KEY ("modified_by_id") REFERENCES "public"."tg_permissions"("user_id") ON DELETE no action ON UPDATE no action; +--> statement-breakpoint +INSERT INTO web_freshman_cards (id, title, description, icon, href, created_by_sub) VALUES +('featured-1', 'Graduatorie', 'Risultati storici e soglie di accesso per i vari corsi di laurea.', 'chart', 'https://rankings.polinetwork.org/', 'system:freshman-migration'), +('featured-2', 'Progetto TOL', 'Informazioni sul test di ingresso (TOL) e su come prepararsi al meglio.', 'monitor', 'https://tol.polinetwork.org/', 'system:freshman-migration'), +('guides-1', 'Guida della matricola', 'Dalla prima iscrizione alla vita in campus: il manuale completo per ogni nuovo studente.', 'book', '/matricole/guida', 'system:freshman-migration'), +('guides-2', 'Mappa Microonde', 'Dove scaldare il pranzo nei vari campus.', 'map', '', 'system:freshman-migration'), +('guides-3', 'Corsi a Scelta', 'Consigli su come comporre il Piano di Studi.', 'book', '', 'system:freshman-migration'), +('guides-4', 'News', 'Le ultime novità dagli studenti del Politecnico.', 'news', '', 'system:freshman-migration'), +('guides-5', 'Borsa di Studio', 'Requisiti e scadenze per le agevolazioni.', 'money', '', 'system:freshman-migration'), +('materials-1', 'Materiali', 'Il più grande archivio didattico creato dagli studenti per gli studenti del Politecnico di Milano. Migliaia di appunti e temi d’esame.', 'folder', '/materials', 'system:freshman-migration'), +('tools-1', 'Sync Orario', '', 'repeat', 'https://bebora.github.io/polimi-schedule-js/', 'system:freshman-migration'), +('tools-2', 'WeBeep Sync', '', 'refresh', 'https://github.com/toto04/webeep-sync', 'system:freshman-migration'), +('telegram-1', 'Generale', '', 'bell', 'https://t.me/PoliGruppo', 'system:freshman-migration'), +('telegram-2', 'Matricole', '', 'user', 'https://t.me/polimatricole', 'system:freshman-migration'), +('telegram-3', 'Ripetizioni', '', 'users', 'https://t.me/joinchat/l10OF3vF6fEzOTRk', 'system:freshman-migration'), +('telegram-4', 'PoliBook', '', 'book', 'https://t.me/joinchat/6nmuXXhAOr1mYTJk', 'system:freshman-migration'), +('telegram-5', 'DSU', '', 'money', 'https://t.me/joinchat/4kO9DtAiTVM0NTU0', 'system:freshman-migration'), +('telegram-6', 'Mercatino', '', 'cart', 'https://t.me/mercatinopolimi', 'system:freshman-migration'), +('telegram-7', 'Case', '', 'home', 'https://t.me/joinchat/jmpNOitFNFw1MzJk', 'system:freshman-migration'), +('telegram-8', 'Consigli PC', '', 'monitor', 'https://t.me/joinchat/oD7NGpl0W6VjYmI0', 'system:freshman-migration'); +--> statement-breakpoint +DO $$ +DECLARE category integer; selected integer; +BEGIN + SELECT id INTO category FROM web_faq_categories WHERE title_it = 'Matricole' ORDER BY id LIMIT 1; + IF category IS NULL THEN + INSERT INTO web_faq_categories (title_it, title_en, icon, created_by_sub) + VALUES ('Matricole', 'Freshmen', 'graduation-cap', 'system:freshman-migration') RETURNING id INTO category; + END IF; + SELECT id INTO selected FROM web_faqs WHERE title_it = 'Le lezioni della prima settimana si tengono regolarmente?' ORDER BY id LIMIT 1; + IF selected IS NULL THEN + INSERT INTO web_faqs (title_it, title_en, description_it, description_en, category_id, created_by_sub) + VALUES ('Le lezioni della prima settimana si tengono regolarmente?', 'Are classes held during the first week?', 'Spesso, la prima settimana, i Professori utilizzano le ore destinate alle esercitazioni per qualche ora di lezione in più: saranno quindi i docenti a specificare come verranno utilizzate queste ore. Dunque, le esercitazioni non sono da considerarsi annullate, salvo diversa comunicazione da parte del docente o dell''esercitatore.', 'Teachers will explain how lecture and exercise sessions are organized during the first week. Unless your teacher announces otherwise, attend the scheduled sessions.', category, 'system:freshman-migration') RETURNING id INTO selected; + END IF; + INSERT INTO web_freshman_faqs (position, faq_id, created_by_sub) VALUES (0, selected, 'system:freshman-migration'); + SELECT id INTO selected FROM web_faqs WHERE title_it = 'Come faccio ad accedere ai materiali del corso?' ORDER BY id LIMIT 1; + IF selected IS NULL THEN + INSERT INTO web_faqs (title_it, title_en, description_it, description_en, category_id, created_by_sub) + VALUES ('Come faccio ad accedere ai materiali del corso?', 'How do I access course materials?', 'I materiali dei corsi sono disponibili su Beep (il portale e-learning del Politecnico). Puoi accedervi con le tue credenziali di Ateneo (codice persona e password). Ogni docente carica le proprie slide, esercizi e risorse direttamente sulla pagina del corso.', 'Access the university e-learning platform with your university credentials. Teachers publish course slides, exercises and other materials on their course pages.', category, 'system:freshman-migration') RETURNING id INTO selected; + END IF; + INSERT INTO web_freshman_faqs (position, faq_id, created_by_sub) VALUES (1, selected, 'system:freshman-migration'); + SELECT id INTO selected FROM web_faqs WHERE title_it = 'Come funziona la prenotazione agli esami?' ORDER BY id LIMIT 1; + IF selected IS NULL THEN + INSERT INTO web_faqs (title_it, title_en, description_it, description_en, category_id, created_by_sub) + VALUES ('Come funziona la prenotazione agli esami?', 'How do I register for exams?', 'La prenotazione agli esami avviene tramite il portale Online Services (servizionline.polimi.it). Devi prenotarti entro la scadenza indicata per ogni appello. Ricordati di verificare i prerequisiti necessari prima di iscriverti a un esame.', 'Register through the university Online Services before the deadline for each exam session, checking any prerequisites.', category, 'system:freshman-migration') RETURNING id INTO selected; + END IF; + INSERT INTO web_freshman_faqs (position, faq_id, created_by_sub) VALUES (2, selected, 'system:freshman-migration'); + SELECT id INTO selected FROM web_faqs WHERE title_it = 'Cos''è il codice persona e dove lo trovo?' ORDER BY id LIMIT 1; + IF selected IS NULL THEN + INSERT INTO web_faqs (title_it, title_en, description_it, description_en, category_id, created_by_sub) + VALUES ('Cos''è il codice persona e dove lo trovo?', 'What is the codice persona and where can I find it?', 'Il codice persona è il tuo identificativo univoco al Politecnico, composto da 6 cifre. Lo trovi nella lettera di ammissione o nella mail di benvenuto ricevuta dopo l''immatricolazione. Serve per accedere a tutti i servizi online dell''Ateneo.', 'The codice persona is your university account identifier. Check your admission or enrollment documents and university account details.', category, 'system:freshman-migration') RETURNING id INTO selected; + END IF; + INSERT INTO web_freshman_faqs (position, faq_id, created_by_sub) VALUES (3, selected, 'system:freshman-migration'); + SELECT id INTO selected FROM web_faqs WHERE title_it = 'Come posso contattare PoliNetwork?' ORDER BY id LIMIT 1; + IF selected IS NULL THEN + INSERT INTO web_faqs (title_it, title_en, description_it, description_en, category_id, created_by_sub) + VALUES ('Come posso contattare PoliNetwork?', 'How can I contact PoliNetwork?', 'Puoi contattare PoliNetwork tramite i nostri canali Telegram o attraverso il sito ufficiale. Siamo un''associazione studentesca e offriamo supporto, risorse e una community per tutti gli studenti del Politecnico.', 'Contact PoliNetwork through its Telegram channels or the contact links on the website.', category, 'system:freshman-migration') RETURNING id INTO selected; + END IF; + INSERT INTO web_freshman_faqs (position, faq_id, created_by_sub) VALUES (4, selected, 'system:freshman-migration'); +END $$; diff --git a/drizzle/meta/0022_snapshot.json b/drizzle/meta/0022_snapshot.json new file mode 100644 index 0000000..be6543b --- /dev/null +++ b/drizzle/meta/0022_snapshot.json @@ -0,0 +1,2857 @@ +{ + "id": "892792a8-a188-4268-8654-819faef5b9c3", + "prevId": "8e7f9ab3-a889-4434-8bca-e74f5881d145", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_accounts": { + "name": "auth_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_accounts_user_id_auth_users_id_fk": { + "name": "auth_accounts_user_id_auth_users_id_fk", + "tableFrom": "auth_accounts", + "tableTo": "auth_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_passkey": { + "name": "auth_passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkey_userId_idx": { + "name": "passkey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_credentialID_idx": { + "name": "passkey_credentialID_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_passkey_user_id_auth_users_id_fk": { + "name": "auth_passkey_user_id_auth_users_id_fk", + "tableFrom": "auth_passkey", + "tableTo": "auth_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_sessions": { + "name": "auth_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_sessions_user_id_auth_users_id_fk": { + "name": "auth_sessions_user_id_auth_users_id_fk", + "tableFrom": "auth_sessions", + "tableTo": "auth_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_sessions_token_unique": { + "name": "auth_sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_users": { + "name": "auth_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tg_id": { + "name": "tg_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "tg_username": { + "name": "tg_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_users_email_unique": { + "name": "auth_users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_verifications": { + "name": "auth_verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.common_group_labels": { + "name": "common_group_labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "common_group_labels_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "label": { + "name": "label", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "default": "'#ffffff'" + }, + "granted_by_id": { + "name": "granted_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "modified_by_id": { + "name": "modified_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_by_sub": { + "name": "created_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "modified_by_sub": { + "name": "modified_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "common_group_labels_granted_by_id_tg_permissions_user_id_fk": { + "name": "common_group_labels_granted_by_id_tg_permissions_user_id_fk", + "tableFrom": "common_group_labels", + "tableTo": "tg_permissions", + "columnsFrom": [ + "granted_by_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "common_group_labels_modified_by_id_tg_permissions_user_id_fk": { + "name": "common_group_labels_modified_by_id_tg_permissions_user_id_fk", + "tableFrom": "common_group_labels", + "tableTo": "tg_permissions", + "columnsFrom": [ + "modified_by_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "common_group_labels_label_unique": { + "name": "common_group_labels_label_unique", + "nullsNotDistinct": false, + "columns": [ + "label" + ] + } + }, + "policies": {}, + "checkConstraints": { + "common_group_labels_creator_check": { + "name": "common_group_labels_creator_check", + "value": "\"common_group_labels\".\"granted_by_id\" IS NOT NULL OR \"common_group_labels\".\"created_by_sub\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.common_idp_state": { + "name": "common_idp_state", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tg_audit_log": { + "name": "tg_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "tg_audit_log_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "admin_id": { + "name": "admin_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "until": { + "name": "until", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "actor_sub": { + "name": "actor_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_tg_id": { + "name": "actor_tg_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "client": { + "name": "client", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "basis": { + "name": "basis", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'legacy'" + }, + "review": { + "name": "review", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auditlog_adminid_idx": { + "name": "auditlog_adminid_idx", + "columns": [ + { + "expression": "admin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auditlog_actor_tg_id_idx": { + "name": "auditlog_actor_tg_id_idx", + "columns": [ + { + "expression": "actor_tg_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auditlog_idempotency_key_idx": { + "name": "auditlog_idempotency_key_idx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tg_grants": { + "name": "tg_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "tg_grants_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "granted_by_id": { + "name": "granted_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "valid_since": { + "name": "valid_since", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true + }, + "valid_until": { + "name": "valid_until", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true + }, + "interrupted_by_id": { + "name": "interrupted_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "interrupted_at": { + "name": "interrupted_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tg_grants_user_id_idx": { + "name": "tg_grants_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tg_grants_granted_by_id_tg_permissions_user_id_fk": { + "name": "tg_grants_granted_by_id_tg_permissions_user_id_fk", + "tableFrom": "tg_grants", + "tableTo": "tg_permissions", + "columnsFrom": [ + "granted_by_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tg_grants_interrupted_by_id_tg_permissions_user_id_fk": { + "name": "tg_grants_interrupted_by_id_tg_permissions_user_id_fk", + "tableFrom": "tg_grants", + "tableTo": "tg_permissions", + "columnsFrom": [ + "interrupted_by_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tg_grants_v2": { + "name": "tg_grants_v2", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "tg_grants_v2_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "telegram_user_id": { + "name": "telegram_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "valid_since": { + "name": "valid_since", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true + }, + "valid_until": { + "name": "valid_until", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "granted_by_sub": { + "name": "granted_by_sub", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_via_client": { + "name": "granted_via_client", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "interrupted_at": { + "name": "interrupted_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "interrupted_by_sub": { + "name": "interrupted_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "interrupted_by_tg_id": { + "name": "interrupted_by_tg_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tg_grants_v2_telegram_user_id_idx": { + "name": "tg_grants_v2_telegram_user_id_idx", + "columns": [ + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "tg_grants_v2_validity_check": { + "name": "tg_grants_v2_validity_check", + "value": "\"tg_grants_v2\".\"valid_since\" < \"tg_grants_v2\".\"valid_until\"" + } + }, + "isRLSEnabled": false + }, + "public.tg_groups": { + "name": "tg_groups", + "schema": "", + "columns": { + "telegram_id": { + "name": "telegram_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "hide": { + "name": "hide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tg_groups_link_unique": { + "name": "tg_groups_link_unique", + "nullsNotDistinct": false, + "columns": [ + "link" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tg_group_label_relations": { + "name": "tg_group_label_relations", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "label_id": { + "name": "label_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "tg_group_label_relations_group_id_tg_groups_telegram_id_fk": { + "name": "tg_group_label_relations_group_id_tg_groups_telegram_id_fk", + "tableFrom": "tg_group_label_relations", + "tableTo": "tg_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "telegram_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tg_group_label_relations_label_id_common_group_labels_id_fk": { + "name": "tg_group_label_relations_label_id_common_group_labels_id_fk", + "tableFrom": "tg_group_label_relations", + "tableTo": "common_group_labels", + "columnsFrom": [ + "label_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tg_group_label_relations_group_id_label_id_pk": { + "name": "tg_group_label_relations_group_id_label_id_pk", + "columns": [ + "group_id", + "label_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tg_link": { + "name": "tg_link", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ttl": { + "name": "ttl", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tg_username": { + "name": "tg_username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tg_id": { + "name": "tg_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "tg_link_user_id_auth_users_id_fk": { + "name": "tg_link_user_id_auth_users_id_fk", + "tableFrom": "tg_link", + "tableTo": "auth_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tg_link_tg_id_unique": { + "name": "tg_link_tg_id_unique", + "nullsNotDistinct": false, + "columns": [ + "tg_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tg_messages": { + "name": "tg_messages", + "schema": "", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "varchar(8704)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tg_messages_chat_author_timestamp_idx": { + "name": "tg_messages_chat_author_timestamp_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "tg_messages_chat_id_message_id_pk": { + "name": "tg_messages_chat_id_message_id_pk", + "columns": [ + "chat_id", + "message_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tg_group_admins": { + "name": "tg_group_admins", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "added_by_id": { + "name": "added_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "tg_group_admins_user_id_group_id_pk": { + "name": "tg_group_admins_user_id_group_id_pk", + "columns": [ + "user_id", + "group_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tg_permissions": { + "name": "tg_permissions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "roles": { + "name": "roles", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{\"admin\"}'" + }, + "added_by_id": { + "name": "added_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "modified_by_id": { + "name": "modified_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tg_test": { + "name": "tg_test", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "tg_test_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "text": { + "name": "text", + "type": "varchar", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tg_users": { + "name": "tg_users", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "is_bot": { + "name": "is_bot", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "lang_code": { + "name": "lang_code", + "type": "varchar(35)", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wa_group_label_relations": { + "name": "wa_group_label_relations", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "label_id": { + "name": "label_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "wa_group_label_relations_group_id_wa_groups_group_id_fk": { + "name": "wa_group_label_relations_group_id_wa_groups_group_id_fk", + "tableFrom": "wa_group_label_relations", + "tableTo": "wa_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "group_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wa_group_label_relations_label_id_common_group_labels_id_fk": { + "name": "wa_group_label_relations_label_id_common_group_labels_id_fk", + "tableFrom": "wa_group_label_relations", + "tableTo": "common_group_labels", + "columnsFrom": [ + "label_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "wa_group_label_relations_group_id_label_id_pk": { + "name": "wa_group_label_relations_group_id_label_id_pk", + "columns": [ + "group_id", + "label_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wa_groups": { + "name": "wa_groups", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "wa_groups_group_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "link": { + "name": "link", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "hide": { + "name": "hide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wa_groups_link_unique": { + "name": "wa_groups_link_unique", + "nullsNotDistinct": false, + "columns": [ + "link" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.web_associations": { + "name": "web_associations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "web_associations_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_it": { + "name": "description_it", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_en": { + "name": "description_en", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "facebook": { + "name": "facebook", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instagram": { + "name": "instagram", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tiktok": { + "name": "tiktok", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "x": { + "name": "x", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "youtube": { + "name": "youtube", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram": { + "name": "telegram", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linkedin": { + "name": "linkedin", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "spotify": { + "name": "spotify", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "modified_by_id": { + "name": "modified_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_by_sub": { + "name": "created_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "modified_by_sub": { + "name": "modified_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "web_associations_created_by_id_tg_permissions_user_id_fk": { + "name": "web_associations_created_by_id_tg_permissions_user_id_fk", + "tableFrom": "web_associations", + "tableTo": "tg_permissions", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "web_associations_modified_by_id_tg_permissions_user_id_fk": { + "name": "web_associations_modified_by_id_tg_permissions_user_id_fk", + "tableFrom": "web_associations", + "tableTo": "tg_permissions", + "columnsFrom": [ + "modified_by_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "web_associations_creator_check": { + "name": "web_associations_creator_check", + "value": "\"web_associations\".\"created_by_id\" IS NOT NULL OR \"web_associations\".\"created_by_sub\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.web_faq_categories": { + "name": "web_faq_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "web_faq_categories_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "title_it": { + "name": "title_it", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title_en": { + "name": "title_en", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "modified_by_id": { + "name": "modified_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_by_sub": { + "name": "created_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "modified_by_sub": { + "name": "modified_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "web_faq_categories_created_by_id_tg_permissions_user_id_fk": { + "name": "web_faq_categories_created_by_id_tg_permissions_user_id_fk", + "tableFrom": "web_faq_categories", + "tableTo": "tg_permissions", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "web_faq_categories_modified_by_id_tg_permissions_user_id_fk": { + "name": "web_faq_categories_modified_by_id_tg_permissions_user_id_fk", + "tableFrom": "web_faq_categories", + "tableTo": "tg_permissions", + "columnsFrom": [ + "modified_by_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "web_faq_categories_creator_check": { + "name": "web_faq_categories_creator_check", + "value": "\"web_faq_categories\".\"created_by_id\" IS NOT NULL OR \"web_faq_categories\".\"created_by_sub\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.web_faqs": { + "name": "web_faqs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "web_faqs_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "title_it": { + "name": "title_it", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title_en": { + "name": "title_en", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_it": { + "name": "description_it", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_en": { + "name": "description_en", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "modified_by_id": { + "name": "modified_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_by_sub": { + "name": "created_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "modified_by_sub": { + "name": "modified_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "web_faqs_category_id_web_faq_categories_id_fk": { + "name": "web_faqs_category_id_web_faq_categories_id_fk", + "tableFrom": "web_faqs", + "tableTo": "web_faq_categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "web_faqs_created_by_id_tg_permissions_user_id_fk": { + "name": "web_faqs_created_by_id_tg_permissions_user_id_fk", + "tableFrom": "web_faqs", + "tableTo": "tg_permissions", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "web_faqs_modified_by_id_tg_permissions_user_id_fk": { + "name": "web_faqs_modified_by_id_tg_permissions_user_id_fk", + "tableFrom": "web_faqs", + "tableTo": "tg_permissions", + "columnsFrom": [ + "modified_by_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "web_faqs_creator_check": { + "name": "web_faqs_creator_check", + "value": "\"web_faqs\".\"created_by_id\" IS NOT NULL OR \"web_faqs\".\"created_by_sub\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.web_freshman_cards": { + "name": "web_freshman_cards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "href": { + "name": "href", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "modified_by_id": { + "name": "modified_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_by_sub": { + "name": "created_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "modified_by_sub": { + "name": "modified_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "web_freshman_cards_created_by_id_tg_permissions_user_id_fk": { + "name": "web_freshman_cards_created_by_id_tg_permissions_user_id_fk", + "tableFrom": "web_freshman_cards", + "tableTo": "tg_permissions", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "web_freshman_cards_modified_by_id_tg_permissions_user_id_fk": { + "name": "web_freshman_cards_modified_by_id_tg_permissions_user_id_fk", + "tableFrom": "web_freshman_cards", + "tableTo": "tg_permissions", + "columnsFrom": [ + "modified_by_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "web_freshman_cards_creator_check": { + "name": "web_freshman_cards_creator_check", + "value": "\"web_freshman_cards\".\"created_by_id\" IS NOT NULL OR \"web_freshman_cards\".\"created_by_sub\" IS NOT NULL" + }, + "web_freshman_cards_slot_check": { + "name": "web_freshman_cards_slot_check", + "value": "\"web_freshman_cards\".\"id\" in ('featured-1', 'featured-2', 'guides-1', 'guides-2', 'guides-3', 'guides-4', 'guides-5', 'materials-1', 'tools-1', 'tools-2', 'telegram-1', 'telegram-2', 'telegram-3', 'telegram-4', 'telegram-5', 'telegram-6', 'telegram-7', 'telegram-8')" + }, + "web_freshman_cards_icon_check": { + "name": "web_freshman_cards_icon_check", + "value": "\"web_freshman_cards\".\"icon\" in ('chart', 'monitor', 'book', 'map', 'news', 'money', 'folder', 'repeat', 'refresh', 'bell', 'user', 'users', 'cart', 'home')" + } + }, + "isRLSEnabled": false + }, + "public.web_freshman_faqs": { + "name": "web_freshman_faqs", + "schema": "", + "columns": { + "position": { + "name": "position", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "faq_id": { + "name": "faq_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "modified_by_id": { + "name": "modified_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_by_sub": { + "name": "created_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "modified_by_sub": { + "name": "modified_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "web_freshman_faqs_faq_id_web_faqs_id_fk": { + "name": "web_freshman_faqs_faq_id_web_faqs_id_fk", + "tableFrom": "web_freshman_faqs", + "tableTo": "web_faqs", + "columnsFrom": [ + "faq_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "web_freshman_faqs_created_by_id_tg_permissions_user_id_fk": { + "name": "web_freshman_faqs_created_by_id_tg_permissions_user_id_fk", + "tableFrom": "web_freshman_faqs", + "tableTo": "tg_permissions", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "web_freshman_faqs_modified_by_id_tg_permissions_user_id_fk": { + "name": "web_freshman_faqs_modified_by_id_tg_permissions_user_id_fk", + "tableFrom": "web_freshman_faqs", + "tableTo": "tg_permissions", + "columnsFrom": [ + "modified_by_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "web_freshman_faqs_faq_id_unique": { + "name": "web_freshman_faqs_faq_id_unique", + "nullsNotDistinct": false, + "columns": [ + "faq_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "web_freshman_faqs_creator_check": { + "name": "web_freshman_faqs_creator_check", + "value": "\"web_freshman_faqs\".\"created_by_id\" IS NOT NULL OR \"web_freshman_faqs\".\"created_by_sub\" IS NOT NULL" + }, + "web_freshman_faqs_position_check": { + "name": "web_freshman_faqs_position_check", + "value": "\"web_freshman_faqs\".\"position\" between 0 and 4" + } + }, + "isRLSEnabled": false + }, + "public.web_group_link_reports": { + "name": "web_group_link_reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "web_group_link_reports_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "group_id": { + "name": "group_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "varchar(2)", + "primaryKey": false, + "notNull": false + }, + "report_type": { + "name": "report_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "reported_link": { + "name": "reported_link", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.web_guides_matricole": { + "name": "web_guides_matricole", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "web_guides_matricole_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file": { + "name": "file", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "modified_by_id": { + "name": "modified_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_by_sub": { + "name": "created_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "modified_by_sub": { + "name": "modified_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "web_guides_matricole_created_by_id_tg_permissions_user_id_fk": { + "name": "web_guides_matricole_created_by_id_tg_permissions_user_id_fk", + "tableFrom": "web_guides_matricole", + "tableTo": "tg_permissions", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "web_guides_matricole_modified_by_id_tg_permissions_user_id_fk": { + "name": "web_guides_matricole_modified_by_id_tg_permissions_user_id_fk", + "tableFrom": "web_guides_matricole", + "tableTo": "tg_permissions", + "columnsFrom": [ + "modified_by_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "web_guides_matricole_version_unique": { + "name": "web_guides_matricole_version_unique", + "nullsNotDistinct": false, + "columns": [ + "version" + ] + } + }, + "policies": {}, + "checkConstraints": { + "web_guides_matricole_creator_check": { + "name": "web_guides_matricole_creator_check", + "value": "\"web_guides_matricole\".\"created_by_id\" IS NOT NULL OR \"web_guides_matricole\".\"created_by_sub\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.web_projects": { + "name": "web_projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "web_projects_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_it": { + "name": "description_it", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_en": { + "name": "description_en", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_by_id": { + "name": "created_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "modified_by_id": { + "name": "modified_by_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_by_sub": { + "name": "created_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "modified_by_sub": { + "name": "modified_by_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (0) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "web_projects_created_by_id_tg_permissions_user_id_fk": { + "name": "web_projects_created_by_id_tg_permissions_user_id_fk", + "tableFrom": "web_projects", + "tableTo": "tg_permissions", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "web_projects_modified_by_id_tg_permissions_user_id_fk": { + "name": "web_projects_modified_by_id_tg_permissions_user_id_fk", + "tableFrom": "web_projects", + "tableTo": "tg_permissions", + "columnsFrom": [ + "modified_by_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "web_projects_creator_check": { + "name": "web_projects_creator_check", + "value": "\"web_projects\".\"created_by_id\" IS NOT NULL OR \"web_projects\".\"created_by_sub\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.web_test": { + "name": "web_test", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "web_test_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "text": { + "name": "text", + "type": "varchar", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": { + "public.groups_view": { + "columns": { + "telegram_id": { + "name": "telegram_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "link": { + "name": "link", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "hide": { + "name": "hide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "definition": "(select \"telegram_id\", \"title\", \"link\", \"hide\", 'tg' as \"type\" from \"tg_groups\") union all (select \"group_id\", \"title\", \"link\", \"hide\", 'wa' as \"type\" from \"wa_groups\")", + "name": "groups_view", + "schema": "public", + "isExisting": false, + "materialized": false + }, + "public.group_label_relations_view": { + "columns": { + "group_id": { + "name": "group_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "label_id": { + "name": "label_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "definition": "(select \"group_id\", \"label_id\", 'tg' as \"type\" from \"tg_group_label_relations\") union all (select \"group_id\", \"label_id\", 'wa' as \"type\" from \"wa_group_label_relations\")", + "name": "group_label_relations_view", + "schema": "public", + "isExisting": false, + "materialized": false + } + }, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index f57b425..28e64e7 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -155,6 +155,13 @@ "when": 1791589154604, "tag": "0021_idp_audit_grants_authors", "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1792447413866, + "tag": "0022_freshman_content", + "breakpoints": true } ] } \ No newline at end of file diff --git a/scripts/seed-db.ts b/scripts/seed-db.ts index 3d175f9..0a23434 100644 --- a/scripts/seed-db.ts +++ b/scripts/seed-db.ts @@ -6,6 +6,7 @@ import { DB, SCHEMA } from "@/db" import { ARRAY_AUDIT_TYPE, AUDIT_BASIS, AUDIT_REVIEW, AUDIT_TYPE } from "@/db/schema/tg/audit-log" import type { TUserRole } from "@/db/schema/tg/permissions" import { tgMessagesCipher } from "@/routers/tg/messages" +import { defaultFreshmanCards } from "@/routers/web/freshman/seed" import { encryptUser } from "@/utils/users" const USERS_COUNT = 2000 @@ -704,6 +705,9 @@ const FAQ_CONTENT: { }, ] +// Clear featured references first when explicitly replacing the development FAQ catalog. +if (force) await DB.delete(SCHEMA.WEB.freshmanFaqs) + await seedTable("web_faq_categories", SCHEMA.WEB.faqCategories, () => FAQ_CONTENT.map(({ titleIt, titleEn, icon }) => ({ titleIt, titleEn, icon, ...seedAuthor })) ) @@ -733,6 +737,19 @@ await seedTable("web_faqs", SCHEMA.WEB.faqs, () => }) ) +await seedTable("web_freshman_cards", SCHEMA.WEB.freshmanCards, () => + defaultFreshmanCards.map((card) => ({ ...card, ...seedAuthor })) +) +const [featuredFaq] = await DB.select().from(SCHEMA.WEB.freshmanFaqs).limit(1) +if (!featuredFaq) { + const catalog = await DB.select({ id: SCHEMA.WEB.faqs.id }).from(SCHEMA.WEB.faqs).orderBy(SCHEMA.WEB.faqs.id).limit(5) + if (catalog.length === 5) { + await DB.insert(SCHEMA.WEB.freshmanFaqs).values( + catalog.map((faq, position) => ({ position, faqId: faq.id, ...seedAuthor })) + ) + } else console.warn("SEED: at least five FAQs are needed to initialize the Freshman selections") +} + await seedTable("web_guides_matricole", SCHEMA.WEB.guidesMatricole, () => [ { version: "2024.1", date: "2024-07-15" }, diff --git a/src/db/schema/web/freshman.ts b/src/db/schema/web/freshman.ts new file mode 100644 index 0000000..5a99070 --- /dev/null +++ b/src/db/schema/web/freshman.ts @@ -0,0 +1,58 @@ +import { sql } from "drizzle-orm" +import { bigint, check, integer, text } from "drizzle-orm/pg-core" +import { authorSubColumns, hasCreator, timeColumns } from "@/db/columns" +import { freshmanIcons, freshmanSlots } from "@/routers/web/freshman/constants" +import { createTable } from "../create-table" +import { permissions } from "../tg/permissions" +import { faqs } from "./faqs" + +export const freshmanCards = createTable.web( + "freshman_cards", + { + id: text("id").primaryKey(), + title: text("title").notNull(), + description: text("description").notNull(), + icon: text("icon", { enum: freshmanIcons }).notNull(), + href: text("href").notNull(), + createdBy: bigint("created_by_id", { mode: "number" }).references(() => permissions.userId), + modifiedBy: bigint("modified_by_id", { mode: "number" }).references(() => permissions.userId), + ...authorSubColumns, + ...timeColumns, + }, + (t) => [ + hasCreator("web_freshman_cards", t), + check( + "web_freshman_cards_slot_check", + sql`${t.id} in (${sql.join( + freshmanSlots.map((slot) => sql.raw(`'${slot.id}'`)), + sql`, ` + )})` + ), + check( + "web_freshman_cards_icon_check", + sql`${t.icon} in (${sql.join( + freshmanIcons.map((icon) => sql.raw(`'${icon}'`)), + sql`, ` + )})` + ), + ] +) + +export const freshmanFaqs = createTable.web( + "freshman_faqs", + { + position: integer("position").primaryKey(), + faqId: integer("faq_id") + .notNull() + .unique() + .references(() => faqs.id, { onDelete: "restrict" }), + createdBy: bigint("created_by_id", { mode: "number" }).references(() => permissions.userId), + modifiedBy: bigint("modified_by_id", { mode: "number" }).references(() => permissions.userId), + ...authorSubColumns, + ...timeColumns, + }, + (t) => [ + hasCreator("web_freshman_faqs", t), + check("web_freshman_faqs_position_check", sql`${t.position} between 0 and 4`), + ] +) diff --git a/src/db/schema/web/index.ts b/src/db/schema/web/index.ts index 955b178..8df6487 100644 --- a/src/db/schema/web/index.ts +++ b/src/db/schema/web/index.ts @@ -1,8 +1,17 @@ import * as associations from "./associations" import * as faqs from "./faqs" +import * as freshman from "./freshman" import * as groupLinkReports from "./group-link-reports" import * as guides_matricole from "./guides_matricole" import * as projects from "./projects" import * as test from "./test" -export const schema = { ...associations, ...test, ...faqs, ...projects, ...guides_matricole, ...groupLinkReports } +export const schema = { + ...associations, + ...test, + ...faqs, + ...freshman, + ...projects, + ...guides_matricole, + ...groupLinkReports, +} diff --git a/src/routers/web/faqs.ts b/src/routers/web/faqs.ts index fb12b80..d41041a 100644 --- a/src/routers/web/faqs.ts +++ b/src/routers/web/faqs.ts @@ -1,4 +1,4 @@ -import { asc, eq } from "drizzle-orm" +import { asc, eq, sql } from "drizzle-orm" import z from "zod" import { DB, SCHEMA } from "@/db" import { authorOf, createdByColumns, modifiedByColumns } from "@/idp/author" @@ -8,6 +8,7 @@ import { createTRPCRouter, policy } from "@/trpc" const FAQS = SCHEMA.WEB.faqs const FAQ_CATEGORIES = SCHEMA.WEB.faqCategories +const FEATURED = SCHEMA.WEB.freshmanFaqs export default createTRPCRouter({ getAllFaqs: policy(publicData) @@ -171,10 +172,20 @@ export default createTRPCRouter({ ) .mutation(async ({ input }) => { const { id } = input - const deleted = await DB.delete(FAQS).where(eq(FAQS.id, id)).returning() - - if (deleted.length === 0) return { error: "NOT_FOUND" } - return { error: null } + return DB.transaction(async (tx) => { + // Match selectFaq's lock order so a FAQ cannot become selected between this check and deletion. + await tx.execute(sql`LOCK TABLE ${FEATURED} IN SHARE ROW EXCLUSIVE MODE`) + const [featured] = await tx + .select({ position: FEATURED.position }) + .from(FEATURED) + .where(eq(FEATURED.faqId, id)) + .limit(1) + if (featured) return { error: "FEATURED_FAQ" } + const deleted = await tx.delete(FAQS).where(eq(FAQS.id, id)).returning() + + if (deleted.length === 0) return { error: "NOT_FOUND" } + return { error: null } + }) }), deleteFaqsCategory: policy(dashboard("web:content:write")) @@ -185,9 +196,19 @@ export default createTRPCRouter({ ) .mutation(async ({ input }) => { const { id } = input - const deleted = await DB.delete(FAQ_CATEGORIES).where(eq(FAQ_CATEGORIES.id, id)).returning() - - if (deleted.length === 0) return { error: "NOT_FOUND" } - return { error: null } + return DB.transaction(async (tx) => { + await tx.execute(sql`LOCK TABLE ${FEATURED} IN SHARE ROW EXCLUSIVE MODE`) + const [featured] = await tx + .select({ position: FEATURED.position }) + .from(FEATURED) + .innerJoin(FAQS, eq(FEATURED.faqId, FAQS.id)) + .where(eq(FAQS.categoryId, id)) + .limit(1) + if (featured) return { error: "FEATURED_FAQ" } + const deleted = await tx.delete(FAQ_CATEGORIES).where(eq(FAQ_CATEGORIES.id, id)).returning() + + if (deleted.length === 0) return { error: "NOT_FOUND" } + return { error: null } + }) }), }) diff --git a/src/routers/web/freshman.ts b/src/routers/web/freshman.ts new file mode 100644 index 0000000..406cc3f --- /dev/null +++ b/src/routers/web/freshman.ts @@ -0,0 +1,140 @@ +import { TRPCError } from "@trpc/server" +import { asc, eq, inArray, sql } from "drizzle-orm" +import { DB, SCHEMA } from "@/db" +import { authorOf, modifiedByColumns } from "@/idp/author" +import { dashboard, publicData } from "@/idp/policies" +import { freshmanFaqCount, freshmanSlots } from "@/routers/web/freshman/constants" +import { + freshmanContentOutput, + moveFreshmanCardInput, + selectFreshmanFaqInput, + updateFreshmanCardInput, +} from "@/routers/web/freshman/validation" +import { createTRPCRouter, policy } from "@/trpc" + +const CARDS = SCHEMA.WEB.freshmanCards +const FEATURED = SCHEMA.WEB.freshmanFaqs +const FAQS = SCHEMA.WEB.faqs + +export default createTRPCRouter({ + getContent: policy(publicData) + .output(freshmanContentOutput) + .query(async () => { + // A repeatable snapshot prevents a multi-row swap from appearing partially applied. + return DB.transaction( + async (tx) => { + const cards = await tx.select().from(CARDS) + const featured = await tx + .select({ + faqId: FAQS.id, + titleIt: FAQS.titleIt, + titleEn: FAQS.titleEn, + descriptionIt: FAQS.descriptionIt, + descriptionEn: FAQS.descriptionEn, + }) + .from(FEATURED) + .innerJoin(FAQS, eq(FEATURED.faqId, FAQS.id)) + .orderBy(asc(FEATURED.position)) + const ordered = freshmanSlots.map((slot) => { + const card = cards.find((row) => row.id === slot.id) + if (!card) + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Freshman content is not initialized. Apply the database migration.", + }) + return { + id: slot.id, + group: slot.group, + title: card.title, + description: card.description, + icon: card.icon, + href: card.href, + } + }) + return { resources: ordered.slice(0, 10), telegram: ordered.slice(10), faqs: featured } + }, + { isolationLevel: "repeatable read", accessMode: "read only" } + ) + }), + + updateCard: policy(dashboard("web:content:write")) + .input(updateFreshmanCardInput) + .mutation(async ({ input, ctx }) => { + const { id, modifiedBy, ...fields } = input + const [updated] = await DB.update(CARDS) + .set({ ...fields, ...modifiedByColumns(authorOf(ctx.actor, modifiedBy)) }) + .where(eq(CARDS.id, id)) + .returning({ id: CARDS.id }) + if (!updated) throw new TRPCError({ code: "NOT_FOUND", message: "The website slot is not initialized." }) + return { error: null } + }), + + moveCard: policy(dashboard("web:content:write")) + .input(moveFreshmanCardInput) + .mutation(async ({ input, ctx }) => { + const index = freshmanSlots.findIndex((slot) => slot.id === input.id) + const slot = freshmanSlots[index] + const next = freshmanSlots[index + input.direction] + if (!next || next.group !== slot.group) + throw new TRPCError({ code: "BAD_REQUEST", message: "Cards can only move within their website section." }) + const author = modifiedByColumns(authorOf(ctx.actor, input.modifiedBy)) + await DB.transaction(async (tx) => { + const rows = await tx + .select() + .from(CARDS) + .where(inArray(CARDS.id, [slot.id, next.id])) + .orderBy(asc(CARDS.id)) + .for("update") + const current = rows.find((row) => row.id === slot.id) + const target = rows.find((row) => row.id === next.id) + if (!current || !target) + throw new TRPCError({ code: "NOT_FOUND", message: "The website slots are not initialized." }) + const values = (row: typeof current) => ({ + title: row.title, + description: row.description, + href: row.href, + icon: row.icon, + ...author, + }) + await tx.update(CARDS).set(values(target)).where(eq(CARDS.id, current.id)) + await tx.update(CARDS).set(values(current)).where(eq(CARDS.id, target.id)) + }) + return { error: null } + }), + + selectFaq: policy(dashboard("web:content:write")) + .input(selectFreshmanFaqInput) + .mutation(async ({ input, ctx }) => { + const author = modifiedByColumns(authorOf(ctx.actor, input.modifiedBy)) + await DB.transaction(async (tx) => { + // Serialize writes to the five slots before reading; public snapshot reads remain unblocked. + // Catalog deletions take the same lock before checking whether a FAQ is selected. + await tx.execute(sql`LOCK TABLE ${FEATURED} IN SHARE ROW EXCLUSIVE MODE`) + const rows = await tx.select().from(FEATURED).orderBy(asc(FEATURED.position)) + if (rows.length !== freshmanFaqCount) + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "The FAQ positions are not initialized." }) + const [faq] = await tx.select({ id: FAQS.id }).from(FAQS).where(eq(FAQS.id, input.faqId)).for("key share") + if (!faq) throw new TRPCError({ code: "NOT_FOUND", message: "This FAQ no longer exists." }) + const current = rows.find((row) => row.position === input.position) + if (!current) throw new TRPCError({ code: "NOT_FOUND" }) + if (current.faqId === input.faqId) return + const existing = rows.find((row) => row.faqId === input.faqId) + if (existing) { + // Reinsert the pair atomically so the declared immediate unique constraint also permits swaps. + // Preserve each slot's creation metadata and record this edit on both positions. + await tx.delete(FEATURED).where(inArray(FEATURED.position, [current.position, existing.position])) + const updatedAt = new Date() + await tx.insert(FEATURED).values([ + { ...current, faqId: existing.faqId, ...author, updatedAt }, + { ...existing, faqId: current.faqId, ...author, updatedAt }, + ]) + } else { + await tx + .update(FEATURED) + .set({ faqId: input.faqId, ...author }) + .where(eq(FEATURED.position, input.position)) + } + }) + return { error: null } + }), +}) diff --git a/src/routers/web/freshman/constants.ts b/src/routers/web/freshman/constants.ts new file mode 100644 index 0000000..c42aee8 --- /dev/null +++ b/src/routers/web/freshman/constants.ts @@ -0,0 +1,35 @@ +/** Website-owned slots: only their contents may be changed by editors. */ +export const freshmanSlots = [ + { id: "featured-1", section: "resources", group: "featured" }, + { id: "featured-2", section: "resources", group: "featured" }, + { id: "guides-1", section: "resources", group: "guides" }, + { id: "guides-2", section: "resources", group: "guides" }, + { id: "guides-3", section: "resources", group: "guides" }, + { id: "guides-4", section: "resources", group: "guides" }, + { id: "guides-5", section: "resources", group: "guides" }, + { id: "materials-1", section: "resources", group: "materials" }, + { id: "tools-1", section: "resources", group: "tools" }, + { id: "tools-2", section: "resources", group: "tools" }, + ...Array.from( + { length: 8 }, + (_, index) => ({ id: `telegram-${index + 1}`, section: "telegram", group: "telegram" }) as const + ), +] as const + +export const freshmanIcons = [ + "chart", + "monitor", + "book", + "map", + "news", + "money", + "folder", + "repeat", + "refresh", + "bell", + "user", + "users", + "cart", + "home", +] as const +export const freshmanFaqCount = 5 diff --git a/src/routers/web/freshman/seed.ts b/src/routers/web/freshman/seed.ts new file mode 100644 index 0000000..6c714bd --- /dev/null +++ b/src/routers/web/freshman/seed.ts @@ -0,0 +1,130 @@ +/** Initial website cards for the explicit development seed, matching migration 0022. */ +export const defaultFreshmanCards = [ + { + id: "featured-1", + title: "Graduatorie", + description: "Risultati storici e soglie di accesso per i vari corsi di laurea.", + icon: "chart", + href: "https://rankings.polinetwork.org/", + }, + { + id: "featured-2", + title: "Progetto TOL", + description: "Informazioni sul test di ingresso (TOL) e su come prepararsi al meglio.", + icon: "monitor", + href: "https://tol.polinetwork.org/", + }, + { + id: "guides-1", + title: "Guida della matricola", + description: "Dalla prima iscrizione alla vita in campus: il manuale completo per ogni nuovo studente.", + icon: "book", + href: "/matricole/guida", + }, + { + id: "guides-2", + title: "Mappa Microonde", + description: "Dove scaldare il pranzo nei vari campus.", + icon: "map", + href: "", + }, + { + id: "guides-3", + title: "Corsi a Scelta", + description: "Consigli su come comporre il Piano di Studi.", + icon: "book", + href: "", + }, + { + id: "guides-4", + title: "News", + description: "Le ultime novità dagli studenti del Politecnico.", + icon: "news", + href: "", + }, + { + id: "guides-5", + title: "Borsa di Studio", + description: "Requisiti e scadenze per le agevolazioni.", + icon: "money", + href: "", + }, + { + id: "materials-1", + title: "Materiali", + description: + "Il più grande archivio didattico creato dagli studenti per gli studenti del Politecnico di Milano. Migliaia di appunti e temi d’esame.", + icon: "folder", + href: "/materials", + }, + { + id: "tools-1", + title: "Sync Orario", + description: "", + icon: "repeat", + href: "https://bebora.github.io/polimi-schedule-js/", + }, + { + id: "tools-2", + title: "WeBeep Sync", + description: "", + icon: "refresh", + href: "https://github.com/toto04/webeep-sync", + }, + { + id: "telegram-1", + title: "Generale", + description: "", + icon: "bell", + href: "https://t.me/PoliGruppo", + }, + { + id: "telegram-2", + title: "Matricole", + description: "", + icon: "user", + href: "https://t.me/polimatricole", + }, + { + id: "telegram-3", + title: "Ripetizioni", + description: "", + icon: "users", + href: "https://t.me/joinchat/l10OF3vF6fEzOTRk", + }, + { + id: "telegram-4", + title: "PoliBook", + description: "", + icon: "book", + href: "https://t.me/joinchat/6nmuXXhAOr1mYTJk", + }, + { + id: "telegram-5", + title: "DSU", + description: "", + icon: "money", + href: "https://t.me/joinchat/4kO9DtAiTVM0NTU0", + }, + { + id: "telegram-6", + title: "Mercatino", + description: "", + icon: "cart", + href: "https://t.me/mercatinopolimi", + }, + { + id: "telegram-7", + title: "Case", + description: "", + icon: "home", + href: "https://t.me/joinchat/jmpNOitFNFw1MzJk", + }, + { + id: "telegram-8", + title: "Consigli PC", + description: "", + icon: "monitor", + href: "https://t.me/joinchat/oD7NGpl0W6VjYmI0", + }, +] as const diff --git a/src/routers/web/freshman/validation.ts b/src/routers/web/freshman/validation.ts new file mode 100644 index 0000000..6b3b605 --- /dev/null +++ b/src/routers/web/freshman/validation.ts @@ -0,0 +1,70 @@ +import z from "zod" +import { freshmanFaqCount, freshmanIcons, freshmanSlots } from "./constants" + +const destination = z + .string() + .trim() + .max(2048) + .refine((value) => { + if (value === "") return true + if (value.startsWith("/") && !value.startsWith("//") && !/[\\\s]/.test(value)) return true + if (!URL.canParse(value)) return false + const url = new URL(value) + return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password + }, "Use a site path or an HTTP/HTTPS URL without credentials.") + +export const freshmanCardFields = z + .object({ + title: z.string().trim().min(1).max(120), + description: z.string().trim().max(1000), + icon: z.enum(freshmanIcons), + href: destination, + }) + .strict() +export const freshmanSlotId = z + .string() + .refine((id) => freshmanSlots.some((slot) => slot.id === id), "Unknown website slot.") +export const updateFreshmanCardInput = freshmanCardFields + .extend({ + id: freshmanSlotId, + modifiedBy: z.number().int().positive().optional(), + }) + .superRefine((input, ctx) => { + if (input.id.startsWith("telegram-") && input.href === "") + ctx.addIssue({ code: "custom", path: ["href"], message: "Enter a group link." }) + }) +export const moveFreshmanCardInput = z + .object({ + id: freshmanSlotId, + direction: z.union([z.literal(-1), z.literal(1)]), + modifiedBy: z.number().int().positive().optional(), + }) + .strict() +export const selectFreshmanFaqInput = z + .object({ + position: z + .number() + .int() + .min(0) + .max(freshmanFaqCount - 1), + faqId: z.number().int().positive(), + modifiedBy: z.number().int().positive().optional(), + }) + .strict() + +const publicCard = freshmanCardFields.extend({ id: z.string(), group: z.string() }) +export const freshmanContentOutput = z.object({ + resources: z.array(publicCard).length(10), + telegram: z.array(publicCard).length(8), + faqs: z + .array( + z.object({ + faqId: z.number().int(), + titleIt: z.string(), + titleEn: z.string(), + descriptionIt: z.string(), + descriptionEn: z.string(), + }) + ) + .length(freshmanFaqCount), +}) diff --git a/src/routers/web/index.ts b/src/routers/web/index.ts index 70f94da..233b8db 100644 --- a/src/routers/web/index.ts +++ b/src/routers/web/index.ts @@ -2,7 +2,8 @@ import { createTRPCRouter } from "@/trpc" import { reports } from "../web/reports" import associations from "./associations" import faqs from "./faqs" +import freshman from "./freshman" import guides_matricole from "./guides_matricole" import projects from "./projects" -export const webRouter = createTRPCRouter({ associations, faqs, projects, guides_matricole, reports }) +export const webRouter = createTRPCRouter({ associations, faqs, freshman, projects, guides_matricole, reports }) diff --git a/tests/fixtures/freshman-integration.ts b/tests/fixtures/freshman-integration.ts new file mode 100644 index 0000000..49ac7da --- /dev/null +++ b/tests/fixtures/freshman-integration.ts @@ -0,0 +1,224 @@ +import assert from "node:assert/strict" +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { SQL } from "bun" +import { drizzle } from "drizzle-orm/bun-sql" +import { migrate } from "drizzle-orm/bun-sql/migrator" +import type { Context } from "@/trpc" + +// Only the explicit disposable test port is accepted; application database configuration is never used. +const port = Number(process.env.FRESHMAN_TEST_DB_PORT) +if (!Number.isInteger(port) || port < 10000) + throw new Error("Set FRESHMAN_TEST_DB_PORT to an isolated PostgreSQL port >= 10000.") +const database = `freshman_api_test_${crypto.randomUUID().replaceAll("-", "")}` +const config = { hostname: "127.0.0.1", port, username: "freshman_test", password: "freshman_test" } +const admin = new SQL({ ...config, database: "postgres" }) +const previousMigrations = await mkdtemp(join(tmpdir(), "freshman-migrations-")) +await admin.unsafe(`CREATE DATABASE "${database}"`) +const connection = new SQL({ ...config, database }) +let closeBackend: (() => Promise) | undefined +try { + const journal = JSON.parse(await readFile("drizzle/meta/_journal.json", "utf8")) as { + entries: Array<{ tag: string }> + } + const previous = { ...journal, entries: journal.entries.filter((entry) => entry.tag !== "0022_freshman_content") } + await import("node:fs/promises").then(({ mkdir }) => mkdir(join(previousMigrations, "meta"))) + await writeFile(join(previousMigrations, "meta/_journal.json"), JSON.stringify(previous)) + for (const entry of previous.entries) + await writeFile(join(previousMigrations, `${entry.tag}.sql`), await readFile(`drizzle/${entry.tag}.sql`)) + await migrate(drizzle(connection), { migrationsFolder: previousMigrations }) + const [category] = + await connection`INSERT INTO web_faq_categories (title_it, title_en, created_by_sub) VALUES ('Existing', 'Existing', 'retained') RETURNING id` + const title = "Le lezioni della prima settimana si tengono regolarmente?" + const [retained] = + await connection`INSERT INTO web_faqs (title_it, title_en, description_it, description_en, category_id, created_by_sub) VALUES (${title}, 'Retained English', 'Retained answer', 'Retained English answer', ${category.id}, 'retained') RETURNING id` + + Object.assign(process.env, { + NODE_ENV: "test", + DB_HOST: "127.0.0.1", + DB_PORT: String(port), + DB_USER: config.username, + DB_PASS: config.password, + DB_NAME: database, + BETTER_AUTH_SECRET: "freshman-test-secret-at-least-thirty-two-characters", + ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }) + const { DB } = await import("@/db") // Runs the new migration through the actual application entry point. + closeBackend = () => DB.$client.close() + const freshman = (await import("@/routers/web/freshman")).default + const faqs = (await import("@/routers/web/faqs")).default + const editorContext: Context = { + auth: { + kind: "token", + actor: { kind: "user", sub: "freshman-editor", client: "admin", telegramId: null }, + scopes: new Set(["backend:admin"]), + }, + access: { + has: () => true, + current: () => null, + subjectBySub: () => undefined, + subjectByTelegramId: () => undefined, + status: () => ({ lastSyncAt: 1, fresh: true, generation: 1, subjects: 1 }), + }, + } + const editor = freshman.createCaller(editorContext) + const catalog = faqs.createCaller(editorContext) + const website = freshman.createCaller({ + auth: { kind: "token", actor: { kind: "service", client: "website" }, scopes: new Set(["backend:public:read"]) }, + }) + const before = await website.getContent() + assert.equal(before.resources.length, 10) + assert.equal(before.telegram.length, 8) + assert.equal(before.faqs.length, 5) + assert.equal(before.faqs[0].faqId, retained.id) + assert.equal(before.faqs[0].descriptionIt, "Retained answer") + assert.equal(before.faqs[0].titleEn, "Retained English") + assert.equal("modifiedBySub" in before.resources[0], false) + + const card = before.resources[0] + const fields = { + title: "Updated graduation resource", + description: card.description, + icon: card.icon, + href: "https://example.org/new", + } + await editor.updateCard({ id: card.id, ...fields, modifiedBy: 999 }) + assert.equal((await website.getContent()).resources[0].title, fields.title) + assert.deepEqual( + (await connection`SELECT modified_by_id, modified_by_sub FROM web_freshman_cards WHERE id = ${card.id}`)[0], + { modified_by_id: null, modified_by_sub: "freshman-editor" } + ) + for (const href of ["javascript:alert(1)", "//example.org", "/\\example.org"]) + await assert.rejects(editor.updateCard({ id: card.id, ...fields, href }), { code: "BAD_REQUEST" }) + await assert.rejects(editor.moveCard({ id: "featured-2", direction: 1 }), { code: "BAD_REQUEST" }) + await assert.rejects(editor.selectFaq({ position: 0, faqId: 2_000_000_000 }), { code: "NOT_FOUND" }) + + // Pause deletion on a catalog row, then race it with selecting that same FAQ. + // Observe PostgreSQL locks instead of relying on timing or arbitrary sleeps. + async function waitFor(condition: () => Promise) { + const deadline = Date.now() + 5000 + while (!(await condition())) { + assert.ok(Date.now() < deadline, "timed out waiting for the database lock") + await new Promise((resolve) => setImmediate(resolve)) + } + } + for (const target of ["faq", "category"] as const) { + const [raceCategory] = + await connection`INSERT INTO web_faq_categories (title_it, title_en, created_by_sub) VALUES ('Race', 'Race', 'test') RETURNING id` + const [raceFaq] = + await connection`INSERT INTO web_faqs (title_it, title_en, description_it, description_en, category_id, created_by_sub) VALUES ('Race', 'Race', 'Answer', 'Answer', ${raceCategory.id}, 'test') RETURNING id` + const blocker = new SQL({ ...config, database, max: 1 }) + try { + await blocker.unsafe("BEGIN") + await blocker`SELECT id FROM web_faqs WHERE id = ${raceFaq.id} FOR NO KEY UPDATE` + const deletion = + target === "faq" ? catalog.deleteFaqs({ id: raceFaq.id }) : catalog.deleteFaqsCategory({ id: raceCategory.id }) + const deletionResult = Promise.allSettled([deletion]) + await waitFor( + async () => + ( + await connection`SELECT 1 FROM pg_stat_activity WHERE datname = ${database} AND wait_event_type = 'Lock' AND query ILIKE 'delete%'` + ).length > 0 + ) + let selectionFinished = false + const selectionResult = Promise.allSettled([editor.selectFaq({ position: 0, faqId: raceFaq.id })]).then( + (result) => { + selectionFinished = true + return result + } + ) + await waitFor( + async () => + selectionFinished || + ( + await connection`SELECT 1 FROM pg_stat_activity WHERE datname = ${database} AND wait_event_type = 'Lock' AND query ILIKE '%web_freshman_faqs%'` + ).length > 0 + ) + await blocker.unsafe("COMMIT") + const [deleted] = await deletionResult + const [selected] = await selectionResult + assert.equal(deleted.status, "fulfilled", `concurrent ${target} deletion must return a domain result`) + if (deleted.status === "fulfilled" && deleted.value.error === null) { + assert.equal(selected.status, "rejected") + if (selected.status === "rejected") assert.equal(selected.reason.code, "NOT_FOUND") + } else { + assert.equal(deleted.status === "fulfilled" && deleted.value.error, "FEATURED_FAQ") + assert.equal(selected.status, "fulfilled") + } + await editor.selectFaq({ position: 0, faqId: before.faqs[0].faqId }) + assert.equal((await website.getContent()).faqs.length, 5) + } finally { + await blocker.unsafe("ROLLBACK") + await blocker.close() + } + } + + // The migration must match the immediate uniqueness declared by the Drizzle schema. + const [constraint] = + await connection`SELECT condeferrable FROM pg_constraint WHERE conrelid = 'web_freshman_faqs'::regclass AND conname = 'web_freshman_faqs_faq_id_unique'` + assert.equal(constraint.condeferrable, false) + const originalSlots = + await connection`SELECT position, created_at, created_by_sub FROM web_freshman_faqs ORDER BY position` + await editor.selectFaq({ position: 0, faqId: before.faqs[1].faqId }) + await editor.selectFaq({ position: 0, faqId: before.faqs[0].faqId }) + assert.deepEqual( + await connection`SELECT position, created_at, created_by_sub FROM web_freshman_faqs ORDER BY position`, + originalSlots, + "swaps must retain slot creation metadata" + ) + + const changed = await website.getContent() + await Promise.all(Array.from({ length: 12 }, () => editor.moveCard({ id: "featured-1", direction: 1 }))) + assert.deepEqual( + (await website.getContent()).resources, + changed.resources, + "an even number of concurrent swaps must preserve contents" + ) + await Promise.all( + Array.from({ length: 12 }, (_, index) => + editor.selectFaq({ position: index % 2, faqId: before.faqs[(index % 2) + 2].faqId }) + ) + ) + const swapped = await website.getContent() + assert.equal(swapped.faqs.length, 5) + assert.equal(new Set(swapped.faqs.map((faq) => faq.faqId)).size, 5) + assert.deepEqual(new Set(swapped.faqs.map((faq) => faq.faqId)), new Set(before.faqs.map((faq) => faq.faqId))) + + await catalog.editFaqs({ + id: retained.id, + titleIt: title, + titleEn: "Updated English", + descriptionIt: "Updated Italian answer", + descriptionEn: "Updated English answer", + categoryId: category.id, + }) + assert.equal( + (await website.getContent()).faqs.find((faq) => faq.faqId === retained.id)?.descriptionIt, + "Updated Italian answer" + ) + assert.deepEqual(await catalog.deleteFaqs({ id: retained.id }), { error: "FEATURED_FAQ" }) + assert.deepEqual(await catalog.deleteFaqsCategory({ id: category.id }), { error: "FEATURED_FAQ" }) + await assert.rejects(connection`DELETE FROM web_faqs WHERE id = ${retained.id}`, { + errno: "23001", + constraint: "web_freshman_faqs_faq_id_web_faqs_id_fk", + }) + + const [extra] = + await connection`INSERT INTO web_faqs (title_it, title_en, description_it, description_en, category_id, created_by_sub) VALUES ('New choice', 'New choice', 'New answer', 'New answer', ${category.id}, 'test') RETURNING id` + await editor.selectFaq({ position: 0, faqId: extra.id }) + const after = await website.getContent() + assert.equal(after.faqs[0].faqId, extra.id) + assert.equal(new Set(after.faqs.map((faq) => faq.faqId)).size, 5) + assert.equal((await connection`SELECT count(*)::int AS count FROM web_freshman_cards`)[0].count, 18) + assert.equal((await connection`SELECT count(*)::int AS count FROM web_freshman_faqs`)[0].count, 5) + console.log( + "PASS: migration FAQ reuse, fixed counts, public reads, durable writes, authorship, invalid inputs, concurrent swaps, catalog propagation and deletion protection" + ) +} finally { + await closeBackend?.() + await connection.close() + await admin.unsafe(`DROP DATABASE "${database}"`) + await admin.close() + await rm(previousMigrations, { recursive: true }) +} diff --git a/tests/freshman.integration.test.ts b/tests/freshman.integration.test.ts new file mode 100644 index 0000000..7175715 --- /dev/null +++ b/tests/freshman.integration.test.ts @@ -0,0 +1,16 @@ +import { execFile } from "node:child_process" +import { promisify } from "node:util" +import { describe, expect, it } from "vitest" + +const exec = promisify(execFile) +const port = process.env.FRESHMAN_TEST_DB_PORT + +describe.skipIf(!port)("Freshman PostgreSQL integration", () => { + it("keeps the website/admin contract durable and consistent under concurrent changes", async () => { + const { stdout } = await exec("bun", ["--no-env-file", "tests/fixtures/freshman-integration.ts"], { + env: { PATH: process.env.PATH, FRESHMAN_TEST_DB_PORT: port }, + maxBuffer: 1024 * 1024, + }) + expect(stdout).toContain("PASS: migration FAQ reuse") + }, 30_000) +}) diff --git a/tests/freshman.test.ts b/tests/freshman.test.ts new file mode 100644 index 0000000..4ccfd5b --- /dev/null +++ b/tests/freshman.test.ts @@ -0,0 +1,140 @@ +import type { Actor } from "@polinetwork/auth-kit" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { freshmanSlots } from "@/routers/web/freshman/constants" +import { + moveFreshmanCardInput, + selectFreshmanFaqInput, + updateFreshmanCardInput, +} from "@/routers/web/freshman/validation" +import type { Context } from "@/trpc" + +vi.hoisted(() => { + Object.assign(process.env, { + NODE_ENV: "test", + BETTER_AUTH_SECRET: "test-secret-with-at-least-thirty-two-characters", + ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + DB_HOST: "localhost", + DB_USER: "postgres", + DB_PASS: "postgres", + }) +}) + +const db = vi.hoisted(() => ({ transaction: vi.fn(), set: vi.fn(), update: vi.fn() })) +vi.mock("@/db", async () => ({ + DB: { transaction: db.transaction, update: db.update }, + SCHEMA: { WEB: (await import("@/db/schema/web")).schema }, +})) + +import freshman from "@/routers/web/freshman" + +const fields = { title: "Title", description: "Description", icon: "book" as const, href: "/materials" } +const editor: Actor = { kind: "user", sub: "editor", client: "admin", telegramId: null } +const service: Actor = { kind: "service", client: "website" } + +function context(actor: Actor, permissions: string[], scopes = ["backend:admin"], fresh = true): Context { + return { + auth: { kind: "token", actor, scopes: new Set(scopes) }, + access: { + has: (_, permission) => fresh && permissions.includes(permission), + current: () => null, + subjectBySub: () => undefined, + subjectByTelegramId: () => undefined, + status: () => ({ lastSyncAt: 1, fresh, generation: 1, subjects: 1 }), + }, + } +} + +beforeEach(() => { + vi.clearAllMocks() + const chain = { where: () => ({ returning: async () => [{ id: "featured-1" }] }) } + db.set.mockReturnValue(chain) + db.update.mockReturnValue({ set: db.set }) +}) + +describe("Freshman validation", () => { + it("accepts only fixed slots, safe destinations, bounded content and known icons", () => { + expect(freshmanSlots).toHaveLength(18) + expect(updateFreshmanCardInput.parse({ id: "featured-1", ...fields }).href).toBe("/materials") + for (const href of [ + "javascript:alert(1)", + "data:text/html,test", + "//evil.example", + "/\\evil.example", + "https://user:secret@example.org", + ]) { + expect(updateFreshmanCardInput.safeParse({ id: "featured-1", ...fields, href }).success).toBe(false) + } + for (const invalid of [ + { id: "new-slot" }, + { title: " " }, + { title: "x".repeat(121) }, + { description: "x".repeat(1001) }, + { icon: "arbitrary" }, + { group: "tools" }, + ]) { + expect(updateFreshmanCardInput.safeParse({ id: "featured-1", ...fields, ...invalid }).success).toBe(false) + } + expect(updateFreshmanCardInput.safeParse({ id: "telegram-1", ...fields, href: "" }).success).toBe(false) + expect(updateFreshmanCardInput.safeParse({ id: "guides-2", ...fields, href: "" }).success).toBe(true) + }) + + it("requires an adjacent move and one of the five FAQ positions", () => { + for (const direction of [0, 2, -2, 0.5]) + expect(moveFreshmanCardInput.safeParse({ id: "featured-1", direction }).success).toBe(false) + for (const position of [-1, 5, 0.5]) + expect(selectFreshmanFaqInput.safeParse({ position, faqId: 1 }).success).toBe(false) + expect(selectFreshmanFaqInput.safeParse({ position: 0, faqId: 0 }).success).toBe(false) + }) +}) + +describe("Freshman authorization", () => { + it("allows website public-read and dashboard reads, excluding database authors from output", async () => { + const card = (id: string, group: string) => ({ id, group, ...fields }) + const output = { + resources: freshmanSlots.filter((slot) => slot.section === "resources").map((slot) => card(slot.id, slot.group)), + telegram: freshmanSlots.filter((slot) => slot.section === "telegram").map((slot) => card(slot.id, slot.group)), + faqs: Array.from({ length: 5 }, (_, index) => ({ + faqId: index + 1, + titleIt: "IT", + titleEn: "EN", + descriptionIt: "IT", + descriptionEn: "EN", + })), + } + db.transaction.mockResolvedValue({ ...output, createdBySub: "private" }) + expect(await freshman.createCaller(context(service, [], ["backend:public:read"])).getContent()).toEqual(output) + expect(await freshman.createCaller(context(editor, ["admin:access"])).getContent()).toEqual(output) + await expect(freshman.createCaller(context(service, [], ["backend:tg:read"])).getContent()).rejects.toMatchObject({ + code: "FORBIDDEN", + }) + await expect(freshman.createCaller(context(editor, [])).getContent()).rejects.toMatchObject({ code: "FORBIDDEN" }) + }) + + it.each([ + context(editor, ["admin:access"]), + context(service, [], ["backend:public:read"]), + context(editor, ["web:content:write"], []), + context(editor, ["web:content:write"], undefined, false), + ])("denies all mutations without the exact editor boundary", async (ctx) => { + const caller = freshman.createCaller(ctx) + await expect(caller.updateCard({ id: "featured-1", ...fields })).rejects.toMatchObject({ code: "FORBIDDEN" }) + await expect(caller.moveCard({ id: "featured-1", direction: 1 })).rejects.toMatchObject({ code: "FORBIDDEN" }) + await expect(caller.selectFaq({ position: 0, faqId: 1 })).rejects.toMatchObject({ code: "FORBIDDEN" }) + expect(db.update).not.toHaveBeenCalled() + expect(db.transaction).not.toHaveBeenCalled() + }) + + it("takes authorship from the token, ignoring a forged legacy author", async () => { + await freshman + .createCaller(context(editor, ["web:content:write"])) + .updateCard({ id: "featured-1", ...fields, modifiedBy: 999 }) + expect(db.set).toHaveBeenCalledWith({ ...fields, modifiedBy: null, modifiedBySub: "editor" }) + }) + + it("rejects crossing a website section before issuing any database write", async () => { + const caller = freshman.createCaller(context(editor, ["web:content:write"])) + await expect(caller.moveCard({ id: "featured-2", direction: 1 })).rejects.toMatchObject({ code: "BAD_REQUEST" }) + await expect(caller.moveCard({ id: "featured-1", direction: -1 })).rejects.toMatchObject({ code: "BAD_REQUEST" }) + expect(db.transaction).not.toHaveBeenCalled() + }) +}) diff --git a/tests/seed-db.test.ts b/tests/seed-db.test.ts index 630b809..066e9a1 100644 --- a/tests/seed-db.test.ts +++ b/tests/seed-db.test.ts @@ -110,28 +110,32 @@ describe.skipIf(!testPort)("database seed", () => { expect(await query("SELECT count(*)::int AS count FROM tg_messages")).toEqual([{ count: 1 }]) }, 30_000) - it.each([false, true])("reconciles FAQ categories with a matching category present: %s", async (matching) => { - await query(`INSERT INTO web_faq_categories (title_it, title_en, created_by_sub) - VALUES ('Custom', 'Custom', 'retained')`) - if (matching) { + it.each([false, true])( + "reconciles FAQ categories with a matching category present: %s", + async (matching) => { await query(`INSERT INTO web_faq_categories (title_it, title_en, created_by_sub) + VALUES ('Custom', 'Custom', 'retained')`) + if (matching) { + await query(`INSERT INTO web_faq_categories (title_it, title_en, created_by_sub) VALUES ('Gruppi', 'Existing groups', 'retained')`) - } + } - await seed() + await seed() - expect(await query("SELECT count(*)::int AS count FROM web_faqs")).toEqual([{ count: 9 }]) - expect(await query("SELECT count(*)::int AS count FROM web_faq_categories")).toEqual([{ count: 5 }]) - expect(await query("SELECT title_en FROM web_faq_categories WHERE title_it = 'Custom'")).toEqual([ - { title_en: "Custom" }, - ]) - if (matching) { - expect(await query("SELECT title_en FROM web_faq_categories WHERE title_it = 'Gruppi'")).toEqual([ - { title_en: "Existing groups" }, + expect(await query("SELECT count(*)::int AS count FROM web_faqs")).toEqual([{ count: 9 }]) + expect(await query("SELECT count(*)::int AS count FROM web_faq_categories")).toEqual([{ count: 5 }]) + expect(await query("SELECT title_en FROM web_faq_categories WHERE title_it = 'Custom'")).toEqual([ + { title_en: "Custom" }, ]) - expect(await query("SELECT count(*)::int AS count FROM web_faqs WHERE category_id = 2")).toEqual([{ count: 3 }]) - } - }, 30_000) + if (matching) { + expect(await query("SELECT title_en FROM web_faq_categories WHERE title_it = 'Gruppi'")).toEqual([ + { title_en: "Existing groups" }, + ]) + expect(await query("SELECT count(*)::int AS count FROM web_faqs WHERE category_id = 2")).toEqual([{ count: 3 }]) + } + }, + 30_000 + ) it("preserves samples on reruns and replaces populated sample tables with --force", async () => { await seed() From 9ccaea16bff1a7b500e95dbcb44486f2a8b14da4 Mon Sep 17 00:00:00 2001 From: Bianca Date: Tue, 20 Oct 2026 10:49:29 +0200 Subject: [PATCH 2/3] fix: update material links to use full URLs in freshman content --- drizzle/0026_freshman_content.sql | 2 +- src/routers/web/freshman/seed.ts | 2 +- tests/seed-db.test.ts | 38 ++++++++++++++----------------- 3 files changed, 19 insertions(+), 23 deletions(-) diff --git a/drizzle/0026_freshman_content.sql b/drizzle/0026_freshman_content.sql index 410cb0b..5b7caf9 100644 --- a/drizzle/0026_freshman_content.sql +++ b/drizzle/0026_freshman_content.sql @@ -43,7 +43,7 @@ INSERT INTO web_freshman_cards (id, title, description, icon, href, created_by_s ('guides-3', 'Corsi a Scelta', 'Consigli su come comporre il Piano di Studi.', 'book', '', 'system:freshman-migration'), ('guides-4', 'News', 'Le ultime novità dagli studenti del Politecnico.', 'news', '', 'system:freshman-migration'), ('guides-5', 'Borsa di Studio', 'Requisiti e scadenze per le agevolazioni.', 'money', '', 'system:freshman-migration'), -('materials-1', 'Materiali', 'Il più grande archivio didattico creato dagli studenti per gli studenti del Politecnico di Milano. Migliaia di appunti e temi d’esame.', 'folder', '/materials', 'system:freshman-migration'), +('materials-1', 'Materiali', 'Il più grande archivio didattico creato dagli studenti per gli studenti del Politecnico di Milano. Migliaia di appunti e temi d’esame.', 'folder', 'https://polinetwork.org/it/materials/view/', 'system:freshman-migration'), ('tools-1', 'Sync Orario', '', 'repeat', 'https://bebora.github.io/polimi-schedule-js/', 'system:freshman-migration'), ('tools-2', 'WeBeep Sync', '', 'refresh', 'https://github.com/toto04/webeep-sync', 'system:freshman-migration'), ('telegram-1', 'Generale', '', 'bell', 'https://t.me/PoliGruppo', 'system:freshman-migration'), diff --git a/src/routers/web/freshman/seed.ts b/src/routers/web/freshman/seed.ts index 0a657c4..aff0b23 100644 --- a/src/routers/web/freshman/seed.ts +++ b/src/routers/web/freshman/seed.ts @@ -55,7 +55,7 @@ export const defaultFreshmanCards = [ description: "Il più grande archivio didattico creato dagli studenti per gli studenti del Politecnico di Milano. Migliaia di appunti e temi d’esame.", icon: "folder", - href: "/materials", + href: "https://polinetwork.org/it/materials/view/", }, { id: "tools-1", diff --git a/tests/seed-db.test.ts b/tests/seed-db.test.ts index 066e9a1..630b809 100644 --- a/tests/seed-db.test.ts +++ b/tests/seed-db.test.ts @@ -110,32 +110,28 @@ describe.skipIf(!testPort)("database seed", () => { expect(await query("SELECT count(*)::int AS count FROM tg_messages")).toEqual([{ count: 1 }]) }, 30_000) - it.each([false, true])( - "reconciles FAQ categories with a matching category present: %s", - async (matching) => { - await query(`INSERT INTO web_faq_categories (title_it, title_en, created_by_sub) + it.each([false, true])("reconciles FAQ categories with a matching category present: %s", async (matching) => { + await query(`INSERT INTO web_faq_categories (title_it, title_en, created_by_sub) VALUES ('Custom', 'Custom', 'retained')`) - if (matching) { - await query(`INSERT INTO web_faq_categories (title_it, title_en, created_by_sub) + if (matching) { + await query(`INSERT INTO web_faq_categories (title_it, title_en, created_by_sub) VALUES ('Gruppi', 'Existing groups', 'retained')`) - } + } - await seed() + await seed() - expect(await query("SELECT count(*)::int AS count FROM web_faqs")).toEqual([{ count: 9 }]) - expect(await query("SELECT count(*)::int AS count FROM web_faq_categories")).toEqual([{ count: 5 }]) - expect(await query("SELECT title_en FROM web_faq_categories WHERE title_it = 'Custom'")).toEqual([ - { title_en: "Custom" }, + expect(await query("SELECT count(*)::int AS count FROM web_faqs")).toEqual([{ count: 9 }]) + expect(await query("SELECT count(*)::int AS count FROM web_faq_categories")).toEqual([{ count: 5 }]) + expect(await query("SELECT title_en FROM web_faq_categories WHERE title_it = 'Custom'")).toEqual([ + { title_en: "Custom" }, + ]) + if (matching) { + expect(await query("SELECT title_en FROM web_faq_categories WHERE title_it = 'Gruppi'")).toEqual([ + { title_en: "Existing groups" }, ]) - if (matching) { - expect(await query("SELECT title_en FROM web_faq_categories WHERE title_it = 'Gruppi'")).toEqual([ - { title_en: "Existing groups" }, - ]) - expect(await query("SELECT count(*)::int AS count FROM web_faqs WHERE category_id = 2")).toEqual([{ count: 3 }]) - } - }, - 30_000 - ) + expect(await query("SELECT count(*)::int AS count FROM web_faqs WHERE category_id = 2")).toEqual([{ count: 3 }]) + } + }, 30_000) it("preserves samples on reruns and replaces populated sample tables with --force", async () => { await seed() From 32a2692d95995322a4aa64b7cf7a4d3f21bf2e90 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Thu, 22 Oct 2026 12:22:07 +0200 Subject: [PATCH 3/3] fix(web): take freshman authorship from the IdP token only #88 removed the legacy author argument from authorOf, so freshman writes now use tokenOnly policies like the other web content mutations. The legacy modifiedBy input is still accepted and ignored. --- src/routers/web/freshman.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/routers/web/freshman.ts b/src/routers/web/freshman.ts index 406cc3f..ccb5c75 100644 --- a/src/routers/web/freshman.ts +++ b/src/routers/web/freshman.ts @@ -57,19 +57,19 @@ export default createTRPCRouter({ ) }), - updateCard: policy(dashboard("web:content:write")) + updateCard: policy(dashboard("web:content:write"), { tokenOnly: true }) .input(updateFreshmanCardInput) .mutation(async ({ input, ctx }) => { - const { id, modifiedBy, ...fields } = input + const { id, modifiedBy: _legacyAuthor, ...fields } = input const [updated] = await DB.update(CARDS) - .set({ ...fields, ...modifiedByColumns(authorOf(ctx.actor, modifiedBy)) }) + .set({ ...fields, ...modifiedByColumns(authorOf(ctx.actor)) }) .where(eq(CARDS.id, id)) .returning({ id: CARDS.id }) if (!updated) throw new TRPCError({ code: "NOT_FOUND", message: "The website slot is not initialized." }) return { error: null } }), - moveCard: policy(dashboard("web:content:write")) + moveCard: policy(dashboard("web:content:write"), { tokenOnly: true }) .input(moveFreshmanCardInput) .mutation(async ({ input, ctx }) => { const index = freshmanSlots.findIndex((slot) => slot.id === input.id) @@ -77,7 +77,7 @@ export default createTRPCRouter({ const next = freshmanSlots[index + input.direction] if (!next || next.group !== slot.group) throw new TRPCError({ code: "BAD_REQUEST", message: "Cards can only move within their website section." }) - const author = modifiedByColumns(authorOf(ctx.actor, input.modifiedBy)) + const author = modifiedByColumns(authorOf(ctx.actor)) await DB.transaction(async (tx) => { const rows = await tx .select() @@ -102,10 +102,10 @@ export default createTRPCRouter({ return { error: null } }), - selectFaq: policy(dashboard("web:content:write")) + selectFaq: policy(dashboard("web:content:write"), { tokenOnly: true }) .input(selectFreshmanFaqInput) .mutation(async ({ input, ctx }) => { - const author = modifiedByColumns(authorOf(ctx.actor, input.modifiedBy)) + const author = modifiedByColumns(authorOf(ctx.actor)) await DB.transaction(async (tx) => { // Serialize writes to the five slots before reading; public snapshot reads remain unblocked. // Catalog deletions take the same lock before checking whether a FAQ is selected.