diff --git a/programmerbar-web/migrations/0029_dear_mattie_franklin.sql b/programmerbar-web/migrations/0029_dear_mattie_franklin.sql new file mode 100644 index 00000000..e812fe1a --- /dev/null +++ b/programmerbar-web/migrations/0029_dear_mattie_franklin.sql @@ -0,0 +1,56 @@ +CREATE TABLE `board_member` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `category` text NOT NULL, + `membership` text, + `roles` text NOT NULL, + `sort_order` integer DEFAULT 0 NOT NULL, + `revision` integer DEFAULT 1 NOT NULL +); + +--> statement-breakpoint +-- Preserve the existing public history when moving it into D1. +INSERT INTO board_member (id, name, category, membership, roles, sort_order) VALUES +('board-history-001', 'Brigt A.T Håvardstun', 'founder', NULL, '[{"title":"Co-founder"}]', 0), +('board-history-002', 'Sandra Lekve', 'founder', NULL, '[{"title":"Co-founder"}]', 1), +('board-history-003', 'Vegard Jensløkken', 'founder', NULL, '[{"title":"Co-founder"}]', 2), +('board-history-004', 'Eivind D. Halderaker', 'founder', NULL, '[{"title":"Co-founder"}]', 3), +('board-history-005', 'Erik Fjelltveit Nyhuus', 'current', NULL, '[{"title":"Leder"}]', 4), +('board-history-006', 'Steffen Andre Pettersen', 'current', NULL, '[{"title":"Sosialansvarlig"}]', 5), +('board-history-007', 'Siren Bjorøy', 'current', NULL, '[{"title":"Bookingansvarlig"}]', 6), +('board-history-008', 'Ole Straumland', 'current', NULL, '[{"title":"Frivilligansvarlig"}]', 7), +('board-history-009', 'August Ebne Røeggen', 'current', NULL, '[{"title":"Nestleder"}]', 8), +('board-history-010', 'Sturla Rognskog Mella', 'current', '{"start":"Høst 2025"}', '[{"title":"Frivilligansvarlig og webansvarlig","period":{"start":"Vår 2026"}},{"title":"Assistent for frivilligansvarlig","period":{"start":"Høst 2025","end":"Høst 2025"}}]', 9), +('board-history-011', 'Tord Vikøren Vikestad', 'current', '{"start":"Høst 2025"}', '[{"title":"Bygg/admin","period":{"start":"Høst 2025"}}]', 10), +('board-history-012', 'Henrik Sætre Breivik', 'current', '{"start":"Høst 2025"}', '[{"title":"Quiz- og arrangementsansvarlig","period":{"start":"Høst 2025"}}]', 11), +('board-history-013', 'Anna Sviland', 'current', '{"start":"Høst 2026"}', '[]', 12), +('board-history-014', 'Anna Valencia Fari', 'current', '{"start":"Høst 2026"}', '[]', 13), +('board-history-015', 'Oddmund Gullbrå Steinsland', 'current', '{"start":"Høst 2026"}', '[]', 14), +('board-history-016', 'Maya Shantseva', 'current', '{"start":"Høst 2026"}', '[]', 15), +('board-history-017', 'Torjus Berntsen', 'current', '{"start":"Høst 2026"}', '[]', 16), +('board-history-018', 'Mina Tolfsen', 'past', NULL, '[]', 17), +('board-history-019', 'Ask Rud Persson', 'past', NULL, '[]', 18), +('board-history-020', 'Palma Rud Persson', 'past', NULL, '[]', 19), +('board-history-021', 'Sigurd Johnsen Setså', 'past', NULL, '[]', 20), +('board-history-022', 'Erlend Raa Vågset', 'past', NULL, '[]', 21), +('board-history-023', 'Sofia Hestenes Eika', 'past', NULL, '[]', 22), +('board-history-024', 'Kristoffer Borg Nilsen', 'past', NULL, '[]', 23), +('board-history-025', 'Lars Lismoen', 'past', NULL, '[]', 24), +('board-history-026', 'Yoeri Otten', 'past', NULL, '[]', 25), +('board-history-027', 'Eirik Øygard', 'past', NULL, '[]', 26), +('board-history-028', 'Arne Natskår', 'past', NULL, '[]', 27), +('board-history-029', 'Alexander Alf Iversen', 'past', NULL, '[]', 28), +('board-history-030', 'Henrik Trondseth', 'past', NULL, '[]', 29), +('board-history-031', 'Emil Johannessen', 'past', NULL, '[]', 30), +('board-history-032', 'Andre Normann', 'past', NULL, '[]', 31), +('board-history-033', 'Sofie Nhu Nguyen', 'past', NULL, '[]', 32), +('board-history-034', 'Simen Hauge Østbø', 'past', NULL, '[]', 33), +('board-history-035', 'Stian Munkejord', 'past', NULL, '[]', 34), +('board-history-036', 'Gard Heine Kalland', 'past', NULL, '[]', 35), +('board-history-037', 'Lars Bysheim', 'past', NULL, '[]', 36), +('board-history-038', 'Lars Haukland', 'past', NULL, '[]', 37), +('board-history-039', 'Eirik Rekve Thorsheim', 'past', NULL, '[]', 38), +('board-history-040', 'Tony Bao Lam', 'past', '{"end":"Vår 2026"}', '[{"title":"Innkjøp","period":{"end":"Vår 2026"}}]', 39), +('board-history-041', 'Lene Soltveit', 'past', '{"end":"Vår 2026"}', '[{"title":"Økonomiansvarlig","period":{"end":"Vår 2026"}}]', 40), +('board-history-042', 'Ole Magnus Fon Johnsen', 'past', '{"end":"Vår 2026"}', '[{"title":"Webansvarlig","period":{"end":"Vår 2026"}}]', 41), +('board-history-043', 'Fredrik Hast Sørli', 'past', '{"start":"Høst 2025","end":"Vår 2026"}', '[{"title":"Økonomiassistent","period":{"start":"Høst 2025","end":"Vår 2026"}}]', 42); diff --git a/programmerbar-web/migrations/meta/0029_snapshot.json b/programmerbar-web/migrations/meta/0029_snapshot.json new file mode 100644 index 00000000..11930225 --- /dev/null +++ b/programmerbar-web/migrations/meta/0029_snapshot.json @@ -0,0 +1,1208 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "7cf34021-def4-4271-a8d3-ee7ef8f93136", + "prevId": "e982dbc1-a960-4bbf-ad49-b48bdc24d6d8", + "tables": { + "claimed_credits": { + "name": "claimed_credits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credit_cost": { + "name": "credit_cost", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "claimed_credits_user_id_user_id_fk": { + "name": "claimed_credits_user_id_user_id_fk", + "tableFrom": "claimed_credits", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "board_member": { + "name": "board_member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "membership": { + "name": "membership", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "roles": { + "name": "roles", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "contact_submission": { + "name": "contact_submission", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "submitted_at": { + "name": "submitted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event": { + "name": "event", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "event_slug_idx": { + "name": "event_slug_idx", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification": { + "name": "notification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_notifications_user_id": { + "name": "idx_notifications_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_notifications_archived_at": { + "name": "idx_notifications_archived_at", + "columns": [ + "archived_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notification_user_id_user_id_fk": { + "name": "notification_user_id_user_id_fk", + "tableFrom": "notification", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_subscription": { + "name": "push_subscription", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "push_subscription_endpoint_unique": { + "name": "push_subscription_endpoint_unique", + "columns": [ + "endpoint" + ], + "isUnique": true + }, + "idx_push_subscriptions_user_id": { + "name": "idx_push_subscriptions_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "push_subscription_user_id_user_id_fk": { + "name": "push_subscription_user_id_user_id_fk", + "tableFrom": "push_subscription", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shift": { + "name": "shift", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_at": { + "name": "start_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_at": { + "name": "end_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "shift_event_id_event_id_fk": { + "name": "shift_event_id_event_id_fk", + "tableFrom": "shift", + "tableTo": "event", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_shift": { + "name": "user_shift", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shift_id": { + "name": "shift_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_beer_claimed": { + "name": "is_beer_claimed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'accepted'" + } + }, + "indexes": { + "user_shift_user_id_shift_id_unique": { + "name": "user_shift_user_id_shift_id_unique", + "columns": [ + "user_id", + "shift_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_shift_user_id_user_id_fk": { + "name": "user_shift_user_id_user_id_fk", + "tableFrom": "user_shift", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_shift_shift_id_shift_id_fk": { + "name": "user_shift_shift_id_shift_id_fk", + "tableFrom": "user_shift", + "tableTo": "shift", + "columnsFrom": [ + "shift_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "feide_id": { + "name": "feide_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'normal'" + }, + "additional_beers": { + "name": "additional_beers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "alt_email": { + "name": "alt_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_deleted": { + "name": "is_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_trained": { + "name": "is_trained", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "can_refer": { + "name": "can_refer", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "email_idx": { + "name": "email_idx", + "columns": [ + "email" + ], + "isUnique": true + }, + "feide_id_idx": { + "name": "feide_id_idx", + "columns": [ + "feide_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "producers": { + "name": "producers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image_id": { + "name": "image_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "product_types": { + "name": "product_types", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "products": { + "name": "products", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sku": { + "name": "sku", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_sold_out": { + "name": "is_sold_out", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ordinary_price": { + "name": "ordinary_price", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "student_price": { + "name": "student_price", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "internal_price": { + "name": "internal_price", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credits": { + "name": "credits", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "volume": { + "name": "volume", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alcohol_content": { + "name": "alcohol_content", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "variants": { + "name": "variants", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_id": { + "name": "image_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "producer_id": { + "name": "producer_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "products_producer_id_producers_id_fk": { + "name": "products_producer_id_producers_id_fk", + "tableFrom": "products", + "tableTo": "producers", + "columnsFrom": [ + "producer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "product_product_types": { + "name": "product_product_types", + "columns": { + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "product_type_id": { + "name": "product_type_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "product_product_types_product_id_products_id_fk": { + "name": "product_product_types_product_id_products_id_fk", + "tableFrom": "product_product_types", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "product_product_types_product_type_id_product_types_id_fk": { + "name": "product_product_types_product_type_id_product_types_id_fk", + "tableFrom": "product_product_types", + "tableTo": "product_types", + "columnsFrom": [ + "product_type_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "images": { + "name": "images", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "referral": { + "name": "referral", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "referred_by": { + "name": "referred_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "referred": { + "name": "referred", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "referral_referred_by_user_id_fk": { + "name": "referral_referred_by_user_id_fk", + "tableFrom": "referral", + "tableTo": "user", + "columnsFrom": [ + "referred_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "referral_referred_user_id_fk": { + "name": "referral_referred_user_id_fk", + "tableFrom": "referral", + "tableTo": "user", + "columnsFrom": [ + "referred" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pending_application": { + "name": "pending_application", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "feide_id": { + "name": "feide_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "pending_application_email_idx": { + "name": "pending_application_email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "pending_application_feide_id_idx": { + "name": "pending_application_feide_id_idx", + "columns": [ + "feide_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/programmerbar-web/migrations/meta/_journal.json b/programmerbar-web/migrations/meta/_journal.json index 76f030a8..dceb5387 100644 --- a/programmerbar-web/migrations/meta/_journal.json +++ b/programmerbar-web/migrations/meta/_journal.json @@ -190,6 +190,13 @@ "when": 1789595596194, "tag": "0026_thankful_kylun", "breakpoints": true + }, + { + "idx": 29, + "version": "6", + "when": 1789753789061, + "tag": "0029_dear_mattie_franklin", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/programmerbar-web/src/app.d.ts b/programmerbar-web/src/app.d.ts index a8617d95..48f71129 100644 --- a/programmerbar-web/src/app.d.ts +++ b/programmerbar-web/src/app.d.ts @@ -19,6 +19,7 @@ declare global { feideProvider: import('$lib/server/auth/feide').FeideProvider; // Serivces + boardHistoryService: import('$lib/server/services/board-history.service').BoardHistoryService; emailService: import('$lib/server/services/email.service').EmailService; statusService: import('$lib/server/services/status.service').StatusService; rateLimitService: import('$lib/server/services/rate-limit.service').RateLimitService; diff --git a/programmerbar-web/src/hooks.server.ts b/programmerbar-web/src/hooks.server.ts index dfe34533..16cfb011 100644 --- a/programmerbar-web/src/hooks.server.ts +++ b/programmerbar-web/src/hooks.server.ts @@ -1,4 +1,5 @@ import { createAuth } from '$lib/server/auth/lucia'; +import { BoardHistoryService } from '$lib/server/services/board-history.service'; import { FeideProvider } from '$lib/server/auth/feide'; import { createDatabase } from '$lib/server/db/drizzle'; import { BanService } from '$lib/server/services/ban.service'; @@ -50,6 +51,7 @@ const setup: Handle = async ({ event, resolve }) => { // Setup database const db = createDatabase(DB); event.locals.db = db; + event.locals.boardHistoryService = new BoardHistoryService(db); // Setup auth const auth = createAuth(db); diff --git a/programmerbar-web/src/lib/board-history.test.ts b/programmerbar-web/src/lib/board-history.test.ts new file mode 100644 index 00000000..c838043e --- /dev/null +++ b/programmerbar-web/src/lib/board-history.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; +import { + boardMemberSchema, + getBoardYears, + overlapsYear, + sortByFirstMembership, + visibleRoles, + type BoardMemberInput +} from './board-history'; + +describe('board history display', () => { + const member: BoardMemberInput = { + name: 'Test', + category: 'current', + roles: [ + { title: 'Assistent', period: { start: 'Høst 2025', end: 'Høst 2025' } }, + { title: 'Ansvarlig', period: { start: 'Vår 2026' } }, + { title: 'Fremtidig verv', period: { start: 'Vår 2027' } } + ] + }; + it('puts unknown dates first then sorts by the earliest known start without mutating input', () => { + const list: BoardMemberInput[] = [ + { name: 'Ukjent', category: 'past', roles: [], membership: { end: 'Vår 2025' } }, + { name: 'Ny', category: 'current', roles: [], membership: { start: 'Høst 2026' } }, + { ...member, membership: { start: 'Høst 2026' } } + ]; + expect(sortByFirstMembership(list).map((entry) => entry.name)).toEqual([ + 'Ukjent', + 'Test', + 'Ny' + ]); + expect(list[0].name).toBe('Ukjent'); + }); + it('shows only active roles by default and expands the full history on request', () => { + const now = new Date('2026-09-19T12:00:00Z'); + expect(visibleRoles(member, 'all', false, now).map((role) => role.title)).toEqual([ + 'Ansvarlig' + ]); + expect(visibleRoles(member, 'all', true, now)).toEqual(member.roles); + expect(visibleRoles({ ...member, category: 'past' }, 'all', false, now)).toEqual(member.roles); + }); + it('keeps year filtering independent of the current-role display', () => { + expect(visibleRoles(member, '2025', false).map((role) => role.title)).toEqual([ + 'Assistent', + 'Ansvarlig' + ]); + expect(visibleRoles(member, '2026', true).map((role) => role.title)).toEqual([ + 'Ansvarlig', + 'Fremtidig verv' + ]); + }); +}); + +describe('board history validation and periods', () => { + it('allows unknown dates and overlapping roles', () => { + expect( + boardMemberSchema.safeParse({ name: 'Medlem', category: 'past', roles: [] }).success + ).toBe(true); + expect( + boardMemberSchema.safeParse({ + name: 'Medlem', + category: 'current', + roles: [ + { title: 'Leder', period: { start: 'Høst 2025' } }, + { title: 'Webansvarlig', period: { start: 'Høst 2025' } } + ] + }).success + ).toBe(true); + }); + + it('rejects invalid semesters, backwards periods and current membership with an end', () => { + for (const membership of [ + { start: 'Sommer 2025' }, + { start: 'Høst 2026', end: 'Vår 2026' }, + { start: 'Høst 20250' } + ]) + expect( + boardMemberSchema.safeParse({ name: 'Medlem', category: 'past', membership, roles: [] }) + .success + ).toBe(false); + expect( + boardMemberSchema.safeParse({ + name: 'Medlem', + category: 'current', + membership: { end: 'Vår 2026' }, + roles: [] + }).success + ).toBe(false); + expect( + boardMemberSchema.safeParse({ + name: 'Medlem', + category: 'past', + roles: [{ title: 'Leder', period: { start: 'Høst 2026', end: 'Vår 2025' } }] + }).success + ).toBe(false); + }); + + it('filters completed roles and does not invent unknown membership boundaries', () => { + expect(overlapsYear({ start: 'Høst 2025', end: 'Vår 2026' }, 2025, false)).toBe(true); + expect(overlapsYear({ start: 'Høst 2025', end: 'Vår 2026' }, 2026, false)).toBe(false); + expect(overlapsYear({ start: 'Høst 2025' }, 2026, true)).toBe(true); + expect(overlapsYear({ start: 'Høst 2025' }, 2026, false)).toBe(false); + expect(overlapsYear({ end: 'Vår 2026' }, 2024, false)).toBe(false); + expect(overlapsYear(undefined, 2025, true)).toBe(false); + }); + + it('adds future board years when dates are registered', () => { + expect( + getBoardYears([ + { + name: 'Medlem', + category: 'past', + membership: { start: 'Vår 2026', end: 'Høst 2027' }, + roles: [] + } + ]) + ).toEqual([2027, 2026, 2025]); + expect(getBoardYears([])).toEqual([]); + }); +}); diff --git a/programmerbar-web/src/lib/board-history.ts b/programmerbar-web/src/lib/board-history.ts new file mode 100644 index 00000000..b9518d02 --- /dev/null +++ b/programmerbar-web/src/lib/board-history.ts @@ -0,0 +1,109 @@ +import { z } from 'zod'; + +export type Semester = `Vår ${number}` | `Høst ${number}`; + +export function semesterIndex(semester: Semester): number { + const [season, year] = semester.split(' '); + return Number(year) * 2 + (season === 'Høst' ? 1 : 0); +} + +const semesterSchema = z + .string() + .regex(/^(Vår|Høst) (19|20|21)\d{2}$/, 'Bruk for eksempel Høst 2025 eller Vår 2026.') + .transform((value) => value as Semester); + +const periodSchema = z + .object({ + start: semesterSchema.optional(), + end: semesterSchema.optional() + }) + .refine( + (period) => + !period.start || !period.end || semesterIndex(period.start) <= semesterIndex(period.end), + { + message: 'Sluttsemester kan ikke være før startsemester.' + } + ); + +export const boardMemberSchema = z + .object({ + name: z.string().trim().min(1, 'Skriv inn et navn.').max(150), + category: z.enum(['current', 'past', 'founder']), + membership: periodSchema.optional(), + roles: z + .array( + z.object({ + title: z.string().trim().min(1, 'Skriv inn navnet på vervet.').max(150), + period: periodSchema.optional() + }) + ) + .max(30) + }) + .refine((member) => member.category !== 'current' || !member.membership?.end, { + message: 'Velg tidligere medlem når medlemsperioden har et sluttsemester.' + }); + +export type BoardMemberInput = z.infer; +export type Period = NonNullable; +export type BoardMember = BoardMemberInput & { id: string; revision: number; sortOrder: number }; + +export function sortByFirstMembership(members: T[]): T[] { + function firstStart(member: T): number { + const starts = [member.membership?.start, ...member.roles.map((role) => role.period?.start)] + .filter((start): start is Semester => !!start) + .map(semesterIndex); + return starts.length ? Math.min(...starts) : -Infinity; + } + // Stable sorting preserves the existing order for equal or unknown start dates. + return [...members].sort((a, b) => firstStart(a) - firstStart(b)); +} + +export function visibleRoles( + member: BoardMemberInput, + year: string, + history: boolean, + now = new Date() +): BoardMemberInput['roles'] { + if (year !== 'all') + return member.roles.filter((role) => + overlapsYear(role.period, Number(year), member.category === 'current') + ); + if (member.category !== 'current' || history) return member.roles; + const semester = now.getUTCFullYear() * 2 + (now.getUTCMonth() >= 6 ? 1 : 0); + return member.roles.filter( + (role) => + (!role.period?.start || semesterIndex(role.period.start) <= semester) && + (!role.period?.end || semesterIndex(role.period.end) >= semester) + ); +} + +export function formatPeriod(start?: string, end?: string, current = false): string { + if (start && end) return start === end ? start : `${start} – ${end.toLowerCase()}`; + if (end) return `Til og med ${end.toLowerCase()}`; + if (start) return current ? `${start} – nå` : `Fra ${start.toLowerCase()}`; + return ''; +} + +export function overlapsYear(period: Period | undefined, year: number, current: boolean): boolean { + if (!period?.start && !period?.end) return false; + // Unknown boundaries must not imply membership in earlier/later years. + const start = semesterIndex((period.start ?? period.end)!); + const end = period.end ? semesterIndex(period.end) : current ? Infinity : start; + return start <= (year + 1) * 2 && end >= year * 2 + 1; +} + +export function getBoardYears(members: BoardMemberInput[]): number[] { + const years = members.flatMap((member) => + [member.membership, ...member.roles.map((role) => role.period)].flatMap((period) => + [period?.start, period?.end].flatMap((semester) => + semester ? [Math.floor((semesterIndex(semester) - 1) / 2)] : [] + ) + ) + ); + return years.length + ? Array.from( + { length: Math.max(...years) - Math.min(...years) + 1 }, + (_, index) => Math.max(...years) - index + ) + : []; +} diff --git a/programmerbar-web/src/lib/components/app/BoardMemberEditor.svelte b/programmerbar-web/src/lib/components/app/BoardMemberEditor.svelte new file mode 100644 index 00000000..659ae739 --- /dev/null +++ b/programmerbar-web/src/lib/components/app/BoardMemberEditor.svelte @@ -0,0 +1,214 @@ + + +
+

{member ? 'Rediger medlem' : 'Legg til medlem'}

+

+ La ukjente semestre stå tomme. Bruk «Høst 2025» eller «Vår 2026». Endringene blir synlige for + alle når du lagrer. +

+ {#if message}{/if} +
+ + + +
+ + +
+ Medlemsperiode i styret +
+ + +
+
+
+

Verv og perioder

+

+ Registrer en periode på hvert verv for å vise det i riktig styreår. Du kan legge til flere + verv, også med overlappende perioder. +

+ {#each roles as role, index (role.key)} +
+ Verv {index + 1} + +
+ + +
+ +
+ {/each} + +
+
+ + +
+
+
+ {#if member} + {#if confirmDelete} +
+ + +

Vil du slette {member.name} og alle vervene deres fra historikken?

+
+ + +
+
+ {:else} + + {/if} + {/if} +
diff --git a/programmerbar-web/src/lib/server/db/schemas/board-members.ts b/programmerbar-web/src/lib/server/db/schemas/board-members.ts new file mode 100644 index 00000000..0e21a65d --- /dev/null +++ b/programmerbar-web/src/lib/server/db/schemas/board-members.ts @@ -0,0 +1,12 @@ +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; +import type { BoardMemberInput, Period } from '../../../board-history'; + +export const boardMembers = sqliteTable('board_member', { + id: text().primaryKey().notNull(), + name: text().notNull(), + category: text({ enum: ['current', 'past', 'founder'] }).notNull(), + membership: text({ mode: 'json' }).$type(), + roles: text({ mode: 'json' }).$type().notNull(), + sortOrder: integer().notNull().default(0), + revision: integer().notNull().default(1) +}); diff --git a/programmerbar-web/src/lib/server/db/schemas/index.ts b/programmerbar-web/src/lib/server/db/schemas/index.ts index e84f8a07..dd5c632f 100644 --- a/programmerbar-web/src/lib/server/db/schemas/index.ts +++ b/programmerbar-web/src/lib/server/db/schemas/index.ts @@ -1,4 +1,5 @@ export * from './claimed-credit'; +export * from './board-members'; export * from './contact-submissions'; export * from './events'; export * from './invitations'; diff --git a/programmerbar-web/src/lib/server/services/board-history.service.test.ts b/programmerbar-web/src/lib/server/services/board-history.service.test.ts new file mode 100644 index 00000000..0d62147f --- /dev/null +++ b/programmerbar-web/src/lib/server/services/board-history.service.test.ts @@ -0,0 +1,86 @@ +import { readFileSync } from 'node:fs'; +import { DatabaseSync } from 'node:sqlite'; +import { drizzle } from 'drizzle-orm/sqlite-proxy'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { boardMemberSchema } from '../../board-history'; +import type { Database } from '../db/drizzle'; +import { BoardHistoryService } from './board-history.service'; + +describe('board history persistence', () => { + let sqlite: DatabaseSync; + let service: BoardHistoryService; + beforeEach(() => { + sqlite = new DatabaseSync(':memory:'); + sqlite.exec( + readFileSync( + new URL('../../../../migrations/0029_dear_mattie_franklin.sql', import.meta.url), + 'utf8' + ) + ); + const db = drizzle( + async (query, params) => { + const statement = sqlite.prepare(query); + statement.setReturnArrays(true); + return { rows: statement.all(...params) }; + }, + undefined, + { casing: 'snake_case' } + ); + service = new BoardHistoryService(db as unknown as Database); + }); + afterEach(() => sqlite.close()); + + it('migrates all 43 existing people, categories and role history', async () => { + const members = await service.getAll(); + expect(members).toHaveLength(43); + expect(members.filter((member) => member.category === 'founder')).toHaveLength(4); + expect(members.filter((member) => member.category === 'current')).toHaveLength(13); + for (const member of members) expect(boardMemberSchema.safeParse(member).success).toBe(true); + expect(members.find((member) => member.name === 'Sturla Rognskog Mella')?.roles).toHaveLength( + 2 + ); + expect(members.find((member) => member.name === 'Fredrik Hast Sørli')?.membership).toEqual({ + start: 'Høst 2025', + end: 'Vår 2026' + }); + }); + + it('creates, moves and clears a member without changing other people', async () => { + const before = await service.getAll(); + const id = await service.create({ + name: 'Testperson', + category: 'current', + membership: { start: 'Høst 2026' }, + roles: [{ title: 'Leder', period: { start: 'Høst 2026' } }] + }); + expect((await service.getAll()).find((member) => member.id === id)?.roles[0].title).toBe( + 'Leder' + ); + expect(await service.update(id, 1, { name: 'Nytt navn', category: 'past', roles: [] })).toBe( + true + ); + const after = await service.getAll(); + expect(after.find((member) => member.id === id)).toMatchObject({ + name: 'Nytt navn', + category: 'past', + revision: 2, + roles: [], + membership: undefined + }); + expect(after.filter((member) => member.id !== id)).toEqual(before); + }); + + it('rejects stale updates/deletes and deletes only the chosen entry', async () => { + const id = await service.create({ name: 'Testperson', category: 'current', roles: [] }); + expect(await service.update(id, 1, { name: 'Oppdatert', category: 'current', roles: [] })).toBe( + true + ); + expect(await service.update(id, 1, { name: 'Utdatert', category: 'past', roles: [] })).toBe( + false + ); + expect(await service.delete(id, 1)).toBe(false); + expect(await service.delete(id, 2)).toBe(true); + expect(await service.delete(id, 2)).toBe(false); + expect(await service.getAll()).toHaveLength(43); + }); +}); diff --git a/programmerbar-web/src/lib/server/services/board-history.service.ts b/programmerbar-web/src/lib/server/services/board-history.service.ts new file mode 100644 index 00000000..5304b4da --- /dev/null +++ b/programmerbar-web/src/lib/server/services/board-history.service.ts @@ -0,0 +1,54 @@ +import { and, asc, eq, max, sql } from 'drizzle-orm'; +import { nanoid } from 'nanoid'; +import type { BoardMember, BoardMemberInput } from '../../board-history'; +import type { Database } from '../db/drizzle'; +import { boardMembers } from '../db/schemas/board-members'; + +export class BoardHistoryService { + #db: Database; + + constructor(db: Database) { + this.#db = db; + } + + async getAll(): Promise { + const rows = await this.#db + .select() + .from(boardMembers) + .orderBy(asc(boardMembers.sortOrder), asc(boardMembers.id)); + return rows.map((row) => ({ ...row, membership: row.membership ?? undefined })); + } + + async create(input: BoardMemberInput): Promise { + const [last] = await this.#db.select({ order: max(boardMembers.sortOrder) }).from(boardMembers); + const id = nanoid(); + await this.#db.insert(boardMembers).values({ + ...input, + id, + membership: input.membership ?? null, + sortOrder: (last.order ?? -1) + 1 + }); + return id; + } + + async update(id: string, revision: number, input: BoardMemberInput): Promise { + const rows = await this.#db + .update(boardMembers) + .set({ + ...input, + membership: input.membership ?? null, + revision: sql`${boardMembers.revision} + 1` + }) + .where(and(eq(boardMembers.id, id), eq(boardMembers.revision, revision))) + .returning({ id: boardMembers.id }); + return rows.length > 0; + } + + async delete(id: string, revision: number): Promise { + const rows = await this.#db + .delete(boardMembers) + .where(and(eq(boardMembers.id, id), eq(boardMembers.revision, revision))) + .returning({ id: boardMembers.id }); + return rows.length > 0; + } +} diff --git a/programmerbar-web/src/routes/(app)/emeritus/+page.server.ts b/programmerbar-web/src/routes/(app)/emeritus/+page.server.ts new file mode 100644 index 00000000..e1f5d15f --- /dev/null +++ b/programmerbar-web/src/routes/(app)/emeritus/+page.server.ts @@ -0,0 +1,62 @@ +import { error, fail } from '@sveltejs/kit'; +import { z } from 'zod'; +import { boardMemberSchema } from '../../../lib/board-history'; +import type { Actions, PageServerLoad } from './$types'; + +const identitySchema = z.object({ + id: z.string().min(1).max(100), + revision: z.coerce.number().int().positive() +}); +const conflict = + 'Oppføringen er endret eller slettet av noen andre. Last siden på nytt før du prøver igjen.'; + +export const load: PageServerLoad = async ({ locals }) => ({ + boardMembers: await locals.boardHistoryService.getAll(), + canEdit: locals.user?.role === 'board' +}); + +export const actions: Actions = { + save: async ({ locals, request }) => { + if (locals.user?.role !== 'board') error(403, 'Bare styret kan redigere styrehistorikken.'); + const form = await request.formData(); + const raw = form.get('member'); + if (typeof raw !== 'string' || raw.length > 30000) + return fail(400, { message: 'Ugyldig skjema.' }); + let input: unknown; + try { + input = JSON.parse(raw); + } catch { + return fail(400, { message: 'Ugyldig skjema.' }); + } + const parsed = boardMemberSchema.safeParse(input); + if (!parsed.success) return fail(400, { message: parsed.error.issues[0].message }); + const id = form.get('id'); + if (id) { + const identity = identitySchema.safeParse({ id, revision: form.get('revision') }); + if (!identity.success) return fail(400, { message: 'Ugyldig oppføring.' }); + if ( + !(await locals.boardHistoryService.update( + identity.data.id, + identity.data.revision, + parsed.data + )) + ) + return fail(409, { message: conflict }); + } else { + await locals.boardHistoryService.create(parsed.data); + } + return { success: true }; + }, + delete: async ({ locals, request }) => { + if (locals.user?.role !== 'board') error(403, 'Bare styret kan redigere styrehistorikken.'); + const form = await request.formData(); + const identity = identitySchema.safeParse({ + id: form.get('id'), + revision: form.get('revision') + }); + if (!identity.success) return fail(400, { message: 'Ugyldig oppføring.' }); + if (!(await locals.boardHistoryService.delete(identity.data.id, identity.data.revision))) + return fail(409, { message: conflict }); + return { success: true }; + } +}; diff --git a/programmerbar-web/src/routes/(app)/emeritus/+page.svelte b/programmerbar-web/src/routes/(app)/emeritus/+page.svelte index a3e30ea7..26a5bfa9 100644 --- a/programmerbar-web/src/routes/(app)/emeritus/+page.svelte +++ b/programmerbar-web/src/routes/(app)/emeritus/+page.svelte @@ -1,59 +1,169 @@ - const coFounders = [ - { name: 'Brigt A.T Håvardstun', role: 'Co-founder' }, - { name: 'Sandra Lekve', role: 'Co-founder' }, - { name: 'Vegard Jensløkken', role: 'Co-founder' }, - { name: 'Eivind D. Halderaker', role: 'Co-founder' } - ]; +{#snippet checkboxMark(checked: boolean)} + +{/snippet} - const members = [ - 'Erik Fjelltveit Nyhuus', - 'Lene Soltveit', - 'Steffen Andre Pettersen', - 'Siren Bjorøy', - 'Ole Straumland', - 'Tony Lam', - 'Eirik Rekve Thorsheim', - 'Ole Magnus Fon Johnsen', - 'August Ebne Røeggen', - 'Fredrik Hast Sørli', - 'Sturla Rognskog Mella', - 'Tord Vikøren Vikestad', - 'Henrik Sætre Breivik' - ]; +{#snippet displayControls(options: DisplayOptions, label: string, allowHistory = false)} + {@const choiceClass = + 'border-foreground-muted/25 bg-background text-foreground-secondary hover:border-primary/60 hover:bg-primary/5 has-[:checked]:border-primary has-[:checked]:bg-primary/10 has-[:checked]:text-foreground-primary has-[:focus-visible]:ring-primary has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-offset-2 has-[:focus-visible]:ring-offset-background has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-40 flex min-h-11 cursor-pointer items-center gap-2.5 rounded-lg border px-3 py-2 text-sm transition-colors motion-reduce:transition-none'} +
+ Vis informasjon: {label} + + + {#if allowHistory} + + {/if} +
+{/snippet} - const pastMembers = [ - 'Mina Tolfsen', - 'Ask Rud Persson', - 'Palma Rud Persson', - 'Sigurd Johnsen Setså', - 'Erlend Raa Vågset', - 'Sofia Hestenes Eika', - 'Kristoffer Borg Nilsen', - 'Lars Lismoen', - 'Yoeri Otten', - 'Eirik Øygard', - 'Arne Natskår', - 'Alexander Alf Iversen', - 'Henrik Trondseth', - 'Emil Johannessen', - 'Andre Normann', - 'Sofie Nhu Nguyen', - 'Simen Hauge Østbø', - 'Stian Munkejord', - 'Gard Heine Kalland', - 'Lars Bysheim', - 'Lars Haukland' - ]; - +{#snippet memberList(list: Member[], options: DisplayOptions)} +
    + {#each list as member (member.id)} + {@const current = member.category === 'current'} + {@const roles = visibleRoles(member, selectedYear, options.history)} + {@const membershipPeriod = formatPeriod( + member.membership?.start, + member.membership?.end, + current + )} +
  • + {#if data.canEdit} + + {/if} + {member.name} + {#if options.periods && membershipPeriod} + I styret: {membershipPeriod} + {/if} + {#if options.roles && roles.length} +
      + {#each roles as entry (entry)} + {@const period = formatPeriod(entry.period?.start, entry.period?.end, current)} +
    • + {entry.title} + {#if options.periods && period} + {period} + {/if} +
    • + {/each} +
    + {/if} + {#if data.canEdit && editing?.id === member.id} +
    + {#key editing.id} + { + closeEditor(); + saved = true; + }} + /> + {/key} +
    + {/if} +
  • + {/each} +
+{/snippet}
- + {#if data.canEdit} +
+ +

+ Du er innlogget som styremedlem og kan redigere historikken her. +

+ {#if saved}

Endringen er lagret.

{/if} +
+ {#if adding} + { + closeEditor(); + saved = true; + }} + /> + {/if} + {/if}
-

- ## Co-founders -

-
    - {#each coFounders as founder (founder.name)} -
  • - {founder.name} - ({founder.role}) -
  • + + +

    + Listen er sortert etter første registrerte startsemester, eldste først. Ukjent start + vises øverst. Historikken er ikke komplett. Flere kan ha sittet i styret i de ulike + semestrene uten å være lagt til her ennå. + {#if selectedYear !== 'all'} + Utvalget viser medlemmer med registrert periode i dette styreåret. Medlemmer uten + kjent periode finner du i hele listen. + {/if} +

+ {#if selectedYear === 'all'} + +
+

+ ## Co-founders +

+ {@render memberList(coFounders, { roles: true, periods: false, history: true })} +
- -
-

- ## Styremedlemmer -

-
    - {#each members as member (member)} -
  • - {member} -
  • - {/each} -
-
+
+

+ ## Styremedlemmer +

+ {@render displayControls(currentDisplay, 'Visning av nåværende medlemmer', true)} + {@render memberList(members, currentDisplay)} +
- -
-

- ## Tidligere Styremedlemmer -

-
    - {#each pastMembers as member (member)} -
  • - {member} -
  • - {/each} -
-
+
+

+ ## Tidligere Styremedlemmer +

+ {@render displayControls(pastDisplay, 'Visning av tidligere medlemmer')} + {@render memberList(pastMembers, pastDisplay)} +
+ {:else} +
+

+ ## Høst {selectedYear} – vår {Number( + selectedYear + ) + 1} +

+

+ {filteredMembers.length} registrerte medlemmer i løpet av styreåret +

+ {#if filteredMembers.length} + {@render displayControls(yearDisplay, 'Visning av valgt styreår')} + {@render memberList(filteredMembers, yearDisplay)} + {:else} +

+ Ingen medlemmer er registrert med periode i dette styreåret ennå. +

+ {/if} +
+ {/if}
diff --git a/programmerbar-web/src/routes/(app)/emeritus/page.server.test.ts b/programmerbar-web/src/routes/(app)/emeritus/page.server.test.ts new file mode 100644 index 00000000..b7a9cc20 --- /dev/null +++ b/programmerbar-web/src/routes/(app)/emeritus/page.server.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest'; +import { actions } from './+page.server'; + +function setup(role: string | null, fields: Record = {}) { + const service = { + create: vi.fn(), + update: vi.fn().mockResolvedValue(true), + delete: vi.fn().mockResolvedValue(true) + }; + const event = { + locals: { user: role ? { role } : null, boardHistoryService: service }, + request: new Request('https://example.test/emeritus', { + method: 'POST', + body: new URLSearchParams(fields) + }) + }; + return { service, event }; +} + +describe('emeritus editor authorization and validation', () => { + it.each([null, 'normal'])('blocks save and delete for %s', async (role) => { + const { event, service } = setup(role); + await expect(actions.save!(event as never)).rejects.toMatchObject({ status: 403 }); + await expect(actions.delete!(event as never)).rejects.toMatchObject({ status: 403 }); + expect(service.create).not.toHaveBeenCalled(); + expect(service.update).not.toHaveBeenCalled(); + expect(service.delete).not.toHaveBeenCalled(); + }); + + it('validates submitted data before writes', async () => { + for (const member of [ + 'not json', + JSON.stringify({ name: '', category: 'current', roles: [] }) + ]) { + const { event, service } = setup('board', { member }); + expect(await actions.save!(event as never)).toMatchObject({ status: 400 }); + expect(service.create).not.toHaveBeenCalled(); + } + }); + + it('allows board members to create and edit history', async () => { + const input = { name: 'Testperson', category: 'current', roles: [] }; + const create = setup('board', { member: JSON.stringify(input) }); + expect(await actions.save!(create.event as never)).toEqual({ success: true }); + expect(create.service.create).toHaveBeenCalledWith(input); + const edit = setup('board', { member: JSON.stringify(input), id: 'member-id', revision: '3' }); + expect(await actions.save!(edit.event as never)).toEqual({ success: true }); + expect(edit.service.update).toHaveBeenCalledWith('member-id', 3, input); + }); + + it('reports a stale edit and requires revision for deletion', async () => { + const edit = setup('board', { + member: JSON.stringify({ name: 'Testperson', category: 'past', roles: [] }), + id: 'member-id', + revision: '1' + }); + edit.service.update.mockResolvedValue(false); + expect(await actions.save!(edit.event as never)).toMatchObject({ status: 409 }); + const invalid = setup('board', { id: 'member-id' }); + expect(await actions.delete!(invalid.event as never)).toMatchObject({ status: 400 }); + expect(invalid.service.delete).not.toHaveBeenCalled(); + const valid = setup('board', { id: 'member-id', revision: '1' }); + expect(await actions.delete!(valid.event as never)).toEqual({ success: true }); + expect(valid.service.delete).toHaveBeenCalledWith('member-id', 1); + }); +});