chore: perauset customer project

This commit is contained in:
e2e
2026-07-11 08:54:43 +02:00
commit 49d05dfa0b
293 changed files with 28421 additions and 0 deletions

50
sql/0001-init.sql Normal file
View File

@@ -0,0 +1,50 @@
CREATE SCHEMA IF NOT EXISTS app;
-- ============================================================
-- Enum types
-- ============================================================
CREATE TYPE app.user_status AS ENUM ('active', 'inactive', 'archived');
CREATE TYPE app.stay_request_type AS ENUM ('guest', 'volunteer', 'staff', 'facilitator', 'day_visit');
CREATE TYPE app.stay_request_status AS ENUM ('submitted', 'under_review', 'approved', 'rejected', 'cancelled');
CREATE TYPE app.stay_type AS ENUM ('guest', 'volunteer', 'staff', 'facilitator', 'day_visitor');
CREATE TYPE app.stay_status AS ENUM ('pending', 'confirmed', 'checked_in', 'checked_out', 'cancelled');
CREATE TYPE app.room_type AS ENUM ('private', 'shared', 'dorm', 'facilitator', 'staff');
CREATE TYPE app.room_block_type AS ENUM ('maintenance', 'retreat_reserved', 'admin_hold', 'other');
CREATE TYPE app.room_request_status AS ENUM ('submitted', 'reviewed', 'satisfied', 'unsatisfied', 'cancelled');
CREATE TYPE app.room_allocation_status AS ENUM ('planned', 'active', 'completed', 'cancelled');
CREATE TYPE app.boat_trip_status AS ENUM ('planned', 'open', 'full', 'closed', 'cancelled', 'completed');
CREATE TYPE app.boat_reservation_status AS ENUM ('submitted', 'under_review', 'confirmed', 'waitlisted', 'declined', 'cancelled');
CREATE TYPE app.boat_manifest_status AS ENUM ('confirmed', 'boarded', 'no_show', 'cancelled');
CREATE TYPE app.task_category AS ENUM ('operations', 'maintenance', 'kitchen', 'retreat', 'transport', 'housekeeping');
CREATE TYPE app.task_status AS ENUM ('planned', 'open', 'assigned', 'in_progress', 'blocked', 'completed', 'cancelled');
CREATE TYPE app.task_assignment_status AS ENUM ('proposed', 'accepted', 'declined', 'reassigned', 'completed');
-- ============================================================
-- Users
-- ============================================================
CREATE TABLE app."user" (
user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
phone TEXT,
first_name TEXT,
last_name TEXT,
display_name TEXT,
roles TEXT[] NOT NULL DEFAULT '{}',
status app.user_status NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE app.user_profile (
profile_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL UNIQUE REFERENCES app."user"(user_id) ON DELETE CASCADE,
date_of_birth DATE,
nationality TEXT,
notes TEXT,
emergency_contact_name TEXT,
emergency_contact_phone TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

88
sql/0002-audit-log.sql Normal file
View File

@@ -0,0 +1,88 @@
-- Audit log table
CREATE TABLE app.audit_log (
audit_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
table_name TEXT NOT NULL,
record_id TEXT NOT NULL,
action TEXT NOT NULL, -- 'INSERT', 'UPDATE', 'DELETE'
user_id UUID, -- from session variable, nullable for system actions
changed_fields JSONB, -- UPDATE: {field: {old, new}}; INSERT: full row; DELETE: full row
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_audit_log_table_record ON app.audit_log(table_name, record_id);
CREATE INDEX idx_audit_log_user ON app.audit_log(user_id);
CREATE INDEX idx_audit_log_occurred ON app.audit_log(occurred_at);
-- Generic trigger function
CREATE OR REPLACE FUNCTION app.audit_trigger_func() RETURNS TRIGGER AS $$
DECLARE
_user_id UUID;
_record_id TEXT;
_changed JSONB;
_old JSONB;
_new JSONB;
_key TEXT;
BEGIN
-- Get current user from session variable (set per-request by app)
BEGIN
_user_id := current_setting('app.current_user_id', true)::UUID;
EXCEPTION WHEN OTHERS THEN
_user_id := NULL;
END;
IF TG_OP = 'DELETE' THEN
_record_id := (row_to_json(OLD) ->> TG_ARGV[0])::TEXT;
INSERT INTO app.audit_log (table_name, record_id, action, user_id, changed_fields)
VALUES (TG_TABLE_NAME, _record_id, 'DELETE', _user_id, row_to_json(OLD)::JSONB);
RETURN OLD;
ELSIF TG_OP = 'INSERT' THEN
_record_id := (row_to_json(NEW) ->> TG_ARGV[0])::TEXT;
INSERT INTO app.audit_log (table_name, record_id, action, user_id, changed_fields)
VALUES (TG_TABLE_NAME, _record_id, 'INSERT', _user_id, row_to_json(NEW)::JSONB);
RETURN NEW;
ELSIF TG_OP = 'UPDATE' THEN
_record_id := (row_to_json(NEW) ->> TG_ARGV[0])::TEXT;
_old := row_to_json(OLD)::JSONB;
_new := row_to_json(NEW)::JSONB;
_changed := '{}'::JSONB;
FOR _key IN SELECT jsonb_object_keys(_new)
LOOP
IF _key IN ('updated_at', 'created_at') THEN CONTINUE; END IF;
IF (_old ->> _key) IS DISTINCT FROM (_new ->> _key) THEN
_changed := _changed || jsonb_build_object(
_key, jsonb_build_object('old', _old -> _key, 'new', _new -> _key)
);
END IF;
END LOOP;
-- Skip if nothing actually changed
IF _changed = '{}'::JSONB THEN RETURN NEW; END IF;
INSERT INTO app.audit_log (table_name, record_id, action, user_id, changed_fields)
VALUES (TG_TABLE_NAME, _record_id, 'UPDATE', _user_id, _changed);
RETURN NEW;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
-- Helper to attach audit trigger to a table
CREATE OR REPLACE FUNCTION app.enable_audit(target_table TEXT, pk_column TEXT DEFAULT 'id') RETURNS VOID AS $$
DECLARE
trigger_name TEXT;
BEGIN
trigger_name := 'audit_' || replace(target_table, '"', '');
EXECUTE format(
'CREATE TRIGGER %I AFTER INSERT OR UPDATE OR DELETE ON app.%s FOR EACH ROW EXECUTE FUNCTION app.audit_trigger_func(%L)',
trigger_name, target_table, pk_column
);
END;
$$ LANGUAGE plpgsql;
-- Enable audit on existing tables
SELECT app.enable_audit('"user"', 'user_id');
SELECT app.enable_audit('user_profile', 'profile_id');

3
sql/0003-seed-data.sql Normal file
View File

@@ -0,0 +1,3 @@
-- Admin user
INSERT INTO app."user" (email, first_name, last_name, display_name, roles, status)
VALUES ('admin@perauset.org', 'Admin', 'User', 'Admin', '{admin}', 'active');

44
sql/0004-stays.sql Normal file
View File

@@ -0,0 +1,44 @@
-- Stay requests (user intent)
CREATE TABLE app.stay_request (
request_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES app."user"(user_id),
request_type app.stay_request_type NOT NULL DEFAULT 'guest',
requested_start_at DATE NOT NULL,
requested_end_at DATE NOT NULL,
retreat_id UUID,
notes TEXT,
status app.stay_request_status NOT NULL DEFAULT 'submitted',
reviewed_by_user_id UUID REFERENCES app."user"(user_id),
reviewed_at TIMESTAMPTZ,
review_notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_stay_request_user ON app.stay_request(user_id);
CREATE INDEX idx_stay_request_status ON app.stay_request(status);
-- Stays (confirmed island presence)
CREATE TABLE app.stay (
stay_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES app."user"(user_id),
stay_type app.stay_type NOT NULL DEFAULT 'guest',
source_request_id UUID REFERENCES app.stay_request(request_id),
retreat_id UUID,
start_at DATE NOT NULL,
end_at DATE NOT NULL,
status app.stay_status NOT NULL DEFAULT 'pending',
checked_in_at TIMESTAMPTZ,
checked_out_at TIMESTAMPTZ,
created_by_user_id UUID NOT NULL REFERENCES app."user"(user_id),
updated_by_user_id UUID REFERENCES app."user"(user_id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_stay_user ON app.stay(user_id);
CREATE INDEX idx_stay_status ON app.stay(status);
CREATE INDEX idx_stay_dates ON app.stay(start_at, end_at);
SELECT app.enable_audit('stay_request', 'request_id');
SELECT app.enable_audit('stay', 'stay_id');

65
sql/0005-rooms.sql Normal file
View File

@@ -0,0 +1,65 @@
-- Rooms
CREATE TABLE app.room (
room_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
room_type app.room_type NOT NULL DEFAULT 'private',
capacity INT NOT NULL DEFAULT 1,
is_active BOOLEAN NOT NULL DEFAULT true,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Room blocks (maintenance, retreat holds, admin reservations)
CREATE TABLE app.room_block (
block_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
room_id UUID NOT NULL REFERENCES app.room(room_id),
block_type app.room_block_type NOT NULL DEFAULT 'maintenance',
retreat_id UUID,
starts_at DATE NOT NULL,
ends_at DATE NOT NULL,
reason TEXT,
created_by_user_id UUID NOT NULL REFERENCES app."user"(user_id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_room_block_room ON app.room_block(room_id);
CREATE INDEX idx_room_block_dates ON app.room_block(starts_at, ends_at);
-- Room requests (preferences)
CREATE TABLE app.room_request (
request_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
stay_id UUID NOT NULL REFERENCES app.stay(stay_id),
requested_room_type app.room_type,
preference_notes TEXT,
status app.room_request_status NOT NULL DEFAULT 'submitted',
reviewed_by_user_id UUID REFERENCES app."user"(user_id),
reviewed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Room allocations (actual assignment)
CREATE TABLE app.room_allocation (
allocation_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
stay_id UUID NOT NULL REFERENCES app.stay(stay_id),
room_id UUID NOT NULL REFERENCES app.room(room_id),
starts_at DATE NOT NULL,
ends_at DATE NOT NULL,
status app.room_allocation_status NOT NULL DEFAULT 'planned',
allocation_reason TEXT,
created_by_user_id UUID NOT NULL REFERENCES app."user"(user_id),
updated_by_user_id UUID REFERENCES app."user"(user_id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_room_allocation_room ON app.room_allocation(room_id);
CREATE INDEX idx_room_allocation_stay ON app.room_allocation(stay_id);
CREATE INDEX idx_room_allocation_dates ON app.room_allocation(starts_at, ends_at);
SELECT app.enable_audit('room', 'room_id');
SELECT app.enable_audit('room_block', 'block_id');
SELECT app.enable_audit('room_request', 'request_id');
SELECT app.enable_audit('room_allocation', 'allocation_id');

84
sql/0006-boats.sql Normal file
View File

@@ -0,0 +1,84 @@
-- Boat routes
CREATE TABLE app.boat_route (
route_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
origin TEXT NOT NULL,
destination TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Boats
CREATE TABLE app.boat (
boat_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
passenger_capacity INT NOT NULL,
cargo_capacity_kg NUMERIC,
is_active BOOLEAN NOT NULL DEFAULT true,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Boat trips
CREATE TABLE app.boat_trip (
trip_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
boat_id UUID REFERENCES app.boat(boat_id),
route_id UUID NOT NULL REFERENCES app.boat_route(route_id),
scheduled_departure_at TIMESTAMPTZ NOT NULL,
scheduled_arrival_at TIMESTAMPTZ,
status app.boat_trip_status NOT NULL DEFAULT 'planned',
notes TEXT,
created_by_user_id UUID NOT NULL REFERENCES app."user"(user_id),
updated_by_user_id UUID REFERENCES app."user"(user_id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_boat_trip_status ON app.boat_trip(status);
CREATE INDEX idx_boat_trip_departure ON app.boat_trip(scheduled_departure_at);
-- Boat reservation requests (user intent)
CREATE TABLE app.boat_reservation_request (
request_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
trip_id UUID NOT NULL REFERENCES app.boat_trip(trip_id),
user_id UUID NOT NULL REFERENCES app."user"(user_id),
stay_id UUID REFERENCES app.stay(stay_id),
retreat_id UUID,
requested_seats INT NOT NULL DEFAULT 1,
cargo_notes TEXT,
special_requirements TEXT,
priority_reason TEXT,
status app.boat_reservation_status NOT NULL DEFAULT 'submitted',
reviewed_by_user_id UUID REFERENCES app."user"(user_id),
reviewed_at TIMESTAMPTZ,
review_notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_boat_reservation_trip ON app.boat_reservation_request(trip_id);
CREATE INDEX idx_boat_reservation_user ON app.boat_reservation_request(user_id);
CREATE INDEX idx_boat_reservation_status ON app.boat_reservation_request(status);
-- Boat manifest entries (confirmed passengers)
CREATE TABLE app.boat_manifest_entry (
entry_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
trip_id UUID NOT NULL REFERENCES app.boat_trip(trip_id),
reservation_request_id UUID REFERENCES app.boat_reservation_request(request_id),
user_id UUID NOT NULL REFERENCES app."user"(user_id),
stay_id UUID REFERENCES app.stay(stay_id),
status app.boat_manifest_status NOT NULL DEFAULT 'confirmed',
confirmed_by_user_id UUID NOT NULL REFERENCES app."user"(user_id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_boat_manifest_trip ON app.boat_manifest_entry(trip_id);
SELECT app.enable_audit('boat_route', 'route_id');
SELECT app.enable_audit('boat', 'boat_id');
SELECT app.enable_audit('boat_trip', 'trip_id');
SELECT app.enable_audit('boat_reservation_request', 'request_id');
SELECT app.enable_audit('boat_manifest_entry', 'entry_id');

56
sql/0007-tasks.sql Normal file
View File

@@ -0,0 +1,56 @@
-- Task templates
CREATE TABLE app.task_template (
template_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
description TEXT,
category app.task_category NOT NULL DEFAULT 'operations',
is_claimable BOOLEAN NOT NULL DEFAULT false,
default_location TEXT,
estimated_minutes INT,
recurrence_cron TEXT, -- cron expression, e.g. '0 8 * * 1,3,5'
is_active BOOLEAN NOT NULL DEFAULT true,
created_by_user_id UUID NOT NULL REFERENCES app."user"(user_id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Task instances
CREATE TABLE app.task_instance (
task_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
template_id UUID REFERENCES app.task_template(template_id),
title TEXT NOT NULL,
description TEXT,
category app.task_category NOT NULL DEFAULT 'operations',
location TEXT,
linked_retreat_id UUID,
linked_stay_id UUID REFERENCES app.stay(stay_id),
scheduled_start_at TIMESTAMPTZ,
scheduled_end_at TIMESTAMPTZ,
status app.task_status NOT NULL DEFAULT 'planned',
created_by_user_id UUID NOT NULL REFERENCES app."user"(user_id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_task_instance_status ON app.task_instance(status);
CREATE INDEX idx_task_instance_category ON app.task_instance(category);
CREATE INDEX idx_task_instance_template ON app.task_instance(template_id);
-- Task assignments
CREATE TABLE app.task_assignment (
assignment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_instance_id UUID NOT NULL REFERENCES app.task_instance(task_id),
assigned_user_id UUID NOT NULL REFERENCES app."user"(user_id),
assigned_by_user_id UUID NOT NULL REFERENCES app."user"(user_id),
status app.task_assignment_status NOT NULL DEFAULT 'proposed',
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_task_assignment_task ON app.task_assignment(task_instance_id);
CREATE INDEX idx_task_assignment_user ON app.task_assignment(assigned_user_id);
SELECT app.enable_audit('task_template', 'template_id');
SELECT app.enable_audit('task_instance', 'task_id');
SELECT app.enable_audit('task_assignment', 'assignment_id');

View File

@@ -0,0 +1,13 @@
CREATE TABLE app.notification (
notification_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES app."user"(user_id),
type TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT,
entity_type TEXT,
entity_id UUID,
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_notification_user ON app.notification(user_id, read_at, created_at DESC);

80
sql/0009-retreats.sql Normal file
View File

@@ -0,0 +1,80 @@
-- ============================================================
-- Retreat enum types
-- ============================================================
CREATE TYPE app.retreat_status AS ENUM ('draft', 'published', 'active', 'completed', 'cancelled');
CREATE TYPE app.retreat_person_role AS ENUM ('participant', 'facilitator', 'assistant_facilitator', 'organizer', 'support_staff');
CREATE TYPE app.retreat_attendance_status AS ENUM ('invited', 'registered', 'confirmed', 'checked_in', 'cancelled');
-- ============================================================
-- Retreats
-- ============================================================
CREATE TABLE app.retreat (
retreat_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT,
start_at DATE NOT NULL,
end_at DATE NOT NULL,
status app.retreat_status NOT NULL DEFAULT 'draft',
capacity INT,
default_check_in_at TIMESTAMPTZ,
default_check_out_at TIMESTAMPTZ,
created_by_user_id UUID NOT NULL REFERENCES app."user"(user_id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_retreat_status ON app.retreat(status);
CREATE INDEX idx_retreat_dates ON app.retreat(start_at, end_at);
-- ============================================================
-- Retreat persons
-- ============================================================
CREATE TABLE app.retreat_person (
retreat_person_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
retreat_id UUID NOT NULL REFERENCES app.retreat(retreat_id) ON DELETE CASCADE,
user_id UUID REFERENCES app."user"(user_id),
stay_id UUID REFERENCES app.stay(stay_id),
full_name TEXT,
email TEXT,
phone TEXT,
role_type app.retreat_person_role NOT NULL DEFAULT 'participant',
attendance_status app.retreat_attendance_status NOT NULL DEFAULT 'registered',
arrival_override_at TIMESTAMPTZ,
departure_override_at TIMESTAMPTZ,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(retreat_id, user_id)
);
CREATE INDEX idx_retreat_person_retreat ON app.retreat_person(retreat_id);
CREATE INDEX idx_retreat_person_user ON app.retreat_person(user_id);
-- ============================================================
-- Retreat schedule items
-- ============================================================
CREATE TABLE app.retreat_schedule_item (
item_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
retreat_id UUID NOT NULL REFERENCES app.retreat(retreat_id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
location TEXT,
lead_retreat_person_id UUID REFERENCES app.retreat_person(retreat_person_id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_retreat_schedule_retreat ON app.retreat_schedule_item(retreat_id);
CREATE INDEX idx_retreat_schedule_times ON app.retreat_schedule_item(starts_at, ends_at);
-- Enable audit
SELECT app.enable_audit('retreat', 'retreat_id');
SELECT app.enable_audit('retreat_person', 'retreat_person_id');
SELECT app.enable_audit('retreat_schedule_item', 'item_id');

76
sql/0010-kitchen.sql Normal file
View File

@@ -0,0 +1,76 @@
-- ============================================================
-- Kitchen / Dietary enum types
-- ============================================================
CREATE TYPE app.meal_type AS ENUM ('breakfast', 'lunch', 'dinner', 'snack');
CREATE TYPE app.meal_status AS ENUM ('planned', 'ready', 'served', 'completed');
CREATE TYPE app.meal_attendance_status AS ENUM ('expected', 'confirmed', 'cancelled', 'served');
CREATE TYPE app.meal_attendance_source AS ENUM ('stay', 'retreat', 'manual');
-- ============================================================
-- Dietary profiles
-- ============================================================
CREATE TABLE app.dietary_profile (
profile_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL UNIQUE REFERENCES app."user"(user_id) ON DELETE CASCADE,
is_vegan BOOLEAN NOT NULL DEFAULT false,
is_vegetarian BOOLEAN NOT NULL DEFAULT false,
is_gluten_free BOOLEAN NOT NULL DEFAULT false,
is_dairy_free BOOLEAN NOT NULL DEFAULT false,
has_nut_allergy BOOLEAN NOT NULL DEFAULT false,
allergy_notes TEXT,
other_requirements TEXT,
dislikes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_dietary_profile_user ON app.dietary_profile(user_id);
-- ============================================================
-- Meal services
-- ============================================================
CREATE TABLE app.meal_service (
service_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
service_type app.meal_type NOT NULL,
service_date DATE NOT NULL,
retreat_id UUID REFERENCES app.retreat(retreat_id),
planned_headcount INT,
menu_notes TEXT,
status app.meal_status NOT NULL DEFAULT 'planned',
created_by_user_id UUID NOT NULL REFERENCES app."user"(user_id),
updated_by_user_id UUID REFERENCES app."user"(user_id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_meal_service_date ON app.meal_service(service_date);
CREATE INDEX idx_meal_service_type ON app.meal_service(service_type);
CREATE INDEX idx_meal_service_retreat ON app.meal_service(retreat_id);
-- ============================================================
-- Meal attendance
-- ============================================================
CREATE TABLE app.meal_attendance (
attendance_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
meal_service_id UUID NOT NULL REFERENCES app.meal_service(service_id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES app."user"(user_id),
stay_id UUID REFERENCES app.stay(stay_id),
source_type app.meal_attendance_source NOT NULL DEFAULT 'manual',
status app.meal_attendance_status NOT NULL DEFAULT 'expected',
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(meal_service_id, user_id)
);
CREATE INDEX idx_meal_attendance_service ON app.meal_attendance(meal_service_id);
CREATE INDEX idx_meal_attendance_user ON app.meal_attendance(user_id);
-- Enable audit
SELECT app.enable_audit('dietary_profile', 'profile_id');
SELECT app.enable_audit('meal_service', 'service_id');
SELECT app.enable_audit('meal_attendance', 'attendance_id');

49
sql/0011-inventory.sql Normal file
View File

@@ -0,0 +1,49 @@
-- ============================================================
-- Inventory enum types
-- ============================================================
CREATE TYPE app.inventory_request_status AS ENUM ('submitted', 'approved', 'rejected', 'fulfilled', 'cancelled');
-- ============================================================
-- Inventory items
-- ============================================================
CREATE TABLE app.inventory_item (
item_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
unit TEXT NOT NULL DEFAULT 'unit',
current_quantity NUMERIC NOT NULL DEFAULT 0,
minimum_quantity NUMERIC NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_inventory_item_active ON app.inventory_item(is_active);
-- ============================================================
-- Inventory requests
-- ============================================================
CREATE TABLE app.inventory_request (
request_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
requested_by_user_id UUID NOT NULL REFERENCES app."user"(user_id),
item_id UUID REFERENCES app.inventory_item(item_id),
item_name TEXT NOT NULL,
quantity NUMERIC NOT NULL,
unit TEXT NOT NULL,
purpose TEXT,
status app.inventory_request_status NOT NULL DEFAULT 'submitted',
reviewed_by_user_id UUID REFERENCES app."user"(user_id),
reviewed_at TIMESTAMPTZ,
review_notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_inventory_request_status ON app.inventory_request(status);
CREATE INDEX idx_inventory_request_user ON app.inventory_request(requested_by_user_id);
-- Enable audit
SELECT app.enable_audit('inventory_item', 'item_id');
SELECT app.enable_audit('inventory_request', 'request_id');

View File

@@ -0,0 +1,6 @@
-- Add category (enum) and subcategory (free text) to inventory items
ALTER TABLE app.inventory_item
ADD COLUMN category TEXT NOT NULL DEFAULT 'operations',
ADD COLUMN subcategory TEXT;
CREATE INDEX idx_inventory_item_category ON app.inventory_item(category);

38
sql/0013-finance.sql Normal file
View File

@@ -0,0 +1,38 @@
-- ============================================================
-- Finance records
-- ============================================================
CREATE TABLE app.finance_record (
finance_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
amount NUMERIC(12,2) NOT NULL,
currency TEXT NOT NULL DEFAULT 'EUR',
record_type TEXT NOT NULL DEFAULT 'expense', -- expense, income, reimbursement
purchased_by_user_id UUID REFERENCES app."user"(user_id),
purchased_at TIMESTAMPTZ NOT NULL DEFAULT now(),
category TEXT NOT NULL DEFAULT 'operations', -- same enum as tasks/inventory
created_by_user_id UUID NOT NULL REFERENCES app."user"(user_id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_finance_record_category ON app.finance_record(category);
CREATE INDEX idx_finance_record_purchased_at ON app.finance_record(purchased_at);
CREATE INDEX idx_finance_record_purchased_by ON app.finance_record(purchased_by_user_id);
-- ============================================================
-- Finance links (polymorphic — links finance records to any entity)
-- ============================================================
CREATE TABLE app.finance_link (
link_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
finance_id UUID NOT NULL REFERENCES app.finance_record(finance_id) ON DELETE CASCADE,
entity_type TEXT NOT NULL, -- inventory_item, inventory_request, stay, boat_trip, retreat, task, room
entity_id UUID NOT NULL,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_finance_link_finance ON app.finance_link(finance_id);
CREATE INDEX idx_finance_link_entity ON app.finance_link(entity_type, entity_id);

View File

@@ -0,0 +1,11 @@
CREATE TABLE app.inventory_transaction (
transaction_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
item_id UUID NOT NULL REFERENCES app.inventory_item(item_id),
quantity_change NUMERIC NOT NULL,
reason TEXT NOT NULL, -- 'stocktake', 'adjustment', 'consumption', 'restock', 'request_fulfilled'
notes TEXT,
created_by_user_id UUID NOT NULL REFERENCES app."user"(user_id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_inventory_transaction_item ON app.inventory_transaction(item_id);
CREATE INDEX idx_inventory_transaction_created ON app.inventory_transaction(created_at);

View File

@@ -0,0 +1 @@
ALTER TABLE app.task_instance ADD COLUMN assigned_role TEXT;

View File

@@ -0,0 +1,4 @@
-- Add mobile and WhatsApp number fields to user table
ALTER TABLE app."user"
ADD COLUMN mobile_number TEXT,
ADD COLUMN whatsapp_number TEXT;

2
sql/0017-room-price.sql Normal file
View File

@@ -0,0 +1,2 @@
-- Add price per night to rooms
ALTER TABLE app.room ADD COLUMN price_per_night NUMERIC(10,2);

327
sql/0018-seed-demo-data.sql Normal file
View File

@@ -0,0 +1,327 @@
-- ============================================================
-- Demo seed data for PerAuset
-- Run after all migrations to populate with realistic test data
-- ============================================================
-- ============================================================
-- Users (diverse roles)
-- ============================================================
INSERT INTO app."user" (email, first_name, last_name, display_name, roles, status, phone, mobile_number, whatsapp_number) VALUES
('sarah@perauset.org', 'Sarah', 'El-Masry', 'Sarah', '{coordinator}', 'active', '+20 100 123 4567', '+20 100 123 4567', '+20 100 123 4567'),
('omar@perauset.org', 'Omar', 'Hassan', 'Omar', '{facilitator}', 'active', '+20 101 234 5678', '+20 101 234 5678', null),
('fatima@perauset.org', 'Fatima', 'Abdel-Rahman', 'Fatima', '{kitchen_lead}', 'active', '+20 102 345 6789', '+20 102 345 6789', '+20 102 345 6789'),
('ahmed@perauset.org', 'Ahmed', 'Nour', 'Ahmed', '{boat_coordinator}', 'active', '+20 103 456 7890', '+20 103 456 7890', null),
('layla@perauset.org', 'Layla', 'Ibrahim', 'Layla', '{staff}', 'active', null, '+20 104 567 8901', '+20 104 567 8901'),
('youssef@perauset.org', 'Youssef', 'Kamal', 'Youssef', '{volunteer}', 'active', null, '+20 105 678 9012', null),
('nadia@perauset.org', 'Nadia', 'Farouk', 'Nadia', '{community_member}', 'active', null, null, null),
('marco@example.com', 'Marco', 'Rossi', 'Marco', '{guest}', 'active', '+39 333 123 4567', null, '+39 333 123 4567'),
('anna@example.com', 'Anna', 'Müller', 'Anna', '{guest}', 'active', '+49 171 234 5678', null, null),
('james@example.com', 'James', 'Chen', 'James', '{volunteer}', 'active', null, '+1 555 987 6543', '+1 555 987 6543')
ON CONFLICT (email) DO NOTHING;
-- ============================================================
-- Dietary profiles
-- ============================================================
INSERT INTO app.dietary_profile (user_id, is_vegetarian, is_vegan, is_gluten_free, is_dairy_free, has_nut_allergy, allergy_notes, other_requirements)
SELECT u.user_id, r.veg, r.vgn, r.gf, r.df, r.nut, r.allergy_notes, r.other
FROM (VALUES
('fatima@perauset.org', true, false, false, false, false, null, 'No onions please'),
('marco@example.com', false, true, true, true, true, 'Severe nut allergy', 'Strict vegan - no honey'),
('anna@example.com', false, false, false, true, false, 'Lactose intolerant', null),
('omar@perauset.org', false, false, false, false, false, null, 'Halal only'),
('nadia@perauset.org', false, false, false, false, false, 'Shellfish allergy', null)
) AS r(email, veg, vgn, gf, df, nut, allergy_notes, other)
JOIN app."user" u ON u.email = r.email
ON CONFLICT (user_id) DO NOTHING;
-- ============================================================
-- Rooms
-- ============================================================
INSERT INTO app.room (code, name, room_type, capacity, price_per_night, notes) VALUES
('R-101', 'Sunrise Room', 'private', 2, 45.00, 'East-facing, morning sun'),
('R-102', 'Nile View', 'private', 2, 55.00, 'River view, premium room'),
('R-103', 'Palm Suite', 'facilitator', 2, 60.00, 'Reserved for retreat facilitators'),
('R-201', 'Garden Dorm A', 'dorm', 6, 15.00, 'Ground floor, garden access'),
('R-202', 'Garden Dorm B', 'dorm', 6, 15.00, 'Ground floor, garden access'),
('R-203', 'Rooftop Dorm', 'dorm', 4, 18.00, 'Rooftop terrace access'),
('R-301', 'Staff Room 1', 'staff', 2, null, 'Staff accommodation'),
('R-302', 'Staff Room 2', 'staff', 2, null, 'Staff accommodation'),
('R-401', 'Shared Room 1', 'shared', 3, 25.00, 'Shared bathroom'),
('R-402', 'Shared Room 2', 'shared', 3, 25.00, 'Shared bathroom')
ON CONFLICT (code) DO NOTHING;
-- ============================================================
-- Boat routes and boats
-- ============================================================
INSERT INTO app.boat_route (name, origin, destination) VALUES
('Main Ferry', 'Aswan Marina', 'Per Auset Island'),
('Express Shuttle', 'Aswan Corniche', 'Per Auset Island'),
('Supply Run', 'Aswan Market Dock', 'Per Auset Island')
ON CONFLICT DO NOTHING;
INSERT INTO app.boat (name, passenger_capacity) VALUES
('Lotus', 20),
('Felucca Star', 12),
('Supply Barge', 8)
ON CONFLICT DO NOTHING;
-- ============================================================
-- Boat trips (upcoming)
-- ============================================================
INSERT INTO app.boat_trip (route_id, boat_id, status, scheduled_departure_at, scheduled_arrival_at, notes, created_by_user_id)
SELECT
r.route_id, b.boat_id, 'open'::app.boat_trip_status,
(CURRENT_DATE + interval '1 day' + interval '8 hours')::timestamptz,
(CURRENT_DATE + interval '1 day' + interval '8 hours 30 minutes')::timestamptz,
'Morning ferry', u.user_id
FROM app.boat_route r, app.boat b, app."user" u
WHERE r.name = 'Main Ferry' AND b.name = 'Lotus' AND u.email = 'ahmed@perauset.org'
ON CONFLICT DO NOTHING;
INSERT INTO app.boat_trip (route_id, boat_id, status, scheduled_departure_at, scheduled_arrival_at, notes, created_by_user_id)
SELECT
r.route_id, b.boat_id, 'open'::app.boat_trip_status,
(CURRENT_DATE + interval '1 day' + interval '16 hours')::timestamptz,
(CURRENT_DATE + interval '1 day' + interval '16 hours 30 minutes')::timestamptz,
'Evening return', u.user_id
FROM app.boat_route r, app.boat b, app."user" u
WHERE r.name = 'Main Ferry' AND b.name = 'Lotus' AND u.email = 'ahmed@perauset.org'
ON CONFLICT DO NOTHING;
INSERT INTO app.boat_trip (route_id, boat_id, status, scheduled_departure_at, scheduled_arrival_at, notes, created_by_user_id)
SELECT
r.route_id, b.boat_id, 'planned'::app.boat_trip_status,
(CURRENT_DATE + interval '3 days' + interval '10 hours')::timestamptz,
(CURRENT_DATE + interval '3 days' + interval '10 hours 45 minutes')::timestamptz,
'Weekly supply run', u.user_id
FROM app.boat_route r, app.boat b, app."user" u
WHERE r.name = 'Supply Run' AND b.name = 'Supply Barge' AND u.email = 'ahmed@perauset.org'
ON CONFLICT DO NOTHING;
-- ============================================================
-- Stay requests & stays
-- ============================================================
INSERT INTO app.stay_request (user_id, request_type, requested_start_at, requested_end_at, status, notes)
SELECT u.user_id, r.request_type::app.stay_request_type, r.start_at::date, r.end_at::date, r.status::app.stay_request_status, r.notes
FROM (VALUES
('marco@example.com', 'guest', CURRENT_DATE + 2, CURRENT_DATE + 9, 'submitted', 'First visit, excited to learn about permaculture'),
('anna@example.com', 'guest', CURRENT_DATE + 5, CURRENT_DATE + 12, 'submitted', 'Interested in the kitchen program'),
('james@example.com', 'volunteer', CURRENT_DATE + 7, CURRENT_DATE + 37, 'approved', 'Gardening and construction skills'),
('nadia@perauset.org', 'day_visit', CURRENT_DATE + 3, CURRENT_DATE + 3, 'approved', null)
) AS r(email, request_type, start_at, end_at, status, notes)
JOIN app."user" u ON u.email = r.email;
-- Create stays from approved requests
INSERT INTO app.stay (user_id, source_request_id, start_at, end_at, stay_type, status, created_by_user_id)
SELECT sr.user_id, sr.request_id, sr.requested_start_at, sr.requested_end_at,
(CASE sr.request_type::text
WHEN 'guest' THEN 'guest'
WHEN 'volunteer' THEN 'volunteer'
WHEN 'day_visit' THEN 'day_visitor'
ELSE 'guest'
END)::app.stay_type,
'confirmed'::app.stay_status, (SELECT user_id FROM app."user" WHERE email = 'sarah@perauset.org')
FROM app.stay_request sr
WHERE sr.status = 'approved';
-- ============================================================
-- Room allocations
-- ============================================================
INSERT INTO app.room_allocation (room_id, stay_id, starts_at, ends_at, status, allocation_reason, created_by_user_id)
SELECT r.room_id, s.stay_id, s.start_at, s.end_at, 'active'::app.room_allocation_status, 'Guest stay',
(SELECT user_id FROM app."user" WHERE email = 'sarah@perauset.org')
FROM app.stay s
JOIN app."user" u ON u.user_id = s.user_id
JOIN app.room r ON r.code = CASE u.email
WHEN 'james@example.com' THEN 'R-201'
ELSE 'R-101'
END
WHERE s.status = 'confirmed'
LIMIT 2;
-- ============================================================
-- Inventory items
-- ============================================================
INSERT INTO app.inventory_item (name, unit, current_quantity, minimum_quantity, category, subcategory) VALUES
('Rice', 'kg', 50, 20, 'kitchen', 'staples'),
('Olive Oil', 'liter', 15, 5, 'kitchen', 'cooking-oils'),
('Flour', 'kg', 30, 10, 'kitchen', 'staples'),
('Tomatoes', 'kg', 8, 5, 'kitchen', 'fresh-produce'),
('Onions', 'kg', 12, 5, 'kitchen', 'fresh-produce'),
('Garlic', 'kg', 3, 1, 'kitchen', 'fresh-produce'),
('Lentils', 'kg', 25, 10, 'kitchen', 'staples'),
('Chickpeas (dried)', 'kg', 15, 5, 'kitchen', 'staples'),
('Tahini', 'jar', 6, 3, 'kitchen', 'condiments'),
('Soap (handmade)', 'bar', 40, 15, 'rooms', 'toiletries'),
('Towels', 'piece', 30, 10, 'rooms', 'linens'),
('Bed Sheets', 'set', 20, 8, 'rooms', 'linens'),
('Mosquito Nets', 'piece', 15, 5, 'rooms', 'supplies'),
('Garden Hose', 'piece', 3, 1, 'garden', 'tools'),
('Compost Bags', 'roll', 5, 2, 'garden', 'supplies'),
('Garden Shears', 'piece', 4, 2, 'garden', 'tools'),
('Solar Panel Cleaner', 'liter', 4, 2, 'equipment', 'cleaning'),
('First Aid Kit', 'kit', 3, 2, 'equipment', 'safety'),
('Boat Fuel', 'liter', 80, 30, 'equipment', 'fuel'),
('Life Jackets', 'piece', 25, 15, 'equipment', 'safety'),
('Yoga Mats', 'piece', 20, 10, 'other', 'retreat'),
('Whiteboard Markers', 'pack', 5, 2, 'other', 'office')
ON CONFLICT DO NOTHING;
-- ============================================================
-- Task templates
-- ============================================================
INSERT INTO app.task_template (title, description, category, is_claimable, default_location, estimated_minutes, recurrence_cron, created_by_user_id)
SELECT t.title, t.description, t.category::app.task_category, t.is_claimable, t.location, t.minutes, t.cron,
(SELECT user_id FROM app."user" WHERE email = 'sarah@perauset.org')
FROM (VALUES
('Morning kitchen prep', 'Prepare breakfast ingredients and set up kitchen', 'kitchen', true, 'Main Kitchen', 60, 'daily'),
('Evening cleanup', 'Clean kitchen and dining area after dinner', 'kitchen', true, 'Main Kitchen', 45, 'daily'),
('Garden watering', 'Water all garden beds and check irrigation', 'maintenance', true, 'Garden', 30, 'daily'),
('Guest room turnover', 'Clean and prepare rooms for new guests', 'housekeeping', true, null, 45, null),
('Compost management', 'Turn compost piles and add new material', 'maintenance', true, 'Compost Area', 30, 'weekly'),
('Boat inspection', 'Check fuel, safety equipment, and hull condition', 'transport', false, 'Dock', 30, 'weekly'),
('Inventory check', 'Review kitchen inventory and note low-stock items', 'operations', false, 'Storage Room', 45, 'weekly'),
('Solar panel cleaning', 'Clean all solar panels', 'maintenance', true, 'Rooftop', 60, 'monthly')
) AS t(title, description, category, is_claimable, location, minutes, cron);
-- ============================================================
-- Task instances (active)
-- ============================================================
INSERT INTO app.task_instance (title, description, category, status, location, scheduled_start_at, scheduled_end_at, created_by_user_id)
SELECT t.title, t.description, t.category::app.task_category, t.status::app.task_status, t.location,
t.start_at::timestamptz, t.end_at::timestamptz,
(SELECT user_id FROM app."user" WHERE email = 'sarah@perauset.org')
FROM (VALUES
('Morning kitchen prep', 'Prepare breakfast', 'kitchen', 'open', 'Main Kitchen',
CURRENT_DATE + interval '6 hours', CURRENT_DATE + interval '7 hours'),
('Garden watering', 'Water beds and check drip lines', 'maintenance', 'open', 'Garden',
CURRENT_DATE + interval '7 hours', CURRENT_DATE + interval '7 hours 30 minutes'),
('Guest room turnover - R-101', 'Clean Sunrise Room for new arrival', 'housekeeping', 'assigned', null,
CURRENT_DATE + interval '10 hours', CURRENT_DATE + interval '11 hours'),
('Evening cleanup', 'Kitchen and dining cleanup', 'kitchen', 'open', 'Main Kitchen',
CURRENT_DATE + interval '20 hours', CURRENT_DATE + interval '21 hours'),
('Fix leaky faucet', 'Bathroom faucet in R-201 is dripping', 'maintenance', 'in_progress', 'R-201',
CURRENT_DATE - interval '1 day', null),
('Organize storage room', 'Sort supplies and update inventory labels', 'operations', 'open', 'Storage Room',
CURRENT_DATE + interval '2 days', CURRENT_DATE + interval '2 days 2 hours'),
('Prepare yoga space', 'Set up mats and clean the retreat hall', 'retreat', 'open', 'Retreat Hall',
CURRENT_DATE + interval '3 days' + interval '8 hours', CURRENT_DATE + interval '3 days' + interval '9 hours')
) AS t(title, description, category, status, location, start_at, end_at);
-- Assign some tasks
INSERT INTO app.task_assignment (task_instance_id, assigned_user_id, assigned_by_user_id, status)
SELECT ti.task_id, u.user_id,
(SELECT user_id FROM app."user" WHERE email = 'sarah@perauset.org'),
'accepted'::app.task_assignment_status
FROM app.task_instance ti
JOIN app."user" u ON u.email = CASE ti.title
WHEN 'Guest room turnover - R-101' THEN 'layla@perauset.org'
WHEN 'Fix leaky faucet' THEN 'youssef@perauset.org'
END
WHERE ti.status IN ('assigned', 'in_progress');
-- ============================================================
-- Meal services (this week)
-- ============================================================
INSERT INTO app.meal_service (service_type, service_date, planned_headcount, menu_notes, created_by_user_id)
SELECT m.service_type::app.meal_type, m.service_date::date, m.headcount, m.notes,
(SELECT user_id FROM app."user" WHERE email = 'fatima@perauset.org')
FROM (VALUES
('breakfast', CURRENT_DATE, 12, 'Ful medames, falafel, bread, tahini, fresh vegetables'),
('lunch', CURRENT_DATE, 15, 'Koshari with lentils and pasta, salad, pickles'),
('dinner', CURRENT_DATE, 14, 'Grilled vegetables, rice, molokhia soup'),
('breakfast', CURRENT_DATE + 1, 12, 'Shakshuka, cheese, olives, fresh bread'),
('lunch', CURRENT_DATE + 1, 15, 'Stuffed vine leaves, hummus, tabbouleh'),
('dinner', CURRENT_DATE + 1, 14, 'Lentil soup, grilled fish, salad'),
('breakfast', CURRENT_DATE + 2, 14, 'Pancakes, fruit, yogurt, honey'),
('lunch', CURRENT_DATE + 2, 16, 'Vegetable tagine, couscous, mint tea')
) AS m(service_type, service_date, headcount, notes);
-- ============================================================
-- Retreats
-- ============================================================
INSERT INTO app.retreat (name, slug, description, start_at, end_at, capacity, status, created_by_user_id)
SELECT r.name, r.slug, r.description, r.start_at::date, r.end_at::date, r.capacity, r.status::app.retreat_status,
(SELECT user_id FROM app."user" WHERE email = 'omar@perauset.org')
FROM (VALUES
('Spring Permaculture Intensive', 'spring-permaculture-2026',
'A 7-day deep dive into permaculture design, soil building, and sustainable food systems on the island.',
CURRENT_DATE + 14, CURRENT_DATE + 21, 20, 'published'),
('Yoga & Meditation Retreat', 'yoga-meditation-april',
'A 5-day retreat focused on daily yoga, meditation, and mindful living by the Nile.',
CURRENT_DATE + 30, CURRENT_DATE + 35, 15, 'draft'),
('Community Building Workshop', 'community-building-may',
'Learn participatory decision-making, conflict resolution, and community governance.',
CURRENT_DATE + 45, CURRENT_DATE + 48, 25, 'draft')
) AS r(name, slug, description, start_at, end_at, capacity, status);
-- Add people to the published retreat
INSERT INTO app.retreat_person (retreat_id, user_id, role_type, attendance_status)
SELECT ret.retreat_id, u.user_id, r.role::app.retreat_person_role, 'confirmed'::app.retreat_attendance_status
FROM (VALUES
('omar@perauset.org', 'facilitator'),
('sarah@perauset.org', 'organizer'),
('marco@example.com', 'participant'),
('anna@example.com', 'participant'),
('james@example.com', 'participant'),
('nadia@perauset.org', 'participant')
) AS r(email, role)
JOIN app."user" u ON u.email = r.email
JOIN app.retreat ret ON ret.slug = 'spring-permaculture-2026';
-- Add schedule to the published retreat
INSERT INTO app.retreat_schedule_item (retreat_id, title, description, starts_at, ends_at, location)
SELECT ret.retreat_id, s.title, s.description,
(ret.start_at + s.day_offset + s.start_time)::timestamptz,
(ret.start_at + s.day_offset + s.end_time)::timestamptz,
s.location
FROM (VALUES
('Welcome Circle', 'Introduction and intention setting', interval '0 days', interval '16 hours', interval '18 hours', 'Retreat Hall'),
('Permaculture Principles', 'Theory session on 12 permaculture principles', interval '1 day', interval '9 hours', interval '12 hours', 'Classroom'),
('Garden Walk', 'Guided tour of existing permaculture gardens', interval '1 day', interval '14 hours', interval '16 hours', 'Garden'),
('Soil Building Workshop', 'Hands-on composting and soil preparation', interval '2 days', interval '9 hours', interval '12 hours', 'Compost Area'),
('Water Systems Design', 'Designing rainwater harvesting and greywater systems', interval '3 days', interval '9 hours', interval '12 hours', 'Classroom'),
('Food Forest Planning', 'Planning and planting a food forest', interval '4 days', interval '9 hours', interval '12 hours', 'Garden'),
('Closing Circle', 'Reflection, sharing, and next steps', interval '6 days', interval '16 hours', interval '18 hours', 'Retreat Hall')
) AS s(title, description, day_offset, start_time, end_time, location)
JOIN app.retreat ret ON ret.slug = 'spring-permaculture-2026';
-- ============================================================
-- Finance records
-- ============================================================
INSERT INTO app.finance_record (name, description, amount, currency, record_type, category, purchased_at, created_by_user_id, purchased_by_user_id)
SELECT f.name, f.description, f.amount, 'EUR', f.record_type, f.category,
(CURRENT_DATE - f.days_ago)::timestamptz,
(SELECT user_id FROM app."user" WHERE email = 'sarah@perauset.org'),
(SELECT user_id FROM app."user" WHERE email = f.buyer)
FROM (VALUES
('Weekly market groceries', 'Vegetables, fruits, bread from Aswan market', 180.00, 'expense', 'kitchen', 2, 'fatima@perauset.org'),
('Boat fuel', 'Diesel for Lotus and Supply Barge', 95.00, 'expense', 'transport', 3, 'ahmed@perauset.org'),
('Guest donation - Marco', 'Voluntary contribution for stay', 200.00, 'income', 'operations', 1, 'sarah@perauset.org'),
('Solar panel parts', 'Replacement inverter for roof array', 320.00, 'expense', 'maintenance', 7, 'sarah@perauset.org'),
('Retreat deposit - Spring Permaculture', 'Deposit for materials and facilitator', 500.00, 'expense', 'retreat', 10, 'omar@perauset.org'),
('Soap making supplies', 'Olive oil, lye, essential oils', 45.00, 'expense', 'operations', 5, 'layla@perauset.org'),
('Workshop fee income', 'Day workshop attendee fees', 150.00, 'income', 'retreat', 4, 'sarah@perauset.org'),
('Plumbing repair parts', 'Faucet and pipe fittings', 35.00, 'expense', 'maintenance', 1, 'youssef@perauset.org'),
('Monthly internet', 'Satellite internet subscription', 75.00, 'expense', 'operations', 15, 'sarah@perauset.org'),
('Guest donation - Anna', 'Contribution for kitchen program stay', 150.00, 'income', 'kitchen', 3, 'sarah@perauset.org')
) AS f(name, description, amount, record_type, category, days_ago, buyer);
-- ============================================================
-- Notifications
-- ============================================================
INSERT INTO app.notification (user_id, type, title, body)
SELECT u.user_id, 'info', n.title, n.body
FROM (VALUES
('sarah@perauset.org', 'New stay request', 'Marco Rossi has submitted a stay request for Mar 22 - Mar 29'),
('sarah@perauset.org', 'New stay request', 'Anna Müller has submitted a stay request for Mar 25 - Apr 1'),
('fatima@perauset.org', 'Low stock alert', 'Tomatoes are below minimum quantity (8 kg < 5 kg threshold)'),
('ahmed@perauset.org', 'Boat trip tomorrow', 'Morning ferry to Per Auset Island departing at 8:00 AM'),
('layla@perauset.org', 'Task assigned', 'You have been assigned: Guest room turnover - R-101'),
('youssef@perauset.org', 'Task assigned', 'You have been assigned: Fix leaky faucet in R-201'),
('omar@perauset.org', 'Retreat published', 'Spring Permaculture Intensive is now published with 6 participants'),
('marco@example.com', 'Stay request submitted', 'Your stay request for Mar 22 - Mar 29 has been submitted'),
('james@example.com', 'Stay approved', 'Your volunteer stay request has been approved. Welcome!'),
('admin@perauset.org', 'Weekly summary', '3 active stays, 2 pending requests, 7 open tasks, 2 boat trips scheduled')
) AS n(email, title, body)
JOIN app."user" u ON u.email = n.email;

View File

@@ -0,0 +1,12 @@
-- Exchange rates table for daily currency conversion
CREATE TABLE app.exchange_rate (
rate_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
base_currency TEXT NOT NULL DEFAULT 'EGP',
target_currency TEXT NOT NULL,
rate NUMERIC(12,6) NOT NULL,
fetched_at DATE NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX idx_exchange_rate_unique ON app.exchange_rate(base_currency, target_currency, fetched_at);
CREATE INDEX idx_exchange_rate_date ON app.exchange_rate(fetched_at DESC);

View File

@@ -0,0 +1,5 @@
-- Add base amount in EGP for all finance records
ALTER TABLE app.finance_record ADD COLUMN amount_egp NUMERIC(12,2);
-- For existing EGP records, set amount_egp = amount
UPDATE app.finance_record SET amount_egp = amount WHERE currency = 'EGP';

1
sql/0021-user-avatar.sql Normal file
View File

@@ -0,0 +1 @@
ALTER TABLE app."user" ADD COLUMN avatar_url TEXT;