diff --git a/.gitignore b/.gitignore index 2baddd5..c46132a 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,3 @@ frontend/dist/ # OS .DS_Store Thumbs.db - -# Other -*.sql diff --git a/db/periodic-table.sql b/db/periodic-table.sql new file mode 100644 index 0000000..dc1a451 --- /dev/null +++ b/db/periodic-table.sql @@ -0,0 +1,392 @@ +-- TAGS: Global (Shared across all projects) +CREATE TABLE tags ( + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tag_code VARCHAR(10) NOT NULL UNIQUE, + tag_description TEXT NOT NULL +); + +-- GROUPS: Global (Shared across all projects) +CREATE TABLE groups ( + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + group_name TEXT NOT NULL UNIQUE, + hex_color VARCHAR(7) NOT NULL +); + +-- PRIORITIES: Global +CREATE TABLE priorities ( + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + priority_name TEXT NOT NULL UNIQUE, + priority_num INT NOT NULL DEFAULT 0 +); + +-- VALIDATION STATUSES: Global (Approved, Rejected, etc.) +CREATE TABLE validation_statuses ( + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + status_name TEXT NOT NULL UNIQUE +); + +-- [NEW] REQUIREMENT STATUSES: Global (Draft, Regular, Deprecated) +-- This allows you to manage lifecycle states dynamically. +CREATE TABLE requirement_statuses ( + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + status_code VARCHAR(20) NOT NULL UNIQUE, -- e.g. 'DRAFT' + status_name TEXT NOT NULL, -- e.g. 'Draft' + description TEXT +); + +-- Seed initial statuses so the FKs work immediately +INSERT INTO requirement_statuses (status_code, status_name, description) VALUES +('DRAFT', 'Draft', 'Initial version, not ready for review'), +('REGULAR', 'Regular', 'Active requirement'); + +-- ROLES: System-wide roles (e.g., Admin, User) +CREATE TABLE roles ( + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + role_name TEXT NOT NULL UNIQUE +); + +-- USERS: System-wide users +CREATE TABLE users ( + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + sub TEXT NOT NULL UNIQUE, -- Keycloak Subject ID + username TEXT NOT NULL, + full_name TEXT, + role_id INT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + CONSTRAINT fk_users_role FOREIGN KEY (role_id) REFERENCES roles (id) +); + +-- PROJECTS: The container for requirements +CREATE TABLE projects ( + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + project_name TEXT NOT NULL, + project_desc TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- PROJECT_MEMBERS: Controls Access (Many-to-Many) +CREATE TABLE project_members ( + project_id INT NOT NULL, + user_id INT NOT NULL, + joined_at TIMESTAMPTZ DEFAULT NOW(), + + PRIMARY KEY (project_id, user_id), + + CONSTRAINT fk_pm_project FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE, + CONSTRAINT fk_pm_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +-- REQUIREMENTS: Updated with Status ID +CREATE TABLE requirements ( + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + project_id INT NOT NULL, + user_id INT NOT NULL, + tag_id INT NOT NULL, + status_id INT NOT NULL DEFAULT 1, -- [NEW] Defaults to ID 1 (Draft) + last_editor_id INT, + req_name TEXT NOT NULL, + req_desc TEXT, + priority_id INT, + version INT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + CONSTRAINT fk_req_project FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE, + CONSTRAINT fk_req_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL, + CONSTRAINT fk_req_editor FOREIGN KEY (last_editor_id) REFERENCES users (id), + CONSTRAINT fk_req_priority FOREIGN KEY (priority_id) REFERENCES priorities (id), + CONSTRAINT fk_req_tag FOREIGN KEY (tag_id) REFERENCES tags (id), + CONSTRAINT fk_req_status FOREIGN KEY (status_id) REFERENCES requirement_statuses (id) -- [NEW] +); + +-- REQUIREMENTS_GROUPS: Join table for M:N relationship +CREATE TABLE requirements_groups ( + requirement_id INT NOT NULL, + group_id INT NOT NULL, + + PRIMARY KEY (requirement_id, group_id), + + CONSTRAINT fk_rg_req FOREIGN KEY (requirement_id) REFERENCES requirements (id) ON DELETE CASCADE, + CONSTRAINT fk_rg_group FOREIGN KEY (group_id) REFERENCES groups (id) ON DELETE CASCADE +); + +-- VALIDATIONS: No change +CREATE TABLE validations ( + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + requirement_id INT NOT NULL, + user_id INT NOT NULL, + status_id INT NOT NULL, + req_version_snapshot INT NOT NULL, + comment TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + + CONSTRAINT fk_validation_req FOREIGN KEY (requirement_id) REFERENCES requirements (id) ON DELETE CASCADE, + CONSTRAINT fk_validation_user FOREIGN KEY (user_id) REFERENCES users (id), + CONSTRAINT fk_validation_status FOREIGN KEY (status_id) REFERENCES validation_statuses (id) +); + +-- REQUIREMENTS_HISTORY: Updated to track 'status_id' changes +CREATE TABLE requirements_history ( + history_id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + original_req_id INT NOT NULL, + project_id INT, + status_id INT, -- [NEW] Added to history + req_name TEXT, + req_desc TEXT, + priority_id INT, + tag_id INT, + version INT, + valid_from TIMESTAMPTZ, + valid_to TIMESTAMPTZ, + edited_by INT +); + +-- The function to archive the old row before update +CREATE OR REPLACE FUNCTION archive_requirement_change() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO requirements_history ( + original_req_id, + project_id, + status_id, -- [NEW] + req_name, + req_desc, + priority_id, + tag_id, + version, + valid_from, + valid_to, + edited_by + ) + VALUES ( + OLD.id, + OLD.project_id, + OLD.status_id, -- [NEW] + OLD.req_name, + OLD.req_desc, + OLD.priority_id, + OLD.tag_id, + OLD.version, + OLD.updated_at, + NOW(), + OLD.last_editor_id + ); + + NEW.version := OLD.version + 1; + NEW.updated_at := NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Bind Trigger to requirements +CREATE TRIGGER trigger_audit_requirements +BEFORE UPDATE ON requirements +FOR EACH ROW +EXECUTE FUNCTION archive_requirement_change(); + +-- Indexes for performance +CREATE INDEX idx_req_project ON requirements(project_id); +CREATE INDEX idx_req_tag ON requirements(tag_id); +CREATE INDEX idx_req_priority ON requirements(priority_id); +CREATE INDEX idx_req_user ON requirements(user_id); +CREATE INDEX idx_req_status ON requirements(status_id); -- [NEW] +CREATE INDEX idx_pm_user ON project_members(user_id); + +-- RELATIONSHIP_TYPES: Defines valid connection types per project +CREATE TABLE relationship_types ( + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + project_id INT NOT NULL, + type_name TEXT NOT NULL, + type_description TEXT, + inverse_type_name TEXT, + + CONSTRAINT fk_rel_type_project FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE, + CONSTRAINT uq_rel_type_name_project UNIQUE (project_id, type_name) +); + +-- REQUIREMENT_LINKS: The actual connections between requirements +CREATE TABLE requirement_links ( + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + source_req_id INT NOT NULL, + target_req_id INT NOT NULL, + relationship_type_id INT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + created_by INT, + + CONSTRAINT fk_link_source FOREIGN KEY (source_req_id) REFERENCES requirements (id) ON DELETE CASCADE, + CONSTRAINT fk_link_target FOREIGN KEY (target_req_id) REFERENCES requirements (id) ON DELETE CASCADE, + CONSTRAINT fk_link_type FOREIGN KEY (relationship_type_id) REFERENCES relationship_types (id) ON DELETE CASCADE, + CONSTRAINT fk_link_creator FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL, + + CONSTRAINT check_no_self_link CHECK (source_req_id <> target_req_id), + CONSTRAINT uq_req_link_pair UNIQUE (source_req_id, target_req_id, relationship_type_id) +); + +CREATE INDEX idx_link_source ON requirement_links(source_req_id); +CREATE INDEX idx_link_target ON requirement_links(target_req_id); + +-- REQUIREMENT_COMMENTS: Top-level comments +CREATE TABLE requirement_comments ( + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + requirement_id INT NOT NULL, + user_id INT, + comment_text TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + is_deleted BOOLEAN DEFAULT FALSE, + + CONSTRAINT fk_rc_req FOREIGN KEY (requirement_id) REFERENCES requirements (id) ON DELETE CASCADE, + CONSTRAINT fk_rc_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL +); + +-- REQUIREMENT_COMMENT_REPLIES: Responses to top-level comments +CREATE TABLE requirement_comment_replies ( + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + parent_comment_id INT NOT NULL, + user_id INT, + reply_text TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + is_deleted BOOLEAN DEFAULT FALSE, + + CONSTRAINT fk_rcr_parent FOREIGN KEY (parent_comment_id) REFERENCES requirement_comments (id) ON DELETE CASCADE, + CONSTRAINT fk_rcr_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL +); + +CREATE INDEX idx_rc_req ON requirement_comments(requirement_id); +CREATE INDEX idx_rcr_parent ON requirement_comment_replies(parent_comment_id); + +-- 1. HISTORY TABLE: Captures deleted/changed links with Snapshots +CREATE TABLE requirement_links_history ( + history_id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + original_link_id INT, -- The ID of the link from the main table + source_req_id INT, + target_req_id INT, + relationship_type_id INT, -- Kept for reference + relationship_type_snapshot TEXT, -- [IMPORTANT] Text copy of type name (e.g. "Depends On") + inverse_type_snapshot TEXT, -- [IMPORTANT] Text copy of inverse name (e.g. "Is Depended On By") + created_by INT, + valid_from TIMESTAMPTZ, -- When the link was created + valid_to TIMESTAMPTZ DEFAULT NOW() -- When the link was deleted/changed +); + +-- Indexes for querying link history +CREATE INDEX idx_link_hist_source ON requirement_links_history(source_req_id); +CREATE INDEX idx_link_hist_target ON requirement_links_history(target_req_id); + +-- 2. FUNCTION: Handles the archiving with Snapshot lookup +CREATE OR REPLACE FUNCTION archive_link_change() +RETURNS TRIGGER AS $$ +DECLARE + v_type_name TEXT; + v_inverse_name TEXT; +BEGIN + -- Fetch current names from the configuration table to snapshot them + SELECT type_name, inverse_type_name INTO v_type_name, v_inverse_name + FROM relationship_types + WHERE id = OLD.relationship_type_id; + + -- Insert into history with the text snapshots + INSERT INTO requirement_links_history ( + original_link_id, + source_req_id, + target_req_id, + relationship_type_id, + relationship_type_snapshot, + inverse_type_snapshot, + created_by, + valid_from, + valid_to + ) + VALUES ( + OLD.id, + OLD.source_req_id, + OLD.target_req_id, + OLD.relationship_type_id, + v_type_name, -- Saved permanently + v_inverse_name, -- Saved permanently + OLD.created_by, + OLD.created_at, + NOW() + ); + + IF (TG_OP = 'DELETE') THEN + RETURN OLD; + ELSE + RETURN NEW; + END IF; +END; +$$ LANGUAGE plpgsql; + +-- 3. TRIGGER: Binds the function to the table +CREATE TRIGGER trigger_audit_links +BEFORE UPDATE OR DELETE ON requirement_links +FOR EACH ROW +EXECUTE FUNCTION archive_link_change(); + +-- 1. PRE-REQUISITE: Add created_at to the join table +-- We need this to know when the relationship began (valid_from). +ALTER TABLE requirements_groups +ADD COLUMN created_at TIMESTAMPTZ DEFAULT NOW(); + +-- 2. HISTORY TABLE: Captures removed/changed group associations +CREATE TABLE requirements_groups_history ( + history_id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + requirement_id INT NOT NULL, + group_id INT NOT NULL, + + group_name_snapshot TEXT, + group_hex_color_snapshot VARCHAR(7), + + valid_from TIMESTAMPTZ, -- When the association was created + valid_to TIMESTAMPTZ DEFAULT NOW() -- When the association was removed/changed +); + +-- Index for querying history by requirement +CREATE INDEX idx_req_groups_hist_req ON requirements_groups_history(requirement_id); + +-- 3. FUNCTION: Handles the archiving with Snapshot lookup +CREATE OR REPLACE FUNCTION archive_requirements_groups_change() +RETURNS TRIGGER AS $$ +DECLARE + v_group_name TEXT; + v_hex_color VARCHAR(7); +BEGIN + -- Fetch current group details to snapshot them + -- We do this so the history remains readable even if the Group ID is deleted later + SELECT group_name, hex_color INTO v_group_name, v_hex_color + FROM groups + WHERE id = OLD.group_id; + + -- Insert into history + INSERT INTO requirements_groups_history ( + requirement_id, + group_id, + group_name_snapshot, + group_hex_color_snapshot, + valid_from, + valid_to + ) + VALUES ( + OLD.requirement_id, + OLD.group_id, + v_group_name, -- Saved permanently + v_hex_color, -- Saved permanently + OLD.created_at, -- The time it was originally linked + NOW() -- The time it was removed + ); + + IF (TG_OP = 'DELETE') THEN + RETURN OLD; + ELSE + RETURN NEW; + END IF; +END; +$$ LANGUAGE plpgsql; + +-- 4. TRIGGER: Binds the function to the table +-- Triggers on DELETE (removing a group) or UPDATE (swapping a group ID directly) +CREATE TRIGGER trigger_audit_requirements_groups +BEFORE UPDATE OR DELETE ON requirements_groups +FOR EACH ROW +EXECUTE FUNCTION archive_requirements_groups_change(); \ No newline at end of file diff --git a/docker-compose.dev.yaml b/docker-compose-dev.yaml similarity index 68% rename from docker-compose.dev.yaml rename to docker-compose-dev.yaml index d607691..aa90172 100644 --- a/docker-compose.dev.yaml +++ b/docker-compose-dev.yaml @@ -1,7 +1,3 @@ -# Development docker-compose -# Use this for local development with hot reload -# Run: docker-compose -f docker-compose.dev.yaml up - services: # =========================================== # Keycloak Identity Provider @@ -12,9 +8,15 @@ services: environment: KEYCLOAK_ADMIN: admin KEYCLOAK_ADMIN_PASSWORD: admin + # Set hostname so tokens are issued with consistent issuer URL + KC_HOSTNAME: http://localhost:8081 + KC_HOSTNAME_STRICT: false + KC_HTTP_ENABLED: true ports: - "8081:8080" command: start-dev + volumes: + - keycloak_data:/opt/keycloak/data healthcheck: test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8080 && echo -e 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '200 OK'"] interval: 10s @@ -23,7 +25,7 @@ services: start_period: 30s # =========================================== - # FastAPI Backend (with hot reload) + # FastAPI Backend # =========================================== fastapi-app: build: @@ -41,10 +43,28 @@ services: - SUPER_ADMIN_USERNAME=${SUPER_ADMIN_USERNAME:-} - SUPER_ADMIN_EMAIL=${SUPER_ADMIN_EMAIL:-} - SUPER_ADMIN_PASSWORD=${SUPER_ADMIN_PASSWORD:-} + extra_hosts: + - "localhost:host-gateway" depends_on: keycloak: condition: service_healthy + # =========================================== + # React Frontend + # =========================================== + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: frontend + ports: + - "3000:80" + depends_on: + - fastapi-app + networks: default: name: keycloak-auth-network + +volumes: + keycloak_data: \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index aa90172..1ac1334 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,29 +1,4 @@ services: - # =========================================== - # Keycloak Identity Provider - # =========================================== - keycloak: - image: quay.io/keycloak/keycloak:latest - container_name: keycloak - environment: - KEYCLOAK_ADMIN: admin - KEYCLOAK_ADMIN_PASSWORD: admin - # Set hostname so tokens are issued with consistent issuer URL - KC_HOSTNAME: http://localhost:8081 - KC_HOSTNAME_STRICT: false - KC_HTTP_ENABLED: true - ports: - - "8081:8080" - command: start-dev - volumes: - - keycloak_data:/opt/keycloak/data - healthcheck: - test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8080 && echo -e 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '200 OK'"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 30s - # =========================================== # FastAPI Backend # =========================================== @@ -45,9 +20,6 @@ services: - SUPER_ADMIN_PASSWORD=${SUPER_ADMIN_PASSWORD:-} extra_hosts: - "localhost:host-gateway" - depends_on: - keycloak: - condition: service_healthy # =========================================== # React Frontend @@ -64,7 +36,4 @@ services: networks: default: - name: keycloak-auth-network - -volumes: - keycloak_data: \ No newline at end of file + name: keycloak-auth-network \ No newline at end of file diff --git a/k8s/prod.yaml b/k8s/prod.yaml new file mode 100644 index 0000000..9a62585 --- /dev/null +++ b/k8s/prod.yaml @@ -0,0 +1,79 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: periodic-table +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: fastapi-app + namespace: periodic-table +spec: + replicas: 2 + selector: + matchLabels: + app: fastapi-app + template: + metadata: + labels: + app: fastapi-app + spec: + containers: + - name: fastapi-app + image: periodic-table-backend:latest # TODO: push to registry and set full image name + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8080 + envFrom: + - secretRef: + name: periodic-table-env # Provide secrets for APP settings +--- +apiVersion: v1 +kind: Service +metadata: + name: fastapi-app + namespace: periodic-table +spec: + selector: + app: fastapi-app + ports: + - port: 8080 + targetPort: 8080 + name: http + type: ClusterIP +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: frontend + namespace: periodic-table +spec: + replicas: 2 + selector: + matchLabels: + app: frontend + template: + metadata: + labels: + app: frontend + spec: + containers: + - name: frontend + image: periodic-table-frontend:latest # TODO: push to registry and set full image name + imagePullPolicy: IfNotPresent + ports: + - containerPort: 80 +--- +apiVersion: v1 +kind: Service +metadata: + name: frontend + namespace: periodic-table +spec: + selector: + app: frontend + ports: + - port: 80 + targetPort: 80 + name: http + type: LoadBalancer