feat(education): add class relationships

This commit is contained in:
2026-07-31 12:53:13 +08:00
parent 3fdc4edfe0
commit 428d4e10fd
26 changed files with 937 additions and 11 deletions

View File

@@ -0,0 +1,124 @@
CREATE TABLE education_class (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
name VARCHAR(100) NOT NULL,
description VARCHAR(500),
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
creator VARCHAR(64) DEFAULT '',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) DEFAULT '',
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT ck_education_class_status CHECK (status IN ('ACTIVE', 'ARCHIVED'))
);
COMMENT ON TABLE education_class IS 'Education class aggregate owned by one tenant';
CREATE INDEX idx_education_class_tenant_status ON education_class (tenant_id, status, id) WHERE deleted = false;
CREATE TABLE education_class_member (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
class_id BIGINT NOT NULL REFERENCES education_class(id),
member_user_id BIGINT NOT NULL,
role VARCHAR(20) NOT NULL,
joined_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
creator VARCHAR(64) DEFAULT '',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) DEFAULT '',
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT ck_education_class_member_role CHECK (role IN ('STUDENT', 'TEACHER')),
CONSTRAINT uk_education_class_member_tenant_class_user UNIQUE (tenant_id, class_id, member_user_id)
);
COMMENT ON TABLE education_class_member IS 'Education-domain relationship to an existing Member user; no account credentials are stored';
CREATE INDEX idx_education_class_member_tenant_user ON education_class_member (tenant_id, member_user_id, class_id) WHERE deleted = false;
CREATE TABLE education_class_invitation (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
class_id BIGINT NOT NULL REFERENCES education_class(id),
invitee_member_user_id BIGINT NOT NULL,
role VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
expires_at TIMESTAMP NOT NULL,
accepted_at TIMESTAMP,
accepted_by BIGINT,
created_by BIGINT NOT NULL,
create_idempotency_key VARCHAR(64) NOT NULL,
create_request_hash VARCHAR(64) NOT NULL,
creator VARCHAR(64) DEFAULT '',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) DEFAULT '',
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT ck_education_class_invitation_role CHECK (role IN ('STUDENT', 'TEACHER')),
CONSTRAINT ck_education_class_invitation_status CHECK (status IN ('PENDING', 'ACCEPTED', 'EXPIRED')),
CONSTRAINT ck_education_class_invitation_acceptance CHECK (
(status = 'ACCEPTED' AND accepted_at IS NOT NULL AND accepted_by = invitee_member_user_id)
OR (status <> 'ACCEPTED' AND accepted_at IS NULL AND accepted_by IS NULL)
),
CONSTRAINT uk_education_class_invitation_create_key UNIQUE (tenant_id, created_by, create_idempotency_key)
);
COMMENT ON TABLE education_class_invitation IS 'Duplicate-safe invitation for an existing Member user';
CREATE INDEX idx_education_class_invitation_invitee ON education_class_invitation (tenant_id, invitee_member_user_id, status, expires_at);
CREATE TABLE education_class_invitation_audit (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
invitation_id BIGINT NOT NULL REFERENCES education_class_invitation(id),
action VARCHAR(20) NOT NULL,
actor_id BIGINT NOT NULL,
occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
detail JSONB,
creator VARCHAR(64) DEFAULT '',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) DEFAULT '',
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT ck_education_class_invitation_audit_action CHECK (action IN ('CREATED', 'ACCEPTED')),
CONSTRAINT uk_education_class_invitation_audit_action UNIQUE (tenant_id, invitation_id, action)
);
COMMENT ON TABLE education_class_invitation_audit IS 'Append-only audit of invitation creation and acceptance';
CREATE OR REPLACE FUNCTION education_validate_class_relationship_tenant()
RETURNS TRIGGER AS $$
DECLARE owner_tenant BIGINT;
BEGIN
SELECT tenant_id INTO owner_tenant FROM education_class WHERE id = NEW.class_id AND deleted = false;
IF owner_tenant IS NULL OR owner_tenant <> NEW.tenant_id THEN
RAISE EXCEPTION 'education class relationship cannot cross tenants';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_education_class_member_tenant
BEFORE INSERT OR UPDATE ON education_class_member
FOR EACH ROW EXECUTE FUNCTION education_validate_class_relationship_tenant();
CREATE TRIGGER trg_education_class_invitation_tenant
BEFORE INSERT OR UPDATE ON education_class_invitation
FOR EACH ROW EXECUTE FUNCTION education_validate_class_relationship_tenant();
CREATE OR REPLACE FUNCTION education_validate_invitation_audit_tenant()
RETURNS TRIGGER AS $$
DECLARE owner_tenant BIGINT;
BEGIN
SELECT tenant_id INTO owner_tenant FROM education_class_invitation WHERE id = NEW.invitation_id;
IF owner_tenant IS NULL OR owner_tenant <> NEW.tenant_id THEN
RAISE EXCEPTION 'education invitation audit cannot cross tenants';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_education_class_invitation_audit_tenant
BEFORE INSERT OR UPDATE ON education_class_invitation_audit
FOR EACH ROW EXECUTE FUNCTION education_validate_invitation_audit_tenant();
CREATE OR REPLACE FUNCTION education_class_invitation_audit_append_only()
RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION 'education class invitation audit is append-only';
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_education_class_invitation_audit_immutable
BEFORE UPDATE OR DELETE ON education_class_invitation_audit
FOR EACH ROW EXECUTE FUNCTION education_class_invitation_audit_append_only();