-- Extensions and domain types CREATE EXTENSION IF NOT EXISTS citext; CREATE TYPE sun_exposure AS ENUM ('sunny', 'partial_shade', 'shade'); CREATE TYPE soil_condition AS ENUM ('dry', 'moist', 'boggy'); CREATE TYPE soil_reaction AS ENUM ('alkaline', 'acidic', 'neutral'); CREATE TYPE plant_lifecycle AS ENUM ('annual', 'biennial', 'perennial'); CREATE TYPE plant_status AS ENUM ('alive', 'dead', 'removed', 'infested', 'harvested'); CREATE TYPE care_instruction_status AS ENUM ('good', 'bad', 'untested', 'testing', 'planned'); CREATE TYPE task_trigger_type AS ENUM ( 'month_of_year', 'relative_to_planting', 'relative_to_last_task', 'relative_to_sowing', 'relative_to_harvest', 'relative_to_species_planting' ); CREATE TYPE task_template_origin AS ENUM ( 'manual', 'season_sowing', 'season_planting', 'season_harvest' ); -- Authentication and users CREATE TABLE users ( id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, name text NOT NULL, email citext NOT NULL UNIQUE, password_hash bytea NOT NULL, activated boolean NOT NULL, application_role text NOT NULL DEFAULT 'application:user', color text NOT NULL DEFAULT (ARRAY['#d95f02','#1b9e77','#7570b3','#e7298a','#66a61e','#e6ab02','#a6761d','#1f78b4'])[1 + floor(random() * 8)::int], deleted_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), version integer NOT NULL DEFAULT 1, CONSTRAINT users_color_format CHECK (color ~ '^#[0-9A-Fa-f]{6}$') ); CREATE TABLE sessions ( token text PRIMARY KEY, data bytea NOT NULL, expiry timestamptz NOT NULL ); CREATE INDEX sessions_expiry_idx ON sessions (expiry); CREATE TABLE tokens ( hash bytea PRIMARY KEY, user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE, expiry timestamptz NOT NULL, scope text NOT NULL ); CREATE TABLE user_email_changes ( user_id bigint PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, email citext NOT NULL UNIQUE, token_hash bytea NOT NULL UNIQUE, expires_at timestamptz NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); -- Gardens, roles, and permissions CREATE TABLE gardens ( id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, name text NOT NULL, description text NOT NULL DEFAULT '', image_data text NOT NULL DEFAULT '', created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), version integer NOT NULL DEFAULT 1 ); CREATE TABLE roles ( name text PRIMARY KEY, scope text NOT NULL CHECK (scope IN ('application', 'garden')), label text NOT NULL, system boolean NOT NULL DEFAULT false, garden_id bigint REFERENCES gardens(id) ON DELETE CASCADE, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT roles_scope_garden_check CHECK ( (scope = 'application' AND garden_id IS NULL) OR scope = 'garden' ) ); CREATE INDEX roles_garden_id_idx ON roles(garden_id) WHERE garden_id IS NOT NULL; CREATE TABLE role_permissions ( role_name text NOT NULL REFERENCES roles(name) ON DELETE CASCADE, permission text NOT NULL, PRIMARY KEY (role_name, permission) ); ALTER TABLE users ADD CONSTRAINT users_application_role_fkey FOREIGN KEY (application_role) REFERENCES roles(name); CREATE TABLE garden_members ( garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE, user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE, role text NOT NULL DEFAULT 'member' REFERENCES roles(name), joined_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (garden_id, user_id) ); CREATE INDEX garden_members_user_id_idx ON garden_members (user_id); CREATE TABLE garden_invites ( id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE, email citext NOT NULL, role text NOT NULL DEFAULT 'member' REFERENCES roles(name), token_hash bytea NOT NULL UNIQUE, invited_by bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at timestamptz NOT NULL, accepted_at timestamptz, created_at timestamptz NOT NULL DEFAULT now() ); CREATE UNIQUE INDEX garden_invites_pending_email_unique ON garden_invites (garden_id, email) WHERE accepted_at IS NULL; CREATE INDEX garden_invites_garden_idx ON garden_invites (garden_id, created_at); CREATE TABLE garden_role_permission_overrides ( garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE, role_name text NOT NULL REFERENCES roles(name) ON DELETE CASCADE, permission text NOT NULL, granted boolean NOT NULL, PRIMARY KEY (garden_id, role_name, permission) ); -- Species catalogue and care instructions CREATE TABLE species_categories ( id bigserial PRIMARY KEY, name text NOT NULL, sort_order integer NOT NULL DEFAULT 0, active boolean NOT NULL DEFAULT true, lifecycle plant_lifecycle, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, version integer NOT NULL DEFAULT 1 ); CREATE UNIQUE INDEX species_categories_name_key ON species_categories (lower(name)); CREATE TABLE species ( id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, garden_id bigint REFERENCES gardens(id) ON DELETE CASCADE, category_id bigint REFERENCES species_categories(id) ON DELETE RESTRICT, common_name text NOT NULL, cultivar text NOT NULL DEFAULT '', botanical_name text NOT NULL DEFAULT '', sun_exposure sun_exposure, soil_condition soil_condition, soil_reaction soil_reaction, winter_protection text, spacing_cm integer, height_cm integer, sow_month_from smallint CHECK (sow_month_from BETWEEN 1 AND 12), sow_day_from smallint CHECK (sow_day_from BETWEEN 1 AND 31), sow_month_to smallint CHECK (sow_month_to BETWEEN 1 AND 12), sow_day_to smallint CHECK (sow_day_to BETWEEN 1 AND 31), planting_month_from smallint CHECK (planting_month_from BETWEEN 1 AND 12), planting_day_from smallint CHECK (planting_day_from BETWEEN 1 AND 31), planting_month_to smallint CHECK (planting_month_to BETWEEN 1 AND 12), planting_day_to smallint CHECK (planting_day_to BETWEEN 1 AND 31), harvest_month_from smallint CHECK (harvest_month_from BETWEEN 1 AND 12), harvest_day_from smallint CHECK (harvest_day_from BETWEEN 1 AND 31), harvest_month_to smallint CHECK (harvest_month_to BETWEEN 1 AND 12), harvest_day_to smallint CHECK (harvest_day_to BETWEEN 1 AND 31), notes text NOT NULL DEFAULT '', attributes jsonb NOT NULL DEFAULT '{}', image_data text NOT NULL DEFAULT '', created_by bigint REFERENCES users(id), updated_by bigint REFERENCES users(id), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), version integer NOT NULL DEFAULT 1 ); CREATE UNIQUE INDEX species_global_unique ON species (common_name, cultivar) WHERE garden_id IS NULL; CREATE UNIQUE INDEX species_garden_unique ON species (garden_id, common_name, cultivar) WHERE garden_id IS NOT NULL; CREATE TABLE care_instructions ( id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, species_id bigint NOT NULL REFERENCES species(id) ON DELETE CASCADE, text text NOT NULL, status care_instruction_status NOT NULL DEFAULT 'untested', created_by bigint NOT NULL REFERENCES users(id), updated_by bigint NOT NULL REFERENCES users(id), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), version integer NOT NULL DEFAULT 1 ); CREATE INDEX care_instructions_species ON care_instructions(species_id); CREATE TABLE species_task_templates ( id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, species_id bigint NOT NULL REFERENCES species(id) ON DELETE CASCADE, title text NOT NULL, description text NOT NULL DEFAULT '', trigger_type task_trigger_type NOT NULL, month_from smallint CHECK (month_from BETWEEN 1 AND 12), day_from smallint CHECK (day_from BETWEEN 1 AND 31), month_to smallint CHECK (month_to BETWEEN 1 AND 12), day_to smallint CHECK (day_to BETWEEN 1 AND 31), offset_days_from integer, offset_days_to integer, interval_days smallint, trigger_offset integer NOT NULL DEFAULT 0 CHECK (trigger_offset >= 0), trigger_offset_unit text NOT NULL DEFAULT 'day' CHECK (trigger_offset_unit IN ('day', 'week', 'month')), duration integer NOT NULL DEFAULT 0 CHECK (duration >= 0), duration_unit text NOT NULL DEFAULT 'day' CHECK (duration_unit IN ('day', 'week', 'month')), recurrence text NOT NULL DEFAULT '' CHECK (recurrence IN ('', 'daily', 'weekly', 'monthly', 'yearly')), recurrence_interval integer NOT NULL DEFAULT 1 CHECK (recurrence_interval > 0), origin task_template_origin NOT NULL DEFAULT 'manual', priority smallint NOT NULL DEFAULT 0, active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), version integer NOT NULL DEFAULT 1, CHECK (offset_days_from IS NULL OR offset_days_to IS NULL OR offset_days_from <= offset_days_to) ); CREATE INDEX species_task_templates_species_id_idx ON species_task_templates (species_id) WHERE active = true; CREATE UNIQUE INDEX species_task_templates_derived_origin_key ON species_task_templates (species_id, origin) WHERE origin <> 'manual'; -- Locations, plants, and tasks CREATE TABLE locations ( id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE, parent_id bigint REFERENCES locations(id) ON DELETE SET NULL, name text NOT NULL, description text NOT NULL DEFAULT '', kind text NOT NULL DEFAULT '', area_sqm numeric(10,2), sun_exposure sun_exposure, soil_condition soil_condition, soil_reaction soil_reaction, attributes jsonb NOT NULL DEFAULT '{}', image_data text NOT NULL DEFAULT '', created_by bigint REFERENCES users(id), updated_by bigint REFERENCES users(id), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), version integer NOT NULL DEFAULT 1 ); CREATE INDEX locations_garden_id_idx ON locations (garden_id); CREATE INDEX locations_parent_id_idx ON locations (parent_id); CREATE TABLE plants ( id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE, species_id bigint REFERENCES species(id) ON DELETE SET NULL, name text NOT NULL, notes text NOT NULL DEFAULT '', acquired_at date, status plant_status NOT NULL DEFAULT 'alive', removed_at date, attributes jsonb NOT NULL DEFAULT '{}', image_data text NOT NULL DEFAULT '', created_by bigint REFERENCES users(id), updated_by bigint REFERENCES users(id), planted_by bigint REFERENCES users(id), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), version integer NOT NULL DEFAULT 1 ); CREATE INDEX plants_garden_id_idx ON plants (garden_id) WHERE status = 'alive'; CREATE TABLE plant_locations ( id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, plant_id bigint NOT NULL REFERENCES plants(id) ON DELETE CASCADE, location_id bigint NOT NULL REFERENCES locations(id) ON DELETE CASCADE, quantity integer NOT NULL DEFAULT 1, planted_at date, removed_at date, notes text NOT NULL DEFAULT '', created_at timestamptz NOT NULL DEFAULT now(), version integer NOT NULL DEFAULT 1 ); CREATE UNIQUE INDEX plant_location_active ON plant_locations (plant_id, location_id) WHERE removed_at IS NULL; CREATE INDEX plant_locations_location_id_idx ON plant_locations (location_id) WHERE removed_at IS NULL; CREATE TABLE tasks ( id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE, plant_id bigint REFERENCES plants(id) ON DELETE CASCADE, location_id bigint REFERENCES locations(id) ON DELETE CASCADE, template_id bigint REFERENCES species_task_templates(id) ON DELETE SET NULL, title text NOT NULL, description text NOT NULL DEFAULT '', due_at_start timestamptz, due_at_end timestamptz, generated_for date, completed_at timestamptz, completed_by bigint REFERENCES users(id), priority smallint NOT NULL DEFAULT 0, active boolean NOT NULL DEFAULT true, recurrence text NOT NULL DEFAULT '' CHECK (recurrence IN ('', 'daily', 'weekly', 'monthly', 'yearly')), recurrence_interval integer NOT NULL DEFAULT 1 CHECK (recurrence_interval > 0), repeat_from_id bigint REFERENCES tasks(id) ON DELETE SET NULL, plant_status_on_completion plant_status, created_by bigint NOT NULL REFERENCES users(id), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), version integer NOT NULL DEFAULT 1, CHECK (due_at_start IS NULL OR due_at_end IS NULL OR due_at_start <= due_at_end), CONSTRAINT tasks_completion_user_check CHECK ( (completed_at IS NULL AND completed_by IS NULL) OR (completed_at IS NOT NULL AND completed_by IS NOT NULL) ) ); CREATE INDEX tasks_garden_id_due_at_end_idx ON tasks (garden_id, due_at_end) WHERE completed_at IS NULL; CREATE INDEX tasks_garden_id_due_at_start_idx ON tasks (garden_id, due_at_start) WHERE completed_at IS NULL; CREATE INDEX tasks_plant_id_idx ON tasks (plant_id) WHERE plant_id IS NOT NULL; CREATE UNIQUE INDEX tasks_repeat_from_unique ON tasks (repeat_from_id) WHERE repeat_from_id IS NOT NULL; CREATE UNIQUE INDEX tasks_template_slot_unique ON tasks (plant_id, template_id, generated_for) WHERE template_id IS NOT NULL; CREATE TABLE task_template_opt_outs ( plant_id bigint NOT NULL REFERENCES plants(id) ON DELETE CASCADE, template_id bigint NOT NULL REFERENCES species_task_templates(id) ON DELETE CASCADE, created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (plant_id, template_id) ); CREATE TABLE task_priorities ( id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, name text NOT NULL UNIQUE, value smallint NOT NULL UNIQUE CHECK (value BETWEEN -100 AND 100), sort_order integer NOT NULL DEFAULT 0, active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), version integer NOT NULL DEFAULT 1 ); -- Tags CREATE TABLE tags ( id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, garden_id bigint REFERENCES gardens(id) ON DELETE CASCADE, name text NOT NULL, UNIQUE (garden_id, name) ); CREATE UNIQUE INDEX tags_global_name_key ON tags(name) WHERE garden_id IS NULL; CREATE TABLE task_tags ( task_id bigint NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE CASCADE, PRIMARY KEY (task_id, tag_id) ); CREATE TABLE plant_tags ( plant_id bigint NOT NULL REFERENCES plants(id) ON DELETE CASCADE, tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE CASCADE, PRIMARY KEY (plant_id, tag_id) ); CREATE TABLE species_tags ( species_id bigint NOT NULL REFERENCES species(id) ON DELETE CASCADE, tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE CASCADE, PRIMARY KEY (species_id, tag_id) ); -- Image library CREATE TABLE images ( id bigserial PRIMARY KEY, garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE, file_name text NOT NULL DEFAULT '', media_type text NOT NULL, data bytea NOT NULL, size bigint NOT NULL, checksum text NOT NULL, source text NOT NULL DEFAULT 'upload', created_by bigint REFERENCES users(id) ON DELETE SET NULL, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX images_garden_created_idx ON images(garden_id, created_at DESC, id DESC); CREATE INDEX images_garden_checksum_idx ON images(garden_id, checksum); ALTER TABLE gardens ADD COLUMN image_id bigint REFERENCES images(id) ON DELETE SET NULL; ALTER TABLE species ADD COLUMN image_id bigint REFERENCES images(id) ON DELETE SET NULL; ALTER TABLE plants ADD COLUMN image_id bigint REFERENCES images(id) ON DELETE SET NULL; ALTER TABLE locations ADD COLUMN image_id bigint REFERENCES images(id) ON DELETE SET NULL; CREATE TABLE entity_image_history ( id bigserial PRIMARY KEY, garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE, entity_type text NOT NULL CHECK (entity_type IN ('garden', 'species', 'plant', 'location')), entity_id bigint NOT NULL, previous_image_id bigint REFERENCES images(id) ON DELETE SET NULL, image_id bigint REFERENCES images(id) ON DELETE SET NULL, changed_by bigint REFERENCES users(id) ON DELETE SET NULL, changed_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX entity_image_history_entity_idx ON entity_image_history(garden_id, entity_type, entity_id, changed_at DESC); -- Journal CREATE TABLE journal_entries ( id bigserial PRIMARY KEY, garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE, author_id bigint NOT NULL REFERENCES users(id), entry_type text NOT NULL DEFAULT 'journal' CHECK (entry_type IN ('journal', 'pinboard')), title text NOT NULL, body text NOT NULL DEFAULT '', created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), version integer NOT NULL DEFAULT 1 ); CREATE INDEX journal_entries_garden_created_idx ON journal_entries(garden_id, created_at DESC, id DESC); CREATE INDEX journal_entries_garden_type_created_idx ON journal_entries(garden_id, entry_type, created_at DESC, id DESC); CREATE TABLE journal_entry_tags ( journal_entry_id bigint NOT NULL REFERENCES journal_entries(id) ON DELETE CASCADE, tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE CASCADE, PRIMARY KEY (journal_entry_id, tag_id) ); CREATE TABLE journal_attachments ( id bigserial PRIMARY KEY, journal_entry_id bigint NOT NULL REFERENCES journal_entries(id) ON DELETE CASCADE, image_id bigint REFERENCES images(id) ON DELETE RESTRICT, file_name text NOT NULL, media_type text NOT NULL, data bytea NOT NULL, size bigint NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX journal_attachments_entry_idx ON journal_attachments(journal_entry_id, id); -- Application settings and lifecycle history CREATE TABLE application_settings ( singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), lifecycle_status_enabled boolean NOT NULL DEFAULT false, lifecycle_removal_month smallint NOT NULL DEFAULT 12 CHECK (lifecycle_removal_month BETWEEN 1 AND 12), lifecycle_removal_day smallint NOT NULL DEFAULT 1 CHECK (lifecycle_removal_day BETWEEN 1 AND 31), timezone text NOT NULL DEFAULT 'Europe/Berlin', updated_at timestamptz NOT NULL DEFAULT now(), version integer NOT NULL DEFAULT 1 ); CREATE TABLE plant_status_history ( id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, plant_id bigint NOT NULL REFERENCES plants(id) ON DELETE CASCADE, from_status plant_status NOT NULL, to_status plant_status NOT NULL, reason text NOT NULL, effective_at date NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX plant_status_history_plant_id_idx ON plant_status_history (plant_id, created_at DESC); -- Initial reference data INSERT INTO roles (name, scope, label, system) VALUES ('application:user', 'application', 'Nutzer', true), ('application:admin', 'application', 'Administrator', true), ('owner', 'garden', 'Eigentümer', true), ('admin', 'garden', 'Administrator', true), ('member', 'garden', 'Mitglied', true), ('worker', 'garden', 'Mitarbeiter', true), ('viewer', 'garden', 'Leser', true); INSERT INTO role_permissions (role_name, permission) VALUES ('application:user', 'gardens:create'), ('application:admin', 'gardens:create'), ('application:admin', 'global_species:write'), ('application:admin', 'users:manage'), ('application:admin', 'application_settings:write'), ('application:admin', 'roles:manage'), ('owner', '*'), ('admin', 'garden:*'), ('member', 'garden:read'), ('member', 'content:write'), ('member', 'plants:create'), ('member', 'plants:read:own'), ('member', 'plants:read:other'), ('member', 'plants:update:own'), ('member', 'plants:delete:own'), ('member', 'locations:create'), ('member', 'locations:read:own'), ('member', 'locations:read:other'), ('member', 'locations:update:own'), ('member', 'locations:delete:own'), ('member', 'tasks:create'), ('member', 'tasks:read:own'), ('member', 'tasks:read:other'), ('member', 'tasks:update:own'), ('member', 'tasks:delete:own'), ('member', 'tasks:complete:own'), ('member', 'tasks:complete:other'), ('worker', 'garden:read'), ('worker', 'tasks:read:own'), ('worker', 'tasks:read:other'), ('worker', 'tasks:complete:own'), ('worker', 'tasks:complete:other'), ('viewer', 'garden:read'), ('viewer', 'plants:read:own'), ('viewer', 'plants:read:other'), ('viewer', 'locations:read:own'), ('viewer', 'locations:read:other'), ('viewer', 'tasks:read:own'), ('viewer', 'tasks:read:other'); INSERT INTO task_priorities (name, value, sort_order) VALUES ('Niedrig', -5, 10), ('Normal', 0, 20), ('Erhöht', 3, 30), ('Hoch', 5, 40); INSERT INTO application_settings (singleton) VALUES (true); INSERT INTO species_categories (name, sort_order) VALUES ('Gehölz', 10), ('Gemüse', 20), ('Kraut', 30), ('Obst', 40), ('Staude', 50);