diff --git a/drizzle/0004_tiny_vanisher.sql b/drizzle/0004_tiny_vanisher.sql new file mode 100644 index 0000000..542ac95 --- /dev/null +++ b/drizzle/0004_tiny_vanisher.sql @@ -0,0 +1,66 @@ +CREATE TABLE "image_consents" ( + "id" serial PRIMARY KEY NOT NULL, + "member_id" integer NOT NULL, + "decision" varchar(20) NOT NULL, + "scopes" text, + "signatory_name" varchar(200), + "signature_png" text, + "consent_version" varchar(40) NOT NULL, + "ip" varchar(120), + "user_agent" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "meeting_registrations" ( + "id" serial PRIMARY KEY NOT NULL, + "meeting_id" integer NOT NULL, + "member_id" integer, + "attendee_name" varchar(200) NOT NULL, + "attendee_email" varchar(200), + "image_consent" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "meetings" ( + "id" serial PRIMARY KEY NOT NULL, + "title" varchar(200) NOT NULL, + "starts_at" timestamp with time zone NOT NULL, + "location" varchar(240), + "description" text, + "capacity" integer DEFAULT 30 NOT NULL, + "image_url" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "past_meeting_photos" ( + "id" serial PRIMARY KEY NOT NULL, + "past_meeting_id" integer NOT NULL, + "image_url" text NOT NULL, + "caption" varchar(200), + "position" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "past_meetings" ( + "id" serial PRIMARY KEY NOT NULL, + "title" varchar(200) NOT NULL, + "event_date" timestamp with time zone NOT NULL, + "location" varchar(240), + "description" text, + "participants" text, + "meeting_id" integer, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "site_settings" ( + "key" varchar(120) PRIMARY KEY NOT NULL, + "value" text, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "image_consents" ADD CONSTRAINT "image_consents_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "meeting_registrations" ADD CONSTRAINT "meeting_registrations_meeting_id_meetings_id_fk" FOREIGN KEY ("meeting_id") REFERENCES "public"."meetings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "meeting_registrations" ADD CONSTRAINT "meeting_registrations_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "past_meeting_photos" ADD CONSTRAINT "past_meeting_photos_past_meeting_id_past_meetings_id_fk" FOREIGN KEY ("past_meeting_id") REFERENCES "public"."past_meetings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "past_meetings" ADD CONSTRAINT "past_meetings_meeting_id_meetings_id_fk" FOREIGN KEY ("meeting_id") REFERENCES "public"."meetings"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "meeting_registrations_meeting_member_idx" ON "meeting_registrations" USING btree ("meeting_id","member_id"); \ No newline at end of file diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..e7838b1 --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,1035 @@ +{ + "id": "8ae94381-2b0a-4691-b3a4-b59ff6ff8542", + "prevId": "1bcd4ce1-1820-4acb-afe5-5f336715f25a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.activity_log": { + "name": "activity_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "dot": { + "name": "dot", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'#2C6FB3'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "accent": { + "name": "accent", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'#E0A63C'" + }, + "tint": { + "name": "tint", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'#f6efdc'" + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "categories_slug_unique": { + "name": "categories_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contact_messages": { + "name": "contact_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "contact_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.image_consents": { + "name": "image_consents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "decision": { + "name": "decision", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signatory_name": { + "name": "signatory_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "signature_png": { + "name": "signature_png", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consent_version": { + "name": "consent_version", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "image_consents_member_id_members_id_fk": { + "name": "image_consents_member_id_members_id_fk", + "tableFrom": "image_consents", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meeting_registrations": { + "name": "meeting_registrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "meeting_id": { + "name": "meeting_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attendee_name": { + "name": "attendee_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "attendee_email": { + "name": "attendee_email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "image_consent": { + "name": "image_consent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "meeting_registrations_meeting_member_idx": { + "name": "meeting_registrations_meeting_member_idx", + "columns": [ + { + "expression": "meeting_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "meeting_registrations_meeting_id_meetings_id_fk": { + "name": "meeting_registrations_meeting_id_meetings_id_fk", + "tableFrom": "meeting_registrations", + "tableTo": "meetings", + "columnsFrom": [ + "meeting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "meeting_registrations_member_id_members_id_fk": { + "name": "meeting_registrations_member_id_members_id_fk", + "tableFrom": "meeting_registrations", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meetings": { + "name": "meetings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "varchar(240)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "varchar(240)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "highlighted": { + "name": "highlighted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "member_since": { + "name": "member_since", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "hours": { + "name": "hours", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "members_category_id_categories_id_fk": { + "name": "members_category_id_categories_id_fk", + "tableFrom": "members", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.membership_requests": { + "name": "membership_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "request_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.past_meeting_photos": { + "name": "past_meeting_photos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "past_meeting_id": { + "name": "past_meeting_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "caption": { + "name": "caption", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "past_meeting_photos_past_meeting_id_past_meetings_id_fk": { + "name": "past_meeting_photos_past_meeting_id_past_meetings_id_fk", + "tableFrom": "past_meeting_photos", + "tableTo": "past_meetings", + "columnsFrom": [ + "past_meeting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.past_meetings": { + "name": "past_meetings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "event_date": { + "name": "event_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "varchar(240)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "participants": { + "name": "participants", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meeting_id": { + "name": "meeting_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "past_meetings_meeting_id_meetings_id_fk": { + "name": "past_meetings_meeting_id_meetings_id_fk", + "tableFrom": "past_meetings", + "tableTo": "meetings", + "columnsFrom": [ + "meeting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.promotions": { + "name": "promotions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "badge": { + "name": "badge", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_id": { + "name": "member_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "promo_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "valid_until": { + "name": "valid_until", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "promotions_member_id_members_id_fk": { + "name": "promotions_member_id_members_id_fk", + "tableFrom": "promotions", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.site_settings": { + "name": "site_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(120)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "member_id": { + "name": "member_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "must_change_password": { + "name": "must_change_password", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "temp_password": { + "name": "temp_password", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_member_id_members_id_fk": { + "name": "users_member_id_members_id_fk", + "tableFrom": "users", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.contact_status": { + "name": "contact_status", + "schema": "public", + "values": [ + "new", + "read", + "archived" + ] + }, + "public.member_status": { + "name": "member_status", + "schema": "public", + "values": [ + "active", + "pending" + ] + }, + "public.promo_status": { + "name": "promo_status", + "schema": "public", + "values": [ + "pending", + "live", + "expired", + "rejected" + ] + }, + "public.request_status": { + "name": "request_status", + "schema": "public", + "values": [ + "new", + "approved", + "rejected" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "moderator", + "editor", + "member" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 3e428f0..d78f4ac 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1782545608919, "tag": "0003_outstanding_jimmy_woo", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1784303079565, + "tag": "0004_tiny_vanisher", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/association/page.tsx b/src/app/association/page.tsx new file mode 100644 index 0000000..428aba0 --- /dev/null +++ b/src/app/association/page.tsx @@ -0,0 +1,311 @@ +import Link from "next/link"; +import { and, asc, desc, eq, gte, inArray, sql } from "drizzle-orm"; +import { auth } from "@/auth"; +import { db } from "@/db"; +import { + meetingRegistrations, + meetings, + pastMeetingPhotos, + pastMeetings, +} from "@/db/schema"; +import { PastMeetingGallery } from "@/components/PastMeetingGallery"; +import { SiteFooter } from "@/components/SiteFooter"; +import { SiteHeader } from "@/components/SiteHeader"; +import { getSiteSettings, parseBoardMembers, splitLines } from "@/lib/site-settings"; +import { cancelMeetingRegistration, registerForMeeting } from "../backend/actions"; + +export const dynamic = "force-dynamic"; + +function formatDate(date: Date) { + return new Intl.DateTimeFormat("fr-FR", { + weekday: "long", + day: "numeric", + month: "long", + year: "numeric", + }).format(date); +} + +function formatTime(date: Date) { + return new Intl.DateTimeFormat("fr-FR", { hour: "2-digit", minute: "2-digit" }).format(date); +} + +export default async function AssociationPage() { + const [settings, session] = await Promise.all([getSiteSettings(), auth()]); + const now = new Date(); + const memberId = session?.user.memberId ?? null; + + const upcoming = await db + .select({ + id: meetings.id, + title: meetings.title, + startsAt: meetings.startsAt, + location: meetings.location, + description: meetings.description, + capacity: meetings.capacity, + imageUrl: meetings.imageUrl, + registered: sql`count(${meetingRegistrations.id})`, + }) + .from(meetings) + .leftJoin(meetingRegistrations, eq(meetingRegistrations.meetingId, meetings.id)) + .where(gte(meetings.startsAt, now)) + .groupBy(meetings.id) + .orderBy(asc(meetings.startsAt)); + + const past = await db + .select() + .from(pastMeetings) + .orderBy(desc(pastMeetings.eventDate)); + + const photoRows = await db + .select() + .from(pastMeetingPhotos) + .orderBy(asc(pastMeetingPhotos.position), asc(pastMeetingPhotos.id)); + + const meetingIds = past.map((item) => item.meetingId).filter((id): id is number => id != null); + const linkedRegistrations = + meetingIds.length > 0 + ? await db + .select({ + meetingId: meetingRegistrations.meetingId, + attendeeName: meetingRegistrations.attendeeName, + }) + .from(meetingRegistrations) + .where(inArray(meetingRegistrations.meetingId, meetingIds)) + .orderBy(asc(meetingRegistrations.attendeeName)) + : []; + + const myRegistrations = + memberId && upcoming.length > 0 + ? await db + .select({ meetingId: meetingRegistrations.meetingId }) + .from(meetingRegistrations) + .where( + and( + eq(meetingRegistrations.memberId, memberId), + inArray( + meetingRegistrations.meetingId, + upcoming.map((meeting) => meeting.id) + ) + ) + ) + : []; + const myMeetingIds = new Set(myRegistrations.map((row) => row.meetingId)); + + const photosByPast = new Map(); + for (const photo of photoRows) { + photosByPast.set(photo.pastMeetingId, [...(photosByPast.get(photo.pastMeetingId) ?? []), photo]); + } + const registrationsByMeeting = new Map(); + for (const reg of linkedRegistrations) { + registrationsByMeeting.set(reg.meetingId, [ + ...(registrationsByMeeting.get(reg.meetingId) ?? []), + reg.attendeeName, + ]); + } + + const officers = [ + ["Vice-présidence", settings.association_vice_president], + ["Trésorerie", settings.association_treasurer], + ["Secrétariat", settings.association_secretary], + ].filter(([, name]) => name); + const board = parseBoardMembers(settings.association_board_members); + + return ( +
+ + +
+
+
+
+ Association +
+

+ {settings.association_name} +

+

+ {settings.association_intro} +

+
+
+ +
+
+
+
+ Mission +
+

+ Réseau, rencontres, réussite locale. +

+
+

+ {settings.association_mission} +

+
+
+ +
+
+
+
+ Rencontres +
+

+ Prochaines dates +

+
+ {!session?.user && ( + + Se connecter pour s'inscrire + + )} +
+ + {upcoming.length === 0 && ( +
+ Aucune rencontre programmée pour le moment. +
+ )} + +
+ {upcoming.map((meeting) => { + const registered = Number(meeting.registered); + const remaining = Math.max(0, meeting.capacity - registered); + const isRegistered = myMeetingIds.has(meeting.id); + return ( +
+
+
+
+ {formatDate(meeting.startsAt)} · {formatTime(meeting.startsAt)} +
+

+ {meeting.title} +

+ {meeting.location &&
{meeting.location}
} +

+ {meeting.description} +

+
+ {registered}/{meeting.capacity} inscrits + {remaining} place(s) +
+ {memberId ? ( + isRegistered ? ( +
+ + +
+ ) : ( +
+ + + +
+ ) + ) : ( + + Connexion adhérent + + )} +
+
+ ); + })} +
+
+ +
+
+
+ Archives +
+

+ Rencontres passées +

+ {past.length === 0 && ( +
+ Aucune rencontre passée publiée pour le moment. +
+ )} +
+ {past.map((item) => { + const linkedNames = item.meetingId ? registrationsByMeeting.get(item.meetingId) ?? [] : []; + const manualNames = splitLines(item.participants ?? ""); + const names = linkedNames.length > 0 ? linkedNames : manualNames; + return ( +
+
+ {formatDate(item.eventDate)} +
+

+ {item.title} +

+ {item.location &&
{item.location}
} +

{item.description}

+ {names.length > 0 && ( +
+ Participants: {names.slice(0, 6).join(", ")} + {names.length > 6 ? ` +${names.length - 6}` : ""} +
+ )} + +
+ ); + })} +
+
+
+ +
+
+ Gouvernance +
+

+ Directoire +

+
+
+ {settings.association_president_photo && ( + // eslint-disable-next-line @next/next/no-img-element + + )} +
{settings.association_president_role}
+

+ {settings.association_president || "Présidence à renseigner"} +

+ {settings.association_president_message && ( +

{settings.association_president_message}

+ )} +
+
+ {[...officers.map(([role, name]) => ({ role, name })), ...board].map((person, index) => ( +
+
{person.role}
+
+ {person.name} +
+
+ ))} + {officers.length === 0 && board.length === 0 && ( +
+ Les membres du directoire seront affichés ici après configuration. +
+ )} +
+
+
+
+ + +
+ ); +} diff --git a/src/app/backend/BackendShell.tsx b/src/app/backend/BackendShell.tsx index dce5b9a..b7869c4 100644 --- a/src/app/backend/BackendShell.tsx +++ b/src/app/backend/BackendShell.tsx @@ -15,8 +15,10 @@ const TITLES: Record = { "Modération des promotions", "Validez les offres soumises par les adhérents", ], + "/backend/rencontres": ["Rencontres", "Gérer les prochaines dates et les archives"], "/backend/administrateurs": ["Administrateurs", "Gérer les accès à l'administration"], "/backend/categories": ["Catégories", "Gérer les métiers de l'annuaire"], + "/backend/parametres": ["Paramètres du site", "Configurer l'association et les mentions légales"], "/backend/espace": ["Mon espace adhérent", "Publiez et suivez vos promotions"], "/backend/changer-mot-de-passe": ["Mot de passe", "Sécurisez votre compte"], }; @@ -141,11 +143,21 @@ export function BackendShell({ )} + {can(user.role, "manageMeetings") && ( + + {dotRound}Rencontres + + )} {can(user.role, "manageCategories") && ( {dotDiamond}Catégories )} + {can(user.role, "manageSettings") && ( + + {dot}Paramètres + + )} {can(user.role, "manageAdmins") && ( {dot}Administrateurs diff --git a/src/app/backend/actions.ts b/src/app/backend/actions.ts index df13552..64beae9 100644 --- a/src/app/backend/actions.ts +++ b/src/app/backend/actions.ts @@ -1,7 +1,9 @@ "use server"; import { revalidatePath } from "next/cache"; -import { eq, sql } from "drizzle-orm"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { and, eq, sql } from "drizzle-orm"; import bcrypt from "bcryptjs"; import { auth, signOut } from "@/auth"; import { db } from "@/db"; @@ -9,12 +11,19 @@ import { activityLog, categories, contactMessages, + imageConsents, + meetingRegistrations, + meetings, members, membershipRequests, + pastMeetingPhotos, + pastMeetings, promotions, + siteSettings, users, } from "@/db/schema"; import { can, LABEL_TO_ROLE } from "@/lib/rbac"; +import { SITE_SETTING_DEFAULTS } from "@/lib/site-settings"; import type { AppRole } from "@/types/next-auth"; const CATEGORY_PALETTE = [ @@ -62,6 +71,21 @@ async function requireRole(): Promise<{ role: AppRole; memberId: number | null; }; } +function asString(formData: FormData, key: string) { + return String(formData.get(key) ?? "").trim(); +} + +function asDate(formData: FormData, key: string) { + const raw = asString(formData, key); + const date = raw ? new Date(raw) : null; + if (!date || Number.isNaN(date.getTime())) throw new Error("Date invalide"); + return date; +} + +function asNullableString(formData: FormData, key: string) { + return asString(formData, key) || null; +} + // ---- Promotion moderation ---- export async function moderatePromo(formData: FormData) { const { role } = await requireRole(); @@ -370,6 +394,247 @@ export async function deleteCategory(formData: FormData) { revalidatePath("/annuaire"); } +// ---- Rencontres ---- +export async function createMeeting(formData: FormData) { + const { role } = await requireRole(); + if (!can(role, "manageMeetings")) throw new Error("Accès refusé"); + + const title = asString(formData, "title"); + if (!title) return; + await db.insert(meetings).values({ + title, + startsAt: asDate(formData, "startsAt"), + location: asNullableString(formData, "location"), + description: asNullableString(formData, "description"), + capacity: Number(formData.get("capacity") ?? 30) || 30, + imageUrl: asNullableString(formData, "imageUrl"), + }); + await logActivity(`Rencontre ajoutée : ${title}`, "#2C6FB3"); + revalidatePath("/backend/rencontres"); + revalidatePath("/association"); +} + +export async function updateMeeting(formData: FormData) { + const { role } = await requireRole(); + if (!can(role, "manageMeetings")) throw new Error("Accès refusé"); + + const id = Number(formData.get("id")); + const title = asString(formData, "title"); + if (!id || !title) return; + await db + .update(meetings) + .set({ + title, + startsAt: asDate(formData, "startsAt"), + location: asNullableString(formData, "location"), + description: asNullableString(formData, "description"), + capacity: Number(formData.get("capacity") ?? 30) || 30, + imageUrl: asNullableString(formData, "imageUrl"), + }) + .where(eq(meetings.id, id)); + revalidatePath("/backend/rencontres"); + revalidatePath("/association"); +} + +export async function deleteMeeting(formData: FormData) { + const { role } = await requireRole(); + if (!can(role, "manageMeetings")) throw new Error("Accès refusé"); + const id = Number(formData.get("id")); + if (!id) return; + await db.delete(meetings).where(eq(meetings.id, id)); + revalidatePath("/backend/rencontres"); + revalidatePath("/association"); +} + +export async function registerForMeeting(formData: FormData) { + const session = await auth(); + if (!session?.user?.memberId) throw new Error("Compte adhérent requis"); + const memberId = session.user.memberId; + const meetingId = Number(formData.get("meetingId")); + if (!meetingId) return; + + await db.transaction(async (tx) => { + const [meeting] = await tx.select().from(meetings).where(eq(meetings.id, meetingId)); + if (!meeting) throw new Error("Rencontre introuvable"); + if (meeting.startsAt.getTime() < Date.now()) throw new Error("Cette rencontre est passée"); + + const duplicate = await tx + .select({ id: meetingRegistrations.id }) + .from(meetingRegistrations) + .where(and(eq(meetingRegistrations.meetingId, meetingId), eq(meetingRegistrations.memberId, memberId))); + if (duplicate.length > 0) return; + + const [{ total } = { total: 0 }] = await tx + .select({ total: sql`count(*)` }) + .from(meetingRegistrations) + .where(eq(meetingRegistrations.meetingId, meetingId)); + if (Number(total) >= meeting.capacity) throw new Error("Rencontre complète"); + + const [member] = await tx + .select({ name: members.name, email: members.email }) + .from(members) + .where(eq(members.id, memberId)); + await tx.insert(meetingRegistrations).values({ + meetingId, + memberId, + attendeeName: member?.name ?? session.user.name ?? "Adhérent", + attendeeEmail: member?.email ?? session.user.email ?? null, + imageConsent: formData.get("imageConsent") === "on", + }); + }); + + revalidatePath("/association"); + revalidatePath("/backend/espace"); +} + +export async function cancelMeetingRegistration(formData: FormData) { + const session = await auth(); + if (!session?.user?.memberId) throw new Error("Compte adhérent requis"); + const meetingId = Number(formData.get("meetingId")); + if (!meetingId) return; + await db + .delete(meetingRegistrations) + .where( + and( + eq(meetingRegistrations.meetingId, meetingId), + eq(meetingRegistrations.memberId, session.user.memberId) + ) + ); + revalidatePath("/association"); + revalidatePath("/backend/espace"); +} + +export async function createPastMeeting(formData: FormData) { + const { role } = await requireRole(); + if (!can(role, "manageMeetings")) throw new Error("Accès refusé"); + const title = asString(formData, "title"); + if (!title) return; + await db.insert(pastMeetings).values({ + title, + eventDate: asDate(formData, "eventDate"), + location: asNullableString(formData, "location"), + description: asNullableString(formData, "description"), + participants: asNullableString(formData, "participants"), + meetingId: formData.get("meetingId") ? Number(formData.get("meetingId")) : null, + }); + revalidatePath("/backend/rencontres"); + revalidatePath("/association"); +} + +export async function updatePastMeeting(formData: FormData) { + const { role } = await requireRole(); + if (!can(role, "manageMeetings")) throw new Error("Accès refusé"); + const id = Number(formData.get("id")); + const title = asString(formData, "title"); + if (!id || !title) return; + await db + .update(pastMeetings) + .set({ + title, + eventDate: asDate(formData, "eventDate"), + location: asNullableString(formData, "location"), + description: asNullableString(formData, "description"), + participants: asNullableString(formData, "participants"), + meetingId: formData.get("meetingId") ? Number(formData.get("meetingId")) : null, + }) + .where(eq(pastMeetings.id, id)); + revalidatePath("/backend/rencontres"); + revalidatePath("/association"); +} + +export async function deletePastMeeting(formData: FormData) { + const { role } = await requireRole(); + if (!can(role, "manageMeetings")) throw new Error("Accès refusé"); + const id = Number(formData.get("id")); + if (!id) return; + await db.delete(pastMeetings).where(eq(pastMeetings.id, id)); + revalidatePath("/backend/rencontres"); + revalidatePath("/association"); +} + +export async function addPastMeetingPhoto(formData: FormData) { + const { role } = await requireRole(); + if (!can(role, "manageMeetings")) throw new Error("Accès refusé"); + const pastMeetingId = Number(formData.get("pastMeetingId")); + const imageUrl = asString(formData, "imageUrl"); + if (!pastMeetingId || !imageUrl) return; + const [{ max } = { max: 0 }] = await db + .select({ max: sql`coalesce(max(${pastMeetingPhotos.position}), 0)` }) + .from(pastMeetingPhotos) + .where(eq(pastMeetingPhotos.pastMeetingId, pastMeetingId)); + await db.insert(pastMeetingPhotos).values({ + pastMeetingId, + imageUrl, + caption: asNullableString(formData, "caption"), + position: Number(max) + 1, + }); + revalidatePath("/backend/rencontres"); + revalidatePath("/association"); +} + +export async function deletePastMeetingPhoto(formData: FormData) { + const { role } = await requireRole(); + if (!can(role, "manageMeetings")) throw new Error("Accès refusé"); + const id = Number(formData.get("id")); + if (!id) return; + await db.delete(pastMeetingPhotos).where(eq(pastMeetingPhotos.id, id)); + revalidatePath("/backend/rencontres"); + revalidatePath("/association"); +} + +// ---- Paramètres du site ---- +export async function saveSiteSettings(formData: FormData) { + const { role } = await requireRole(); + if (!can(role, "manageSettings")) throw new Error("Accès refusé"); + const updatedAt = new Date(); + + for (const key of Object.keys(SITE_SETTING_DEFAULTS)) { + await db + .insert(siteSettings) + .values({ key, value: asString(formData, key), updatedAt }) + .onConflictDoUpdate({ + target: siteSettings.key, + set: { value: asString(formData, key), updatedAt }, + }); + } + + revalidatePath("/backend/parametres"); + revalidatePath("/association"); + revalidatePath("/mentions-legales"); + revalidatePath("/confidentialite"); + revalidatePath("/"); +} + +// ---- RGPD / droit à l'image ---- +export async function saveImageConsent(formData: FormData) { + const session = await auth(); + if (!session?.user?.memberId) throw new Error("Compte adhérent requis"); + + const decision = asString(formData, "decision"); + if (!["accepted", "refused"].includes(decision)) throw new Error("Décision invalide"); + const signatoryName = asString(formData, "signatoryName") || session.user.name || "Adhérent"; + const signaturePng = asString(formData, "signaturePng"); + if (decision === "accepted" && !signaturePng) { + throw new Error("Signature requise pour autoriser le droit à l'image"); + } + const h = await headers(); + + await db.insert(imageConsents).values({ + memberId: session.user.memberId, + decision, + scopes: decision === "accepted" ? "site,social,print" : "", + signatoryName, + signaturePng: decision === "accepted" ? signaturePng : null, + consentVersion: "2026-07-v1", + ip: h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null, + userAgent: h.get("user-agent") ?? null, + }); + + revalidatePath("/backend"); + revalidatePath("/backend/espace"); + redirect("/backend/espace"); +} + // ---- Self password change (forced at first login) ---- export async function changeOwnPassword(formData: FormData) { const session = await auth(); diff --git a/src/app/backend/espace/page.tsx b/src/app/backend/espace/page.tsx index 1cb81a5..90b1eea 100644 --- a/src/app/backend/espace/page.tsx +++ b/src/app/backend/espace/page.tsx @@ -1,10 +1,11 @@ import { asc, desc, eq } from "drizzle-orm"; import { auth } from "@/auth"; import { db } from "@/db"; -import { categories, members, promotions, type Member } from "@/db/schema"; +import { categories, imageConsents, members, promotions, type Member } from "@/db/schema"; import { ImageField } from "@/components/ImageField"; +import { ImageConsentForm } from "@/components/ImageConsentForm"; import { communeOptions } from "@/lib/communes"; -import { updateOwnProfile } from "../actions"; +import { saveImageConsent, updateOwnProfile } from "../actions"; import { MemberSpaceForm } from "./MemberSpaceForm"; export const dynamic = "force-dynamic"; @@ -29,6 +30,7 @@ export default async function EspacePage() { let subtitle = "Espace adhérent"; let profile: Member | null = null; let myPromos: { id: number; title: string; status: string; createdAt: Date; imageUrl: string | null }[] = []; + let latestConsent: { decision: string; createdAt: Date } | null = null; if (memberId) { const [m] = await db.select().from(members).where(eq(members.id, memberId)); @@ -51,6 +53,13 @@ export default async function EspacePage() { .from(promotions) .where(eq(promotions.memberId, memberId)) .orderBy(desc(promotions.createdAt)); + const [consent] = await db + .select({ decision: imageConsents.decision, createdAt: imageConsents.createdAt }) + .from(imageConsents) + .where(eq(imageConsents.memberId, memberId)) + .orderBy(desc(imageConsents.createdAt)) + .limit(1); + latestConsent = consent ?? null; } const catRows = await db @@ -154,6 +163,27 @@ export default async function EspacePage() { )} + {memberId && ( +
+
+
+

+ Droit à l'image +

+
+ Vous pouvez modifier votre décision à tout moment. +
+
+ {latestConsent && ( + + {latestConsent.decision === "accepted" ? "Autorisé" : "Refusé"} · {fmtDate(latestConsent.createdAt)} + + )} +
+ +
+ )} +
diff --git a/src/app/backend/layout.tsx b/src/app/backend/layout.tsx index 732c7e4..121c869 100644 --- a/src/app/backend/layout.tsx +++ b/src/app/backend/layout.tsx @@ -1,9 +1,11 @@ import { redirect } from "next/navigation"; -import { eq } from "drizzle-orm"; +import { desc, eq } from "drizzle-orm"; import { auth } from "@/auth"; import { db } from "@/db"; -import { contactMessages, membershipRequests, promotions } from "@/db/schema"; +import { contactMessages, imageConsents, membershipRequests, promotions } from "@/db/schema"; +import { ImageConsentForm } from "@/components/ImageConsentForm"; import { isStaff } from "@/lib/rbac"; +import { saveImageConsent } from "./actions"; import { BackendShell } from "./BackendShell"; export const dynamic = "force-dynamic"; @@ -18,6 +20,48 @@ export default async function BackendLayout({ redirect("/login"); } + if ( + session.user.role === "member" && + session.user.memberId && + !session.user.mustChangePassword + ) { + const latestConsent = await db + .select({ id: imageConsents.id }) + .from(imageConsents) + .where(eq(imageConsents.memberId, session.user.memberId)) + .orderBy(desc(imageConsents.createdAt)) + .limit(1); + + if (latestConsent.length === 0) { + return ( +
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Plein R +
+
+ Consentement RGPD +
+
Droit à l'image - première connexion adhérent
+
+
+
+

+ Votre choix sur le droit à l'image +

+

+ Avant d'accéder à votre espace, indiquez si Plein R peut utiliser votre image dans + les supports liés aux rencontres et à la vie de l'association. +

+ +
+
+
+ ); + } + } + let pendingCount = 0; let inboxCount = 0; if (isStaff(session.user.role)) { diff --git a/src/app/backend/parametres/page.tsx b/src/app/backend/parametres/page.tsx new file mode 100644 index 0000000..b6f0b8a --- /dev/null +++ b/src/app/backend/parametres/page.tsx @@ -0,0 +1,130 @@ +import { redirect } from "next/navigation"; +import type { CSSProperties } from "react"; +import { auth } from "@/auth"; +import { ImageField } from "@/components/ImageField"; +import { can } from "@/lib/rbac"; +import { getSiteSettings } from "@/lib/site-settings"; +import { saveSiteSettings } from "../actions"; + +export const dynamic = "force-dynamic"; + +export default async function ParametresPage() { + const session = await auth(); + if (!can(session?.user.role, "manageSettings")) redirect("/backend"); + + const settings = await getSiteSettings(); + + return ( +
+
+

Identité du site

+
+ + + + + + +
+ +
+ + +
+
+ +
+

Directoire

+
+ + + + + + + + +
+
+ +
+

Mentions légales & confidentialité

+
+ + + + + + +
+ +
+ +
+
+ Remplissez ces champs avant mise en production: ils alimentent les pages publiques + Mentions légales et Confidentialité. +
+
+ + +
+ ); +} + +function Field({ + label, + name, + value, + placeholder, +}: { + label: string; + name: string; + value: string; + placeholder?: string; +}) { + return ( +
+ + +
+ ); +} + +function Area({ label, name, value }: { label: string; name: string; value: string }) { + return ( +
+ +