diff --git a/README.md b/README.md index a2dcfc1..f054ed6 100644 --- a/README.md +++ b/README.md @@ -111,3 +111,11 @@ Emails (`src/emails/templates`, preview with `bun run email`): Signatures are checked against the CIE certification authorities of the Ministero dell'Interno bundled in `src/esign/trust`. Tests: `bunx vitest run tests/esign-*.test.ts` (validated with poppler `pdfsig` when installed). + +## Freshman content + +`web.freshman` exposes fixed-slot content for the website and authorized admin editors. Migration +`0026_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/0026_freshman_content.sql b/drizzle/0026_freshman_content.sql new file mode 100644 index 0000000..5b7caf9 --- /dev/null +++ b/drizzle/0026_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', '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'), +('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/0026_snapshot.json b/drizzle/meta/0026_snapshot.json new file mode 100644 index 0000000..97a921a --- /dev/null +++ b/drizzle/meta/0026_snapshot.json @@ -0,0 +1,3666 @@ +{ + "id": "4cd59385-7abd-4ec9-8509-a7aac8e688df", + "prevId": "33542854-3513-411e-af76-254bf84afbaa", + "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.esign_documents": { + "name": "esign_documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pages": { + "name": "pages", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by_sub": { + "name": "created_by_sub", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "esign_documents_request_version_idx": { + "name": "esign_documents_request_version_idx", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"esign_documents\".\"request_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "esign_documents_request_id_esign_requests_id_fk": { + "name": "esign_documents_request_id_esign_requests_id_fk", + "tableFrom": "esign_documents", + "tableTo": "esign_requests", + "columnsFrom": [ + "request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.esign_drive_access": { + "name": "esign_drive_access", + "schema": "", + "columns": { + "drive_id": { + "name": "drive_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "request_roles": { + "name": "request_roles", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "request_permissions": { + "name": "request_permissions", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "sign_roles": { + "name": "sign_roles", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "sign_permissions": { + "name": "sign_permissions", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "updated_by_sub": { + "name": "updated_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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.esign_events": { + "name": "esign_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "at": { + "name": "at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_sub": { + "name": "actor_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "esign_events_request_idx": { + "name": "esign_events_request_idx", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "esign_events_request_id_esign_requests_id_fk": { + "name": "esign_events_request_id_esign_requests_id_fk", + "tableFrom": "esign_events", + "tableTo": "esign_requests", + "columnsFrom": [ + "request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.esign_jobs": { + "name": "esign_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ticket_hash": { + "name": "ticket_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "signer_id": { + "name": "signer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sub": { + "name": "sub", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "base_version": { + "name": "base_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "places": { + "name": "places", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "step": { + "name": "step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "prepared": { + "name": "prepared", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "prepared_pdf": { + "name": "prepared_pdf", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "seen_at": { + "name": "seen_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp (3) with time zone", + "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": { + "esign_jobs_request_idx": { + "name": "esign_jobs_request_idx", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "esign_jobs_request_id_esign_requests_id_fk": { + "name": "esign_jobs_request_id_esign_requests_id_fk", + "tableFrom": "esign_jobs", + "tableTo": "esign_requests", + "columnsFrom": [ + "request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "esign_jobs_signer_id_esign_signers_id_fk": { + "name": "esign_jobs_signer_id_esign_signers_id_fk", + "tableFrom": "esign_jobs", + "tableTo": "esign_signers", + "columnsFrom": [ + "signer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "esign_jobs_ticket_hash_unique": { + "name": "esign_jobs_ticket_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "ticket_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.esign_profiles": { + "name": "esign_profiles", + "schema": "", + "columns": { + "sub": { + "name": "sub", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fiscal_code": { + "name": "fiscal_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "certificate_subject": { + "name": "certificate_subject", + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.esign_requests": { + "name": "esign_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'in_progress'" + }, + "ordered": { + "name": "ordered", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "delivery": { + "name": "delivery", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "drives": { + "name": "drives", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "created_by_sub": { + "name": "created_by_sub", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp (3) with time zone", + "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": { + "esign_requests_status_idx": { + "name": "esign_requests_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "esign_requests_created_by_idx": { + "name": "esign_requests_created_by_idx", + "columns": [ + { + "expression": "created_by_sub", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.esign_signers": { + "name": "esign_signers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sub": { + "name": "sub", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "places": { + "name": "places", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "signatures_done": { + "name": "signatures_done", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "signed_at": { + "name": "signed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "fiscal_code": { + "name": "fiscal_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "certificate_subject": { + "name": "certificate_subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "certificate_issuer": { + "name": "certificate_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decline_reason": { + "name": "decline_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": { + "esign_signers_request_idx": { + "name": "esign_signers_request_idx", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "esign_signers_sub_idx": { + "name": "esign_signers_sub_idx", + "columns": [ + { + "expression": "sub", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "esign_signers_request_position_idx": { + "name": "esign_signers_request_position_idx", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "esign_signers_request_id_esign_requests_id_fk": { + "name": "esign_signers_request_id_esign_requests_id_fk", + "tableFrom": "esign_signers", + "tableTo": "esign_requests", + "columnsFrom": [ + "request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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 3f0d13e..b98f585 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -183,6 +183,13 @@ "when": 1792237528329, "tag": "0025_esign_drive_access", "breakpoints": true + }, + { + "idx": 26, + "version": "7", + "when": 1792449139688, + "tag": "0026_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 a680016..f6b99cd 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) @@ -167,10 +168,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")) @@ -181,9 +192,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..ccb5c75 --- /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"), { tokenOnly: true }) + .input(updateFreshmanCardInput) + .mutation(async ({ input, ctx }) => { + const { id, modifiedBy: _legacyAuthor, ...fields } = input + const [updated] = await DB.update(CARDS) + .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"), { tokenOnly: true }) + .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)) + 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"), { tokenOnly: true }) + .input(selectFreshmanFaqInput) + .mutation(async ({ input, ctx }) => { + 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. + 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..aff0b23 --- /dev/null +++ b/src/routers/web/freshman/seed.ts @@ -0,0 +1,130 @@ +/** Initial website cards for the explicit development seed, matching migration 0026. */ +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: "https://polinetwork.org/it/materials/view/", + }, + { + 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..f072ba4 --- /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 !== "0026_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() + }) +})