diff --git a/.github/workflows/container_builds.yml b/.github/workflows/container_builds.yml index a6cd22727..8d496a8cf 100644 --- a/.github/workflows/container_builds.yml +++ b/.github/workflows/container_builds.yml @@ -22,7 +22,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 3 @@ -62,7 +62,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set Image Version run: | diff --git a/.github/workflows/eslint.yml b/.github/workflows/eslint.yml index ef39f6598..0f7233865 100644 --- a/.github/workflows/eslint.yml +++ b/.github/workflows/eslint.yml @@ -1,16 +1,20 @@ -name: Run ESLint and build frontend +name: Build frontend on: pull_request jobs: frontend: - name: Lint and build JS + name: Build JS runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'yarn' + cache-dependency-path: frontend/yarn.lock - run: | cd frontend && \ corepack install -g yarn && \ yarn && \ - yarn lint-staged && \ yarn build diff --git a/.github/workflows/go_test.yml b/.github/workflows/go_test.yml index 54424dcff..f0818266b 100644 --- a/.github/workflows/go_test.yml +++ b/.github/workflows/go_test.yml @@ -6,10 +6,10 @@ jobs: tests: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: go-version: "1.26.3" diff --git a/.github/workflows/security-frontend.yml b/.github/workflows/security-frontend.yml index aef343f3a..557ab0d0a 100644 --- a/.github/workflows/security-frontend.yml +++ b/.github/workflows/security-frontend.yml @@ -29,7 +29,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: '18' + node-version: '22' cache: 'yarn' cache-dependency-path: frontend/yarn.lock diff --git a/.gitignore b/.gitignore index 9ce83ca65..f042d8db8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,25 +3,30 @@ .claude .idea .claude/* +node_modules/ Claude.md AGENTS.md .github/workflows/test.yml -/frontend/.husky/staged-files jar /config/logs /logs/*.log /logs/*.json +/frontend/dist/ +/frontend/node_modules/ +/frontend/.vite/ /frontend/public/build /frontend/public/thumbnails/* /frontend/public/*.mp4 /frontend/public/videos/* +*.tsbuildinfo /migrations/migrations /provider-middleware/providers.db /backend/server /provider-middleware/middleware +/provider-middleware/provider-middleware /provider-middleware/bin /backend/bin /bin/* /tmp /csvs/zims/*.zim -/.claude \ No newline at end of file +/.claude diff --git a/Makefile b/Makefile index ea641abeb..d11b60c5c 100644 --- a/Makefile +++ b/Makefile @@ -43,7 +43,7 @@ reset: ascii_art init: ascii_art @echo 'Installing dependencies...' - go install github.com/pressly/goose/v3/cmd/goose@latest && go install github.com/air-verse/air@latest && cd frontend && yarn install && yarn prepare && cd .. + go install github.com/pressly/goose/v3/cmd/goose@latest && go install github.com/air-verse/air@latest && cd frontend && yarn install && cd .. @echo 'Dependencies installed successfully.' ./config/zims.sh docker compose up $(BUILD_RECREATE) diff --git a/backend/go.mod b/backend/go.mod index c01959bb5..0f6b4a849 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -20,7 +20,7 @@ require ( github.com/sirupsen/logrus v1.9.4 github.com/stretchr/testify v1.11.1 github.com/teambition/rrule-go v1.8.2 - golang.org/x/net v0.49.0 // indirect + golang.org/x/net v0.53.0 // indirect golang.org/x/oauth2 v0.34.0 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.1 @@ -65,7 +65,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.8.0 // indirect + github.com/jackc/pgx/v5 v5.9.0 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect @@ -86,11 +86,11 @@ require ( github.com/robfig/cron/v3 v3.0.1 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.47.0 // indirect + golang.org/x/crypto v0.50.0 // indirect golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.67.7 // indirect diff --git a/backend/go.sum b/backend/go.sum index ca56e1a5e..8aaa8c82b 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -79,8 +79,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= -github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/pgx/v5 v5.9.0 h1:T/dI+2TvmI2H8s/KH1/lXIbz1CUFk3gn5oTjr0/mBsE= +github.com/jackc/pgx/v5 v5.9.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -164,27 +164,27 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/backend/migrations/00065_add_instructor_to_overrides.sql b/backend/migrations/00065_add_instructor_to_overrides.sql new file mode 100644 index 000000000..d314efb2d --- /dev/null +++ b/backend/migrations/00065_add_instructor_to_overrides.sql @@ -0,0 +1,6 @@ +-- +goose Up +ALTER TABLE public.program_class_event_overrides + ADD COLUMN instructor_id INTEGER REFERENCES public.users(id); + +-- +goose Down +ALTER TABLE public.program_class_event_overrides DROP COLUMN IF EXISTS instructor_id; diff --git a/backend/migrations/00066_create_user_notes.sql b/backend/migrations/00066_create_user_notes.sql new file mode 100644 index 000000000..dda6834e1 --- /dev/null +++ b/backend/migrations/00066_create_user_notes.sql @@ -0,0 +1,16 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS public.user_notes ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + note TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + create_user_id INTEGER NOT NULL REFERENCES public.users(id), + update_user_id INTEGER REFERENCES public.users(id) +); + +CREATE INDEX IF NOT EXISTS idx_user_notes_user_id ON public.user_notes(user_id); + +-- +goose Down +DROP TABLE IF EXISTS public.user_notes; diff --git a/backend/migrations/00067_add_columns_to_program_class_events.sql b/backend/migrations/00067_add_columns_to_program_class_events.sql new file mode 100644 index 000000000..b8d2df7f1 --- /dev/null +++ b/backend/migrations/00067_add_columns_to_program_class_events.sql @@ -0,0 +1,45 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE public.program_class_events + ADD COLUMN instructor_id INTEGER REFERENCES public.users(id) ON DELETE SET NULL, + ADD COLUMN reason CHARACTER VARYING(255) DEFAULT NULL, + ADD COLUMN is_cancelled BOOLEAN NOT NULL DEFAULT FALSE; + +UPDATE public.program_class_events pce +SET instructor_id = pc.instructor_id +FROM public.program_classes pc +WHERE pce.class_id = pc.id + AND pc.instructor_id IS NOT NULL; + +DROP INDEX IF EXISTS idx_program_classes_instructor_id; +ALTER TABLE public.program_classes DROP COLUMN IF EXISTS instructor_id; + +CREATE INDEX idx_program_class_events_instructor_id ON public.program_class_events(instructor_id); + +COMMENT ON COLUMN public.program_class_events.instructor_id IS 'Foreign key to users table; migrated from program_classes.instructor_id as the authoritative location for per-event instructor assignment'; +COMMENT ON COLUMN public.program_class_events.reason IS 'Optional reason recorded when the event was created or modified (e.g. room change, instructor change, reschedule)'; +COMMENT ON COLUMN public.program_class_events.is_cancelled IS 'Used for cancelling an entire series of events when requested'; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS idx_program_class_events_instructor_id; + +-- Restore instructor_id on program_classes +ALTER TABLE public.program_classes + ADD COLUMN instructor_id INTEGER REFERENCES public.users(id) ON DELETE SET NULL; + +-- Backfill program_classes.instructor_id from program_class_events +UPDATE public.program_classes pc +SET instructor_id = pce.instructor_id +FROM public.program_class_events pce +WHERE pce.class_id = pc.id + AND pce.instructor_id IS NOT NULL; + +CREATE INDEX idx_program_classes_instructor_id ON public.program_classes(instructor_id); + +ALTER TABLE public.program_class_events + DROP COLUMN IF EXISTS instructor_id, + DROP COLUMN IF EXISTS reason, + DROP COLUMN IF EXISTS is_cancelled; +-- +goose StatementEnd diff --git a/backend/migrations/00068_add_delete_guard_indexes.sql b/backend/migrations/00068_add_delete_guard_indexes.sql new file mode 100644 index 000000000..f2e1a35f3 --- /dev/null +++ b/backend/migrations/00068_add_delete_guard_indexes.sql @@ -0,0 +1,11 @@ +-- +goose Up +CREATE INDEX IF NOT EXISTS idx_program_completions_program_class_id + ON public.program_completions (program_class_id) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_program_classes_history_table_name_parent_ref_id + ON public.program_classes_history (table_name, parent_ref_id); + +-- +goose Down +DROP INDEX IF EXISTS idx_program_completions_program_class_id; +DROP INDEX IF EXISTS idx_program_classes_history_table_name_parent_ref_id; diff --git a/backend/migrations/go.mod b/backend/migrations/go.mod index bddb29bbd..24be16ac2 100644 --- a/backend/migrations/go.mod +++ b/backend/migrations/go.mod @@ -3,7 +3,7 @@ module migrations go 1.26.3 require ( - github.com/jackc/pgx/v5 v5.7.6 + github.com/jackc/pgx/v5 v5.9.0 github.com/joho/godotenv v1.5.1 github.com/nats-io/nats.go v1.37.0 github.com/ory/kratos-client-go v1.2.0 @@ -23,7 +23,6 @@ require ( github.com/nats-io/nkeys v0.4.7 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect - github.com/stretchr/testify v1.11.1 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.45.0 // indirect golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect diff --git a/backend/migrations/go.sum b/backend/migrations/go.sum index a70a9fd8e..8445a4e33 100644 --- a/backend/migrations/go.sum +++ b/backend/migrations/go.sum @@ -9,8 +9,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= -github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/pgx/v5 v5.9.0 h1:T/dI+2TvmI2H8s/KH1/lXIbz1CUFk3gn5oTjr0/mBsE= +github.com/jackc/pgx/v5 v5.9.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= diff --git a/backend/seeder/go.mod b/backend/seeder/go.mod index 48dafc047..f29be11e3 100644 --- a/backend/seeder/go.mod +++ b/backend/seeder/go.mod @@ -13,12 +13,10 @@ require ( require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.7.6 // indirect + github.com/jackc/pgx/v5 v5.9.0 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/stretchr/testify v1.11.1 // indirect - golang.org/x/crypto v0.45.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/text v0.31.0 // indirect ) diff --git a/backend/seeder/go.sum b/backend/seeder/go.sum index 63278718a..362a5e645 100644 --- a/backend/seeder/go.sum +++ b/backend/seeder/go.sum @@ -6,8 +6,11 @@ github.com/go-faker/faker/v4 v4.6.0/go.mod h1:ZmrHuVtTTm2Em9e0Du6CJ9CADaLEzGXW62 github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= -github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.0 h1:T/dI+2TvmI2H8s/KH1/lXIbz1CUFk3gn5oTjr0/mBsE= +github.com/jackc/pgx/v5 v5.9.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -20,11 +23,13 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/backend/seeder/main.go b/backend/seeder/main.go index e738cc5f5..76f52b758 100644 --- a/backend/seeder/main.go +++ b/backend/seeder/main.go @@ -641,10 +641,9 @@ func createFacilityPrograms(db *gorm.DB) ([]models.ProgramClass, error) { log.Printf("Failed to create program credit type: %v", err) } class := models.ProgramClass{ - Capacity: capacities[rand.Intn(len(capacities))], - Name: programs[i].Name, - InstructorName: faker.Name(), - Description: programClassDescriptions[programs[i].Name], + Capacity: capacities[rand.Intn(len(capacities))], + Name: programs[i].Name, + Description: programClassDescriptions[programs[i].Name], Status: models.Scheduled, //this will change during new class development StartDt: time.Now().Add(14 * 24 * time.Hour), EndDt: &endDates[rand.Intn(len(endDates))], diff --git a/backend/src/database/activity.go b/backend/src/database/activity.go index 207e003ce..0ac710ca4 100644 --- a/backend/src/database/activity.go +++ b/backend/src/database/activity.go @@ -120,7 +120,7 @@ func (db *DB) GetTotalStudentsEnrolled(facilityID *uint) (int, error) { func (db *DB) GetTotalHourlyActivity(facilityID *uint) (int, error) { var totalActivity int subQry := db.Table("activities a"). - Select("CAST(COALESCE(ROUND(SUM(a.total_time)/3600, 0), 0) AS INTEGER) AS total_activity_time"). + Select("CAST(COALESCE(ROUND(SUM(a.time_delta)/3600, 0), 0) AS INTEGER) AS total_activity_time"). Joins("INNER JOIN users u ON u.id = a.user_id") if facilityID != nil { subQry = subQry.Where("u.facility_id = ?", facilityID) @@ -151,7 +151,7 @@ func (db *DB) GetLearningInsights(facilityID *uint) ([]models.LearningInsight, e c.name, COUNT(DISTINCT ue.user_id) AS enrollments, (SELECT COUNT(DISTINCT o.user_id) FROM outcomes o WHERE o.course_id = c.id) AS completions, - (SELECT ROUND(COALESCE(SUM(a.total_time) / 3600, 0), 2) + (SELECT ROUND(COALESCE(SUM(a.time_delta) / 3600, 0), 2) FROM activities a WHERE a.course_id = c.id) AS activity_hours FROM courses c LEFT JOIN user_enrollments ue ON ue.course_id = c.id diff --git a/backend/src/database/class_enrollments.go b/backend/src/database/class_enrollments.go index 058fae048..3eec86258 100644 --- a/backend/src/database/class_enrollments.go +++ b/backend/src/database/class_enrollments.go @@ -353,7 +353,7 @@ type EnrollmentDetails struct { func (db *DB) GetProgramClassEnrollmentsForProgram(args *models.QueryContext, classId int, status string) ([]EnrollmentDetails, error) { content := make([]EnrollmentDetails, 0, args.PerPage) search := args.SearchQuery() - tx := db.WithContext(args.Ctx).Table("program_class_enrollments pse").Select("pse.*, u.name_last || ', ' || u.name_first as name_full, u.doc_id, c.name as class_name, c.start_dt, pc.created_at as completion_dt"). + tx := db.WithContext(args.Ctx).Table("program_class_enrollments pse").Select("pse.*, u.name_first || ' ' || u.name_last as name_full, u.doc_id, c.name as class_name, c.start_dt, pc.created_at as completion_dt"). Joins("JOIN program_classes c ON pse.class_id = c.id AND c.deleted_at IS NULL"). Joins("JOIN users u ON pse.user_id = u.id AND u.deleted_at IS NULL"). Joins("LEFT JOIN program_completions pc ON pc.user_id = pse.user_id AND pc.program_class_id = ?", classId). @@ -380,21 +380,24 @@ func (db *DB) GetProgramClassEnrollmentsForProgram(args *models.QueryContext, cl return content, nil } -func (db *DB) GetProgramClassEnrollmentsAttendance(page, perPage, id int) (int64, []models.ProgramClassEventAttendance, error) { - content := []models.ProgramClassEventAttendance{} +func (db *DB) GetProgramClassEnrollmentsAttendance(page, perPage, id int) (int64, []models.AttendanceRecordResponse, error) { + content := []models.AttendanceRecordResponse{} var total int64 tx := db.Table("program_class_event_attendance att"). - Select("*"). - Joins("JOIN program_class_events evt ON att.event_id = evt.id and evt.deleted_at IS NULL"). - Joins("JOIN program_classes ps ON evt.class_id = ps.id and ps.deleted_at IS NULL"). - Joins("JOIN program_class_enrollments pse ON ps.id = pse.class_id and pse.deleted_at IS NULL"). + Select(`att.user_id, att.date, att.attendance_status, att.note, + COALESCE(CONCAT(admin.name_first, ' ', admin.name_last), '') AS marked_by`). + Joins("JOIN program_class_events evt ON att.event_id = evt.id AND evt.deleted_at IS NULL"). + Joins("JOIN program_classes ps ON evt.class_id = ps.id AND ps.deleted_at IS NULL"). + Joins("JOIN program_class_enrollments pse ON ps.id = pse.class_id AND pse.deleted_at IS NULL"). + Joins("LEFT JOIN users admin ON att.create_user_id = admin.id"). Where("pse.id = ?", id). + Where("att.user_id = pse.user_id"). Where("att.deleted_at IS NULL") if err := tx.Count(&total).Error; err != nil { return 0, nil, newNotFoundDBError(err, "class event") } - if err := tx.Limit(perPage). + if err := tx.Order("att.date DESC").Limit(perPage). Offset(calcOffset(page, perPage)). Find(&content).Error; err != nil { return 0, nil, newNotFoundDBError(err, "class event attendance") diff --git a/backend/src/database/class_events.go b/backend/src/database/class_events.go index 81f71de66..17c4f2066 100644 --- a/backend/src/database/class_events.go +++ b/backend/src/database/class_events.go @@ -14,7 +14,6 @@ import ( "github.com/sirupsen/logrus" "github.com/teambition/rrule-go" "gorm.io/gorm" - "gorm.io/gorm/clause" ) /* @@ -65,7 +64,11 @@ func (db *DB) CreateRescheduleEventSeries(ctx *models.QueryContext, events []mod var changeLogEntry *models.ChangeLogEntry for _, event := range events { if event.ID == 0 { - changeLogEntry = models.NewChangeLogEntry("program_classes", "event_rescheduled_series", models.StringPtr(""), &event.RecurrenceRule, event.ClassID, ctx.UserID) + logField := "event_rescheduled_series" + if event.IsCancelled { + logField = "event_cancelled_series" + } + changeLogEntry = models.NewChangeLogEntry("program_classes", logField, models.StringPtr(""), &event.RecurrenceRule, event.ClassID, ctx.UserID) if err := tx.Create(&event).Error; err != nil { tx.Rollback() return newCreateDBError(err, "program_class_events") @@ -78,9 +81,11 @@ func (db *DB) CreateRescheduleEventSeries(ctx *models.QueryContext, events []mod } } - if err := tx.Create(&changeLogEntry).Error; err != nil { - tx.Rollback() - return newCreateDBError(err, "change_log_entries") + if changeLogEntry != nil { + if err := tx.Create(&changeLogEntry).Error; err != nil { + tx.Rollback() + return newCreateDBError(err, "change_log_entries") + } } if err := tx.Commit().Error; err != nil { return NewDBError(err, "unable to commit the database transaction") @@ -88,9 +93,102 @@ func (db *DB) CreateRescheduleEventSeries(ctx *models.QueryContext, events []mod return nil } +// UncancelEventSeries restores a series-level cancellation from restoreDate onwards. +// It closes the cancelled series with UNTIL = restoreDate-1 and creates a new +// non-cancelled series starting from restoreDate with the same recurrence pattern. +func (db *DB) UncancelEventSeries(ctx *models.QueryContext, eventID uint, restoreDate string) error { + tx := db.WithContext(ctx.Ctx).Begin() + if tx.Error != nil { + return NewDBError(tx.Error, "unable to start database transaction") + } + + var cancelledSeries models.ProgramClassEvent + if err := tx.First(&cancelledSeries, eventID).Error; err != nil { + tx.Rollback() + return newGetRecordsDBError(err, "program_class_events") + } + + restoreDt, err := time.Parse("2006-01-02", restoreDate) + if err != nil { + tx.Rollback() + return NewDBError(err, "invalid restore_date") + } + untilStr := src.FormatDateForUntil(restoreDt.AddDate(0, 0, -1)) + + // Split the recurrence rule into DTSTART and RRULE lines + lines := strings.Split(cancelledSeries.RecurrenceRule, "\n") + var dtStartLine, rruleLine string + for _, l := range lines { + switch { + case strings.HasPrefix(l, "DTSTART"): + dtStartLine = l + case strings.HasPrefix(l, "RRULE:"): + rruleLine = l + } + } + + // Close the cancelled series: add UNTIL to the RRULE line + closedRrule := src.ReplaceOrAddUntilDate(rruleLine, untilStr) + var closedRule string + if dtStartLine != "" { + closedRule = dtStartLine + "\n" + closedRrule + } else { + closedRule = closedRrule + } + if err := tx.Model(&models.ProgramClassEvent{}).Where("id = ?", eventID). + Update("recurrence_rule", closedRule).Error; err != nil { + tx.Rollback() + return newUpdateDBError(err, "program_class_events") + } + + // Build new non-cancelled series starting from restoreDate + var newDtStartLine string + if dtStartLine != "" { + colonIdx := strings.LastIndex(dtStartLine, ":") + if colonIdx >= 0 && colonIdx+9 <= len(dtStartLine) { + newDtStartLine = dtStartLine[:colonIdx+1] + restoreDt.Format("20060102") + dtStartLine[colonIdx+9:] + } else { + newDtStartLine = dtStartLine + } + } + var newSeriesRule string + if newDtStartLine != "" { + newSeriesRule = newDtStartLine + "\n" + rruleLine + } else { + newSeriesRule = rruleLine + } + + newSeries := models.ProgramClassEvent{ + ClassID: cancelledSeries.ClassID, + Duration: cancelledSeries.Duration, + RecurrenceRule: newSeriesRule, + RoomID: cancelledSeries.RoomID, + InstructorID: cancelledSeries.InstructorID, + } + if err := tx.Create(&newSeries).Error; err != nil { + tx.Rollback() + return newCreateDBError(err, "program_class_events") + } + + return tx.Commit().Error +} + +// UncancelOverride flips an override from cancelled back to non-cancelled via a direct +// UPDATE. Used when undoing only the cancellation of a rescheduled class — the override +// stays in place (preserving the reschedule link) but is no longer cancelled. +func (db *DB) UncancelOverride(ctx *models.QueryContext, overrideID uint) error { + return db.WithContext(ctx.Ctx). + Model(&models.ProgramClassEventOverride{}). + Where("id = ?", overrideID). + Updates(map[string]interface{}{ + "is_cancelled": false, + "reason": "", + }).Error +} + // Soft deletes ProgramClassEventOverride by its ID, removes any associated attendance records, and logs the change. // If the ProgramClassEventOverride to be deleted has an exiting LinkedOverrideEventID (rescheduled event) that ProgramClassEventOverride is deleted -func (db *DB) DeleteOverrideEvent(args *models.QueryContext, eventID int, classID int) error { +func (db *DB) DeleteOverrideEvent(args *models.QueryContext, eventID int, classID int, undoAppliedFuture bool) error { trans := db.WithContext(args.Ctx).Begin() if trans.Error != nil { return NewDBError(trans.Error, "unable to start the database transaction") @@ -109,9 +207,48 @@ func (db *DB) DeleteOverrideEvent(args *models.QueryContext, eventID int, classI return newGetRecordsDBError(err, "program_class_event_overrides") } + // Collect all overrides in the chain for deletion. + // Follows both directions: forward links (LinkedOverrideEventID) and reverse + // links (overrides linking TO this one). BFS traverses the full chain so + // undoing A in A→B→C deletes all three overrides. eventIDs = append(eventIDs, override.ID) + visited := map[uint]bool{override.ID: true} + queue := []uint{override.ID} + // Seed the queue with the forward link if it exists if override.LinkedOverrideEventID != nil { - eventIDs = append(eventIDs, *override.LinkedOverrideEventID) + fwd := *override.LinkedOverrideEventID + if !visited[fwd] { + visited[fwd] = true + eventIDs = append(eventIDs, fwd) + queue = append(queue, fwd) + } + } + for len(queue) > 0 { + currentID := queue[0] + queue = queue[1:] + // Reverse links: overrides that point TO currentID + var reverseLinked []models.ProgramClassEventOverride + if err := trans.Where("linked_override_event_id = ?", currentID).Find(&reverseLinked).Error; err == nil { + for _, rl := range reverseLinked { + if !visited[rl.ID] { + visited[rl.ID] = true + eventIDs = append(eventIDs, rl.ID) + queue = append(queue, rl.ID) + } + } + } + // Forward link: the override that currentID points to + var current models.ProgramClassEventOverride + if err := trans.Where("id = ?", currentID).First(¤t).Error; err == nil { + if current.LinkedOverrideEventID != nil { + fwd := *current.LinkedOverrideEventID + if !visited[fwd] { + visited[fwd] = true + eventIDs = append(eventIDs, fwd) + queue = append(queue, fwd) + } + } + } } eventDate, err = override.GetFormattedOverrideDate("2006-01-02") @@ -167,19 +304,66 @@ func (db *DB) CreateOverrideEvents(ctx *models.QueryContext, overrideEvents []*m ) changeLogEntry = models.NewChangeLogEntry("program_classes", "", nil, nil, 0, ctx.UserID) + var parentCancelIDs []uint for _, overrideEvent := range overrideEvents { if !overrideEvent.IsCancelled && linkedOverrideID != nil { overrideEvent.LinkedOverrideEventID = linkedOverrideID + // Clean up old reschedule targets: when re-rescheduling (A→B then B→C), + // the old target (B's non-cancelled override) must be removed so only + // the new target (C) remains linked to A's cancel override. + if err := trans.Where("linked_override_event_id = ? AND is_cancelled = false AND id != ? AND deleted_at IS NULL", + *linkedOverrideID, overrideEvent.ID). + Delete(&models.ProgramClassEventOverride{}).Error; err != nil { + trans.Rollback() + return NewDBError(err, "unable to clean up old reschedule targets") + } + } + if overrideEvent.ID == 0 { + var existing models.ProgramClassEventOverride + if err := trans.Unscoped(). + Where("event_id = ? AND override_rrule = ? AND deleted_at IS NULL", + overrideEvent.EventID, overrideEvent.OverrideRrule). + First(&existing).Error; err == nil { + // If the existing override is a reschedule target linked from a cancel, + // remember the parent cancel so we can preserve it after the upsert + if existing.LinkedOverrideEventID != nil && *existing.LinkedOverrideEventID != existing.ID { + parentCancelIDs = append(parentCancelIDs, *existing.LinkedOverrideEventID) + } + overrideEvent.ID = existing.ID + // Preserve the chain link when overwriting a reschedule target with a cancel. + // Example: After A→B, B's override has linked=A_cancel. When B is cancelled, + // the incoming cancel has linked=nil. Carry forward so the A←B chain survives. + if overrideEvent.LinkedOverrideEventID == nil && existing.LinkedOverrideEventID != nil { + overrideEvent.LinkedOverrideEventID = existing.LinkedOverrideEventID + } + } } isOverrideUpdate = overrideEvent.ID > 0 - if err := trans.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "id"}}, - DoUpdates: clause.AssignmentColumns([]string{"duration", "override_rrule", "is_cancelled", "room_id", "reason", "linked_override_event_id"}), - }).Create(&overrideEvent).Error; err != nil { - trans.Rollback() - return newCreateDBError(err, "program_class_event_overrides") + if isOverrideUpdate { + // Existing override found — do a direct UPDATE to avoid GORM's + // soft-delete behavior with Create+OnConflict which deletes the + // existing record and creates a new one (breaking chain links). + if err := trans.Model(&models.ProgramClassEventOverride{}). + Where("id = ?", overrideEvent.ID). + Updates(map[string]interface{}{ + "duration": overrideEvent.Duration, + "override_rrule": overrideEvent.OverrideRrule, + "is_cancelled": overrideEvent.IsCancelled, + "room_id": overrideEvent.RoomID, + "reason": overrideEvent.Reason, + "linked_override_event_id": overrideEvent.LinkedOverrideEventID, + "instructor_id": overrideEvent.InstructorID, + }).Error; err != nil { + trans.Rollback() + return newCreateDBError(err, "program_class_event_overrides") + } + } else { + if err := trans.Create(&overrideEvent).Error; err != nil { + trans.Rollback() + return newCreateDBError(err, "program_class_event_overrides") + } } - if overrideEvent.IsCancelled && len(overrideEvents) < 2 { //only add log for cancelled event + if overrideEvent.IsCancelled && len(overrideEvents) < 2 { changeLogEntry.FieldName = "event_cancelled" changeLogEntry.OldValue = models.StringPtr("") changeLogEntry.ParentRefID = overrideEvent.ClassID @@ -193,10 +377,30 @@ func (db *DB) CreateOverrideEvents(ctx *models.QueryContext, overrideEvents []*m changeLogEntry.FieldName = "event_rescheduled" changeLogEntry.ParentRefID = overrideEvent.ClassID changeLogEntry.NewValue = eventSummary - } else if overrideEvent.IsCancelled { //not logging the cancelled one from rescheduling action + } else if overrideEvent.IsCancelled { linkedOverrideID = &overrideEvent.ID changeLogEntry.OldValue = &overrideEvent.OverrideRrule } + if !overrideEvent.IsCancelled && overrideEvent.InstructorID != nil { + var instructor models.User + if err := trans.Select("name_first", "name_last").First(&instructor, *overrideEvent.InstructorID).Error; err == nil { + eventDate, _ := overrideEvent.GetFormattedOverrideDate("1/02/2006") + summary := instructor.NameFirst + " " + instructor.NameLast + " on " + *eventDate + changeLogEntry.FieldName = "event_substitute_instructor" + changeLogEntry.ParentRefID = overrideEvent.ClassID + changeLogEntry.NewValue = &summary + } + } + if !overrideEvent.IsCancelled && overrideEvent.RoomID != nil && len(overrideEvents) == 1 { + var room models.Room + if err := trans.Select("name").First(&room, *overrideEvent.RoomID).Error; err == nil { + eventDate, _ := overrideEvent.GetFormattedOverrideDate("1/02/2006") + summary := room.Name + " on " + *eventDate + changeLogEntry.FieldName = "event_room_changed" + changeLogEntry.ParentRefID = overrideEvent.ClassID + changeLogEntry.NewValue = &summary + } + } if overrideEvent.IsCancelled || isOverrideUpdate { //delete attendance eventDate, err = overrideEvent.GetFormattedOverrideDate("2006-01-02") if err != nil { @@ -210,6 +414,17 @@ func (db *DB) CreateOverrideEvents(ctx *models.QueryContext, overrideEvents []*m } } + // Restore parent cancel overrides that may have been soft-deleted by the upsert. + // This preserves the A→B→C chain: when rescheduling B→C, A's cancel must survive. + if len(parentCancelIDs) > 0 { + if err := trans.Unscoped().Model(&models.ProgramClassEventOverride{}). + Where("id IN ?", parentCancelIDs). + Update("deleted_at", nil).Error; err != nil { + trans.Rollback() + return NewDBError(err, "unable to restore parent cancel overrides") + } + } + if err := trans.Create(&changeLogEntry).Error; err != nil { trans.Rollback() return newCreateDBError(err, "change_log_entries") @@ -246,7 +461,13 @@ func (db *DB) syncClassDateBoundaries(trans *gorm.DB, classID uint) error { } originalBaseStart := rRule.OrigOptions.Dtstart.In(time.UTC) - ruleUntil := rRule.GetUntil() + // rrule-go's GetUntil() returns a far-future date (~year 2318) instead of zero + // when no UNTIL is present, so check the raw string for an explicit UNTIL clause + hasExplicitUntil := strings.Contains(event.RecurrenceRule, "UNTIL=") + var ruleUntil time.Time + if hasExplicitUntil { + ruleUntil = rRule.GetUntil() + } startBoundary := class.StartDt endBoundary := startBoundary.AddDate(5, 0, 0) @@ -452,19 +673,21 @@ func (db *DB) NewEventOverride(eventId int, form *models.OverrideForm) (*models. func (db *DB) GetFacilityCalendar(args *models.QueryContext, dtRng *models.DateRange, classID int) ([]models.FacilityProgramClassEvent, error) { events := make([]models.FacilityProgramClassEvent, 0, 10) - // TO DO: finish adding overrides as is_cancelled (so it renders in the frontend) tx := db.WithContext(args.Ctx).Table("program_class_events pcev"). Select(`pcev.id, pcev.created_at, pcev.updated_at, pcev.deleted_at, pcev.class_id, pcev.duration, pcev.recurrence_rule, pcev.room_id, + pcev.instructor_id, pcev.is_cancelled, pcev.reason, r.name as room, c.program_id as program_id, p.name as program_name, - c.instructor_name, + COALESCE(iu.name_first || ' ' || iu.name_last, '') as instructor_name, c.name as class_name, c.status as class_status, STRING_AGG(CONCAT(u.id, ':', u.name_last, ', ', u.name_first), '|' ORDER BY u.name_last) FILTER (WHERE e.enrollment_status = 'Enrolled') AS enrolled_users`). - Joins("JOIN program_classes c ON c.id = pcev.class_id and c.archived_at IS NULL"). + Joins("JOIN program_classes c ON c.id = pcev.class_id AND c.archived_at IS NULL AND c.deleted_at IS NULL"). Joins("JOIN programs p ON p.id = c.program_id"). + Where("pcev.deleted_at IS NULL"). Joins("LEFT JOIN rooms r ON r.id = pcev.room_id"). + Joins("LEFT JOIN users iu ON iu.id = pcev.instructor_id"). Joins("LEFT JOIN program_class_enrollments e ON e.class_id = c.id"). Joins("LEFT JOIN users u ON e.user_id = u.id"). Where("c.facility_id = ?", args.FacilityID) @@ -475,7 +698,7 @@ func (db *DB) GetFacilityCalendar(args *models.QueryContext, dtRng *models.DateR if !args.IsAdmin { tx = tx.Where("u.id = ? AND e.enrollment_status = 'Enrolled'", args.UserID) } - tx = tx.Group("pcev.id, c.instructor_name, c.name, c.status, c.program_id, p.name, r.name") + tx = tx.Group("pcev.id, iu.name_first, iu.name_last, c.name, c.status, c.program_id, p.name, r.name") if err := tx.Scan(&events).Error; err != nil { return nil, newGetRecordsDBError(err, "program_class_events") } @@ -511,8 +734,9 @@ func (db *DB) GetFacilityCalendar(args *models.QueryContext, dtRng *models.DateR canonicalHour, canonicalMinute := getCanonicalHourAndMinute(occurrences, args.Timezone) overrides := overrideEventMap[event.ID] var ( - isCancelled, isRescheduled bool - overrideID uint + isCancelled, isRescheduled, isRoomOrInstructorOverride bool + overrideID uint + overrideReason string ) userLocation, _ := time.LoadLocation(args.Timezone) for _, occurrence := range occurrences { @@ -527,15 +751,46 @@ func (db *DB) GetFacilityCalendar(args *models.QueryContext, dtRng *models.DateR 0, userLocation, ) - isCancelled, isRescheduled, overrideID = checkEventCancelledAndRescheduled(consistentOccurrence, overrides, args.Timezone) + // Series-level cancellation: every occurrence is cancelled, bypass override logic + if event.IsCancelled { + endTime := consistentOccurrence.Add(duration) + pce := event.ProgramClassEvent + if event.Reason != nil { + r := *event.Reason + pce.Reason = &r + } + facilityEvent := models.FacilityProgramClassEvent{ + ProgramClassEvent: pce, + Room: event.Room, + InstructorName: event.InstructorName, + ProgramID: event.ProgramID, + ProgramName: event.ProgramName, + ClassName: event.ClassName, + EnrolledUsers: event.EnrolledUsers, + ClassStatus: event.ClassStatus, + StartTime: &consistentOccurrence, + EndTime: &endTime, + Frequency: rRule.OrigOptions.Freq.String(), + IsCancelled: true, + } + facilityEvents = append(facilityEvents, facilityEvent) + continue + } - if isCancelled && isRescheduled { + isCancelled, isRescheduled, isRoomOrInstructorOverride, overrideID, overrideReason = checkEventCancelledAndRescheduled(consistentOccurrence, overrides, args.Timezone) + + if (isCancelled && isRescheduled) || isRoomOrInstructorOverride { continue } endTime := consistentOccurrence.Add(duration) + pce := event.ProgramClassEvent + if isCancelled && overrideReason != "" { + r := overrideReason + pce.Reason = &r + } facilityEvent := models.FacilityProgramClassEvent{ - ProgramClassEvent: event.ProgramClassEvent, + ProgramClassEvent: pce, Room: event.Room, InstructorName: event.InstructorName, ProgramID: event.ProgramID, @@ -592,26 +847,48 @@ func (db *DB) GetFacilityCalendar(args *models.QueryContext, dtRng *models.DateR } pce := event.ProgramClassEvent roomName := event.Room + originalRoomName := "" + instructorName := event.InstructorName + originalInstructorName := "" if override.RoomRef != nil { - roomName = override.RoomRef.Name + newRoomName := override.RoomRef.Name + if newRoomName != event.Room { + originalRoomName = event.Room + } + roomName = newRoomName pce.RoomID = override.RoomID } + if override.InstructorID != nil { + pce.InstructorID = override.InstructorID + if override.Instructor != nil { + newInstructorName := strings.TrimSpace(override.Instructor.NameFirst + " " + override.Instructor.NameLast) + if newInstructorName != event.InstructorName { + originalInstructorName = event.InstructorName + } + instructorName = newInstructorName + } else if event.InstructorID == nil || *event.InstructorID != *override.InstructorID { + originalInstructorName = event.InstructorName + instructorName = "Unknown Instructor" + } + } facilityEvent := models.FacilityProgramClassEvent{ - ProgramClassEvent: pce, - Room: roomName, - InstructorName: event.InstructorName, - ProgramID: event.ProgramID, - ProgramName: event.ProgramName, - ClassName: event.ClassName, - EnrolledUsers: event.EnrolledUsers, - ClassStatus: event.ClassStatus, - StartTime: &consistentOverrideDate, - EndTime: &overrideEndTime, - Frequency: rRule.OrigOptions.Freq.String(), - IsOverride: true, - IsCancelled: override.IsCancelled, - OverrideID: override.ID, - LinkedOverrideEvent: linkedOverrideEvent, + ProgramClassEvent: pce, + Room: roomName, + OriginalRoom: originalRoomName, + InstructorName: instructorName, + OriginalInstructorName: originalInstructorName, + ProgramID: event.ProgramID, + ProgramName: event.ProgramName, + ClassName: event.ClassName, + EnrolledUsers: event.EnrolledUsers, + ClassStatus: event.ClassStatus, + StartTime: &consistentOverrideDate, + EndTime: &overrideEndTime, + Frequency: rRule.OrigOptions.Freq.String(), + IsOverride: true, + IsCancelled: override.IsCancelled, + OverrideID: override.ID, + LinkedOverrideEvent: linkedOverrideEvent, } facilityEvents = append(facilityEvents, facilityEvent) } @@ -688,24 +965,26 @@ func getLinkedOverrideEventMap(event models.FacilityProgramClassEvent, overrides return overrideEventMap } -func checkEventCancelledAndRescheduled(occurrence time.Time, overrides []models.ProgramClassEventOverride, timezone string) (bool, bool, uint) { +func checkEventCancelledAndRescheduled(occurrence time.Time, overrides []models.ProgramClassEventOverride, timezone string) (bool, bool, bool, uint, string) { var ( - isCancelled = false - isRescheduled = false - overrideID uint + isCancelled = false + isRescheduled = false + isRoomOrInstructor = false + overrideID uint + overrideReason string ) for _, override := range overrides { - if !override.IsCancelled { //skip + if !override.IsCancelled && override.LinkedOverrideEventID != nil { //skip continue } rRule, err := rrule.StrToRRule(override.OverrideRrule) if err != nil { - logrus.Warnf("unable to parse cancelled override rule: %s", override.OverrideRrule) + logrus.Warnf("unable to parse override rule: %s", override.OverrideRrule) continue } if len(rRule.All()) == 0 { - logrus.Warnf("cancelled override rule does not contain a date instance, rule is: %s", override.OverrideRrule) + logrus.Warnf("override rule does not contain a date instance, rule is: %s", override.OverrideRrule) continue } overrideDate := rRule.All()[0] @@ -727,12 +1006,14 @@ func checkEventCancelledAndRescheduled(occurrence time.Time, overrides []models. //END day light savings time fix here if consistentOverrideDate.Equal(occurrence) { isRescheduled = override.Reason == "rescheduled" - isCancelled = true + isCancelled = override.IsCancelled + isRoomOrInstructor = !override.IsCancelled && !isRescheduled overrideID = override.ID + overrideReason = override.Reason break } } - return isCancelled, isRescheduled, overrideID + return isCancelled, isRescheduled, isRoomOrInstructor, overrideID, overrideReason } func applyOverrides(event models.ProgramClassEvent, start, end time.Time) []models.EventInstance { @@ -830,10 +1111,14 @@ func generateEventInstances(event models.ProgramClassEvent, startDate, endDate t return eventInstances } +func GenerateEventInstances(event models.ProgramClassEvent, startDate, endDate time.Time) []models.EventInstance { + return generateEventInstances(event, startDate, endDate) +} + // GetClassEventInstancesWithAttendanceForRecurrence returns all occurrences for events // for a given class based on each event's recurrence rule (from DTSTART until UNTIL) // along with their associated attendance records. -func (db *DB) GetClassEventInstancesWithAttendanceForRecurrence(classId int, qryCtx *models.QueryContext, month, year string, userId *int) ([]models.ClassEventInstance, error) { +func (db *DB) GetClassEventInstancesWithAttendanceForRecurrence(classId int, qryCtx *models.QueryContext, month, year string, userId *int, allInstances bool) ([]models.ClassEventInstance, error) { loc, err := time.LoadLocation(qryCtx.Timezone) if err != nil { logrus.Error("failed to load timezone") @@ -876,8 +1161,23 @@ func (db *DB) GetClassEventInstancesWithAttendanceForRecurrence(classId int, qry untilTime = enrollment.UpdatedAt.AddDate(0, 0, 1).Truncate(24 * time.Hour) } } else { - startTime = time.Now().Add(time.Hour * 24 * -14) - untilTime = startTime.AddDate(0, 1, 0) + startTime = rRule.GetDTStart() + if allInstances { + // rrule-go's GetUntil() returns a far-future date (~year 2318) instead of zero + // when no UNTIL is present, so check the raw string for an explicit UNTIL clause + hasExplicitUntil := strings.Contains(event.RecurrenceRule, "UNTIL=") + if hasExplicitUntil { + untilTime = rRule.GetUntil().AddDate(0, 0, 1) + } else { + untilTime = time.Now().AddDate(1, 0, 0).Truncate(24 * time.Hour) + } + maxFuture := time.Now().AddDate(0, 6, 0).Truncate(24 * time.Hour) + if untilTime.After(maxFuture) { + untilTime = maxFuture + } + } else { + untilTime = time.Now().AddDate(0, 1, 0).Truncate(24 * time.Hour) + } } } else { yearInt, err := strconv.Atoi(year) @@ -905,10 +1205,12 @@ func (db *DB) GetClassEventInstancesWithAttendanceForRecurrence(classId int, qry } startTime = rRule.GetDTStart() - userLocation, _ := time.LoadLocation(loc.String()) - localStartTime := startTime.In(userLocation) - canonicalHour = localStartTime.Hour() - canonicalMinute = localStartTime.Minute() + // Use the DTSTART's own timezone to extract the canonical wall-clock time, + // not the user's timezone. The DTSTART time represents the class's local + // wall-clock time at the facility, so converting to a different timezone + // would shift the displayed time incorrectly. + canonicalHour = startTime.Hour() + canonicalMinute = startTime.Minute() firstOcc := occurrences[0] //day light savings time issue @@ -953,25 +1255,32 @@ func (db *DB) GetClassEventInstancesWithAttendanceForRecurrence(classId int, qry eventInstances := createEventInstances(event, occurrences, loc, classTime, attendances, canonicalHour, canonicalMinute) qryCtx.Total = int64(len(eventInstances)) - offset := qryCtx.CalcOffset() - end := offset + qryCtx.PerPage - if offset >= len(eventInstances) { - eventInstances = []models.ClassEventInstance{} - } else if end > len(eventInstances) { - eventInstances = eventInstances[offset:] - } else { - eventInstances = eventInstances[offset:end] + if !qryCtx.All { + offset := qryCtx.CalcOffset() + end := offset + qryCtx.PerPage + if offset >= len(eventInstances) { + eventInstances = []models.ClassEventInstance{} + } else if end > len(eventInstances) { + eventInstances = eventInstances[offset:] + } else { + eventInstances = eventInstances[offset:end] + } } return eventInstances, nil } func createEventInstances(event models.ProgramClassEvent, occurrences []time.Time, loc *time.Location, classTime string, attendances []models.ProgramClassEventAttendance, canonicalHour, canonicalMinute int) []models.ClassEventInstance { - var eventInstances []models.ClassEventInstance + // Pre-index attendance by date for O(1) lookup instead of scanning all records per occurrence + attendanceByDate := make(map[string][]models.ProgramClassEventAttendance, len(attendances)) + for i := range attendances { + attendanceByDate[attendances[i].Date] = append(attendanceByDate[attendances[i].Date], attendances[i]) + } + + eventInstances := make([]models.ClassEventInstance, 0, len(occurrences)) //daylight saving time fix (VIP) prevents the time shift for _, occ := range occurrences { - occInLoc := occ.In(loc) - occDateStr := occInLoc.Format("2006-01-02") + occDateStr := occ.In(loc).Format("2006-01-02") consistentOccurrence := time.Date( occ.Year(), occ.Month(), @@ -982,39 +1291,130 @@ func createEventInstances(event models.ProgramClassEvent, occurrences []time.Tim 0, loc, ) - isCancelled, isRescheduled, _ := checkEventCancelledAndRescheduled(consistentOccurrence, event.Overrides, loc.String()) - - if isCancelled && isRescheduled { //skip occurrence - continue - } - - relevantAttendance := src.FilterMap(attendances, func(att models.ProgramClassEventAttendance) bool { - return att.Date == occDateStr - }) + isCancelled, isRescheduled, _, overrideID, _ := checkEventCancelledAndRescheduled(consistentOccurrence, event.Overrides, loc.String()) eventInstance := models.ClassEventInstance{ EventID: event.ID, ClassTime: classTime, Date: occDateStr, - AttendanceRecords: relevantAttendance, + AttendanceRecords: attendanceByDate[occDateStr], IsCancelled: isCancelled, + IsRescheduled: isRescheduled, + } + if isCancelled { + eventInstance.OverrideID = overrideID + } + if isCancelled && isRescheduled { + // Find the final reschedule target by following the chain. + // Simple case: non-cancelled override linked to this cancel (A→B). + // Chain case: cancelled override linked to this cancel (A→B→C), + // then follow B's cancel to find C's non-cancelled reschedule target. + currentID := overrideID + visited := map[uint]bool{currentID: true} + for { + foundTarget := false + // Look for non-cancelled reschedule target linked to currentID + for _, other := range event.Overrides { + if !other.IsCancelled && other.LinkedOverrideEventID != nil && *other.LinkedOverrideEventID == currentID { + rRule, err := rrule.StrToRRule(other.OverrideRrule) + if err == nil && len(rRule.All()) > 0 { + eventInstance.RescheduledToDate = rRule.All()[0].In(loc).Format("2006-01-02") + } + foundTarget = true + break + } + } + if foundTarget { + break + } + // No non-cancelled target found — look for a cancelled intermediate + // (chain case: B was re-rescheduled, so B's override is now cancelled) + nextID := uint(0) + for _, other := range event.Overrides { + if other.IsCancelled && other.LinkedOverrideEventID != nil && *other.LinkedOverrideEventID == currentID && !visited[other.ID] { + nextID = other.ID + break + } + } + if nextID == 0 { + break + } + visited[nextID] = true + currentID = nextID + } } eventInstances = append(eventInstances, eventInstance) } - for _, override := range event.Overrides { //adding the rescheduled ones to instances slice - if override.IsCancelled { //skip cancelled ones + // Collect non-cancelled, non-reschedule overrides (room/instructor/time changes) + // grouped by date so we can match them to base instances by time + type sameDateOverride struct { + override models.ProgramClassEventOverride + startTime string + } + sameDateOverridesByDate := make(map[string][]sameDateOverride) + for _, override := range event.Overrides { + isReschedule := override.LinkedOverrideEventID != nil && *override.LinkedOverrideEventID != override.ID + if override.IsCancelled || isReschedule { continue } - rRule, err := rrule.StrToRRule(override.OverrideRrule) + parsedRule, err := rrule.StrToRRule(override.OverrideRrule) + if err != nil || len(parsedRule.All()) == 0 { + continue + } + overrideDate := parsedRule.All()[0].In(loc) + dateStr := overrideDate.Format("2006-01-02") + startTime := overrideDate.Format("15:04") + sameDateOverridesByDate[dateStr] = append(sameDateOverridesByDate[dateStr], sameDateOverride{ + override: override, + startTime: startTime, + }) + } + // Apply room/instructor/time overrides to base instances, matching by time + for i := range eventInstances { + overrides, ok := sameDateOverridesByDate[eventInstances[i].Date] + if !ok { + continue + } + instStartTime := strings.Split(eventInstances[i].ClassTime, "-")[0] + for _, sdo := range overrides { + if sdo.startTime == instStartTime { + parsedRule, _ := rrule.StrToRRule(sdo.override.OverrideRrule) + overrideDate := parsedRule.All()[0] + duration, err := time.ParseDuration(sdo.override.Duration) + if err == nil { + overrideEnd := overrideDate.Add(duration) + eventInstances[i].ClassTime = overrideDate.In(loc).Format("15:04") + "-" + overrideEnd.In(loc).Format("15:04") + } + eventInstances[i].OverrideID = sdo.override.ID + break + } + } + } + // Index existing instances by date for dedup + instanceByDate := make(map[string]int, len(eventInstances)) + for i, inst := range eventInstances { + instanceByDate[inst.Date] = i + } + // Add rescheduled sessions (overrides with LinkedOverrideEventID pointing to a different override). + // Includes both non-cancelled targets (normal reschedule) and cancelled targets + // (Scenario 1: B was rescheduled from A, then B was cancelled — B is both a + // reschedule target and cancelled, needs to show as "Cancelled + Rescheduled Class"). + for _, override := range event.Overrides { + isReschedule := override.LinkedOverrideEventID != nil && *override.LinkedOverrideEventID != override.ID + if !isReschedule { + continue + } + parsedRule, err := rrule.StrToRRule(override.OverrideRrule) if err != nil { logrus.Warnf("unable to parse reschedule override rule: %s", override.OverrideRrule) continue } - if len(rRule.All()) == 0 { + allOccs := parsedRule.All() + if len(allOccs) == 0 { logrus.Warnf("cancelled override rule does not contain a date instance, rule is: %s", override.OverrideRrule) continue } - overrideDate := rRule.All()[0] + overrideDate := allOccs[0] overrideDateStr := overrideDate.In(loc).Format("2006-01-02") duration, err := time.ParseDuration(override.Duration) @@ -1024,17 +1424,38 @@ func createEventInstances(event models.ProgramClassEvent, occurrences []time.Tim overrideEnd := overrideDate.Add(duration) overrideClassTime := overrideDate.In(loc).Format("15:04") + "-" + overrideEnd.In(loc).Format("15:04") - relevantAttendance := src.FilterMap(attendances, func(att models.ProgramClassEventAttendance) bool { - return att.Date == overrideDateStr - }) + rescheduledFromDate := "" + for _, other := range event.Overrides { + if other.ID == *override.LinkedOverrideEventID && other.IsCancelled { + otherRule, err := rrule.StrToRRule(other.OverrideRrule) + if err == nil && len(otherRule.All()) > 0 { + rescheduledFromDate = otherRule.All()[0].In(loc).Format("2006-01-02") + } + break + } + } rescheduledInstance := models.ClassEventInstance{ - EventID: event.ID, - ClassTime: overrideClassTime, - Date: overrideDateStr, - AttendanceRecords: relevantAttendance, - IsCancelled: false, + EventID: event.ID, + ClassTime: overrideClassTime, + Date: overrideDateStr, + AttendanceRecords: attendanceByDate[overrideDateStr], + IsCancelled: override.IsCancelled, + RescheduledFromDate: rescheduledFromDate, + OverrideID: override.ID, + } + if existingIdx, exists := instanceByDate[overrideDateStr]; exists { + existingInst := eventInstances[existingIdx] + if existingInst.ClassTime == overrideClassTime { + // Same time slot on the same date — replace (same session with reschedule info) + eventInstances[existingIdx] = rescheduledInstance + } else { + // Different times on the same date — keep both as separate sessions + eventInstances = append(eventInstances, rescheduledInstance) + } + } else { + instanceByDate[overrideDateStr] = len(eventInstances) + eventInstances = append(eventInstances, rescheduledInstance) } - eventInstances = append(eventInstances, rescheduledInstance) } slices.SortFunc(eventInstances, func(a, b models.ClassEventInstance) int { return cmp.Compare(b.Date, a.Date) @@ -1086,14 +1507,14 @@ func (db *DB) GetClassEventDatesForRecurrence(classID int, timezone string, mont var canonicalHour, canonicalMinute int if len(occs) > 0 { - localStart := rule.GetDTStart().In(loc) - canonicalHour = localStart.Hour() - canonicalMinute = localStart.Minute() + dtstart := rule.GetDTStart() + canonicalHour = dtstart.Hour() + canonicalMinute = dtstart.Minute() } out := make([]models.EventDates, 0, len(occs)) for _, occ := range occs { - isCancelled, _, _ := checkEventCancelledAndRescheduled(occ, event.Overrides, timezone) + isCancelled, _, _, _, _ := checkEventCancelledAndRescheduled(occ, event.Overrides, timezone) if isCancelled { continue } @@ -1149,7 +1570,7 @@ func (db *DB) GetClassEventDatesForRecurrence(classID int, timezone string, mont func (db *DB) GetProgramClassEventOverrides(qryCtx *models.QueryContext, eventIDs ...uint) ([]models.ProgramClassEventOverride, error) { overrides := make([]models.ProgramClassEventOverride, 0) - if err := db.WithContext(qryCtx.Ctx).Model(&models.ProgramClassEventOverride{}).Preload("RoomRef").Where("event_id IN (?)", eventIDs).Order("id desc").Find(&overrides).Error; err != nil { + if err := db.WithContext(qryCtx.Ctx).Model(&models.ProgramClassEventOverride{}).Preload("RoomRef").Preload("Instructor").Where("event_id IN (?)", eventIDs).Order("id desc").Find(&overrides).Error; err != nil { return nil, newGetRecordsDBError(err, "program_class_event_overrides") } return overrides, nil diff --git a/backend/src/database/dashboard.go b/backend/src/database/dashboard.go new file mode 100644 index 000000000..a45e532fb --- /dev/null +++ b/backend/src/database/dashboard.go @@ -0,0 +1,195 @@ +package database + +import ( + "UnlockEdv2/src/models" +) + +func (db *DB) GetClassDashboardMetrics(ctx *models.QueryContext, facilityID *uint) (models.ClassDashboardMetrics, error) { + var metrics models.ClassDashboardMetrics + + var err error + if metrics.ActiveClasses, err = db.GetActiveClassCount(ctx, facilityID); err != nil { + return metrics, err + } + if metrics.TotalSeats, err = db.GetTotalSeatCount(ctx, facilityID); err != nil { + return metrics, err + } + if metrics.ScheduledClasses, err = db.GetScheduledClassCount(ctx, facilityID); err != nil { + return metrics, err + } + if metrics.TotalEnrollments, err = db.GetTotalEnrollmentsCount(ctx, facilityID); err != nil { + return metrics, err + } + if metrics.AttendanceConcerns, err = db.GetAttendanceConcernsCount(ctx, facilityID); err != nil { + return metrics, err + } + + return metrics, nil +} + +func (db *DB) GetActiveClassCount(ctx *models.QueryContext, facilityID *uint) (int64, error) { + var count int64 + query := db.WithContext(ctx.Ctx). + Model(&models.ProgramClass{}). + Where("status = ?", models.Active). + Where("archived_at IS NULL") + if facilityID != nil { + query = query.Where("facility_id = ?", *facilityID) + } + if err := query.Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +func (db *DB) GetTotalSeatCount(ctx *models.QueryContext, facilityID *uint) (int64, error) { + var totalSeats int64 + query := db.WithContext(ctx.Ctx). + Model(&models.ProgramClass{}). + Where("status = ?", models.Active). + Where("archived_at IS NULL") + if facilityID != nil { + query = query.Where("facility_id = ?", *facilityID) + } + if err := query.Select("COALESCE(SUM(capacity), 0)").Scan(&totalSeats).Error; err != nil { + return 0, err + } + return totalSeats, nil +} + +func (db *DB) GetScheduledClassCount(ctx *models.QueryContext, facilityID *uint) (int64, error) { + var count int64 + query := db.WithContext(ctx.Ctx). + Model(&models.ProgramClass{}). + Where("status = ?", models.Scheduled). + Where("archived_at IS NULL") + if facilityID != nil { + query = query.Where("facility_id = ?", *facilityID) + } + if err := query.Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +func (db *DB) GetTotalEnrollmentsCount(ctx *models.QueryContext, facilityID *uint) (int64, error) { + var count int64 + query := db.WithContext(ctx.Ctx). + Table("program_class_enrollments e"). + Select("COUNT(*)"). + Joins("JOIN program_classes c ON c.id = e.class_id"). + Where("e.enrollment_status = ?", models.Enrolled). + Where("c.status = ?", models.Active). + Where("c.archived_at IS NULL") + if facilityID != nil { + query = query.Where("c.facility_id = ?", *facilityID) + } + if err := query.Scan(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +func (db *DB) GetAttendanceConcernsCount(ctx *models.QueryContext, facilityID *uint) (int64, error) { + attendanceSQL := `SELECT COUNT(DISTINCT c.id) + FROM program_classes c + JOIN program_class_enrollments e ON e.class_id = c.id + WHERE c.status = ? + AND c.archived_at IS NULL + AND e.enrollment_status = ? ` + args := []any{models.Active, models.Enrolled} + if facilityID != nil { + attendanceSQL += "AND c.facility_id = ? " + args = append(args, *facilityID) + } + attendanceSQL += ` AND ( + select count(*) + from program_class_events evt + inner join program_class_event_attendance att on att.event_id = evt.id + where evt.class_id = c.id + and att.user_id = e.user_id + and att.attendance_status = 'absent_unexcused' + ) >= 3 ` + var count int64 + if err := db.WithContext(ctx.Ctx).Raw(attendanceSQL, args...).Scan(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +type facilityCount struct { + FacilityID uint `gorm:"column:facility_id"` + Count int64 `gorm:"column:count"` +} + +func (db *DB) GetFacilityHealthSummaries(ctx *models.QueryContext, facilityID *uint) ([]models.FacilityHealthSummary, error) { + var summaries []models.FacilityHealthSummary + query := `SELECT + f.id as facility_id, + f.name as facility_name, + COALESCE(COUNT(DISTINCT p.id), 0) as programs, + COALESCE(COUNT(DISTINCT pc.id), 0) as active_classes, + COALESCE(COUNT(DISTINCT pce.id), 0) as enrollment + FROM facilities f + LEFT JOIN facilities_programs fp + ON fp.facility_id = f.id + AND fp.deleted_at IS NULL + LEFT JOIN programs p + ON p.id = fp.program_id + AND p.is_active = true + AND p.deleted_at IS NULL + AND p.archived_at IS NULL + LEFT JOIN program_classes pc + ON pc.facility_id = f.id + AND pc.status = ? + AND pc.archived_at IS NULL + LEFT JOIN program_class_enrollments pce + ON pce.class_id = pc.id + AND pce.enrollment_status = ? + WHERE f.deleted_at IS NULL ` + args := []any{models.Active, models.Enrolled} + if facilityID != nil { + query += " AND f.id = ? " + args = append(args, *facilityID) + } + query += "GROUP BY f.id, f.name ORDER BY f.name" + + if err := db.WithContext(ctx.Ctx).Raw(query, args...).Scan(&summaries).Error; err != nil { + return nil, err + } + return summaries, nil +} + +func (db *DB) GetFacilityAttendanceConcerns(ctx *models.QueryContext, facilityID *uint) (map[uint]int64, error) { + attendanceSQL := `SELECT c.facility_id as facility_id, COUNT(DISTINCT c.id) as count + FROM program_classes c + JOIN program_class_enrollments e ON e.class_id = c.id + WHERE c.status = ? + AND c.archived_at IS NULL + AND e.enrollment_status = ? ` + args := []any{models.Active, models.Enrolled} + if facilityID != nil { + attendanceSQL += "AND c.facility_id = ? " + args = append(args, *facilityID) + } + attendanceSQL += ` AND ( + select count(*) + from program_class_events evt + inner join program_class_event_attendance att on att.event_id = evt.id + where evt.class_id = c.id + and att.user_id = e.user_id + and att.attendance_status = 'absent_unexcused' + ) >= 3 + GROUP BY c.facility_id` + + var counts []facilityCount + if err := db.WithContext(ctx.Ctx).Raw(attendanceSQL, args...).Scan(&counts).Error; err != nil { + return nil, err + } + + results := make(map[uint]int64, len(counts)) + for _, entry := range counts { + results[entry.FacilityID] = entry.Count + } + return results, nil +} diff --git a/backend/src/database/delete_guards.go b/backend/src/database/delete_guards.go new file mode 100644 index 000000000..f49f99781 --- /dev/null +++ b/backend/src/database/delete_guards.go @@ -0,0 +1,81 @@ +package database + +import "UnlockEdv2/src/models" + +// ClassBlockingChildren counts every type of child record attached to a class +// in a single round-trip. Used by the delete-check preflight and by the 409 +// response on DELETE /api/program-classes/{id}. +func (db *DB) ClassBlockingChildren(classID int) (models.DeleteBlockingChildren, error) { + var b models.DeleteBlockingChildren + row := db.Raw(` +SELECT + (SELECT COUNT(*) FROM program_class_enrollments + WHERE class_id = ? AND deleted_at IS NULL) AS enrollments, + (SELECT COUNT(*) FROM program_class_events + WHERE class_id = ? AND deleted_at IS NULL) AS events, + (SELECT COUNT(*) FROM program_completions + WHERE program_class_id = ? AND deleted_at IS NULL) AS completions, + (SELECT COUNT(*) FROM program_class_event_attendance pcea + JOIN program_class_events e ON e.id = pcea.event_id + WHERE e.class_id = ? AND pcea.deleted_at IS NULL) AS attendance_flags, + ((SELECT COUNT(*) FROM change_log_entries + WHERE table_name = 'program_classes' AND parent_ref_id = ?) + + (SELECT COUNT(*) FROM program_classes_history + WHERE table_name = 'program_classes' AND parent_ref_id = ?) + ) AS history +`, classID, classID, classID, classID, classID, classID).Row() + + if err := row.Scan(&b.Enrollments, &b.Events, &b.Completions, &b.AttendanceFlags, &b.History); err != nil { + return b, newGetRecordsDBError(err, "delete_guard_class_counts") + } + + var status models.ClassStatus + if err := db.Model(&models.ProgramClass{}). + Where("id = ?", classID). + Select("status"). + Limit(1). + Scan(&status).Error; err != nil { + return b, newGetRecordsDBError(err, "delete_guard_class_status") + } + if status != "" && status != models.Scheduled { + b.NonDeletableStatus = string(status) + } + return b, nil +} + +// ProgramBlockingChildren counts every type of child record across every class +// belonging to the program in a single round-trip. b.Classes is the count of +// classes under the program; the other fields aggregate per-class child rows +// across all of them. Program-level change_log entries are added to History. +func (db *DB) ProgramBlockingChildren(programID int) (models.DeleteBlockingChildren, error) { + var b models.DeleteBlockingChildren + row := db.Raw(` +WITH cls AS ( + SELECT id FROM program_classes + WHERE program_id = ? AND deleted_at IS NULL +) +SELECT + (SELECT COUNT(*) FROM cls) AS classes, + (SELECT COUNT(*) FROM program_class_enrollments + WHERE class_id IN (SELECT id FROM cls) AND deleted_at IS NULL) AS enrollments, + (SELECT COUNT(*) FROM program_class_events + WHERE class_id IN (SELECT id FROM cls) AND deleted_at IS NULL) AS events, + (SELECT COUNT(*) FROM program_completions + WHERE program_class_id IN (SELECT id FROM cls) AND deleted_at IS NULL) AS completions, + (SELECT COUNT(*) FROM program_class_event_attendance pcea + JOIN program_class_events e ON e.id = pcea.event_id + WHERE e.class_id IN (SELECT id FROM cls) AND pcea.deleted_at IS NULL) AS attendance_flags, + ((SELECT COUNT(*) FROM change_log_entries + WHERE table_name = 'program_classes' AND parent_ref_id IN (SELECT id FROM cls)) + + (SELECT COUNT(*) FROM program_classes_history + WHERE table_name = 'program_classes' AND parent_ref_id IN (SELECT id FROM cls)) + + (SELECT COUNT(*) FROM change_log_entries + WHERE table_name = 'programs' AND parent_ref_id = ?) + ) AS history +`, programID, programID).Row() + + if err := row.Scan(&b.Classes, &b.Enrollments, &b.Events, &b.Completions, &b.AttendanceFlags, &b.History); err != nil { + return b, newGetRecordsDBError(err, "delete_guard_program_counts") + } + return b, nil +} diff --git a/backend/src/database/events_attendance.go b/backend/src/database/events_attendance.go index b6ddd1e8e..68c49ad45 100644 --- a/backend/src/database/events_attendance.go +++ b/backend/src/database/events_attendance.go @@ -257,12 +257,18 @@ func (db *DB) GetAttendanceFlagsForClass(classID int, args *models.QueryContext) where c.id = ? and e.enrollment_status = 'Enrolled' and c.status = 'Active' + and u.deleted_at is null and exists (select 1 from program_class_events evt inner join program_class_event_attendance att on att.event_id = evt.id where evt.class_id = c.id and att.attendance_status is not null )` - noAttendanceSQL := `and not exists (select 1 from program_class_events evt + noAttendanceSQL := `and exists (select 1 from program_class_events evt + inner join program_class_event_attendance att on att.event_id = evt.id + where evt.class_id = c.id + and att.user_id = e.user_id + ) + and not exists (select 1 from program_class_events evt inner join program_class_event_attendance att on att.event_id = evt.id where evt.class_id = c.id and att.user_id = e.user_id @@ -280,7 +286,19 @@ func (db *DB) GetAttendanceFlagsForClass(classID int, args *models.QueryContext) and att.user_id = e.user_id and att.attendance_status = 'absent_unexcused' ) >= 3` - //count for pagination + + statsSQL := `, + e.user_id, + (select count(*) from program_class_events evt + inner join program_class_event_attendance att on att.event_id = evt.id + where evt.class_id = c.id and att.user_id = e.user_id + ) as total_sessions, + (select count(*) from program_class_events evt + inner join program_class_event_attendance att on att.event_id = evt.id + where evt.class_id = c.id and att.user_id = e.user_id + and att.attendance_status in ('present','partial') + ) as attended_sessions` + countQuery := fmt.Sprintf(`select count(*) from ( select u.id %s %s union all @@ -291,22 +309,84 @@ func (db *DB) GetAttendanceFlagsForClass(classID int, args *models.QueryContext) return nil, newNotFoundDBError(err, "program_class_event_attendance") } - attendanceQuery := fmt.Sprintf(`select name_first, name_last, doc_id, flag_type from ( + attendanceQuery := fmt.Sprintf(`select name_first, name_last, doc_id, flag_type, user_id, total_sessions, attended_sessions from ( select u.name_first, u.name_last, - u.doc_id, 'no_attendance' as flag_type %s %s + u.doc_id, 'no_attendance' as flag_type %s %s %s union all select u.name_first, u.name_last, - u.doc_id, 'multiple_absences' as flag_type %s %s + u.doc_id, 'multiple_absences' as flag_type %s %s %s ) AS attendance_flags ORDER BY name_last, name_first - LIMIT ? OFFSET ?`, attendanceCoreSQL, noAttendanceSQL, attendanceCoreSQL, multiAbsencesSQL) + LIMIT ? OFFSET ?`, statsSQL, attendanceCoreSQL, noAttendanceSQL, statsSQL, attendanceCoreSQL, multiAbsencesSQL) if err := db.WithContext(args.Ctx).Raw(attendanceQuery, classID, classID, args.PerPage, args.CalcOffset()).Scan(&flags).Error; err != nil { return nil, newNotFoundDBError(err, "program_class_event_attendance") } + for i := range flags { + flags[i].MissedSessions = flags[i].TotalSessions - flags[i].AttendedSessions + if flags[i].TotalSessions > 0 { + flags[i].AttendanceRate = int(float64(flags[i].AttendedSessions) / float64(flags[i].TotalSessions) * 100) + } + } + + if len(flags) > 0 { + if err := db.computeConsecutiveAbsences(classID, flags); err != nil { + return nil, err + } + } + return flags, nil } +func (db *DB) computeConsecutiveAbsences(classID int, flags []models.AttendanceFlag) error { + userIDs := make([]uint, len(flags)) + for i, f := range flags { + userIDs[i] = f.UserID + } + + type record struct { + UserID uint `gorm:"column:user_id"` + Date string `gorm:"column:date"` + AttendanceStatus string `gorm:"column:attendance_status"` + } + var records []record + if err := db.Raw(` + SELECT att.user_id, att.date, att.attendance_status + FROM program_class_event_attendance att + INNER JOIN program_class_events evt ON evt.id = att.event_id + WHERE evt.class_id = ? AND att.user_id IN ? + ORDER BY att.user_id, att.date DESC`, + classID, userIDs, + ).Scan(&records).Error; err != nil { + return newNotFoundDBError(err, "consecutive absences") + } + + byUser := make(map[uint]int) + var currentUser uint + var counting bool + for _, r := range records { + if r.UserID != currentUser { + currentUser = r.UserID + counting = true + byUser[currentUser] = 0 + } + if !counting { + continue + } + switch r.AttendanceStatus { + case "present", "partial": + counting = false + case "absent_excused", "absent_unexcused": + byUser[currentUser]++ + } + } + + for i := range flags { + flags[i].ConsecutiveAbsences = byUser[flags[i].UserID] + } + return nil +} + func (db *DB) GetMissingAttendance(classID int, args *models.QueryContext) (int, error) { var events []models.ProgramClassEvent err := db.WithContext(args.Ctx). @@ -399,6 +479,87 @@ func (db *DB) GetMissingAttendance(classID int, args *models.QueryContext) (int, return missingAttendanceCount, nil } +func (db *DB) GetActiveClassesForMissingAttendance(args *models.QueryContext, facilityID *uint) ([]models.MissingAttendanceClass, error) { + var missClasses []models.MissingAttendanceClass + classQuery := db.WithContext(args.Ctx). + Table("program_classes c"). + Select("c.id, c.name, f.name as facility_name, c.facility_id"). + Joins("JOIN facilities f ON f.id = c.facility_id"). + Where("c.status = ?", models.Active). + Where("c.archived_at IS NULL") + if facilityID != nil { + classQuery = classQuery.Where("c.facility_id = ?", *facilityID) + } + if err := classQuery.Find(&missClasses).Error; err != nil { + return nil, newGetRecordsDBError(err, "program_classes") + } + return missClasses, nil +} + +func (db *DB) GetActiveClassesWithEvents(args *models.QueryContext, facilityID *uint) ([]models.ProgramClass, error) { + var classes []models.ProgramClass + classQuery := db.WithContext(args.Ctx). + Model(&models.ProgramClass{}). + Preload("Events.Overrides"). + Preload("Events.RoomRef"). + Preload("Events.Instructor"). + Preload("Facility"). + Where("status = ?", models.Active). + Where("archived_at IS NULL") + if facilityID != nil { + classQuery = classQuery.Where("facility_id = ?", *facilityID) + } + if err := classQuery.Find(&classes).Error; err != nil { + return nil, newGetRecordsDBError(err, "program_classes") + } + return classes, nil +} + +func (db *DB) GetClassEventsWithOverrides(args *models.QueryContext, classIDs []uint) ([]models.ProgramClassEvent, error) { + var events []models.ProgramClassEvent + if err := db.WithContext(args.Ctx). + Model(&models.ProgramClassEvent{}). + Preload("Overrides"). + Preload("RoomRef"). + Where("class_id IN ?", classIDs). + Find(&events).Error; err != nil { + return nil, newGetRecordsDBError(err, "program_class_events") + } + return events, nil +} + +type AttendanceCount struct { + EventID uint `json:"event_id"` + Date string `json:"date"` + Count int64 `json:"count"` +} + +func (db *DB) GetAttendanceCountsForEvents(args *models.QueryContext, eventIDs []uint, dates []string) ([]AttendanceCount, error) { + var attendanceCounts []AttendanceCount + if err := db.WithContext(args.Ctx). + Model(&models.ProgramClassEventAttendance{}). + Select("event_id, date, COUNT(*) as count"). + Where("event_id IN ? AND date IN ?", eventIDs, dates). + Group("event_id, date"). + Scan(&attendanceCounts).Error; err != nil { + return nil, newGetRecordsDBError(err, "program_class_event_attendance") + } + return attendanceCounts, nil +} + +func (db *DB) GetActiveEnrollmentsForClasses(args *models.QueryContext, classIDs []uint) ([]models.ProgramClassEnrollment, error) { + var enrollments []models.ProgramClassEnrollment + if err := db.WithContext(args.Ctx). + Model(&models.ProgramClassEnrollment{}). + Select("class_id, enrolled_at, enrollment_ended_at"). + Where("class_id IN ?", classIDs). + Where("enrollment_status = ?", models.Enrolled). + Find(&enrollments).Error; err != nil { + return nil, newGetRecordsDBError(err, "program_class_enrollments") + } + return enrollments, nil +} + func (db *DB) CreateAttendanceAuditTrail(ctx context.Context, att *models.ProgramClassEventAttendance, adminID *uint, className string) error { sessionDateParsed, err := time.ParseInLocation("2006-01-02", att.Date, time.Local) @@ -449,3 +610,49 @@ func (db *DB) GetCumulativeAttendanceRateForClass(ctx context.Context, classID i } return attendanceRate, nil } + +func (db *DB) GetCumulativeAttendanceRatesForClasses(ctx context.Context, classIDs []uint) (map[uint]float64, error) { + if len(classIDs) == 0 { + return map[uint]float64{}, nil + } + today := time.Now().Format("2006-01-02") + sql := ` + WITH attendance_credits AS ( + SELECT + pce.class_id, + CASE + WHEN pcea.attendance_status = 'present' THEN 1.0 + WHEN pcea.attendance_status = 'partial' THEN LEAST( + COALESCE(pcea.minutes_attended, pcea.scheduled_minutes, 0)::numeric / + NULLIF(COALESCE(pcea.scheduled_minutes, pcea.minutes_attended, 0), 0), + 1 + ) + ELSE 0 + END as credit + FROM program_class_event_attendance pcea + INNER JOIN program_class_events pce ON pce.id = pcea.event_id + WHERE pce.class_id IN ? AND pcea.date <= ? + ) + SELECT class_id, + COALESCE( + SUM(credit) * 100.0 / NULLIF(COUNT(*), 0), + 0 + ) as attendance_percentage + FROM attendance_credits + GROUP BY class_id` + + type row struct { + ClassID uint `gorm:"column:class_id"` + AttendanceRate float64 `gorm:"column:attendance_percentage"` + } + rows := []row{} + if err := db.WithContext(ctx).Raw(sql, classIDs, today).Scan(&rows).Error; err != nil { + return nil, newNotFoundDBError(err, "program_class_event_attendance") + } + + out := make(map[uint]float64, len(classIDs)) + for _, r := range rows { + out[r.ClassID] = r.AttendanceRate + } + return out, nil +} diff --git a/backend/src/database/facilities.go b/backend/src/database/facilities.go index c418230bc..9e8712c71 100644 --- a/backend/src/database/facilities.go +++ b/backend/src/database/facilities.go @@ -22,6 +22,34 @@ func (db *DB) GetAllFacilities(page, itemsPerPage int) (int64, []models.Facility return total, facilities, nil } +func (db *DB) GetAllFacilitiesWithStats(args *models.QueryContext) ([]models.FacilityWithStats, error) { + if err := db.Model(&models.Facility{}).Count(&args.Total).Error; err != nil { + return nil, newGetRecordsDBError(err, "facilities") + } + const query = `SELECT + f.id, f.name, f.timezone, f.created_at, f.updated_at, + COUNT(DISTINCT CASE WHEN p.is_active = true AND p.archived_at IS NULL AND p.deleted_at IS NULL THEN p.id END) AS active_programs, + COUNT(DISTINCT CASE WHEN pc.status IN ('Active') AND pc.archived_at IS NULL AND pc.deleted_at IS NULL THEN pc.id END) AS active_classes, + COUNT(DISTINCT CASE WHEN u.role = 'student' AND u.deleted_at IS NULL AND u.deactivated_at IS NULL THEN u.id END) AS total_residents + FROM facilities f + LEFT JOIN facilities_programs fp ON fp.facility_id = f.id + AND fp.deleted_at IS NULL + LEFT JOIN programs p ON p.id = fp.program_id + AND p.deleted_at IS NULL + LEFT JOIN program_classes pc ON pc.facility_id = f.id + AND pc.deleted_at IS NULL + LEFT JOIN users u ON u.facility_id = f.id + WHERE f.deleted_at IS NULL + GROUP BY f.id, f.name, f.timezone, f.created_at, f.updated_at + ORDER BY f.name ASC + ` + var results []models.FacilityWithStats + if err := db.Raw(query).Offset(args.CalcOffset()).Limit(args.PerPage).Scan(&results).Error; err != nil { + return nil, newGetRecordsDBError(err, "facilities") + } + return results, nil +} + func (db *DB) GetFacilityByID(id int) (*models.Facility, error) { var facility models.Facility if err := db.Where("id = ?", fmt.Sprintf("%d", id)).First(&facility).Error; err != nil { diff --git a/backend/src/database/helpful_links.go b/backend/src/database/helpful_links.go index 1596a1d0e..e5419d1a7 100644 --- a/backend/src/database/helpful_links.go +++ b/backend/src/database/helpful_links.go @@ -9,6 +9,7 @@ import ( type HelpfulLinkResp struct { models.HelpfulLink IsFavorited bool `json:"is_favorited"` + IsFeatured bool `json:"is_featured"` } func (db *DB) GetHelpfulLinks(args *models.QueryContext, onlyVisible bool) ([]HelpfulLinkResp, error) { @@ -18,7 +19,11 @@ func (db *DB) GetHelpfulLinks(args *models.QueryContext, onlyVisible bool) ([]He Select("1"). Where(`f.content_id = helpful_links.id AND f.user_id = ? AND f.open_content_provider_id = helpful_links.open_content_provider_id`, args.UserID) - tx := db.Model(&models.HelpfulLink{}).Select("helpful_links.*, EXISTS(?) as is_favorited", subQuery) + featuredSubQuery := db.Table("open_content_favorites ff"). + Select("1"). + Where(`ff.content_id = helpful_links.id AND ff.facility_id = ? + AND ff.open_content_provider_id = helpful_links.open_content_provider_id`, args.FacilityID) + tx := db.Model(&models.HelpfulLink{}).Select("helpful_links.*, EXISTS(?) as is_favorited, EXISTS(?) as is_featured", subQuery, featuredSubQuery) if onlyVisible { tx = tx.Where("visibility_status = ?", true) diff --git a/backend/src/database/libraries.go b/backend/src/database/libraries.go index fcfefb933..97aae3700 100644 --- a/backend/src/database/libraries.go +++ b/backend/src/database/libraries.go @@ -11,7 +11,9 @@ import ( type LibraryResponse struct { models.Library - IsFavorited bool `json:"is_favorited"` + IsFavorited bool `json:"is_favorited"` + IsFeatured bool `json:"is_featured"` + Tags []string `json:"tags" gorm:"-"` } // Retrieves either a paginated list of libraries or all libraries based upon the given parameters. @@ -36,6 +38,14 @@ func (db *DB) GetAllLibraries(args *models.QueryContext, visibility string) ([]L CASE WHEN fvs.visibility_status IS NULL THEN false ELSE fvs.visibility_status END AS visibility_status, + EXISTS ( + SELECT 1 + FROM open_content_favorites ff + WHERE ff.content_id = libraries.id + AND ff.open_content_provider_id = libraries.open_content_provider_id + AND ff.open_content_url_id IS NULL + AND ff.facility_id = ? + ) AS is_featured, EXISTS ( SELECT 1 FROM open_content_favorites f @@ -49,7 +59,7 @@ func (db *DB) GetAllLibraries(args *models.QueryContext, visibility string) ([]L } else { criteria, id = "f.user_id = ?", args.UserID } - tx := db.WithContext(args.Ctx).Model(&models.Library{}).Preload("OpenContentProvider").Select(fmt.Sprintf(selectIsFavoriteOrIsFeatured, criteria), id) + tx := db.WithContext(args.Ctx).Model(&models.Library{}).Preload("OpenContentProvider").Select(fmt.Sprintf(selectIsFavoriteOrIsFeatured, criteria), args.FacilityID, id) tx = tx.Joins(`left outer join facility_visibility_statuses fvs on fvs.open_content_provider_id = libraries.open_content_provider_id and fvs.content_id = libraries.id and fvs.facility_id = ?`, args.FacilityID) @@ -115,6 +125,35 @@ func (db *DB) GetAllLibraries(args *models.QueryContext, visibility string) ([]L if err := tx.Find(&libraries).Error; err != nil { return nil, newGetRecordsDBError(err, "libraries") } + if len(libraries) > 0 { + libraryIDs := make([]uint, len(libraries)) + for i, lib := range libraries { + libraryIDs[i] = lib.ID + } + type tagRow struct { + ContentID uint + TagName string + } + var rows []tagRow + if err := db.WithContext(args.Ctx).Raw( + `SELECT oct.content_id, t.name AS tag_name + FROM open_content_tags oct + JOIN tags t ON t.id = oct.tag_id + WHERE oct.content_id IN ?`, libraryIDs, + ).Scan(&rows).Error; err != nil { + log.Warnf("failed to fetch tags for libraries: %v", err) + } else { + tagMap := make(map[uint][]string) + for _, r := range rows { + tagMap[r.ContentID] = append(tagMap[r.ContentID], r.TagName) + } + for i := range libraries { + if tags, ok := tagMap[libraries[i].ID]; ok { + libraries[i].Tags = tags + } + } + } + } return libraries, nil } diff --git a/backend/src/database/program_classes.go b/backend/src/database/program_classes.go index b634ce011..33c130064 100644 --- a/backend/src/database/program_classes.go +++ b/backend/src/database/program_classes.go @@ -8,6 +8,7 @@ import ( "time" "context" + log "github.com/sirupsen/logrus" "github.com/teambition/rrule-go" "gorm.io/gorm" @@ -38,7 +39,11 @@ func parseDateRange(startDate, endDate string) (time.Time, time.Time, error) { func (db *DB) GetClassByID(id int) (*models.ProgramClass, error) { content := &models.ProgramClass{} - if err := db.Preload("Events").Preload("Events.Overrides").Preload("Events.RoomRef").Preload("Enrollments").Preload("Program").Preload("Instructor").First(content, "id = ?", id).Error; err != nil { + if err := db.Preload("Events").Preload("Events.Overrides").Preload("Events.Overrides.Instructor").Preload("Events.Overrides.RoomRef").Preload("Events.RoomRef"). + Preload("Events.Instructor").Preload("Enrollments", func(tx *gorm.DB) *gorm.DB { + return tx.Joins("JOIN users ON users.id = program_class_enrollments.user_id AND users.deleted_at IS NULL") + }). + Preload("Program").Preload("Facility").First(content, "id = ?", id).Error; err != nil { return nil, newNotFoundDBError(err, "program classes") } var enrollments, completed int @@ -57,17 +62,46 @@ func (db *DB) GetClassByID(id int) (*models.ProgramClass, error) { } func (db *DB) GetClassesForFacility(args *models.QueryContext) ([]models.ProgramClass, error) { + return db.GetClasses(args, &args.FacilityID) +} + +func (db *DB) GetClasses(args *models.QueryContext, facilityID *uint) ([]models.ProgramClass, error) { content := []models.ProgramClass{} - tx := db.WithContext(args.Ctx).Model(&models.ProgramClass{}).Where("facility_id = ?", args.FacilityID) + tx := db.WithContext(args.Ctx).Model(&models.ProgramClass{}).Where("archived_at IS NULL") + if facilityID != nil { + tx = tx.Where("facility_id = ?", *facilityID) + } if args.Search != "" { tx = tx.Where("LOWER(name) LIKE ?", args.SearchQuery()) } if err := tx.Count(&args.Total).Error; err != nil { return nil, newGetRecordsDBError(err, "program classes") } - if err := tx.Limit(args.PerPage).Offset(args.CalcOffset()).Find(&content).Error; err != nil { + + tx = tx.Preload("Events").Preload("Events.RoomRef"). + Preload("Enrollments", func(db *gorm.DB) *gorm.DB { + return db.Joins("JOIN users ON users.id = program_class_enrollments.user_id AND users.deleted_at IS NULL") + }). + Preload("Program").Preload("Facility") + if !args.All { + tx = tx.Limit(args.PerPage).Offset(args.CalcOffset()) + } + if err := tx.Find(&content).Error; err != nil { return nil, newGetRecordsDBError(err, "program classes") } + for i := range content { + var enrollments, completed int + for _, e := range content[i].Enrollments { + switch e.EnrollmentStatus { + case models.Enrolled: + enrollments++ + case models.EnrollmentCompleted: + completed++ + } + } + content[i].Enrolled = int64(enrollments) + content[i].Completed = int64(completed) + } return content, nil } @@ -133,41 +167,52 @@ func (db *DB) UpdateProgramClass(content *models.ProgramClass, id int, conflictR } } - ignoredFieldNames := []string{"create_user_id", "update_user_id", "enrollments", "facility", "facilities", "events", "facility_program", "program_id", "start_dt", "end_dt", "program", "enrolled", "instructor"} + ignoredFieldNames := []string{"create_user_id", "update_user_id", "enrollments", "facility", "facilities", "events", "facility_program", "program_id", "facility_id", "start_dt", "end_dt", "program", "enrolled", "completed", "archived_at", "instructor", "instructor_id"} classLogEntries := models.GenerateChangeLogEntries(existing, content, "program_classes", existing.ID, models.DerefUint(content.UpdateUserID), ignoredFieldNames) allChanges = append(allChanges, classLogEntries...) - originalInstructorID := existing.InstructorID + existingID := existing.ID var needsRoomUpdate bool var newRoomID *uint var eventID uint - if len(content.Events) > 0 && len(existing.Events) > 0 && content.Events[0].RoomID != nil { - existingRoomID := existing.Events[0].RoomID - if existingRoomID == nil || *content.Events[0].RoomID != *existingRoomID { - needsRoomUpdate = true - newRoomID = content.Events[0].RoomID - eventID = existing.Events[0].ID + var needsScheduleUpdate bool + var newRecurrenceRule string + var newDuration string + if len(content.Events) > 0 && len(existing.Events) > 0 { + eventID = existing.Events[0].ID + if content.Events[0].RoomID != nil { + existingRoomID := existing.Events[0].RoomID + if existingRoomID == nil || *content.Events[0].RoomID != *existingRoomID { + needsRoomUpdate = true + newRoomID = content.Events[0].RoomID + } + } + if content.Events[0].RecurrenceRule != "" && content.Events[0].RecurrenceRule != existing.Events[0].RecurrenceRule { + needsScheduleUpdate = true + newRecurrenceRule = content.Events[0].RecurrenceRule + } + if content.Events[0].Duration != "" && content.Events[0].Duration != existing.Events[0].Duration { + needsScheduleUpdate = true + newDuration = content.Events[0].Duration } } - models.UpdateStruct(existing, content) + originalStatus := existing.Status - instructorIDChanged := false - if originalInstructorID == nil && content.InstructorID != nil { - instructorIDChanged = true - } else if originalInstructorID != nil && content.InstructorID == nil { - instructorIDChanged = true - } else if originalInstructorID != nil && content.InstructorID != nil && *originalInstructorID != *content.InstructorID { - instructorIDChanged = true - } + models.UpdateStruct(existing, content) + existing.ID = existingID - if instructorIDChanged { - existing.InstructorID = content.InstructorID - existing.InstructorName = content.InstructorName + if content.UpdateInstructor { + if err := trans.Model(&models.ProgramClassEvent{}). + Where("class_id = ?", existing.ID). + Update("instructor_id", content.InstructorID).Error; err != nil { + trans.Rollback() + return nil, nil, newUpdateDBError(err, "program class event instructor") + } } - if err := trans.Session(&gorm.Session{FullSaveAssociations: false}).Updates(&existing).Error; err != nil { + if err := trans.Session(&gorm.Session{FullSaveAssociations: false}).Model(&models.ProgramClass{}).Where("id = ?", existing.ID).Updates(existing).Error; err != nil { trans.Rollback() return nil, nil, newUpdateDBError(err, "program classes") } @@ -178,6 +223,100 @@ func (db *DB) UpdateProgramClass(content *models.ProgramClass, id int, conflictR return nil, nil, newUpdateDBError(err, "program class event room") } existing.Events[0].RoomID = newRoomID + var roomName string + if newRoomID != nil { + var room models.Room + if err := trans.Select("name").First(&room, *newRoomID).Error; err == nil { + roomName = room.Name + } + } + allChanges = append(allChanges, *models.NewChangeLogEntry("program_classes", "event_room_changed", nil, &roomName, existing.ID, models.DerefUint(content.UpdateUserID))) + } + + if needsScheduleUpdate { + if newRecurrenceRule != "" { + if err := trans.Model(&models.ProgramClassEvent{}).Where("id = ?", eventID).Update("recurrence_rule", newRecurrenceRule).Error; err != nil { + trans.Rollback() + return nil, nil, newUpdateDBError(err, "program class event recurrence rule") + } + } + if newDuration != "" { + if err := trans.Model(&models.ProgramClassEvent{}).Where("id = ?", eventID).Update("duration", newDuration).Error; err != nil { + trans.Rollback() + return nil, nil, newUpdateDBError(err, "program class event duration") + } + } + oldRule := existing.Events[0].RecurrenceRule + allChanges = append(allChanges, *models.NewChangeLogEntry("program_classes", "event_rescheduled_series", &oldRule, &newRecurrenceRule, existing.ID, models.DerefUint(content.UpdateUserID))) + } + + newStatus := existing.Status + if newStatus != originalStatus && (newStatus == models.Completed || newStatus == models.Cancelled) { + completionTime := time.Now().UTC() + if err := db.UpdateClassEventRRuleUntilDate(trans, []int{id}, completionTime); err != nil { + trans.Rollback() + return nil, nil, newUpdateDBError(err, "updating class event rrule until date") + } + + var enrollmentStatus models.ProgramEnrollmentStatus + if newStatus == models.Cancelled { + enrollmentStatus = models.EnrollmentCancelled + } else { + enrollmentStatus = models.EnrollmentCompleted + } + + if err := trans. + Model(&models.ProgramClassEnrollment{}). + Where("class_id = ? AND enrollment_status = ?", id, models.Enrolled). + Set("class_id", id). + Update("enrollment_status", enrollmentStatus). + Error; err != nil { + trans.Rollback() + return nil, nil, newUpdateDBError(err, "class enrollment statuses") + } + + if newStatus == models.Completed { + var completedEnrollments []models.ProgramClassEnrollment + if err := trans. + Preload("User.Facility"). + Preload("Class.Program.ProgramCreditTypes"). + Preload("Class.FacilityProg"). + Where("class_id = ? AND enrollment_status = ?", id, models.EnrollmentCompleted). + Find(&completedEnrollments).Error; err != nil { + trans.Rollback() + return nil, nil, newNotFoundDBError(err, "fetching completed enrollments") + } + + if len(completedEnrollments) > 0 { + var admin models.User + if err := trans.First(&admin, "id = ?", models.DerefUint(content.UpdateUserID)).Error; err != nil { + trans.Rollback() + return nil, nil, newNotFoundDBError(err, "admin user") + } + + completions := make([]models.ProgramCompletion, 0, len(completedEnrollments)) + for _, enrollment := range completedEnrollments { + completions = append(completions, models.ProgramCompletion{ + ProgramClassID: enrollment.ClassID, + FacilityName: enrollment.User.Facility.Name, + ProgramName: enrollment.Class.Program.Name, + ProgramOwner: enrollment.Class.GetProgramOwnerOrEmpty(), + ProgramID: enrollment.Class.ProgramID, + AdminEmail: admin.Email, + ProgramClassStartDt: enrollment.Class.StartDt, + CreditType: enrollment.Class.Program.GetUniqueCreditTypeString(), + ProgramClassName: enrollment.Class.Name, + UserID: enrollment.UserID, + EnrolledOnDt: enrollment.CreatedAt, + }) + } + + if err := trans.Create(&completions).Error; err != nil { + trans.Rollback() + return nil, nil, newCreateDBError(err, "enrollment completions") + } + } + } } if len(allChanges) > 0 { @@ -226,15 +365,22 @@ func (db *DB) GetProgramClassDetailsByID(id int, args *models.QueryContext) ([]m query := db.WithContext(args.Ctx).Table("program_classes ps"). Select(`ps.*, fac.name as facility_name, - count(pse.id) as enrolled + count(CASE WHEN pse.enrollment_status = 'Enrolled' THEN 1 END) as enrolled, + count(CASE WHEN pse.enrollment_status = 'Completed' THEN 1 END) as completed, + count(CASE WHEN pse.enrollment_status IN ('Completed', 'Incomplete: Withdrawn', 'Incomplete: Dropped', 'Incomplete: Failed to Complete', 'Incomplete: Transfered') THEN 1 END) as historical_enrollments `). Joins(`join facilities fac on fac.id = ps.facility_id AND fac.deleted_at IS NULL`). - Joins(`left outer join program_class_enrollments pse on pse.class_id = ps.id - and enrollment_status = 'Enrolled'`). //TODO Enrolled may change here - Where(`ps.program_id = ? - and ps.facility_id = ?`, id, args.FacilityID). - Group("ps.id,fac.name") + Joins(`left outer join program_class_enrollments pse on pse.class_id = ps.id`). //TODO Enrollment statuses may change here + Where("ps.program_id = ?", id) + + if args.Params.Get("facility_id") != "" { + query = query.Where("ps.facility_id = ?", args.FacilityID) + } else if !args.CanSwitchFacility { + query = query.Where("ps.facility_id = ?", args.FacilityID) + } + + query = query.Group("ps.id,fac.name") if args.Search != "" { query = query.Where("LOWER(ps.name) LIKE ? OR LOWER(ps.description) LIKE ?", args.SearchQuery(), args.SearchQuery()) } @@ -250,7 +396,12 @@ func (db *DB) GetProgramClassDetailsByID(id int, args *models.QueryContext) ([]m classIDs = append(classIDs, detail.ID) } events := []models.ProgramClassEvent{} - if err := db.Model(&models.ProgramClassEvent{}).Preload("Overrides").Where("class_id IN (?)", classIDs).Find(&events).Error; err != nil { + if err := db.Model(&models.ProgramClassEvent{}). + Preload("RoomRef"). + Preload("Overrides"). + Preload("Overrides.RoomRef"). + Where("class_id IN (?)", classIDs). + Find(&events).Error; err != nil { return nil, newGetRecordsDBError(err, "program_class_events") } eventMap := make(map[uint][]models.ProgramClassEvent) @@ -367,8 +518,7 @@ func (db *DB) GetInstructorNameByID(instructorID uint, facilityID uint) (string, err := db.Table("users"). Select("COALESCE(name_first || ' ' || name_last, username)"). Where("id = ? AND facility_id = ? AND role IN ?", - instructorID, facilityID, - []models.UserRole{models.FacilityAdmin, models.DepartmentAdmin}). + instructorID, facilityID, []models.UserRole{models.FacilityAdmin, models.DepartmentAdmin}). Scan(&instructorName).Error if err != nil { return "", err @@ -390,7 +540,7 @@ func (db *DB) GetClassesByInstructor(instructorID, facilityID int, startDate, en 0 as upcoming_sessions, 0 as cancelled_sessions`, models.Enrolled). Joins("LEFT JOIN program_class_enrollments pce ON pce.class_id = pc.id"). - Where("pc.instructor_id IS NULL AND pc.facility_id = ?", facilityID). + Where("NOT EXISTS (SELECT 1 FROM program_class_events WHERE class_id = pc.id AND instructor_id IS NOT NULL) AND pc.facility_id = ?", facilityID). Where("pc.status != ?", models.Cancelled). Group("pc.id, pc.name"). Order("pc.name") @@ -403,7 +553,7 @@ func (db *DB) GetClassesByInstructor(instructorID, facilityID int, startDate, en 0 as upcoming_sessions, 0 as cancelled_sessions`, models.Enrolled). Joins("LEFT JOIN program_class_enrollments pce ON pce.class_id = pc.id"). - Where("pc.instructor_id = ? AND pc.facility_id = ?", instructorID, facilityID). + Where("EXISTS (SELECT 1 FROM program_class_events WHERE class_id = pc.id AND instructor_id = ?) AND pc.facility_id = ?", instructorID, facilityID). Where("pc.status != ?", models.Cancelled). Group("pc.id, pc.name"). Order("pc.name") @@ -419,7 +569,7 @@ func (db *DB) GetClassesByInstructor(instructorID, facilityID int, startDate, en } for i := range classes { - totalSessions, upcomingSessions, cancelledSessions, err := db.calculateSessionCounts(classes[i].ID, start, end) + totalSessions, upcomingSessions, cancelledSessions, sessionDates, err := db.calculateSessionCounts(classes[i].ID, start, end) if err != nil { return nil, err } @@ -427,12 +577,35 @@ func (db *DB) GetClassesByInstructor(instructorID, facilityID int, startDate, en classes[i].SessionCount = totalSessions classes[i].UpcomingSessions = upcomingSessions classes[i].CancelledSessions = cancelledSessions + classes[i].SessionDates = sessionDates + + var event struct { + RecurrenceRule string `gorm:"column:recurrence_rule"` + Duration string `gorm:"column:duration"` + RoomName string `gorm:"column:room_name"` + } + db.Table("program_class_events pce"). + Select("pce.recurrence_rule, pce.duration, COALESCE(r.name, '') as room_name"). + Joins("LEFT JOIN rooms r ON r.id = pce.room_id"). + Where("pce.class_id = ?", classes[i].ID). + Order("pce.created_at ASC"). + Limit(1). + Scan(&event) + + if event.RecurrenceRule != "" { + rule, rErr := rrule.StrToRRule(event.RecurrenceRule) + if rErr == nil { + classes[i].StartTime = rule.GetDTStart().Format("15:04") + } + } + classes[i].Duration = event.Duration + classes[i].Room = event.RoomName } return classes, nil } -func (db *DB) calculateSessionCounts(classID int, startDate, endDate time.Time) (int, int, int, error) { +func (db *DB) calculateSessionCounts(classID int, startDate, endDate time.Time) (int, int, int, []string, error) { var events []struct { models.ProgramClassEvent @@ -440,10 +613,11 @@ func (db *DB) calculateSessionCounts(classID int, startDate, endDate time.Time) } if err := db.Preload("Overrides").Where("class_id = ?", classID).Find(&events).Error; err != nil { - return 0, 0, 0, newGetRecordsDBError(err, "class events") + return 0, 0, 0, nil, newGetRecordsDBError(err, "class events") } var totalSessions, upcomingSessions, cancelledSessions int + var sessionDates []string for _, event := range events { rule, err := rrule.StrToRRule(event.RecurrenceRule) @@ -474,8 +648,10 @@ func (db *DB) calculateSessionCounts(classID int, startDate, endDate time.Time) actualUpcoming := 0 for _, occurrence := range occurrences { - if !cancelledDates[occurrence.Format("2006-01-02")] { + dateStr := occurrence.Format("2006-01-02") + if !cancelledDates[dateStr] { actualUpcoming++ + sessionDates = append(sessionDates, dateStr) } } upcomingSessions += actualUpcoming @@ -483,7 +659,7 @@ func (db *DB) calculateSessionCounts(classID int, startDate, endDate time.Time) cancelledSessions += cancelledInDateRange } - return totalSessions, upcomingSessions, cancelledSessions, nil + return totalSessions, upcomingSessions, cancelledSessions, sessionDates, nil } func (db *DB) BulkCancelSessions(req *models.BulkCancelSessionsRequest, facilityID int, claims BulkCancelClaims) (*models.BulkCancelSessionsResponse, error) { @@ -519,12 +695,12 @@ func (db *DB) BulkCancelSessions(req *models.BulkCancelSessionsRequest, facility query = tx.Table("program_class_events pce"). Select("pce.*"). Joins("INNER JOIN program_classes pc ON pc.id = pce.class_id"). - Where("pc.instructor_id IS NULL AND pc.facility_id = ?", facilityID) + Where("pce.instructor_id IS NULL AND pc.facility_id = ?", facilityID) } else { query = tx.Table("program_class_events pce"). Select("pce.*"). Joins("INNER JOIN program_classes pc ON pc.id = pce.class_id"). - Where("pc.instructor_id = ? AND pc.facility_id = ?", req.InstructorID, facilityID) + Where("pce.instructor_id = ? AND pc.facility_id = ?", req.InstructorID, facilityID) } if err := query.Find(&baseEvents).Error; err != nil { @@ -802,3 +978,21 @@ func (db *DB) BulkCancelSessions(req *models.BulkCancelSessionsRequest, facility return response, nil } + +func (db *DB) DeleteClass(id int) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("class_id = ?", id).Delete(&models.ProgramClassEvent{}).Error; err != nil { + return newDeleteDBError(err, "program_class_events") + } + if err := tx.Exec(`DELETE FROM change_log_entries WHERE table_name = 'program_classes' AND parent_ref_id = ?`, id).Error; err != nil { + return newDeleteDBError(err, "change_log_entries") + } + if err := tx.Exec(`DELETE FROM program_classes_history WHERE table_name = 'program_classes' AND parent_ref_id = ?`, id).Error; err != nil { + return newDeleteDBError(err, "program_classes_history") + } + if err := tx.Delete(&models.ProgramClass{}, "id = ?", id).Error; err != nil { + return newDeleteDBError(err, "program class") + } + return nil + }) +} diff --git a/backend/src/database/programs.go b/backend/src/database/programs.go index 26768f5d7..c6e21e461 100644 --- a/backend/src/database/programs.go +++ b/backend/src/database/programs.go @@ -35,22 +35,54 @@ func (db *DB) FetchEnrollmentMetrics(programID int, facilityId uint) (*models.Pr COUNT(CASE WHEN pce.enrolled_at IS NOT NULL THEN 1 END) AS total_enrollments, COUNT(DISTINCT CASE WHEN pce.enrollment_status = 'Enrolled' AND pce.enrolled_at IS NOT NULL AND (pce.enrollment_ended_at IS NULL OR pce.enrollment_ended_at > CURRENT_TIMESTAMP) THEN pce.user_id END) as active_residents, CASE - WHEN COUNT(CASE WHEN pce.enrolled_at IS NOT NULL THEN 1 END) = 0 THEN 0 - WHEN COUNT(CASE WHEN pce.enrollment_status = 'Enrolled' AND pce.enrolled_at IS NOT NULL AND (pce.enrollment_ended_at IS NULL OR pce.enrollment_ended_at > CURRENT_TIMESTAMP) THEN 1 END) = 0 - AND COUNT(CASE WHEN pce.enrollment_status = 'Completed' THEN 1 END) = COUNT(CASE WHEN pce.enrolled_at IS NOT NULL THEN 1 END) - THEN 100 - ELSE COUNT(CASE WHEN pce.enrollment_status = 'Completed' THEN 1 END) * 1.0 / COUNT(CASE WHEN pce.enrolled_at IS NOT NULL THEN 1 END) * 100 + WHEN COUNT(CASE WHEN pc.status = 'Completed' AND pce.enrolled_at IS NOT NULL AND pce.enrollment_status != 'Enrolled' THEN 1 END) = 0 THEN 0 + ELSE COUNT(CASE WHEN pc.status = 'Completed' AND pce.enrollment_status = 'Completed' THEN 1 END) * 1.0 + / COUNT(CASE WHEN pc.status = 'Completed' AND pce.enrolled_at IS NOT NULL AND pce.enrollment_status != 'Enrolled' THEN 1 END) * 100 END AS completion_rate ` - if err := db.Table("program_class_enrollments pce"). + tx := db.Table("program_class_enrollments pce"). Select(query). Joins("JOIN program_classes pc ON pce.class_id = pc.id"). - Where("pc.program_id = ?", programID). - Where("pc.facility_id = ?", facilityId). - Scan(&metrics).Error; err != nil { + Where("pc.program_id = ?", programID) + if facilityId != 0 { + tx = tx.Where("pc.facility_id = ?", facilityId) + } + + if err := tx.Scan(&metrics).Error; err != nil { return nil, newGetRecordsDBError(err, "program_class_enrollments") } + + partialAttendanceSQL := buildPartialAttendanceSQL(db.Name(), "pcea") + attendanceQuery := fmt.Sprintf(` + SELECT + COALESCE(SUM( + CASE + WHEN pcea.attendance_status = 'present' THEN 1 + WHEN pcea.attendance_status = 'partial' THEN %s + ELSE 0 + END + ) * 100.0 / + NULLIF(COUNT(CASE WHEN pcea.attendance_status IS NOT NULL AND pcea.attendance_status != '' THEN 1 END), 0), 0) AS attendance_rate + FROM program_classes pc + LEFT JOIN program_class_enrollments pce ON pce.class_id = pc.id + LEFT JOIN program_class_events pcev ON pcev.class_id = pc.id + LEFT JOIN program_class_event_attendance pcea ON pcea.event_id = pcev.id AND pcea.user_id = pce.user_id + WHERE pc.program_id = ?`, partialAttendanceSQL) + + attendanceArgs := []any{programID} + if facilityId != 0 { + attendanceQuery += " AND pc.facility_id = ?" + attendanceArgs = append(attendanceArgs, facilityId) + } + + var attendanceResult struct { + AttendanceRate float64 `json:"attendance_rate"` + } + if err := db.Raw(attendanceQuery, attendanceArgs...).Scan(&attendanceResult).Error; err != nil { + return nil, newGetRecordsDBError(err, "program_class_event_attendance") + } + metrics.AttendanceRate = attendanceResult.AttendanceRate return &metrics, nil } @@ -80,9 +112,9 @@ func (db *DB) GetProgramActiveClassFacilities(ctx context.Context, id uint) ([]s func (db *DB) GetPrograms(args *models.QueryContext) ([]models.Program, error) { content := make([]models.Program, 0, args.PerPage) tx := db.WithContext(args.Ctx).Model(&models.Program{}). - Preload("Facilities"). Preload("ProgramTypes"). - Preload("ProgramCreditTypes") + Preload("ProgramCreditTypes"). + Preload("FacilitiesPrograms.Facility") if len(args.Tags) > 0 { tx = tx.Joins("JOIN program_types t ON t.program_id = programs.id").Where("t.id IN (?)", args.Tags) } @@ -98,6 +130,13 @@ func (db *DB) GetPrograms(args *models.QueryContext) ([]models.Program, error) { if err := tx.Limit(args.PerPage).Offset(args.CalcOffset()).Find(&content).Error; err != nil { return nil, newGetRecordsDBError(err, "programs") } + for i := range content { + for _, fp := range content[i].FacilitiesPrograms { + if fp.Facility != nil { + content[i].Facilities = append(content[i].Facilities, *fp.Facility) + } + } + } return content, nil } @@ -343,10 +382,30 @@ func (db *DB) UpdateProgramStatus(programUpdate map[string]any, id uint) ([]stri } func (db *DB) DeleteProgram(id int) error { - if err := db.Delete(&models.Program{}, id).Error; err != nil { - return newDeleteDBError(err, "programs") - } - return nil + return db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("class_id IN (SELECT id FROM program_classes WHERE program_id = ?)", id). + Delete(&models.ProgramClassEvent{}).Error; err != nil { + return newDeleteDBError(err, "program_class_events") + } + if err := tx.Exec(`DELETE FROM change_log_entries + WHERE (table_name = 'programs' AND parent_ref_id = ?) + OR (table_name = 'program_classes' + AND parent_ref_id IN (SELECT id FROM program_classes WHERE program_id = ?))`, + id, id).Error; err != nil { + return newDeleteDBError(err, "change_log_entries") + } + if err := tx.Exec(`DELETE FROM program_classes_history + WHERE (table_name = 'program_classes' + AND parent_ref_id IN (SELECT id FROM program_classes WHERE program_id = ?)) + OR (table_name = 'programs' AND parent_ref_id = ?)`, + id, id).Error; err != nil { + return newDeleteDBError(err, "program_classes_history") + } + if err := tx.Delete(&models.Program{}, id).Error; err != nil { + return newDeleteDBError(err, "programs") + } + return nil + }) } func (db *DB) GetProgramsFacilitiesStats(args *models.QueryContext, timeFilter int) (models.ProgramsFacilitiesStats, error) { @@ -567,15 +626,19 @@ func (db *DB) GetProgramsOverviewTable(args *models.QueryContext, timeFilter int baseQuery := db.WithContext(args.Ctx).Model(&models.Program{}) + facilityScoped := adminRole == models.FacilityAdmin || adminRole == models.SystemAdmin var selectFields string - if adminRole == models.FacilityAdmin { + if facilityScoped { selectFields = ` programs.id AS program_id, programs.name AS program_name, + programs.description AS description, programs.archived_at AS archived_at, mr.total_enrollments AS total_enrollments, mr.total_active_enrollments AS total_active_enrollments, mr.total_classes AS total_classes, + mr.total_active_classes AS total_active_classes, + mr.total_capacity AS total_capacity, time_filtered_rates.completion_rate AS completion_rate, time_filtered_rates.attendance_rate AS attendance_rate, pt.program_types AS program_types, @@ -587,11 +650,14 @@ func (db *DB) GetProgramsOverviewTable(args *models.QueryContext, timeFilter int selectFields = ` programs.id AS program_id, programs.name AS program_name, + programs.description AS description, programs.archived_at AS archived_at, mr.total_active_facilities AS total_active_facilities, mr.total_enrollments AS total_enrollments, mr.total_active_enrollments AS total_active_enrollments, mr.total_classes AS total_classes, + mr.total_active_classes AS total_active_classes, + mr.total_capacity AS total_capacity, time_filtered_rates.completion_rate AS completion_rate, time_filtered_rates.attendance_rate AS attendance_rate, pt.program_types AS program_types, @@ -602,20 +668,27 @@ func (db *DB) GetProgramsOverviewTable(args *models.QueryContext, timeFilter int } var currentMetricsSubquery string - if adminRole == models.FacilityAdmin { + if facilityScoped { currentMetricsSubquery = fmt.Sprintf(` LEFT JOIN ( SELECT p.id as program_id, COUNT(DISTINCT pce.id) AS total_enrollments, COUNT(DISTINCT CASE WHEN pce.enrollment_status = 'Enrolled' AND pce.enrolled_at IS NOT NULL AND (pce.enrollment_ended_at IS NULL OR pce.enrollment_ended_at > CURRENT_TIMESTAMP) THEN pce.id END) AS total_active_enrollments, - COUNT(DISTINCT CASE WHEN pc.status != 'Cancelled' THEN pc.id END) AS total_classes + COUNT(DISTINCT CASE WHEN pc.status != 'Cancelled' THEN pc.id END) AS total_classes, + COUNT(DISTINCT CASE WHEN pc.status = 'Active' THEN pc.id END) AS total_active_classes, + class_stats.total_capacity FROM programs p JOIN facilities_programs fp ON fp.program_id = p.id LEFT JOIN program_classes pc ON pc.program_id = p.id AND pc.facility_id = %d LEFT JOIN program_class_enrollments pce ON pce.class_id = pc.id + LEFT JOIN ( + SELECT pc2.program_id, COALESCE(SUM(CASE WHEN pc2.status = 'Active' THEN pc2.capacity ELSE 0 END), 0) AS total_capacity + FROM program_classes pc2 + GROUP BY pc2.program_id + ) AS class_stats ON class_stats.program_id = p.id WHERE fp.facility_id = %d - GROUP BY p.id + GROUP BY p.id, class_stats.total_capacity ) AS mr ON mr.program_id = programs.id `, args.FacilityID, args.FacilityID) } else { @@ -626,12 +699,19 @@ func (db *DB) GetProgramsOverviewTable(args *models.QueryContext, timeFilter int COUNT(DISTINCT CASE WHEN p.is_active = true AND p.archived_at IS NULL THEN fp.facility_id END) AS total_active_facilities, COUNT(DISTINCT pce.id) AS total_enrollments, COUNT(DISTINCT CASE WHEN pce.enrollment_status = 'Enrolled' AND pce.enrolled_at IS NOT NULL AND (pce.enrollment_ended_at IS NULL OR pce.enrollment_ended_at > CURRENT_TIMESTAMP) THEN pce.id END) AS total_active_enrollments, - COUNT(DISTINCT CASE WHEN pc.status != 'Cancelled' THEN pc.id END) AS total_classes + COUNT(DISTINCT CASE WHEN pc.status != 'Cancelled' THEN pc.id END) AS total_classes, + COUNT(DISTINCT CASE WHEN pc.status = 'Active' THEN pc.id END) AS total_active_classes, + class_stats.total_capacity FROM programs p LEFT JOIN facilities_programs fp ON fp.program_id = p.id LEFT JOIN program_classes pc ON pc.program_id = p.id LEFT JOIN program_class_enrollments pce ON pce.class_id = pc.id - GROUP BY p.id + LEFT JOIN ( + SELECT pc2.program_id, COALESCE(SUM(CASE WHEN pc2.status = 'Active' THEN pc2.capacity ELSE 0 END), 0) AS total_capacity + FROM program_classes pc2 + GROUP BY pc2.program_id + ) AS class_stats ON class_stats.program_id = p.id + GROUP BY p.id, class_stats.total_capacity ) AS mr ON mr.program_id = programs.id ` } @@ -645,7 +725,7 @@ func (db *DB) GetProgramsOverviewTable(args *models.QueryContext, timeFilter int } var facilityFilterForRates string - if adminRole == models.FacilityAdmin { + if facilityScoped { facilityFilterForRates = fmt.Sprintf("AND pc.facility_id = %d", args.FacilityID) } @@ -688,7 +768,7 @@ func (db *DB) GetProgramsOverviewTable(args *models.QueryContext, timeFilter int GROUP BY program_id ) AS pct ON pct.program_id = programs.id`) - if adminRole == models.FacilityAdmin { + if facilityScoped { tx = tx.Joins("JOIN facilities_programs fp ON fp.program_id = programs.id").Where("fp.facility_id = ?", args.FacilityID) } @@ -714,7 +794,7 @@ func (db *DB) GetProgramsOverviewTable(args *models.QueryContext, timeFilter int op, num := parseOperatorAndValue(val) tx = tx.Where(fmt.Sprintf("mr.total_classes %s ?", op), num) case "mr.total_active_facilities": - if adminRole != models.FacilityAdmin { + if !facilityScoped { op, num := parseOperatorAndValue(val) tx = tx.Where(fmt.Sprintf("mr.total_active_facilities %s ?", op), num) } @@ -764,7 +844,10 @@ func (db *DB) GetProgramsOverviewTable(args *models.QueryContext, timeFilter int tx = tx.Order(args.OrderClause("programs.created_at desc")) } - if err := tx.Limit(args.PerPage).Offset(args.CalcOffset()).Scan(&programsTable).Error; err != nil { + if !args.All { + tx = tx.Limit(args.PerPage).Offset(args.CalcOffset()) + } + if err := tx.Scan(&programsTable).Error; err != nil { return programsTable, newGetRecordsDBError(err, "programs table") } @@ -801,47 +884,48 @@ func (db *DB) GetProgramsCSVData(args *models.QueryContext) ([]models.ProgramCSV Joins("JOIN facilities f ON f.id = u.facility_id"). Joins("LEFT JOIN program_completions c on c.program_class_id = pc.id and c.user_id = u.id"). Joins("LEFT JOIN program_class_events pce on pce.class_id = pc.id"). + Joins("LEFT JOIN users iu ON iu.id = pce.instructor_id"). Joins("LEFT JOIN program_class_event_attendance pca ON pca.event_id = pce.id AND pca.user_id = u.id"). Select(` - f.name AS facility_name, - p.name AS program_name, - pc.name AS class_name, - pc.instructor_name AS instructor_name, - u.id as unlock_ed_id, - u.doc_id AS resident_id, + f.name AS facility_name, + p.name AS program_name, + pc.name AS class_name, + COALESCE(iu.name_first || ' ' || iu.name_last, '') AS instructor_name, + u.id as unlock_ed_id, + u.doc_id AS resident_id, u.name_last AS name_last, u.name_first AS name_first, - pe.created_at as enrollment_date, - COALESCE( - CASE - WHEN c.id IS NOT NULL THEN c.created_at - WHEN pe.enrollment_status IN (?) THEN pe.updated_at - END, - pc.end_dt + pe.created_at as enrollment_date, + COALESCE( + CASE + WHEN c.id IS NOT NULL THEN c.created_at + WHEN pe.enrollment_status IN (?) THEN pe.updated_at + END, + pc.end_dt ) as end_date, - pe.enrollment_status AS end_status, - CASE - WHEN COUNT(CASE WHEN pca.attendance_status IS NOT NULL AND pca.attendance_status != '' THEN 1 END) = 0 THEN 0 - ELSE - COALESCE( - SUM( - CASE - WHEN pca.attendance_status = 'present' THEN 1 - WHEN pca.attendance_status = 'partial' THEN `+partialAttendanceSQL+` - ELSE 0 - END - ) * 100.0 / - NULLIF(COUNT(CASE WHEN pca.attendance_status IS NOT NULL AND pca.attendance_status != '' THEN 1 END), 0), - 0 - ) - END AS attendance_percentage`, statuses). + pe.enrollment_status AS end_status, + CASE + WHEN COUNT(CASE WHEN pca.attendance_status IS NOT NULL AND pca.attendance_status != '' THEN 1 END) = 0 THEN 0 + ELSE + COALESCE( + SUM( + CASE + WHEN pca.attendance_status = 'present' THEN 1 + WHEN pca.attendance_status = 'partial' THEN `+partialAttendanceSQL+` + ELSE 0 + END + ) * 100.0 / + NULLIF(COUNT(CASE WHEN pca.attendance_status IS NOT NULL AND pca.attendance_status != '' THEN 1 END), 0), + 0 + ) + END AS attendance_percentage`, statuses). Where(` ( c.id IS NOT NULL OR pe.enrollment_status IN (?) ) `, statuses). - Group("u.id, u.doc_id, f.name, p.name, pc.name,pc.instructor_name, pe.created_at, pe.enrollment_status, pe.updated_at, c.id, c.created_at, pc.end_dt"). + Group("u.id, u.doc_id, f.name, p.name, pc.name, iu.name_first, iu.name_last, pe.created_at, pe.enrollment_status, pe.updated_at, c.id, c.created_at, pc.end_dt"). Order("f.name ASC, p.name ASC, pc.name ASC, end_date DESC") if !args.All { diff --git a/backend/src/database/rooms.go b/backend/src/database/rooms.go index 3184df233..bee41b2ef 100644 --- a/backend/src/database/rooms.go +++ b/backend/src/database/rooms.go @@ -132,6 +132,9 @@ func checkRRuleConflictsInternal(db *DB, req *models.ConflictCheckRequest) ([]mo if req.ExcludeEventID != nil && booking.EventID == *req.ExcludeEventID { continue } + if req.ExcludeClassID != nil && booking.ClassID == *req.ExcludeClassID { + continue + } if hasLocalTimeOverlap(occ, endTime, booking.StartTime, booking.EndTime, tz) { className := classNamesCache[booking.ClassID] if className == "" { diff --git a/backend/src/database/users.go b/backend/src/database/users.go index 2edca4000..580084e24 100644 --- a/backend/src/database/users.go +++ b/backend/src/database/users.go @@ -22,7 +22,7 @@ func calcOffset(page, perPage int) int { func (db *DB) GetCurrentUsers(args *models.QueryContext, role string) ([]models.User, error) { tx := db.WithContext(args.Ctx).Model(models.User{}). Preload("LoginMetrics"). - Where("facility_id = ?", args.FacilityID) + Preload("Facility") if !args.IncludeDeactivated { tx = tx.Where("deactivated_at IS NULL") @@ -30,13 +30,24 @@ func (db *DB) GetCurrentUsers(args *models.QueryContext, role string) ([]models. switch role { case "system_admin": - tx = tx.Where("role IN ('system_admin', 'department_admin') OR (role = 'facility_admin' AND facility_id = ?)", args.FacilityID) + tx = tx.Where("role IN ('system_admin', 'department_admin', 'facility_admin')") + if args.FacilityID > 0 { + tx = tx.Where("facility_id = ?", args.FacilityID) + } case "department_admin": - tx = tx.Where("(role = 'department_admin') OR (role = 'facility_admin' AND facility_id = ?)", args.FacilityID) + tx = tx.Where("role IN ('department_admin', 'facility_admin')") + if args.FacilityID > 0 { + tx = tx.Where("facility_id = ?", args.FacilityID) + } case "facility_admin": - tx = tx.Where("facility_id = ? and role = 'facility_admin'", args.FacilityID) + tx = tx.Where("facility_id = ? AND role = 'facility_admin'", args.FacilityID) case "student": - tx = tx.Where("facility_id = ? and role = 'student'", args.FacilityID) + tx = tx.Where("role = 'student'") + if args.FacilityID > 0 { + tx = tx.Where("facility_id = ?", args.FacilityID) + } + default: + tx = tx.Where("facility_id = ?", args.FacilityID) } if args.Search != "" { tx = fuzzySearchUsers(tx, args) @@ -45,7 +56,7 @@ func (db *DB) GetCurrentUsers(args *models.QueryContext, role string) ([]models. return nil, newGetRecordsDBError(err, "users") } users := make([]models.User, 0, args.PerPage) - validUserOrderByFields := []string{"name_last", "created_at", "last_login"} + validUserOrderByFields := []string{"name_last", "created_at", "last_login", "doc_id", "username", "role", "facility_name"} if !slices.Contains(validUserOrderByFields, args.OrderBy) { args.OrderBy = "name_last" } @@ -54,6 +65,10 @@ func (db *DB) GetCurrentUsers(args *models.QueryContext, role string) ([]models. tx = tx.Joins("LEFT JOIN login_metrics ON login_metrics.user_id = users.id") orderClause = adjustLastLoginOrderBy(orderClause) } + if args.OrderBy == "facility_name" { + tx = tx.Joins("LEFT JOIN facilities ON facilities.id = users.facility_id") + orderClause = adjustFacilityNameOrderBy(orderClause) + } if err := tx.Order(orderClause). Offset(args.CalcOffset()). Limit(args.PerPage). @@ -65,6 +80,35 @@ func (db *DB) GetCurrentUsers(args *models.QueryContext, role string) ([]models. return users, nil } +type UserStats struct { + Total int64 `json:"total"` + Active int64 `json:"active"` + Inactive int64 `json:"inactive"` +} + +func (db *DB) GetUserStats(ctx context.Context, facilityID *uint) (*UserStats, error) { + var stats UserStats + baseTx := db.WithContext(ctx).Model(&models.User{}).Where("role = ?", models.Student) + if facilityID != nil { + baseTx = baseTx.Where("facility_id = ?", *facilityID) + } + if err := baseTx.Count(&stats.Total).Error; err != nil { + return nil, newGetRecordsDBError(err, "users") + } + daysAgo := time.Now().AddDate(0, 0, -30) + activeTx := db.WithContext(ctx).Model(&models.User{}). + Where("role = ?", models.Student). + Joins("JOIN login_metrics ON users.id = login_metrics.user_id AND login_metrics.last_login > ?", daysAgo) + if facilityID != nil { + activeTx = activeTx.Where("facility_id = ?", *facilityID) + } + if err := activeTx.Count(&stats.Active).Error; err != nil { + return nil, newGetRecordsDBError(err, "users") + } + stats.Inactive = stats.Total - stats.Active + return &stats, nil +} + func (db *DB) GetUserByDocIDAndID(ctx context.Context, docID string, userID int) (*models.User, error) { user := models.User{} if err := db.WithContext(ctx).First(&user, "LOWER(doc_id) = ? and id = ?", strings.ToLower(docID), userID).Error; err != nil { @@ -73,9 +117,16 @@ func (db *DB) GetUserByDocIDAndID(ctx context.Context, docID string, userID int) return &user, nil } -func (db *DB) GetUsersByIDs(userIDs []uint, args *models.QueryContext) ([]models.User, error) { +func (db *DB) GetUsersByIDs(ctx context.Context, userIDs []uint, facID uint, roles ...models.UserRole) ([]models.User, error) { users := make([]models.User, 0, len(userIDs)) - if err := db.WithContext(args.Ctx).Find(&users).Where("id IN (?)", userIDs).Error; err != nil { + tx := db.WithContext(ctx).Where("id IN ?", userIDs) + if len(roles) > 0 { + tx = tx.Where("role IN ?", roles) + } + if facID != 0 { + tx = tx.Where("facility_id = ?", facID) + } + if err := tx.Find(&users).Error; err != nil { return nil, newGetRecordsDBError(err, "users") } return users, nil @@ -192,6 +243,14 @@ func fuzzySearchUsers(tx *gorm.DB, ctx *models.QueryContext) *gorm.DB { return tx } +func (db *DB) GetUserRoleByID(ctx context.Context, id string) (models.UserRole, error) { + var role string + if err := db.WithContext(ctx).Model(&models.User{}).Select("role").Where("id = ?", id).First(&role).Error; err != nil { + return "", newNotFoundDBError(err, "users") + } + return models.UserRole(role), nil +} + func (db *DB) GetUserByID(id uint) (*models.User, error) { user := models.User{} if err := db.Preload("Facility").Preload("LoginMetrics").First(&user, id).Error; err != nil { @@ -584,7 +643,7 @@ func (db *DB) GetChangeLogEntries(args *models.QueryContext, tableName string, r classCategoryFields := map[string][]string{ "info": {"name", "description", "instructor_name", "class"}, "status": {"status", "archived_at"}, - "schedule": {"start_date", "end_date", "start_dt", "end_dt", "meeting_days", "meeting_times", "event_rescheduled_series", "event_rescheduled", "event_cancelled", "event_restored"}, + "schedule": {"start_date", "end_date", "start_dt", "end_dt", "meeting_days", "meeting_times", "event_rescheduled_series", "event_rescheduled", "event_cancelled", "event_restored", "event_substitute_instructor", "event_room_changed"}, "settings": {"capacity", "location", "credit_hours"}, } @@ -635,6 +694,7 @@ func (db *DB) GetUserProgramInfo(args *models.QueryContext, userId int) ([]model base := db.WithContext(args.Ctx). Table("program_class_enrollments AS pce"). Select(` + pce.id AS enrollment_id, pce.enrollment_status AS enrollment_status, p.name AS program_name, p.id AS program_id, @@ -645,9 +705,9 @@ func (db *DB) GetUserProgramInfo(args *models.QueryContext, userId int) ([]model pc.id AS class_id, pce.updated_at, pce.created_at, - ARRAY_TO_STRING(ARRAY_AGG(DISTINCT pct.credit_type), ', ') AS credit_types, + (SELECT ARRAY_TO_STRING(ARRAY_AGG(DISTINCT pct.credit_type), ', ') + FROM program_credit_types pct WHERE pct.program_id = p.id) AS credit_types, - -- count present attendance status COALESCE(SUM( CASE WHEN pcea.attendance_status = 'present' THEN 1 @@ -660,14 +720,13 @@ func (db *DB) GetUserProgramInfo(args *models.QueryContext, userId int) ([]model END ), 0) AS present_attendance, - -- count everything else as absent COALESCE(SUM( CASE WHEN pcea.attendance_status NOT IN ('present','partial') THEN 1 ELSE 0 END - ), 0) AS absent_attendance, pce.change_reason + ), 0) AS absent_attendance, pce.change_reason, + STRING_AGG(DISTINCT e.recurrence_rule, '|') AS recurrence_rule `). Joins("INNER JOIN program_classes pc ON pc.id = pce.class_id"). Joins("INNER JOIN programs p ON p.id = pc.program_id"). - Joins("INNER JOIN program_credit_types pct ON pct.program_id = p.id"). Joins("INNER JOIN program_class_events e ON e.class_id = pc.id"). Joins( `LEFT JOIN program_class_event_attendance pcea @@ -677,7 +736,7 @@ func (db *DB) GetUserProgramInfo(args *models.QueryContext, userId int) ([]model ). Where("pce.user_id = ?", userId). Group(` - pce.enrollment_status, + pce.id, pce.enrollment_status, p.name, p.id, pc.name, pc.status, pc.start_dt, pc.end_dt, pc.id, pce.updated_at, pce.created_at, pce.change_reason`).Order(args.OrderClause("pce.created_at desc")) @@ -698,6 +757,43 @@ func (db *DB) GetUserProgramInfo(args *models.QueryContext, userId int) ([]model return userEnrollments, nil } +func (db *DB) GetUserWeeklyAttendanceRows(ctx context.Context, userID, weeks int) ([]models.WeeklyAttendanceRow, error) { + var rows []models.WeeklyAttendanceRow + query := ` + SELECT DATE_TRUNC('week', date::date) AS week_start, + COALESCE(SUM(CASE WHEN attendance_status IN ('present','partial') THEN 1 ELSE 0 END), 0) AS present_count, + COUNT(*) AS total_count + FROM program_class_event_attendance + WHERE user_id = ? AND date::date >= NOW() - INTERVAL '1 week' * ? + GROUP BY week_start + ORDER BY week_start ASC` + if err := db.WithContext(ctx).Raw(query, userID, weeks).Scan(&rows).Error; err != nil { + return nil, newGetRecordsDBError(err, "weekly_attendance_trend") + } + return rows, nil +} + +func (db *DB) GetUserNotes(ctx context.Context, userID uint) ([]models.UserNoteResponse, error) { + var notes []models.UserNoteResponse + if err := db.WithContext(ctx). + Table("user_notes un"). + Select("un.id, un.created_at AS date, un.note, CONCAT(u.name_first, ' ', u.name_last) AS admin"). + Joins("INNER JOIN users u ON u.id = un.create_user_id"). + Where("un.user_id = ?", userID). + Order("un.created_at DESC"). + Scan(¬es).Error; err != nil { + return nil, newGetRecordsDBError(err, "user_notes") + } + return notes, nil +} + +func (db *DB) CreateUserNote(ctx context.Context, note *models.UserNote) error { + if err := db.WithContext(ctx).Create(note).Error; err != nil { + return newCreateDBError(err, "user_notes") + } + return nil +} + func adjustUserOrderBy(arg string) string { if strings.Contains(arg, "name_last") { return arg + ", name_first" @@ -715,6 +811,16 @@ func adjustLastLoginOrderBy(arg string) string { return arg } +func adjustFacilityNameOrderBy(arg string) string { + if strings.Contains(arg, "facility_name ") { + arg = strings.Replace(arg, "facility_name", "facilities.name", 1) + } + if strings.Contains(arg, "facilities.name") { + return arg + ", users.name_last, users.name_first" + } + return arg +} + func (db *DB) UpdateFailedLogin(userID uint) error { now := time.Now() return db.Transaction(func(tx *gorm.DB) error { @@ -864,7 +970,7 @@ func (db *DB) DeactivatedUsersPresent(userIDs []int) (bool, error) { return count > 0, nil } -func (db *DB) GetResidentAttendanceCSVData(ctx context.Context, userID uint, facilityID uint, all bool) ([]models.ResidentAttendanceCSVData, error) { +func (db *DB) GetResidentAttendanceCSVData(ctx context.Context, userID uint, facilityID uint, all bool, classID *uint) ([]models.ResidentAttendanceCSVData, error) { var csvData []models.ResidentAttendanceCSVData query := ` @@ -881,7 +987,7 @@ func (db *DB) GetResidentAttendanceCSVData(ctx context.Context, userID uint, fac JOIN program_class_enrollments e ON e.class_id = pc.id AND e.user_id = pcea.user_id JOIN users u ON u.id = pcea.user_id WHERE pcea.user_id = ? - AND CAST(DATE(e.enrolled_at) AS TEXT) <= pcea.date + AND (e.enrolled_at IS NULL OR CAST(DATE(e.enrolled_at) AS TEXT) <= pcea.date) AND (e.enrollment_ended_at IS NULL OR CAST(DATE(e.enrollment_ended_at) AS TEXT) >= pcea.date) ` @@ -892,6 +998,11 @@ func (db *DB) GetResidentAttendanceCSVData(ctx context.Context, userID uint, fac args = append(args, facilityID) } + if classID != nil { + query += " AND pc.id = ?" + args = append(args, *classID) + } + query += " ORDER BY p.name ASC, pc.name ASC, pcea.date ASC" if err := db.WithContext(ctx).Raw(query, args...).Scan(&csvData).Error; err != nil { diff --git a/backend/src/database/videos.go b/backend/src/database/videos.go index 08bb89ebb..66ca12a61 100644 --- a/backend/src/database/videos.go +++ b/backend/src/database/videos.go @@ -67,6 +67,7 @@ func (db *DB) FavoriteOpenContent(contentID int, ocpID uint, userID uint, facili type VideoResponse struct { models.Video IsFavorited bool `json:"is_favorited"` + IsFeatured bool `json:"is_featured"` } func (db *DB) GetAllVideos(args *models.QueryContext, visibility string) ([]VideoResponse, error) { @@ -82,7 +83,14 @@ func (db *DB) GetAllVideos(args *models.QueryContext, visibility string) ([]Vide WHERE f.content_id = videos.id AND f.open_content_provider_id = videos.open_content_provider_id AND f.user_id = ? - ) AS is_favorited`, args.UserID). + ) AS is_favorited, + EXISTS ( + SELECT 1 + FROM open_content_favorites ff + WHERE ff.content_id = videos.id + AND ff.open_content_provider_id = videos.open_content_provider_id + AND ff.facility_id = ? + ) AS is_featured`, args.UserID, args.FacilityID). Joins(`left join facility_visibility_statuses fvs on fvs.open_content_provider_id = videos.open_content_provider_id and fvs.content_id = videos.id diff --git a/backend/src/handlers/class_enrollments.go b/backend/src/handlers/class_enrollments.go index ddcfe32e1..e3ec690f0 100644 --- a/backend/src/handlers/class_enrollments.go +++ b/backend/src/handlers/class_enrollments.go @@ -15,6 +15,7 @@ func (srv *Server) registerProgramClassEnrollmentsRoutes() []routeDef { resolve := FacilityAdminResolver("program_classes", "class_id") return []routeDef{ adminFeatureRoute("GET /api/program-classes/{class_id}/enrollments", srv.handleGetEnrollmentsForProgram, axx), + adminFeatureRoute("POST /api/program-classes/{class_id}/enrollment-conflicts", srv.handleCheckEnrollmentConflicts, axx), adminValidatedFeatureRoute("POST /api/program-classes/{class_id}/enrollments", srv.handleEnrollUsersInClass, axx, resolve), adminValidatedFeatureRoute("PATCH /api/program-classes/{class_id}/enrollments", srv.handleUpdateProgramClassEnrollments, axx, resolve), adminValidatedFeatureRoute("PATCH /api/program-classes/{class_id}/enrollments/{enrollment_id}/date", srv.handleUpdateEnrollmentDate, axx, resolve), @@ -56,6 +57,27 @@ func (srv *Server) handleGetEnrollmentsForProgram(w http.ResponseWriter, r *http return writePaginatedResponse(w, http.StatusOK, enrollments, args.IntoMeta()) } +func (srv *Server) handleCheckEnrollmentConflicts(w http.ResponseWriter, r *http.Request, log sLog) error { + classID, err := strconv.Atoi(r.PathValue("class_id")) + if err != nil { + return newInvalidIdServiceError(err, "class ID") + } + body := struct { + UserIDs []int `json:"user_ids"` + }{} + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + return newJSONReqBodyServiceError(err) + } + if len(body.UserIDs) == 0 { + return writeJsonResponse(w, http.StatusOK, map[string]any{"conflicts": []any{}}) + } + conflicts, err := srv.Db.CheckSchedulingConflicts(classID, body.UserIDs) + if err != nil { + return newDatabaseServiceError(err) + } + return writeJsonResponse(w, http.StatusOK, map[string]any{"conflicts": conflicts}) +} + func (srv *Server) handleEnrollUsersInClass(w http.ResponseWriter, r *http.Request, log sLog) error { classID, err := strconv.Atoi(r.PathValue("class_id")) if err != nil { @@ -200,7 +222,7 @@ func (srv *Server) handleUpdateEnrollmentDate(w http.ResponseWriter, r *http.Req } func (srv *Server) handleGetProgramClassEnrollmentsAttendance(w http.ResponseWriter, r *http.Request, log sLog) error { - id, err := strconv.Atoi(r.PathValue("class_id")) + id, err := strconv.Atoi(r.PathValue("enrollment_id")) if err != nil { return newInvalidIdServiceError(err, "class enrollment ID") } diff --git a/backend/src/handlers/class_enrollments_test.go b/backend/src/handlers/class_enrollments_test.go index 254b360c5..549524652 100644 --- a/backend/src/handlers/class_enrollments_test.go +++ b/backend/src/handlers/class_enrollments_test.go @@ -120,14 +120,13 @@ func TestHandleEnrollUsersInClass_Validation(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { class := models.ProgramClass{ - Name: "Test Class", - ProgramID: program.ID, - FacilityID: facility.ID, - Status: tt.classStatus, - InstructorName: "Instructor", - Description: "Description", - Capacity: tt.capacity, - StartDt: time.Now(), + Name: "Test Class", + ProgramID: program.ID, + FacilityID: facility.ID, + Status: tt.classStatus, + Description: "Description", + Capacity: tt.capacity, + StartDt: time.Now(), } if tt.isArchived { class.ArchivedAt = TimePtr(time.Now()) diff --git a/backend/src/handlers/class_events.go b/backend/src/handlers/class_events.go index b69839d53..86448a855 100644 --- a/backend/src/handlers/class_events.go +++ b/backend/src/handlers/class_events.go @@ -3,10 +3,14 @@ package handlers import ( "UnlockEdv2/src" "UnlockEdv2/src/models" + "UnlockEdv2/src/services" "encoding/json" "errors" + "fmt" "net/http" "strconv" + "strings" + "time" ) func (srv *Server) registerClassEventsRoutes() []routeDef { @@ -17,8 +21,12 @@ func (srv *Server) registerClassEventsRoutes() []routeDef { featureRoute("GET /api/program-classes/{class_id}/events", srv.handleGetProgramClassEvents, axx), /* admin */ adminFeatureRoute("GET /api/admin-calendar", srv.handleGetAdminCalendar, axx), + adminFeatureRoute("GET /api/program-classes/todays-schedule", srv.handleGetTodaysSchedule, axx), adminValidatedFeatureRoute("PUT /api/program-classes/{class_id}/events/{event_id}", srv.handleEventOverrides, axx, resolver), + adminValidatedFeatureRoute("PATCH /api/program-classes/{class_id}/events/{event_id}", srv.handlePatchEventOverride, axx, resolver), adminValidatedFeatureRoute("DELETE /api/program-classes/{class_id}/events/{event_override_id}", srv.handleDeleteEventOverride, axx, resolver), + adminValidatedFeatureRoute("POST /api/program-classes/{class_id}/events/{event_override_id}/uncancel", srv.handleUncancelOverride, axx, resolver), + adminValidatedFeatureRoute("POST /api/program-classes/{class_id}/events/{event_id}/uncancel-series", srv.handleUncancelSeries, axx, resolver), adminValidatedFeatureRoute("POST /api/program-classes/{class_id}/events", srv.handleCreateEvent, axx, resolver), adminValidatedFeatureRoute("PUT /api/program-classes/{class_id}/events", srv.handleRescheduleEventSeries, axx, resolver), } @@ -39,6 +47,7 @@ func (srv *Server) handleGetAdminCalendar(w http.ResponseWriter, r *http.Request return newInvalidIdServiceError(err, "class_id") } } + events, err := srv.Db.GetFacilityCalendar(&args, dtRng, classID) if err != nil { return newDatabaseServiceError(err) @@ -59,6 +68,40 @@ func (srv *Server) handleGetStudentCalendar(w http.ResponseWriter, r *http.Reque return writeJsonResponse(w, http.StatusOK, events) } +func (srv *Server) handleGetTodaysSchedule(w http.ResponseWriter, r *http.Request, log sLog) error { + args := srv.getQueryContext(r) + facility := r.URL.Query().Get("facility") + var facilityId *uint + switch facility { + case "all": + facilityId = nil + default: + facilityId = &args.FacilityID + } + + service := services.NewClassesService(srv.Db) + items, err := service.GetTodaysSchedule(&args, facilityId) + if err != nil { + return newDatabaseServiceError(err) + } + + args.Total = int64(len(items)) + if !args.All { + start := args.CalcOffset() + if start < len(items) { + end := start + args.PerPage + if end > len(items) { + end = len(items) + } + items = items[start:end] + } else { + items = []models.TodaysScheduleItem{} + } + } + + return writePaginatedResponse(w, http.StatusOK, items, args.IntoMeta()) +} + func (srv *Server) handleEventOverrides(w http.ResponseWriter, r *http.Request, log sLog) error { eventId, err := strconv.Atoi(r.PathValue("event_id")) if err != nil { @@ -102,12 +145,14 @@ func (srv *Server) handleEventOverrides(w http.ResponseWriter, r *http.Request, return newDatabaseServiceError(err) } eventIDUint := uint(eventId) + classIDUint := uint(classID) conflicts, err := srv.Db.CheckRRuleConflicts(&models.ConflictCheckRequest{ FacilityID: class.FacilityID, RoomID: *override.RoomID, RecurrenceRule: override.OverrideRrule, Duration: override.Duration, ExcludeEventID: &eventIDUint, + ExcludeClassID: &classIDUint, }) if err != nil { return newDatabaseServiceError(err) @@ -123,6 +168,186 @@ func (srv *Server) handleEventOverrides(w http.ResponseWriter, r *http.Request, return writeJsonResponse(w, http.StatusOK, "Override(s) created successfully") } +type patchEventOverrideRequest struct { + Date string `json:"date"` + StartTime string `json:"start_time"` + IsCancelled bool `json:"is_cancelled"` + Reason string `json:"reason"` + RoomID *uint `json:"room_id"` + InstructorID *uint `json:"instructor_id"` + NewDate string `json:"new_date"` + NewStartTime string `json:"new_start_time"` + NewEndTime string `json:"new_end_time"` +} + +func (srv *Server) handlePatchEventOverride(w http.ResponseWriter, r *http.Request, log sLog) error { + eventId, err := strconv.Atoi(r.PathValue("event_id")) + if err != nil { + return newInvalidIdServiceError(err, "event_id") + } + classID, err := strconv.Atoi(r.PathValue("class_id")) + if err != nil { + return newInvalidIdServiceError(err, "class ID") + } + cannotCreateOverride, err := srv.cannotUpdateEvent(classID) + if err != nil { + return err + } + if cannotCreateOverride { + return newBadRequestServiceError(errors.New("cannot create an event override for a completed or cancelled class"), "cannot create event override") + } + var req patchEventOverrideRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return newJSONReqBodyServiceError(err) + } + if req.Date == "" { + return newBadRequestServiceError(errors.New("date is required"), "date is required") + } + if len(req.Reason) > 255 { + return newBadRequestServiceError(errors.New("reason exceeds maximum length of 255 characters"), "reason exceeds maximum length of 255 characters") + } + if req.InstructorID != nil { + class, err := srv.Db.GetClassByID(classID) + if err != nil { + return newDatabaseServiceError(err) + } + name, err := srv.Db.GetInstructorNameByID(*req.InstructorID, class.FacilityID) + if err != nil { + return newDatabaseServiceError(err) + } + if name == "" { + return newForbiddenServiceError(errors.New("instructor does not belong to this facility"), "instructor not authorized for this facility") + } + } + ctx := srv.getQueryContext(r) + event, err := srv.Db.GetEventById(eventId) + if err != nil { + return newDatabaseServiceError(err) + } + eventRule, err := event.GetRRuleWithTimezone(ctx.Timezone) + if err != nil { + return newDatabaseServiceError(err) + } + // Use GetDTStart() (same as createEventInstances) to get the canonical time. + // OrigOptions.Dtstart.In(tz) can give a different hour due to DST on the + // DTSTART date vs the target date. GetDTStart() is what the instance generator uses. + eventStart := eventRule.GetDTStart() + canonicalHour := eventStart.Hour() + canonicalMinute := eventStart.Minute() + + buildRRule := func(dateStr string, optionalTime string) (string, error) { + dateOnly, err := time.Parse("2006-01-02", dateStr) + if err != nil { + return "", err + } + hour, minute := canonicalHour, canonicalMinute + if optionalTime != "" { + parts := strings.Split(optionalTime, ":") + if len(parts) == 2 { + hour, _ = strconv.Atoi(parts[0]) + minute, _ = strconv.Atoi(parts[1]) + } + } + cleanDate := dateOnly.Format("20060102") + return fmt.Sprintf("DTSTART;TZID=%s:%sT%02d%02d00\nRRULE:FREQ=DAILY;COUNT=1", + ctx.Timezone, cleanDate, hour, minute), nil + } + + originalRRule, err := buildRRule(req.Date, req.StartTime) + if err != nil { + return newBadRequestServiceError(err, "invalid date format") + } + + if req.NewDate != "" { + newRRule, err := buildRRule(req.NewDate, req.NewStartTime) + if err != nil { + return newBadRequestServiceError(err, "invalid new_date format") + } + newDuration := event.Duration + if req.NewStartTime != "" && req.NewEndTime != "" { + startParts := strings.Split(req.NewStartTime, ":") + endParts := strings.Split(req.NewEndTime, ":") + if len(startParts) == 2 && len(endParts) == 2 { + sh, _ := strconv.Atoi(startParts[0]) + sm, _ := strconv.Atoi(startParts[1]) + eh, _ := strconv.Atoi(endParts[0]) + em, _ := strconv.Atoi(endParts[1]) + dur := time.Duration(eh-sh)*time.Hour + time.Duration(em-sm)*time.Minute + if dur > 0 { + newDuration = dur.String() + } + } + } + newRoomID := event.RoomID + if req.RoomID != nil { + newRoomID = req.RoomID + } + rescheduleReason := "rescheduled" + if req.Reason != "" { + rescheduleReason = req.Reason + } + overrides := []*models.ProgramClassEventOverride{ + { + EventID: uint(eventId), + ClassID: uint(classID), + Duration: event.Duration, + OverrideRrule: originalRRule, + IsCancelled: true, + Reason: rescheduleReason, + }, + { + EventID: uint(eventId), + ClassID: uint(classID), + Duration: newDuration, + OverrideRrule: newRRule, + IsCancelled: false, + RoomID: newRoomID, + }, + } + if err := srv.WithUserContext(r).CreateOverrideEvents(&ctx, overrides); err != nil { + return newDatabaseServiceError(err) + } + return writeJsonResponse(w, http.StatusOK, "Event rescheduled successfully") + } + + overrideDuration := event.Duration + overrideRRule := originalRRule + if req.NewStartTime != "" { + overrideRRule, err = buildRRule(req.Date, req.NewStartTime) + if err != nil { + return newBadRequestServiceError(err, "invalid start time") + } + if req.NewEndTime != "" { + startParts := strings.Split(req.NewStartTime, ":") + endParts := strings.Split(req.NewEndTime, ":") + if len(startParts) == 2 && len(endParts) == 2 { + sh, _ := strconv.Atoi(startParts[0]) + sm, _ := strconv.Atoi(startParts[1]) + eh, _ := strconv.Atoi(endParts[0]) + em, _ := strconv.Atoi(endParts[1]) + dur := time.Duration(eh-sh)*time.Hour + time.Duration(em-sm)*time.Minute + if dur > 0 { + overrideDuration = dur.String() + } + } + } + } + override := &models.ProgramClassEventOverride{ + EventID: uint(eventId), + ClassID: uint(classID), + Duration: overrideDuration, + OverrideRrule: overrideRRule, + IsCancelled: req.IsCancelled, + Reason: req.Reason, + RoomID: req.RoomID, + InstructorID: req.InstructorID, + } + if err := srv.WithUserContext(r).CreateOverrideEvents(&ctx, []*models.ProgramClassEventOverride{override}); err != nil { + return newDatabaseServiceError(err) + } + return writeJsonResponse(w, http.StatusOK, "Override created successfully") +} + func (srv *Server) handleDeleteEventOverride(w http.ResponseWriter, r *http.Request, log sLog) error { id, err := strconv.Atoi(r.PathValue("event_override_id")) if err != nil { @@ -142,13 +367,47 @@ func (srv *Server) handleDeleteEventOverride(w http.ResponseWriter, r *http.Requ log.add("class_id", classID) log.add("event_override_id", id) args := srv.getQueryContext(r) - err = srv.WithUserContext(r).DeleteOverrideEvent(&args, id, classID) + undoAppliedFuture := r.URL.Query().Get("undo_applied_future") == "true" + err = srv.WithUserContext(r).DeleteOverrideEvent(&args, id, classID, undoAppliedFuture) if err != nil { return newDatabaseServiceError(err) } return writeJsonResponse(w, http.StatusNoContent, "Event override(s) deleted successfully") } +func (srv *Server) handleUncancelSeries(w http.ResponseWriter, r *http.Request, log sLog) error { + eventID, err := strconv.Atoi(r.PathValue("event_id")) + if err != nil { + return newInvalidIdServiceError(err, "event_id") + } + var req struct { + RestoreDate string `json:"restore_date"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return newJSONReqBodyServiceError(err) + } + if req.RestoreDate == "" { + return newBadRequestServiceError(errors.New("restore date is required"), "restore date is required") + } + ctx := srv.getQueryContext(r) + if err := srv.Db.UncancelEventSeries(&ctx, uint(eventID), req.RestoreDate); err != nil { + return newDatabaseServiceError(err) + } + return writeJsonResponse(w, http.StatusOK, "Series uncancelled successfully") +} + +func (srv *Server) handleUncancelOverride(w http.ResponseWriter, r *http.Request, log sLog) error { + overrideID, err := strconv.Atoi(r.PathValue("event_override_id")) + if err != nil { + return newInvalidIdServiceError(err, "event override ID") + } + ctx := srv.getQueryContext(r) + if err := srv.Db.UncancelOverride(&ctx, uint(overrideID)); err != nil { + return newDatabaseServiceError(err) + } + return writeJsonResponse(w, http.StatusOK, "Override uncancelled successfully") +} + func (srv *Server) handleCreateEvent(w http.ResponseWriter, r *http.Request, log sLog) error { classID, err := strconv.Atoi(r.PathValue("class_id")) if err != nil { @@ -222,7 +481,10 @@ func (srv *Server) handleRescheduleEventSeries(w http.ResponseWriter, r *http.Re return newDatabaseServiceError(err) } var excludeEventID *uint - if eventSeriesRequest.EventSeries.ID > 0 { + if eventSeriesRequest.ClosedEventSeries.ID > 0 { + closedID := eventSeriesRequest.ClosedEventSeries.ID + excludeEventID = &closedID + } else if eventSeriesRequest.EventSeries.ID > 0 { excludeEventID = &eventSeriesRequest.EventSeries.ID } conflicts, err := srv.Db.CheckRRuleConflicts(&models.ConflictCheckRequest{ @@ -247,10 +509,11 @@ func (srv *Server) handleRescheduleEventSeries(w http.ResponseWriter, r *http.Re } eventSeriesRequest.EventSeries.ClassID = uint(classID) eventSeriesRequest.ClosedEventSeries.ClassID = uint(classID) - events := []models.ProgramClassEvent{ - eventSeriesRequest.EventSeries, - eventSeriesRequest.ClosedEventSeries, + var events []models.ProgramClassEvent + if eventSeriesRequest.EventSeries.RecurrenceRule != "" { + events = append(events, eventSeriesRequest.EventSeries) } + events = append(events, eventSeriesRequest.ClosedEventSeries) var maxEvent *models.ProgramClassEvent for i := range allEvents { if maxEvent == nil || allEvents[i].ID > maxEvent.ID { @@ -324,7 +587,8 @@ func (srv *Server) handleGetProgramClassEvents(w http.ResponseWriter, r *http.Re } qryCtx := srv.getQueryContext(r) - instances, err := srv.Db.GetClassEventInstancesWithAttendanceForRecurrence(classID, &qryCtx, month, year, userId) + allInstances := r.URL.Query().Get("all") == "true" + instances, err := srv.Db.GetClassEventInstancesWithAttendanceForRecurrence(classID, &qryCtx, month, year, userId, allInstances) if err != nil { return newDatabaseServiceError(err) } diff --git a/backend/src/handlers/classes_handler.go b/backend/src/handlers/classes_handler.go index bdd602c94..95be5c3f4 100644 --- a/backend/src/handlers/classes_handler.go +++ b/backend/src/handlers/classes_handler.go @@ -3,6 +3,7 @@ package handlers import ( "UnlockEdv2/src/database" "UnlockEdv2/src/models" + "UnlockEdv2/src/services" "encoding/json" "fmt" "io" @@ -41,6 +42,7 @@ func (srv *Server) registerClassesRoutes() []routeDef { validatedFeatureRoute("GET /api/program-classes/{class_id}", srv.handleGetClass, axx, resolver), adminValidatedFeatureRoute("GET /api/programs/{program_id}/classes/outcomes", srv.handleGetProgramClassOutcomes, axx, validateFacility("")), adminValidatedFeatureRoute("GET /api/program-classes/{class_id}/attendance-flags", srv.handleGetAttendanceFlagsForClass, axx, resolver), + adminFeatureRoute("GET /api/program-classes/missing-attendance", srv.handleGetMissingAttendanceForFacility, axx), adminValidatedFeatureRoute("GET /api/program-classes/{class_id}/missing-attendance", srv.handleGetMissingAttendance, axx, resolver), adminValidatedFeatureRoute("GET /api/program-classes/{class_id}/attendance-rate", srv.handleGetCumulativeAttendanceRate, axx, resolver), adminValidatedFeatureRoute("GET /api/program-classes/{class_id}/history", srv.handleGetClassHistory, axx, resolver), @@ -56,6 +58,8 @@ func (srv *Server) registerClassesRoutes() []routeDef { return err == nil }), adminValidatedFeatureRoute("PATCH /api/programs/{id}/classes/{class_id}", srv.handleUpdateClass, axx, resolver), + adminValidatedFeatureRoute("DELETE /api/program-classes/{class_id}", srv.handleDeleteClass, axx, resolver), + adminValidatedFeatureRoute("GET /api/program-classes/{class_id}/delete-check", srv.handleGetClassDeleteCheck, axx, resolver), } } @@ -65,7 +69,8 @@ func (srv *Server) handleGetClassesForProgram(w http.ResponseWriter, r *http.Req return newInvalidIdServiceError(err, "program ID") } args := srv.getQueryContext(r) - classes, err := srv.Db.GetProgramClassDetailsByID(id, &args) + service := services.NewClassesService(srv.Db) + classes, err := service.GetProgramClassDetailsForProgram(&args, id) if err != nil { log.add("program_id", id) return newDatabaseServiceError(err) @@ -88,7 +93,15 @@ func (srv *Server) handleGetClass(w http.ResponseWriter, r *http.Request, log sL func (srv *Server) handleIndexClassesForFacility(w http.ResponseWriter, r *http.Request, log sLog) error { args := srv.getQueryContext(r) - classes, err := srv.Db.GetClassesForFacility(&args) + claims := r.Context().Value(ClaimsKey).(*Claims) + facilityParam := r.URL.Query().Get("facility") + var facilityID *uint + if facilityParam == "all" && claims.canSwitchFacility() { + facilityID = nil + } else { + facilityID = &args.FacilityID + } + classes, err := srv.Db.GetClasses(&args, facilityID) if err != nil { return newDatabaseServiceError(err) } @@ -124,7 +137,9 @@ func (srv *Server) handleCreateClass(w http.ResponseWriter, r *http.Request, log if err != nil || instructorName == "" { return newBadRequestServiceError(err, "invalid instructor selected") } - class.InstructorName = instructorName + for i := range class.Events { + class.Events[i].InstructorID = class.InstructorID + } var conflictReq *models.ConflictCheckRequest if len(class.Events) > 0 && class.Events[0].RoomID != nil { @@ -178,30 +193,24 @@ func (srv *Server) handleUpdateClass(w http.ResponseWriter, r *http.Request, log } } + existing, err := srv.Db.GetClassByID(id) + if err != nil { + return newDatabaseServiceError(err) + } + if existing.CannotUpdateClass() { + return newBadRequestServiceError(err, "cannot perform action on class that is completed cancelled or archived") + } + if instructorIDProvided { if class.InstructorID == nil || *class.InstructorID == 0 { class.InstructorID = nil - class.InstructorName = "Unassigned" } else { - instructorName, err := srv.Db.GetInstructorNameByID(*class.InstructorID, claims.FacilityID) if err != nil || instructorName == "" { return newBadRequestServiceError(err, "invalid instructor selected") } - class.InstructorName = instructorName - } - } else { - - existing, err := srv.Db.GetClassByID(id) - if err != nil { - return newDatabaseServiceError(err) } - class.InstructorID = existing.InstructorID - class.InstructorName = existing.InstructorName - } - - if class.CannotUpdateClass() { - return newBadRequestServiceError(err, "cannot perform action on class that is completed cancelled or archived") + class.UpdateInstructor = true } enrolled, err := srv.Db.GetTotalEnrollmentsByClassID(id) if err != nil { @@ -288,7 +297,9 @@ func (srv *Server) handleGetClassHistory(w http.ResponseWriter, r *http.Request, if err != nil { return newDatabaseServiceError(err) } - historyEvents = append(historyEvents, createdByDetails) + if createdByDetails.Action != "" { + historyEvents = append(historyEvents, createdByDetails) + } return writePaginatedResponse(w, http.StatusOK, historyEvents, pageMeta) } @@ -331,6 +342,49 @@ func (srv *Server) handleGetMissingAttendance(w http.ResponseWriter, r *http.Req return writeJsonResponse(w, http.StatusOK, totalMissing) } +func (srv *Server) handleGetMissingAttendanceForFacility(w http.ResponseWriter, r *http.Request, log sLog) error { + args := srv.getQueryContext(r) + facility := r.URL.Query().Get("facility") + var facilityId *uint + switch facility { + case "all": + facilityId = nil + case "": + facilityId = &args.FacilityID + default: + facilityId = &args.FacilityID + } + + days := 3 + if daysQuery := r.URL.Query().Get("days"); daysQuery != "" { + if parsedDays, err := strconv.Atoi(daysQuery); err == nil { + days = parsedDays + } + } + + service := services.NewClassesService(srv.Db) + items, err := service.GetMissingAttendanceForFacility(&args, facilityId, days) + if err != nil { + return newDatabaseServiceError(err) + } + + args.Total = int64(len(items)) + if !args.All { + start := args.CalcOffset() + if start < len(items) { + end := start + args.PerPage + if end > len(items) { + end = len(items) + } + items = items[start:end] + } else { + items = []models.MissingAttendanceItem{} + } + } + + return writePaginatedResponse(w, http.StatusOK, items, args.IntoMeta()) +} + func (srv *Server) handleGetCumulativeAttendanceRate(w http.ResponseWriter, r *http.Request, log sLog) error { classID, err := strconv.Atoi(r.PathValue("class_id")) if err != nil { @@ -422,3 +476,46 @@ func (c *BulkCancelClaimsAdapter) GetFacilityID() uint { func (c *BulkCancelClaimsAdapter) GetTimezone() string { return c.TimeZone } + +func (srv *Server) handleGetClassDeleteCheck(w http.ResponseWriter, r *http.Request, log sLog) error { + id, err := strconv.Atoi(r.PathValue("class_id")) + if err != nil { + return newInvalidIdServiceError(err, "class ID") + } + log.add("class_id", id) + blockers, err := srv.Db.ClassBlockingChildren(id) + if err != nil { + return newDatabaseServiceError(err) + } + payload := struct { + CanDelete bool `json:"can_delete"` + Blockers models.DeleteBlockingChildren `json:"blockers"` + }{ + CanDelete: !blockers.HasAny(), + Blockers: blockers, + } + return writeJsonResponse(w, http.StatusOK, payload) +} + +func (srv *Server) handleDeleteClass(w http.ResponseWriter, r *http.Request, log sLog) error { + id, err := strconv.Atoi(r.PathValue("class_id")) + if err != nil { + return newInvalidIdServiceError(err, "class ID") + } + log.add("class_id", id) + + blockers, err := srv.Db.ClassBlockingChildren(id) + if err != nil { + return newDatabaseServiceError(err) + } + if blockers.HasAny() { + log.info("class delete blocked by child records") + return writeDeleteConflictResponse(w, "cannot delete: class has child records", blockers) + } + + if err := srv.Db.DeleteClass(id); err != nil { + return newDatabaseServiceError(err) + } + log.info("class deleted") + return writeJsonResponse(w, http.StatusNoContent, "Class deleted successfully") +} diff --git a/backend/src/handlers/dashboard.go b/backend/src/handlers/dashboard.go index 6a715a9de..c63b1645e 100644 --- a/backend/src/handlers/dashboard.go +++ b/backend/src/handlers/dashboard.go @@ -2,6 +2,7 @@ package handlers import ( "UnlockEdv2/src/models" + "UnlockEdv2/src/services" "encoding/json" "errors" "fmt" @@ -18,6 +19,8 @@ func (srv *Server) registerDashboardRoutes() []routeDef { resolver := UserRoleResolver("id") return []routeDef{ newAdminRoute("GET /api/department-metrics", srv.handleDepartmentMetrics), + newAdminRoute("GET /api/dashboard/class-metrics", srv.handleClassDashboardMetrics), + newAdminRoute("GET /api/dashboard/facility-health", srv.handleFacilityHealthSummary), newAdminRoute("GET /api/users/{id}/admin-layer2", srv.handleAdminLayer2), validatedFeatureRoute("GET /api/users/{id}/catalog", srv.handleUserCatalog, axx, resolver), validatedFeatureRoute("GET /api/users/{id}/courses", srv.handleUserCourses, axx, resolver), @@ -268,6 +271,52 @@ func (srv *Server) handleDepartmentMetrics(w http.ResponseWriter, r *http.Reques return writeJsonResponse(w, http.StatusOK, cachedData) } +func (srv *Server) handleClassDashboardMetrics(w http.ResponseWriter, r *http.Request, log sLog) error { + args := srv.getQueryContext(r) + facility := r.URL.Query().Get("facility") + var facilityId *uint + switch facility { + case "all": + facilityId = nil + default: + facilityId = &args.FacilityID + } + + metrics, err := srv.Db.GetClassDashboardMetrics(&args, facilityId) + if err != nil { + return newDatabaseServiceError(err) + } + + return writeJsonResponse(w, http.StatusOK, metrics) +} + +func (srv *Server) handleFacilityHealthSummary(w http.ResponseWriter, r *http.Request, log sLog) error { + args := srv.getQueryContext(r) + claims := r.Context().Value(ClaimsKey).(*Claims) + facility := r.URL.Query().Get("facility") + var facilityID *uint + if facility == "all" && claims.canSwitchFacility() { + facilityID = nil + } else { + facilityID = &args.FacilityID + } + + days := 3 + if daysQuery := r.URL.Query().Get("days"); daysQuery != "" { + if parsedDays, err := strconv.Atoi(daysQuery); err == nil { + days = parsedDays + } + } + + service := services.NewClassesService(srv.Db) + summaries, err := service.GetFacilityHealthSummaries(&args, facilityID, days) + if err != nil { + return newDatabaseServiceError(err) + } + + return writeJsonResponse(w, http.StatusOK, summaries) +} + /** * GET: /api/users/{id}/catalog * @Query Params: diff --git a/backend/src/handlers/facilities_handler.go b/backend/src/handlers/facilities_handler.go index b82608295..cab3e3c88 100644 --- a/backend/src/handlers/facilities_handler.go +++ b/backend/src/handlers/facilities_handler.go @@ -12,9 +12,9 @@ func (srv *Server) registerFacilitiesRoutes() []routeDef { return []routeDef{ newAdminRoute("GET /api/facilities", srv.handleIndexFacilities), newAdminRoute("GET /api/facilities/{id}", srv.handleShowFacility), - newSystemAdminRoute("POST /api/facilities", srv.handleCreateFacility), + newDeptAdminRoute("POST /api/facilities", srv.handleCreateFacility), newSystemAdminRoute("DELETE /api/facilities/{id}", srv.handleDeleteFacility), - newSystemAdminRoute("PATCH /api/facilities/{id}", srv.handleUpdateFacility), + newDeptAdminRoute("PATCH /api/facilities/{id}", srv.handleUpdateFacility), newDeptAdminRoute("PUT /api/admin/facility-context/{id}", srv.handleChangeAdminFacility), adminFeatureRoute("GET /api/rooms", srv.handleGetRooms, axx), adminFeatureRoute("POST /api/rooms", srv.handleCreateRoom, axx), @@ -27,13 +27,12 @@ func (srv *Server) registerFacilitiesRoutes() []routeDef { * GET: /api/facility/{id} **/ func (srv *Server) handleIndexFacilities(w http.ResponseWriter, r *http.Request, log sLog) error { - page, perPage := srv.getPaginationInfo(r) - total, facilities, err := srv.Db.GetAllFacilities(page, perPage) + args := srv.getQueryContext(r) + facilities, err := srv.Db.GetAllFacilitiesWithStats(&args) if err != nil { return newDatabaseServiceError(err) } - paginationData := models.NewPaginationInfo(page, perPage, total) - return writePaginatedResponse(w, http.StatusOK, facilities, paginationData) + return writePaginatedResponse(w, http.StatusOK, facilities, args.IntoMeta()) } /** @@ -85,9 +84,6 @@ func (srv *Server) handleCreateFacility(w http.ResponseWriter, r *http.Request, } func (srv *Server) handleUpdateFacility(w http.ResponseWriter, r *http.Request, log sLog) error { - if !userIsSystemAdmin(r) { - return newUnauthorizedServiceError() - } id, err := strconv.Atoi(r.PathValue("id")) if err != nil { return newInvalidIdServiceError(err, "facility ID") @@ -126,6 +122,11 @@ func (srv *Server) handleDeleteFacility(w http.ResponseWriter, r *http.Request, func (srv *Server) handleGetRooms(w http.ResponseWriter, r *http.Request, log sLog) error { facilityID := srv.getFacilityID(r) + if queryParam := r.URL.Query().Get("facility_id"); queryParam != "" { + if parsed, err := strconv.Atoi(queryParam); err == nil { + facilityID = uint(parsed) + } + } log.add("facility_id", facilityID) rooms, err := srv.Db.GetRoomsForFacility(facilityID) if err != nil { diff --git a/backend/src/handlers/middleware.go b/backend/src/handlers/middleware.go index 6eb458ea2..2f16a583d 100644 --- a/backend/src/handlers/middleware.go +++ b/backend/src/handlers/middleware.go @@ -126,7 +126,7 @@ func (srv *Server) libraryProxyMiddleware(next http.Handler) http.Handler { if marshErr != nil { log.Warnf("issue marshaling LibraryProxyPO, error is: %v", marshErr) } - if marshErr != nil { + if marshErr == nil { if _, err := libraryBucket.Put(resourceID, marshaledParams); err != nil { log.Warnf("issue putting LibraryProxyPO into bucket, error is: %v", err) } @@ -259,6 +259,11 @@ func FacilityAdminResolver(table string, param string) RouteResolver { return true } id := r.PathValue(param) + // The facilities table IS the facility — compare path ID directly. + if table == "facilities" { + facID, err := strconv.ParseUint(id, 10, 64) + return err == nil && claims.FacilityID == uint(facID) + } var facID uint columnName := "facility_id" if table == "facilities" { diff --git a/backend/src/handlers/ory.go b/backend/src/handlers/ory.go index 513fc4fdd..576493621 100644 --- a/backend/src/handlers/ory.go +++ b/backend/src/handlers/ory.go @@ -48,6 +48,9 @@ func (srv *Server) deleteAllKratosIdentities(ctx context.Context) error { } func (srv *Server) deleteIdentityInKratos(ctx context.Context, kratosId *string) error { + if kratosId == nil || *kratosId == "" { + return nil + } resp, err := srv.OryClient.IdentityAPI.DeleteIdentity(ctx, *kratosId).Execute() if err != nil { log.WithField("identity", kratosId).Errorln("unable to delete identity from Ory Kratos") @@ -205,6 +208,10 @@ func (srv *Server) updateFacilityInKratosIdentity(userID int, transFacilityID in log.Errorf("error retrieving user with id %d: %v", userID, err) return err } + if user.KratosID == "" { + log.Errorf("user %d has no Kratos identity ID, skipping identity update", userID) + return nil + } identity, resp, err := srv.OryClient.IdentityAPI.GetIdentity(ctx, user.KratosID).Execute() if err != nil { log.Errorf("error fetching identity using id %s: %v", user.KratosID, err) diff --git a/backend/src/handlers/programs_handler.go b/backend/src/handlers/programs_handler.go index 847310ff3..3bcde2b8e 100644 --- a/backend/src/handlers/programs_handler.go +++ b/backend/src/handlers/programs_handler.go @@ -25,6 +25,7 @@ func (srv *Server) registerProgramsRoutes() []routeDef { adminFeatureRoute("GET /api/programs/stats", srv.handleIndexProgramsFacilitiesStats, axx), adminFeatureRoute("GET /api/programs/{id}/history", srv.handleGetProgramHistory, axx), adminFeatureRoute("GET /api/programs/{id}/archive-check", srv.handleGetProgramArchiveCheck, axx), + adminFeatureRoute("GET /api/programs/{id}/delete-check", srv.handleGetProgramDeleteCheck, axx), adminFeatureRoute("POST /api/programs", srv.handleCreateProgram, axx), adminFeatureRoute("DELETE /api/programs/{id}", srv.handleDeleteProgram, axx), adminFeatureRoute("PATCH /api/programs/{id}/status", srv.handleUpdateProgramStatus, axx), @@ -100,7 +101,13 @@ func (srv *Server) handleShowProgram(w http.ResponseWriter, r *http.Request, log } claims := r.Context().Value(ClaimsKey).(*Claims) - facility_id := claims.FacilityID + facilityID := claims.FacilityID + args := srv.getQueryContext(r) + if args.Params.Get("facility_id") != "" { + facilityID = args.FacilityID + } else if claims.canSwitchFacility() { + facilityID = 0 + } program, err := srv.Db.GetProgramByID(id) if err != nil { @@ -108,7 +115,7 @@ func (srv *Server) handleShowProgram(w http.ResponseWriter, r *http.Request, log return newDatabaseServiceError(err) } - overview, err := srv.Db.FetchEnrollmentMetrics(id, facility_id) + overview, err := srv.Db.FetchEnrollmentMetrics(id, facilityID) if err != nil { log.add("program_id", id) return newDatabaseServiceError(err) @@ -255,12 +262,42 @@ func (srv *Server) handleGetProgramArchiveCheck(w http.ResponseWriter, r *http.R return writeJsonResponse(w, http.StatusOK, payload) } +func (srv *Server) handleGetProgramDeleteCheck(w http.ResponseWriter, r *http.Request, log sLog) error { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + return newInvalidIdServiceError(err, "program ID") + } + log.add("program_id", id) + blockers, err := srv.Db.ProgramBlockingChildren(id) + if err != nil { + return newDatabaseServiceError(err) + } + payload := struct { + CanDelete bool `json:"can_delete"` + Blockers models.DeleteBlockingChildren `json:"blockers"` + }{ + CanDelete: !blockers.HasAny(), + Blockers: blockers, + } + return writeJsonResponse(w, http.StatusOK, payload) +} + func (srv *Server) handleDeleteProgram(w http.ResponseWriter, r *http.Request, log sLog) error { id, err := strconv.Atoi(r.PathValue("id")) if err != nil { return newInvalidIdServiceError(err, "program ID") } log.add("program_id", id) + + blockers, err := srv.Db.ProgramBlockingChildren(id) + if err != nil { + return newDatabaseServiceError(err) + } + if blockers.HasAny() { + log.info("program delete blocked by child records") + return writeDeleteConflictResponse(w, "cannot delete: program has child records", blockers) + } + if err = srv.Db.DeleteProgram(id); err != nil { return newDatabaseServiceError(err) } @@ -283,7 +320,9 @@ func (srv *Server) handleGetProgramHistory(w http.ResponseWriter, r *http.Reques if err != nil { return newDatabaseServiceError(err) } - historyEvents = append(historyEvents, createdByDetails) + if createdByDetails.Action != "" { + historyEvents = append(historyEvents, createdByDetails) + } return writePaginatedResponse(w, http.StatusOK, historyEvents, pageMeta) } diff --git a/backend/src/handlers/server.go b/backend/src/handlers/server.go index 2fbe371e3..7f45e2e1e 100644 --- a/backend/src/handlers/server.go +++ b/backend/src/handlers/server.go @@ -126,7 +126,11 @@ func init() { func securityHeadersMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-Frame-Options", "DENY") + if strings.HasPrefix(r.URL.Path, "/api/proxy/") { + w.Header().Set("X-Frame-Options", "SAMEORIGIN") + } else { + w.Header().Set("X-Frame-Options", "DENY") + } w.Header().Set("X-Content-Type-Options", "nosniff") if os.Getenv("APP_ENV") != "dev" { w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") @@ -583,6 +587,19 @@ func writeConflictResponse(w http.ResponseWriter, conflicts []models.RoomConflic return nil } +func writeDeleteConflictResponse(w http.ResponseWriter, message string, blockers models.DeleteBlockingChildren) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + resp := models.Resource[models.DeleteBlockingChildren]{ + Message: message, + Data: blockers, + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + return newResponseServiceError(err) + } + return nil +} + func (srv *Server) errorResponse(w http.ResponseWriter, status int, message string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) diff --git a/backend/src/handlers/user_handler.go b/backend/src/handlers/user_handler.go index 0868df1a1..ca80fab58 100644 --- a/backend/src/handlers/user_handler.go +++ b/backend/src/handlers/user_handler.go @@ -5,6 +5,7 @@ import ( "UnlockEdv2/src/database" "UnlockEdv2/src/jasper" "UnlockEdv2/src/models" + "UnlockEdv2/src/services" "encoding/csv" "encoding/json" "errors" @@ -23,21 +24,27 @@ func (srv *Server) registerUserRoutes() []routeDef { return []routeDef{ validatedRoute("GET /api/users/{id}", srv.handleShowUser, resolver), validatedRoute("GET /api/users/{id}/programs", srv.handleGetUserPrograms, resolver), + validatedRoute("GET /api/users/{id}/attendance-trend", srv.handleGetUserAttendanceTrend, resolver), + validatedRoute("GET /api/users/{id}/notes", srv.handleGetUserNotes, resolver), + validatedAdminRoute("POST /api/users/{id}/notes", srv.handleCreateUserNote, FacilityAdminResolver("users", "id")), /* admin */ + newAdminRoute("GET /api/users/stats", srv.handleGetUserStats), newAdminRoute("GET /api/users", srv.handleIndexUsers), newAdminRoute("POST /api/users", srv.handleCreateUser), newAdminRoute("GET /api/users/resident-verify", srv.handleResidentVerification), newDeptAdminRoute("PATCH /api/users/resident-transfer", srv.handleResidentTransfer), validatedAdminRoute("POST /api/users/{id}/student-password", srv.handleResetStudentPassword, func(tx *database.DB, r *http.Request) bool { - var role string - return tx.WithContext(r.Context()).Model(&models.User{}).Select("role").Where("id = ?", r.PathValue("id")).First(&role).Error == nil && - canResetUserPassword(r.Context().Value(ClaimsKey).(*Claims), models.UserRole(role)) + role, err := tx.GetUserRoleByID(r.Context(), r.PathValue("id")) + return err == nil && canResetUserPassword(r.Context().Value(ClaimsKey).(*Claims), role) }), validatedAdminRoute("DELETE /api/users/{id}", srv.handleDeleteUser, FacilityAdminResolver("users", "id")), validatedAdminRoute("PATCH /api/users/{id}", srv.handleUpdateUser, FacilityAdminResolver("users", "id")), validatedAdminRoute("GET /api/users/{id}/account-history", srv.handleGetUserAccountHistory, resolver), newAdminRoute("POST /api/users/bulk/upload", srv.handleBulkUpload), newAdminRoute("POST /api/users/bulk/create", srv.handleBulkCreate), + newAdminRoute("POST /api/users/bulk/reset-password", srv.handleBulkResetPassword), + newAdminRoute("POST /api/users/bulk/deactivate", srv.handleBulkDeactivateUsers), + newAdminRoute("POST /api/users/bulk/delete", srv.handleBulkDeleteUsers), validatedAdminRoute("POST /api/users/{id}/deactivate", srv.handleDeactivateUser, FacilityAdminResolver("users", "id")), validatedAdminRoute("GET /api/users/{id}/usage-report", srv.handleGenerateUsageReportPDF, FacilityAdminResolver("users", "id")), validatedAdminRoute("GET /api/users/{id}/attendance-export", srv.handleExportResidentAttendanceCSV, UserRoleResolver("id")), @@ -78,6 +85,10 @@ func (srv *Server) handleIndexUsers(w http.ResponseWriter, r *http.Request, log return newDatabaseServiceError(err) } default: + claims := r.Context().Value(ClaimsKey).(*Claims) + if claims.canSwitchFacility() && r.URL.Query().Get("facility_id") == "" { + args.FacilityID = 0 + } users, err = srv.Db.GetCurrentUsers(&args, role) if err != nil { log.add("facility_id", args.FacilityID) @@ -88,6 +99,26 @@ func (srv *Server) handleIndexUsers(w http.ResponseWriter, r *http.Request, log return writePaginatedResponse(w, http.StatusOK, users, args.IntoMeta()) } +func (srv *Server) handleGetUserStats(w http.ResponseWriter, r *http.Request, log sLog) error { + claims := r.Context().Value(ClaimsKey).(*Claims) + var facilityID *uint + if fid := r.URL.Query().Get("facility_id"); fid != "" { + id, err := strconv.Atoi(fid) + if err != nil { + return newInvalidIdServiceError(err, "facility ID") + } + ref := uint(id) + facilityID = &ref + } else if !claims.canSwitchFacility() { + facilityID = &claims.FacilityID + } + stats, err := srv.Db.GetUserStats(r.Context(), facilityID) + if err != nil { + return newDatabaseServiceError(err) + } + return writeJsonResponse(w, http.StatusOK, stats) +} + func (srv *Server) handleGetUnmappedUsers(w http.ResponseWriter, r *http.Request, providerId string, log sLog) error { log.add("subhandlerCall", "HandleGetUnmappedUsers") args := srv.getQueryContext(r) @@ -175,10 +206,10 @@ func (srv *Server) handleCreateUser(w http.ResponseWriter, r *http.Request, log log.add("created_username", reqForm.User.Username) userNameExists, docExists := srv.Db.UserIdentityExists(reqForm.User.Username, reqForm.User.DocID) if userNameExists { - return newBadRequestServiceError(err, "userexists") + return newBadRequestServiceError(err, "Username already exists") } if docExists { - return newBadRequestServiceError(err, "docexists") + return newBadRequestServiceError(err, "Doc ID already exists") } reqForm.User.Username = stripNonAlphaChars(reqForm.User.Username, func(char rune) bool { return unicode.IsLetter(char) || unicode.IsDigit(char) @@ -367,6 +398,17 @@ func canResetUserPassword(currentUser *Claims, toUpdate models.UserRole) bool { } } +func canDeleteUser(currentUser *Claims, toDelete models.UserRole) bool { + switch toDelete { + case models.DepartmentAdmin: + return currentUser.canSwitchFacility() + case models.SystemAdmin: + return currentUser.Role == toDelete + default: + return currentUser.isAdmin() + } +} + func validateUser(user *models.User) string { if strings.ContainsFunc(user.Username, func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsNumber(r) }) { return "alphanum" @@ -489,10 +531,71 @@ func (srv *Server) handleGetUserPrograms(w http.ResponseWriter, r *http.Request, } for i := range userPrograms { userPrograms[i].CalculateAttendancePercentage() + userPrograms[i].Schedule = models.FormatScheduleFromRRule(userPrograms[i].RecurrenceRule) } return writePaginatedResponse(w, http.StatusOK, userPrograms, queryCtx.IntoMeta()) } +func (srv *Server) handleGetUserAttendanceTrend(w http.ResponseWriter, r *http.Request, log sLog) error { + id := r.PathValue("id") + userId, err := strconv.Atoi(id) + if err != nil { + return newInvalidIdServiceError(err, "error converting user_id") + } + weeks := 8 + if wk := r.URL.Query().Get("weeks"); wk != "" { + if parsed, err := strconv.Atoi(wk); err == nil && parsed > 0 { + weeks = parsed + } + } + service := services.NewUsersService(srv.Db) + trends, err := service.GetWeeklyAttendanceTrend(r.Context(), userId, weeks) + if err != nil { + return newDatabaseServiceError(err) + } + return writeJsonResponse(w, http.StatusOK, trends) +} + +func (srv *Server) handleGetUserNotes(w http.ResponseWriter, r *http.Request, log sLog) error { + id := r.PathValue("id") + userId, err := strconv.Atoi(id) + if err != nil { + return newInvalidIdServiceError(err, "error converting user_id") + } + notes, err := srv.Db.GetUserNotes(r.Context(), uint(userId)) + if err != nil { + return newDatabaseServiceError(err) + } + return writeJsonResponse(w, http.StatusOK, notes) +} + +func (srv *Server) handleCreateUserNote(w http.ResponseWriter, r *http.Request, log sLog) error { + id := r.PathValue("id") + userId, err := strconv.Atoi(id) + if err != nil { + return newInvalidIdServiceError(err, "error converting user_id") + } + claims := r.Context().Value(ClaimsKey).(*Claims) + var body struct { + Note string `json:"note"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + return newBadRequestServiceError(err, "invalid request body") + } + if strings.TrimSpace(body.Note) == "" { + return newBadRequestServiceError(errors.New("note is required"), "note cannot be empty") + } + note := &models.UserNote{ + UserID: uint(userId), + Note: strings.TrimSpace(body.Note), + } + note.CreateUserID = &claims.UserID + if err := srv.Db.CreateUserNote(r.Context(), note); err != nil { + return newDatabaseServiceError(err) + } + return writeJsonResponse(w, http.StatusCreated, "Note added successfully") +} + func (srv *Server) handleBulkUpload(w http.ResponseWriter, r *http.Request, log sLog) error { claims := r.Context().Value(ClaimsKey).(*Claims) @@ -697,7 +800,17 @@ func (srv *Server) handleExportResidentAttendanceCSV(w http.ResponseWriter, r *h return newUnauthorizedServiceError() } - csvData, err := srv.Db.GetResidentAttendanceCSVData(r.Context(), uint(userID), claims.FacilityID, queryCtx.All) + allFacilities := claims.canSwitchFacility() || queryCtx.All + var classID *uint + if cid := r.URL.Query().Get("class_id"); cid != "" { + parsed, err := strconv.Atoi(cid) + if err != nil { + return newInvalidIdServiceError(err, "class_id") + } + ref := uint(parsed) + classID = &ref + } + csvData, err := srv.Db.GetResidentAttendanceCSVData(r.Context(), uint(userID), claims.FacilityID, allFacilities, classID) if err != nil { log.add("user_id", userID) return newDatabaseServiceError(err) @@ -708,8 +821,12 @@ func (srv *Server) handleExportResidentAttendanceCSV(w http.ResponseWriter, r *h return newInternalServerServiceError(err, "Failed to convert attendance data to CSV format") } + docLabel := strings.TrimSpace(user.DocID) + if docLabel == "" { + docLabel = fmt.Sprintf("User%d", userID) + } date := time.Now().Format("2006-01-02") - filename := fmt.Sprintf("Attendance-%s-%s.csv", user.DocID, date) + filename := fmt.Sprintf("Attendance-%s-%s.csv", docLabel, date) w.Header().Set("Content-Type", "text/csv") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) @@ -726,3 +843,219 @@ func (srv *Server) handleExportResidentAttendanceCSV(w http.ResponseWriter, r *h return nil } + +func (srv *Server) handleBulkResetPassword(w http.ResponseWriter, r *http.Request, log sLog) error { + claims := r.Context().Value(ClaimsKey).(*Claims) + var req struct { + UserIDs []uint `json:"user_ids"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return newJSONReqBodyServiceError(err) + } + if len(req.UserIDs) == 0 { + return newBadRequestServiceError(errors.New("no user IDs provided"), "user_ids is required") + } + var facID uint + if !claims.canSwitchFacility() { + facID = claims.FacilityID + } + users, err := srv.Db.GetUsersByIDs(r.Context(), req.UserIDs, facID, models.Student, models.FacilityAdmin, models.DepartmentAdmin) + if err != nil { + return newDatabaseServiceError(err) + } + type successEntry struct { + UserID uint `json:"user_id"` + Username string `json:"username"` + Name string `json:"name"` + DocID string `json:"doc_id"` + TempPassword string `json:"temp_password"` + } + type failedEntry struct { + UserID uint `json:"user_id"` + Username string `json:"username"` + Name string `json:"name"` + Reason string `json:"reason"` + } + var successes []successEntry + var failures []failedEntry + for i := range users { + user := &users[i] + if !canResetUserPassword(claims, user.Role) { + log.add("user_id", user.ID) + log.warn("bulk reset: caller not authorized for target role") + failures = append(failures, failedEntry{UserID: user.ID, Username: user.Username, Name: user.NameFirst + " " + user.NameLast, Reason: "not authorized to reset password for this user"}) + continue + } + lockedStatus, err := srv.Db.IsAccountLocked(user.ID) + if err != nil { + log.add("user_id", user.ID) + log.error("bulk reset: error checking locked status") + failures = append(failures, failedEntry{UserID: user.ID, Username: user.Username, Name: user.NameFirst + " " + user.NameLast, Reason: "error checking locked status"}) + continue + } + if lockedStatus.IsLocked { + if err := srv.Db.ResetFailedLoginAttempts(user.ID); err != nil { + log.add("user_id", user.ID) + log.error("bulk reset: error resetting failed login attempts") + failures = append(failures, failedEntry{UserID: user.ID, Username: user.Username, Name: user.NameFirst + " " + user.NameLast, Reason: "error resetting failed login attempts"}) + continue + } + } + newPass, err := user.CreateTempPassword() + if err != nil { + log.add("user_id", user.ID) + log.error("bulk reset: error creating temp password") + failures = append(failures, failedEntry{UserID: user.ID, Username: user.Username, Name: user.NameFirst + " " + user.NameLast, Reason: "error creating temp password"}) + continue + } + if user.KratosID == "" { + if err := srv.HandleCreateUserKratos(user.Username, newPass); err != nil { + log.add("user_id", user.ID) + log.error("bulk reset: error creating user in kratos") + failures = append(failures, failedEntry{UserID: user.ID, Username: user.Username, Name: user.NameFirst + " " + user.NameLast, Reason: "error creating identity"}) + continue + } + } else { + kratosC := claimsFromUser(user) + kratosC.PasswordReset = true + if err := srv.handleUpdatePasswordKratos(kratosC, newPass, true); err != nil { + log.add("user_id", user.ID) + log.error("bulk reset: error updating password in kratos") + failures = append(failures, failedEntry{UserID: user.ID, Username: user.Username, Name: user.NameFirst + " " + user.NameLast, Reason: "error updating password"}) + continue + } + } + resetPw := models.NewUserAccountHistory(user.ID, models.ResetPassword, &claims.UserID, nil, nil) + if err := srv.Db.InsertUserAccountHistoryAction(r.Context(), resetPw); err != nil { + log.add("user_id", user.ID) + log.error("bulk reset: error inserting account history") + } + successes = append(successes, successEntry{ + UserID: user.ID, + Username: user.Username, + Name: user.NameFirst + " " + user.NameLast, + DocID: user.DocID, + TempPassword: newPass, + }) + } + return writeJsonResponse(w, http.StatusOK, map[string]any{ + "successes": successes, + "failures": failures, + }) +} + +func (srv *Server) handleBulkDeactivateUsers(w http.ResponseWriter, r *http.Request, log sLog) error { + claims := r.Context().Value(ClaimsKey).(*Claims) + var req struct { + UserIDs []uint `json:"user_ids"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return newJSONReqBodyServiceError(err) + } + if len(req.UserIDs) == 0 { + return newBadRequestServiceError(errors.New("no user IDs provided"), "user_ids is required") + } + var facID uint + if !claims.canSwitchFacility() { + facID = claims.FacilityID + } + users, err := srv.Db.GetUsersByIDs(r.Context(), req.UserIDs, facID, models.Student) + if err != nil { + return newDatabaseServiceError(err) + } + type failedEntry struct { + UserID uint `json:"user_id"` + Username string `json:"username"` + Name string `json:"name"` + Reason string `json:"reason"` + } + var successCount int + var failures []failedEntry + for _, user := range users { + if err := srv.Db.DeactivateUser(r.Context(), user.ID, &claims.UserID); err != nil { + log.add("user_id", user.ID) + log.error("bulk deactivate: error deactivating user") + failures = append(failures, failedEntry{UserID: user.ID, Username: user.Username, Name: user.NameFirst + " " + user.NameLast, Reason: "error deactivating user"}) + continue + } + successCount++ + } + return writeJsonResponse(w, http.StatusOK, map[string]any{ + "success_count": successCount, + "failed_count": len(failures), + "failures": failures, + }) +} + +type bulkUserFailure struct { + UserID uint `json:"user_id"` + Username string `json:"username"` + Name string `json:"name"` + Reason string `json:"reason"` +} + +func reportMissingUserIDs(requested []uint, found []models.User) []bulkUserFailure { + seen := make(map[uint]struct{}, len(found)) + for _, u := range found { + seen[u.ID] = struct{}{} + } + var missing []bulkUserFailure + for _, id := range requested { + if _, ok := seen[id]; !ok { + missing = append(missing, bulkUserFailure{ + UserID: id, + Reason: "not found or out of scope", + }) + } + } + return missing +} + +func (srv *Server) handleBulkDeleteUsers(w http.ResponseWriter, r *http.Request, log sLog) error { + claims := r.Context().Value(ClaimsKey).(*Claims) + var req struct { + UserIDs []uint `json:"user_ids"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return newJSONReqBodyServiceError(err) + } + if len(req.UserIDs) == 0 { + return newBadRequestServiceError(errors.New("no user IDs provided"), "user_ids is required") + } + var facID uint + if !claims.canSwitchFacility() { + facID = claims.FacilityID + } + users, err := srv.Db.GetUsersByIDs(r.Context(), req.UserIDs, facID, models.Student, models.FacilityAdmin, models.DepartmentAdmin) + if err != nil { + return newDatabaseServiceError(err) + } + var successCount int + failures := reportMissingUserIDs(req.UserIDs, users) + for _, user := range users { + if !canDeleteUser(claims, user.Role) { + log.add("user_id", user.ID) + log.warn("bulk delete: caller not authorized for target role") + failures = append(failures, bulkUserFailure{UserID: user.ID, Username: user.Username, Name: user.NameFirst + " " + user.NameLast, Reason: "not authorized to delete this user"}) + continue + } + if err := srv.deleteIdentityInKratos(r.Context(), &user.KratosID); err != nil { + log.add("user_id", user.ID) + log.error("bulk delete: error deleting identity in kratos") + failures = append(failures, bulkUserFailure{UserID: user.ID, Username: user.Username, Name: user.NameFirst + " " + user.NameLast, Reason: "error deleting identity"}) + continue + } + if err := srv.WithUserContext(r).DeleteUser(int(user.ID)); err != nil { + log.add("user_id", user.ID) + log.error("bulk delete: error deleting user") + failures = append(failures, bulkUserFailure{UserID: user.ID, Username: user.Username, Name: user.NameFirst + " " + user.NameLast, Reason: "error deleting user"}) + continue + } + successCount++ + } + return writeJsonResponse(w, http.StatusOK, map[string]any{ + "success_count": successCount, + "failed_count": len(failures), + "failures": failures, + }) +} diff --git a/backend/src/handlers/utils.go b/backend/src/handlers/utils.go index 12ffd7da9..cc98c02bd 100644 --- a/backend/src/handlers/utils.go +++ b/backend/src/handlers/utils.go @@ -71,7 +71,7 @@ func (srv *Server) getQueryContext(r *http.Request) models.QueryContext { claims := r.Context().Value(ClaimsKey).(*Claims) ctxWithUser := context.WithValue(r.Context(), models.UserIDKey, claims.UserID) f, err := strconv.Atoi(r.URL.Query().Get("facility_id")) - if err != nil { + if err != nil || !claims.canSwitchFacility() { facilityID = claims.FacilityID } else { facilityID = uint(f) @@ -107,6 +107,7 @@ func (srv *Server) getQueryContext(r *http.Request) models.QueryContext { OrderBy: orderBy, Order: order, IsAdmin: claims.isAdmin(), + CanSwitchFacility: claims.canSwitchFacility(), Search: search, Tags: tags, All: all, diff --git a/backend/src/handlers/video_handler.go b/backend/src/handlers/video_handler.go index 387a4d5a0..ffa712318 100644 --- a/backend/src/handlers/video_handler.go +++ b/backend/src/handlers/video_handler.go @@ -160,6 +160,9 @@ func (srv *Server) handlePostVideos(w http.ResponseWriter, r *http.Request, log if err := json.NewDecoder(r.Body).Decode(&video); err != nil { return newBadRequestServiceError(err, "error reading video") } + if len(video.VideoUrls) == 0 { + return newBadRequestServiceError(errors.New("no video URLs provided"), "video_urls is required") + } for _, videoURL := range video.VideoUrls { if err := validateVideoURL(videoURL); err != nil { return newBadRequestServiceError(err, "invalid video URL") diff --git a/backend/src/models/class_event.go b/backend/src/models/class_event.go index 568ac500f..6e8b21f4c 100644 --- a/backend/src/models/class_event.go +++ b/backend/src/models/class_event.go @@ -32,19 +32,55 @@ func (a Attendance) HumanReadable() string { } } +var rruleDayAbbrev = map[string]string{ + "MO": "M", "TU": "T", "WE": "W", "TH": "Th", "FR": "F", "SA": "Sa", "SU": "Su", +} + +func FormatScheduleFromRRule(rruleStr string) string { + if rruleStr == "" { + return "" + } + opts, err := rrule.StrToROption(rruleStr) + if err != nil { + return "" + } + days := make([]string, 0, len(opts.Byweekday)) + for _, wd := range opts.Byweekday { + full := strings.ToUpper(wd.String()[:2]) + if abbr, ok := rruleDayAbbrev[full]; ok { + days = append(days, abbr) + } + } + timeStr := "" + if !opts.Dtstart.IsZero() { + timeStr = opts.Dtstart.Format("3:04 PM") + } + if len(days) > 0 && timeStr != "" { + return strings.Join(days, "/") + " " + timeStr + } + if len(days) > 0 { + return strings.Join(days, "/") + } + return timeStr +} + /** Events are a physical time/place where a 'class' is held in a facility **/ type ProgramClassEvent struct { DatabaseFields - ClassID uint `json:"class_id" gorm:"not null" validate:"required"` - Duration string `json:"duration" gorm:"not null" validate:"required"` - RecurrenceRule string `json:"recurrence_rule" gorm:"not null" validate:"required"` - RoomID *uint `json:"room_id" gorm:"not null"` + ClassID uint `json:"class_id" gorm:"not null" validate:"required"` + Duration string `json:"duration" gorm:"not null" validate:"required"` + RecurrenceRule string `json:"recurrence_rule" gorm:"not null" validate:"required"` + RoomID *uint `json:"room_id" gorm:"not null"` + InstructorID *uint `json:"instructor_id" gorm:"not null" validate:"required"` + Reason *string `json:"reason"` + IsCancelled bool `json:"is_cancelled"` /* Foreign keys */ - Class *ProgramClass `json:"class" gorm:"foreignKey:ClassID;references:ID"` - RoomRef *Room `json:"room_ref,omitempty" gorm:"foreignKey:RoomID;references:ID"` - Attendees []ProgramClassEventAttendance `json:"attendees" gorm:"foreignKey:EventID;references:ID"` - Overrides []ProgramClassEventOverride `json:"overrides" gorm:"foreignKey:EventID;references:ID"` + Class *ProgramClass `json:"class" gorm:"foreignKey:ClassID;references:ID"` + RoomRef *Room `json:"room_ref,omitempty" gorm:"foreignKey:RoomID;references:ID"` + Instructor *User `json:"instructor_ref,omitempty" gorm:"foreignKey:InstructorID;references:ID"` + Attendees []ProgramClassEventAttendance `json:"attendees" gorm:"foreignKey:EventID;references:ID"` + Overrides []ProgramClassEventOverride `json:"overrides" gorm:"foreignKey:EventID;references:ID"` } type DateRange struct { @@ -134,10 +170,12 @@ type ProgramClassEventOverride struct { RoomID *uint `json:"room_id"` Reason string `json:"reason"` LinkedOverrideEventID *uint `json:"linked_override_event_id"` + InstructorID *uint `json:"instructor_id"` /* Foreign keys */ - Event *ProgramClassEvent `json:"event" gorm:"foreignKey:EventID;references:ID"` - RoomRef *Room `json:"room_ref,omitempty" gorm:"foreignKey:RoomID;references:ID"` + Event *ProgramClassEvent `json:"event" gorm:"foreignKey:EventID;references:ID"` + RoomRef *Room `json:"room_ref,omitempty" gorm:"foreignKey:RoomID;references:ID"` + Instructor *User `json:"instructor_ref,omitempty" gorm:"foreignKey:InstructorID;references:ID"` } func (ProgramClassEventOverride) TableName() string { return "program_class_event_overrides" } @@ -213,12 +251,24 @@ func (p *ProgramClassEventAttendance) BeforeCreate(tx *gorm.DB) error { return nil } +type AttendanceRecordResponse struct { + UserID uint `json:"user_id"` + Date string `json:"date"` + AttendanceStatus string `json:"attendance_status"` + Note string `json:"note"` + MarkedBy string `json:"marked_by"` +} + type ClassEventInstance struct { - EventID uint `json:"event_id"` - ClassTime string `json:"class_time"` // e.g. "12:00-14:00" - Date string `json:"date"` - AttendanceRecords []ProgramClassEventAttendance `json:"attendance_records"` - IsCancelled bool `json:"is_cancelled"` + EventID uint `json:"event_id"` + ClassTime string `json:"class_time"` // e.g. "12:00-14:00" + Date string `json:"date"` + AttendanceRecords []ProgramClassEventAttendance `json:"attendance_records"` + IsCancelled bool `json:"is_cancelled"` + IsRescheduled bool `json:"is_rescheduled"` + RescheduledToDate string `json:"rescheduled_to_date,omitempty"` + RescheduledFromDate string `json:"rescheduled_from_date,omitempty"` + OverrideID uint `json:"override_id,omitempty"` } type EnrollmentAttendance struct { @@ -249,10 +299,32 @@ const ( ) type AttendanceFlag struct { - NameFirst string `json:"name_first"` - NameLast string `json:"name_last"` - DocID string `json:"doc_id"` - FlagType AttendanceFlagType `json:"flag_type"` + NameFirst string `json:"name_first"` + NameLast string `json:"name_last"` + DocID string `json:"doc_id"` + FlagType AttendanceFlagType `json:"flag_type"` + UserID uint `json:"user_id"` + TotalSessions int `json:"total_sessions"` + AttendedSessions int `json:"attended_sessions"` + MissedSessions int `json:"missed_sessions"` + AttendanceRate int `json:"attendance_rate"` + ConsecutiveAbsences int `json:"consecutive_absences"` +} + +type MissingAttendanceItem struct { + ClassID uint `json:"class_id"` + ClassName string `json:"class_name"` + FacilityName string `json:"facility_name,omitempty"` + EventID uint `json:"event_id"` + Date string `json:"date"` + StartTime string `json:"start_time"` +} + +type MissingAttendanceClass struct { + ID uint `json:"id"` + Name string `json:"name"` + FacilityName string `json:"facility_name"` + FacilityID uint `json:"facility_id"` } type EventDates struct { @@ -261,20 +333,41 @@ type EventDates struct { ClassTime string `json:"class_time"` // e.g. "12:00-14:00" } +// GetInstructorFromEvents returns the instructor ID and display name from the +// event with the highest ID, mirroring the frontend getInstructorName utility. +func GetInstructorFromEvents(events []ProgramClassEvent) (*uint, string) { + var maxEvent *ProgramClassEvent + for i := range events { + if maxEvent == nil || events[i].ID > maxEvent.ID { + maxEvent = &events[i] + } + } + if maxEvent == nil { + return nil, "" + } + name := "" + if maxEvent.Instructor != nil { + name = strings.TrimSpace(maxEvent.Instructor.NameFirst + " " + maxEvent.Instructor.NameLast) + } + return maxEvent.InstructorID, name +} + type FacilityProgramClassEvent struct { ProgramClassEvent - Room string `json:"room" gorm:"->"` // read-only, populated from joined rooms table - InstructorName string `json:"instructor_name"` - ProgramID uint `json:"program_id"` - ProgramName string `json:"program_name"` - ClassName string `json:"title"` - IsCancelled bool `json:"is_cancelled"` - IsOverride bool `json:"is_override"` - EnrolledUsers string `json:"enrolled_users"` - StartTime *time.Time `json:"start"` - EndTime *time.Time `json:"end"` - Frequency string `json:"frequency"` - ClassStatus ClassStatus `json:"class_status"` - OverrideID uint `json:"override_id"` - LinkedOverrideEvent *FacilityProgramClassEvent `json:"linked_override_event" gorm:"-"` + Room string `json:"room" gorm:"->"` // read-only, populated from joined rooms table + OriginalRoom string `json:"original_room,omitempty" gorm:"-"` + InstructorName string `json:"instructor_name"` + OriginalInstructorName string `json:"original_instructor_name,omitempty" gorm:"-"` + ProgramID uint `json:"program_id"` + ProgramName string `json:"program_name"` + ClassName string `json:"title"` + IsCancelled bool `json:"is_cancelled"` + IsOverride bool `json:"is_override"` + EnrolledUsers string `json:"enrolled_users"` + StartTime *time.Time `json:"start"` + EndTime *time.Time `json:"end"` + Frequency string `json:"frequency"` + ClassStatus ClassStatus `json:"class_status"` + OverrideID uint `json:"override_id"` + LinkedOverrideEvent *FacilityProgramClassEvent `json:"linked_override_event" gorm:"-"` } diff --git a/backend/src/models/dashboard.go b/backend/src/models/dashboard.go index 883245b83..c252d295f 100644 --- a/backend/src/models/dashboard.go +++ b/backend/src/models/dashboard.go @@ -41,3 +41,21 @@ type CachedDashboard[T any] struct { LastCache time.Time `json:"last_cache"` Data T `json:"data"` } + +type FacilityHealthSummary struct { + FacilityID uint `json:"facility_id" gorm:"column:facility_id"` + FacilityName string `json:"facility_name" gorm:"column:facility_name"` + Programs int64 `json:"programs" gorm:"column:programs"` + ActiveClasses int64 `json:"active_classes" gorm:"column:active_classes"` + Enrollment int64 `json:"enrollment" gorm:"column:enrollment"` + MissingAttendance int64 `json:"missing_attendance" gorm:"column:missing_attendance"` + AttendanceConcerns int64 `json:"attendance_concerns" gorm:"column:attendance_concerns"` +} + +type ClassDashboardMetrics struct { + ActiveClasses int64 `json:"active_classes"` + ScheduledClasses int64 `json:"scheduled_classes"` + TotalEnrollments int64 `json:"total_enrollments"` + TotalSeats int64 `json:"total_seats"` + AttendanceConcerns int64 `json:"attendance_concerns"` +} diff --git a/backend/src/models/delete_guards.go b/backend/src/models/delete_guards.go new file mode 100644 index 000000000..202dc756b --- /dev/null +++ b/backend/src/models/delete_guards.go @@ -0,0 +1,25 @@ +package models + +// Classes is zero for class deletes; populated for programs to count classes whose own children block. +// Events and History are informational and never block: scheduled sessions are +// always created with a class, and change-log/history rows are derivative audit +// artifacts. Both are cleaned up alongside the parent on delete. +// NonDeletableStatus is set on class-only checks when the class is in a status +// other than Scheduled (Active/Completed/Cancelled/Paused), which blocks delete. +type DeleteBlockingChildren struct { + Classes int64 `json:"classes"` + Enrollments int64 `json:"enrollments"` + Events int64 `json:"events"` + Completions int64 `json:"completions"` + AttendanceFlags int64 `json:"attendance_flags"` + History int64 `json:"history"` + NonDeletableStatus string `json:"non_deletable_status,omitempty"` +} + +func (b DeleteBlockingChildren) HasAny() bool { + return b.Classes > 0 || + b.Enrollments > 0 || + b.Completions > 0 || + b.AttendanceFlags > 0 || + b.NonDeletableStatus != "" +} diff --git a/backend/src/models/facilities.go b/backend/src/models/facilities.go index c6eaab904..60c2ee990 100644 --- a/backend/src/models/facilities.go +++ b/backend/src/models/facilities.go @@ -2,6 +2,17 @@ package models import "time" +type FacilityWithStats struct { + ID uint `json:"id"` + Name string `json:"name"` + Timezone string `json:"timezone"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ActivePrograms int64 `json:"active_programs"` + ActiveClasses int64 `json:"active_classes"` + TotalResidents int64 `json:"total_residents"` +} + type Facility struct { DatabaseFields Name string `gorm:"size:255;not null" json:"name"` @@ -45,6 +56,7 @@ type ConflictCheckRequest struct { RecurrenceRule string Duration string ExcludeEventID *uint + ExcludeClassID *uint } type RoomConflict struct { diff --git a/backend/src/models/program.go b/backend/src/models/program.go index b2864f308..cec314ff6 100644 --- a/backend/src/models/program.go +++ b/backend/src/models/program.go @@ -160,11 +160,14 @@ type ProgramsFacilitiesStats struct { type ProgramsOverviewTable struct { ProgramID uint `json:"program_id"` ProgramName string `json:"program_name"` + Description string `json:"description"` ArchivedAt *string `json:"archived_at"` TotalActiveFacilities *int64 `json:"total_active_facilities"` TotalEnrollments *int64 `json:"total_enrollments"` TotalActiveEnrollments *int64 `json:"total_active_enrollments"` TotalClasses *int64 `json:"total_classes"` + TotalActiveClasses *int64 `json:"total_active_classes"` + TotalCapacity *int64 `json:"total_capacity"` CompletionRate *float64 `json:"completion_rate"` AttendanceRate *float64 `json:"attendance_rate"` Types string `json:"program_types" gorm:"column:program_types"` @@ -180,6 +183,7 @@ type ProgramOverviewResponse struct { Completions int `json:"completions"` TotalEnrollments int `json:"total_enrollments"` CompletionRate float64 `json:"completion_rate"` + AttendanceRate float64 `json:"attendance_rate"` ActiveClassFacilityIDs []int `json:"active_class_facility_ids" gorm:"-"` } diff --git a/backend/src/models/program_classes.go b/backend/src/models/program_classes.go index 942874897..7047b62ae 100644 --- a/backend/src/models/program_classes.go +++ b/backend/src/models/program_classes.go @@ -25,21 +25,20 @@ with a collection of Events held at a particular Facility */ type ProgramClass struct { DatabaseFields - ProgramID uint `json:"program_id" gorm:"not null"` - FacilityID uint `json:"facility_id" gorm:"not null"` - Capacity int64 `json:"capacity" gorm:"not null"` - Name string `json:"name" gorm:"size:255" validate:"required,max=255"` - InstructorID *uint `json:"instructor_id"` - Instructor *User `json:"instructor" gorm:"foreignKey:InstructorID;references:ID"` - InstructorName string `json:"instructor_name" gorm:"size:255" validate:"omitempty,max=255"` - Description string `json:"description" gorm:"not null" validate:"required,max=255"` - ArchivedAt *time.Time `json:"archived_at"` - StartDt time.Time `gorm:"type:date" json:"start_dt"` - EndDt *time.Time `gorm:"type:date" json:"end_dt"` - Status ClassStatus `json:"status" gorm:"type:class_status" validate:"required"` - CreditHours *int64 `json:"credit_hours"` - Enrolled int64 `json:"enrolled" gorm:"-"` - Completed int64 `json:"completed" gorm:"-"` + ProgramID uint `json:"program_id" gorm:"not null"` + FacilityID uint `json:"facility_id" gorm:"not null"` + Capacity int64 `json:"capacity" gorm:"not null"` + Name string `json:"name" gorm:"size:255" validate:"required,max=255"` + InstructorID *uint `json:"instructor_id" gorm:"-"` + UpdateInstructor bool `json:"-" gorm:"-"` + Description string `json:"description" gorm:"not null" validate:"required,max=255"` + ArchivedAt *time.Time `json:"archived_at"` + StartDt time.Time `gorm:"type:date" json:"start_dt"` + EndDt *time.Time `gorm:"type:date" json:"end_dt"` + Status ClassStatus `json:"status" gorm:"type:class_status" validate:"required"` + CreditHours *int64 `json:"credit_hours"` + Enrolled int64 `json:"enrolled" gorm:"-"` + Completed int64 `json:"completed" gorm:"-"` Program *Program `json:"program" gorm:"foreignKey:ProgramID;references:ID"` Enrollments []ProgramClassEnrollment `json:"enrollments" gorm:"foreignKey:ClassID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` @@ -50,6 +49,18 @@ type ProgramClass struct { func (ProgramClass) TableName() string { return "program_classes" } +type TodaysScheduleItem struct { + ClassID uint `json:"class_id"` + ClassName string `json:"class_name"` + InstructorName string `json:"instructor_name"` + FacilityID uint `json:"facility_id"` + FacilityName string `json:"facility_name"` + EventID uint `json:"event_id"` + Date string `json:"date"` + StartTime string `json:"start_time"` + Room string `json:"room"` +} + func (c *ProgramClass) BeforeCreate(tx *gorm.DB) error { if err := c.DatabaseFields.BeforeCreate(tx); err != nil { return err @@ -162,10 +173,7 @@ func (e *ProgramClassEnrollment) BeforeCreate(tx *gorm.DB) (err error) { // BeforeUpdate hook that runs to set enrolled_at if enrolling in an Active class or enrollment_ended_at if entering a terminal state while class is Active|Paused. func (e *ProgramClassEnrollment) BeforeUpdate(tx *gorm.DB) (err error) { - // allow calling code to override - if !tx.Statement.Changed("enrollment_status") || - tx.Statement.Changed("enrolled_at") || - tx.Statement.Changed("enrollment_ended_at") { + if !tx.Statement.Changed("enrollment_status") { return nil } @@ -185,7 +193,14 @@ func (e *ProgramClassEnrollment) BeforeUpdate(tx *gorm.DB) (err error) { var classID int if v, ok := tx.Get("class_id"); ok { - classID = v.(int) + switch val := v.(type) { + case int: + classID = val + case uint: + classID = int(val) + default: + return fmt.Errorf("unexpected type for 'class_id': %T", val) + } } var classStatus ClassStatus @@ -199,13 +214,17 @@ func (e *ProgramClassEnrollment) BeforeUpdate(tx *gorm.DB) (err error) { } // This likely gets hit when we introduce "Waitlist" as a status - if newEnrollmentStatus == Enrolled && classStatus == Active { + if newEnrollmentStatus == Enrolled && classStatus == Active && !tx.Statement.Changed("enrolled_at") { tx.Statement.SetColumn("enrolled_at", time.Now().UTC()) // ? do we need to worry about updating fields to the same value (enrolled -> enrolled)? } - // This is when an enrollment ends while the class remains active - if IsTerminalEnrollment(newEnrollmentStatus) && (classStatus == Active || classStatus == Paused) { + // Clear enrollment_ended_at when status becomes Enrolled (reactivation) + if newEnrollmentStatus == Enrolled && (classStatus == Active || classStatus == Paused) { + tx.Statement.SetColumn("enrollment_ended_at", nil) + } + + if IsTerminalEnrollment(newEnrollmentStatus) { tx.Statement.SetColumn("enrollment_ended_at", time.Now().UTC()) } @@ -219,8 +238,13 @@ func IsTerminalEnrollment(s ProgramEnrollmentStatus) bool { type ProgramClassDetail struct { ProgramClass - FacilityName string `json:"facility_name"` - Enrolled int `json:"enrolled"` + FacilityName string `json:"facility_name"` + Enrolled int `json:"enrolled"` + HistoricalEnrollments int `json:"historical_enrollments"` + Schedule string `json:"schedule"` + Room string `json:"room"` + AttendanceRate float64 `json:"attendance_rate"` + Completed int `json:"completed"` } type ProgramEnrollmentStatus string @@ -293,20 +317,6 @@ type ConflictDetail struct { Reason string `json:"reason"` } -// GetInstructorNameForJSON returns instructor name with backward compatibility -// Frontend should check instructor object first, then fallback to instructor_name -func (pc *ProgramClass) GetInstructorNameForJSON() string { - // If we have instructor relationship, use it - if pc.Instructor != nil { - if pc.Instructor.NameFirst != "" && pc.Instructor.NameLast != "" { - return pc.Instructor.NameFirst + " " + pc.Instructor.NameLast - } - return pc.Instructor.Username - } - // Fallback to legacy instructor_name field - return pc.InstructorName -} - type BulkCancelSessionsRequest struct { InstructorID int `json:"instructorId" validate:"required,min=0"` StartDate string `json:"startDate" validate:"required"` @@ -341,10 +351,14 @@ type Instructor struct { } type InstructorClassData struct { - ID int `json:"id"` - Name string `json:"name"` - SessionCount int `json:"sessionCount"` - EnrolledCount int `json:"enrolledCount"` - UpcomingSessions int `json:"upcomingSessions"` - CancelledSessions int `json:"cancelledSessions"` + ID int `json:"id"` + Name string `json:"name"` + SessionCount int `json:"sessionCount"` + EnrolledCount int `json:"enrolledCount"` + UpcomingSessions int `json:"upcomingSessions"` + CancelledSessions int `json:"cancelledSessions"` + StartTime string `json:"startTime"` + Duration string `json:"duration"` + Room string `json:"room"` + SessionDates []string `json:"sessionDates" gorm:"-"` } diff --git a/backend/src/models/resource.go b/backend/src/models/resource.go index e0460752a..cafa06081 100644 --- a/backend/src/models/resource.go +++ b/backend/src/models/resource.go @@ -97,6 +97,7 @@ type QueryContext struct { OrderBy string Order string IsAdmin bool + CanSwitchFacility bool Search string Tags []string Total int64 diff --git a/backend/src/models/users.go b/backend/src/models/users.go index e21a3a28e..51ae105d6 100644 --- a/backend/src/models/users.go +++ b/backend/src/models/users.go @@ -201,13 +201,16 @@ type ResidentProgramClassInfo struct { StartDate string `json:"start_date"` EndDate string `json:"end_date"` AttendancePercentage string `json:"attendance_percentage"` - ClassID uint `json:"class_id" ` + ClassID uint `json:"class_id"` ProgramID uint `json:"program_id"` + EnrollmentID uint `json:"enrollment_id"` PresentAttendance int `json:"present_attendance"` AbsentAttendance int `json:"absent_attendance"` CreditTypes string `json:"credit_types"` UpdatedAt string `json:"updated_at"` ChangeReason string `json:"change_reason"` + Schedule string `json:"schedule"` + RecurrenceRule string `json:"-"` } // calculates the attendance percentage, sets the feild AttendancePercentage and returns it's value @@ -225,6 +228,34 @@ func (rpc *ResidentProgramClassInfo) CalculateAttendancePercentage() string { return rpc.AttendancePercentage } +type UserNote struct { + DatabaseFields + UserID uint `json:"user_id" gorm:"not null"` + Note string `json:"note" gorm:"not null"` + + Creator *User `json:"creator,omitempty" gorm:"foreignKey:CreateUserID;references:ID"` +} + +func (UserNote) TableName() string { return "user_notes" } + +type UserNoteResponse struct { + ID uint `json:"id"` + Date time.Time `json:"date"` + Admin string `json:"admin"` + Note string `json:"note"` +} + +type WeeklyAttendanceTrend struct { + Week string `json:"week"` + Rate float64 `json:"rate"` +} + +type WeeklyAttendanceRow struct { + WeekStart time.Time + PresentCount float64 + TotalCount float64 +} + type ResidentTransferProgramConflicts struct { ProgramName string `json:"program_name"` ClassName string `json:"class_name"` diff --git a/backend/src/services/classes.go b/backend/src/services/classes.go new file mode 100644 index 000000000..7b55e5c68 --- /dev/null +++ b/backend/src/services/classes.go @@ -0,0 +1,489 @@ +package services + +import ( + "UnlockEdv2/src/database" + "UnlockEdv2/src/models" + "fmt" + "sort" + "strings" + "time" + + "github.com/teambition/rrule-go" +) + +type ClassesService struct { + db *database.DB +} + +func NewClassesService(db *database.DB) *ClassesService { + return &ClassesService{db: db} +} + +func (svc *ClassesService) GetFacilityHealthSummaries(args *models.QueryContext, facilityID *uint, days int) ([]models.FacilityHealthSummary, error) { + summaries, err := svc.db.GetFacilityHealthSummaries(args, facilityID) + if err != nil { + return nil, err + } + + attendanceConcerns, err := svc.db.GetFacilityAttendanceConcerns(args, facilityID) + if err != nil { + return nil, err + } + + items, classByID, err := svc.getMissingAttendanceForFacilityData(args, facilityID, days) + if err != nil { + return nil, err + } + + missingByFacilityClasses := make(map[uint]map[uint]struct{}) + for _, item := range items { + classInfo, ok := classByID[item.ClassID] + if !ok { + continue + } + if _, exists := missingByFacilityClasses[classInfo.FacilityID]; !exists { + missingByFacilityClasses[classInfo.FacilityID] = make(map[uint]struct{}) + } + missingByFacilityClasses[classInfo.FacilityID][item.ClassID] = struct{}{} + } + missingByFacility := make(map[uint]int64, len(missingByFacilityClasses)) + for facilityRef, classIDs := range missingByFacilityClasses { + missingByFacility[facilityRef] = int64(len(classIDs)) + } + + for i := range summaries { + facilityRef := summaries[i].FacilityID + summaries[i].AttendanceConcerns = attendanceConcerns[facilityRef] + summaries[i].MissingAttendance = missingByFacility[facilityRef] + } + + return summaries, nil +} + +// duplicate, but will remove once we fully removed all business logic from database layer +func getCanonicalHourAndMinute(occurrences []time.Time, timezone string) (int, int) { + if len(occurrences) == 0 { + return 0, 0 + } + firstOccurrence := occurrences[0] + userLocation, err := time.LoadLocation(timezone) + if err != nil { + return firstOccurrence.Hour(), firstOccurrence.Minute() + } + localTime := firstOccurrence.In(userLocation) + return localTime.Hour(), localTime.Minute() +} + +func (svc *ClassesService) GetMissingAttendanceForFacility(args *models.QueryContext, facilityID *uint, days int) ([]models.MissingAttendanceItem, error) { + items, _, err := svc.getMissingAttendanceForFacilityData(args, facilityID, days) + if err != nil { + return nil, err + } + return items, nil +} + +func (svc *ClassesService) getMissingAttendanceForFacilityData(args *models.QueryContext, facilityID *uint, days int) ([]models.MissingAttendanceItem, map[uint]models.MissingAttendanceClass, error) { + if days <= 0 { + days = 3 + } + classes, err := svc.db.GetActiveClassesForMissingAttendance(args, facilityID) + if err != nil { + return nil, nil, err + } + if len(classes) == 0 { + return []models.MissingAttendanceItem{}, map[uint]models.MissingAttendanceClass{}, nil + } + + loc, err := time.LoadLocation(args.Timezone) + if err != nil { + loc = time.UTC + } + nowLocal := time.Now().In(loc) + startDate := time.Now().In(loc).AddDate(0, 0, -days).Truncate(24 * time.Hour) + endDate := time.Now().In(loc).AddDate(0, 0, 1).Truncate(24 * time.Hour) + + classIDs := make([]uint, 0, len(classes)) + classByID := make(map[uint]models.MissingAttendanceClass, len(classes)) + for _, cls := range classes { + classIDs = append(classIDs, cls.ID) + classByID[cls.ID] = cls + } + + events, err := svc.db.GetClassEventsWithOverrides(args, classIDs) + if err != nil { + return nil, nil, err + } + + type eventInstance struct { + models.EventInstance + DateKey string + } + instances := make([]eventInstance, 0) + eventIDSet := make(map[uint]struct{}) + dateSet := make(map[string]struct{}) + + for _, event := range events { + rule, err := event.GetRRuleWithTimezone(args.Timezone) + if err != nil { + continue + } + firstOccurrence := rule.After(time.Time{}, false) + canonicalHour, canonicalMinute := getCanonicalHourAndMinute([]time.Time{firstOccurrence}, args.Timezone) + eventInstances := database.GenerateEventInstances(event, startDate, endDate) + for _, inst := range eventInstances { + if inst.IsCancelled { + continue + } + localStart := inst.StartTime.In(loc) + consistentOccurrence := time.Date( + localStart.Year(), + localStart.Month(), + localStart.Day(), + canonicalHour, + canonicalMinute, + 0, + 0, + loc, + ).UTC() + if consistentOccurrence.After(nowLocal) { + continue + } + inst.StartTime = consistentOccurrence + dateKey := consistentOccurrence.In(loc).Format("2006-01-02") + instances = append(instances, eventInstance{ + EventInstance: inst, + DateKey: dateKey, + }) + eventIDSet[inst.EventID] = struct{}{} + dateSet[dateKey] = struct{}{} + } + } + if len(instances) == 0 { + return []models.MissingAttendanceItem{}, classByID, nil + } + + eventIDs := make([]uint, 0, len(eventIDSet)) + for id := range eventIDSet { + eventIDs = append(eventIDs, id) + } + dates := make([]string, 0, len(dateSet)) + for date := range dateSet { + dates = append(dates, date) + } + + attendanceCounts, err := svc.db.GetAttendanceCountsForEvents(args, eventIDs, dates) + if err != nil { + return nil, nil, err + } + attendanceMap := make(map[string]int64, len(attendanceCounts)) + for _, attendance := range attendanceCounts { + key := fmt.Sprintf("%d_%s", attendance.EventID, attendance.Date) + attendanceMap[key] = attendance.Count + } + + type enrollmentWindow struct { + EnrolledAt *time.Time + EnrollmentEndedAt *time.Time + } + enrollments, err := svc.db.GetActiveEnrollmentsForClasses(args, classIDs) + if err != nil { + return nil, nil, err + } + enrollmentsByClass := make(map[uint][]enrollmentWindow, len(classIDs)) + for _, enrollment := range enrollments { + enrollmentsByClass[enrollment.ClassID] = append(enrollmentsByClass[enrollment.ClassID], enrollmentWindow{ + EnrolledAt: enrollment.EnrolledAt, + EnrollmentEndedAt: enrollment.EnrollmentEndedAt, + }) + } + + items := make([]models.MissingAttendanceItem, 0) + for _, inst := range instances { + enrolledCountOnDate := 0 + for _, enrollment := range enrollmentsByClass[inst.ClassID] { + if enrollment.EnrolledAt == nil { + continue + } + if !enrollment.EnrolledAt.After(inst.StartTime) && + (enrollment.EnrollmentEndedAt == nil || enrollment.EnrollmentEndedAt.After(inst.StartTime.AddDate(0, 0, -1))) { + enrolledCountOnDate++ + } + } + if enrolledCountOnDate == 0 { + continue + } + key := fmt.Sprintf("%d_%s", inst.EventID, inst.DateKey) + attendanceCount := attendanceMap[key] + if int(attendanceCount) == enrolledCountOnDate { + continue + } + classInfo := classByID[inst.ClassID] + items = append(items, models.MissingAttendanceItem{ + ClassID: inst.ClassID, + ClassName: classInfo.Name, + FacilityName: classInfo.FacilityName, + EventID: inst.EventID, + Date: inst.DateKey, + StartTime: inst.StartTime.In(loc).Format("15:04"), + }) + } + + sort.Slice(items, func(i, j int) bool { + if items[i].Date == items[j].Date { + return items[i].StartTime > items[j].StartTime + } + return items[i].Date > items[j].Date + }) + + return items, classByID, nil +} + +func (svc *ClassesService) GetTodaysSchedule(args *models.QueryContext, facilityID *uint) ([]models.TodaysScheduleItem, error) { + classes, err := svc.db.GetActiveClassesWithEvents(args, facilityID) + if err != nil { + return nil, err + } + if len(classes) == 0 { + return []models.TodaysScheduleItem{}, nil + } + + loc, err := time.LoadLocation(args.Timezone) + if err != nil { + loc = time.UTC + } + nowLocal := time.Now().In(loc) + startOfToday := time.Date(nowLocal.Year(), nowLocal.Month(), nowLocal.Day(), 0, 0, 0, 0, loc) + endOfToday := startOfToday.AddDate(0, 0, 1) + + items := make([]models.TodaysScheduleItem, 0) + for _, class := range classes { + startDt := class.StartDt.In(loc) + if startDt.After(endOfToday) { + continue + } + if class.EndDt != nil { + endDt := class.EndDt.In(loc) + if endDt.Before(startOfToday) { + continue + } + } + + for _, event := range class.Events { + rule, err := event.GetRRuleWithTimezone(args.Timezone) + if err != nil { + continue + } + firstOccurrence := rule.After(time.Time{}, false) + canonicalHour, canonicalMinute := getCanonicalHourAndMinute([]time.Time{firstOccurrence}, args.Timezone) + overrideStartTimes := make(map[string]struct{}) + for _, override := range event.Overrides { + if override.IsCancelled { + continue + } + overrideOptions, err := rrule.StrToROption(override.OverrideRrule) + if err != nil { + continue + } + overrideOptions.Dtstart = overrideOptions.Dtstart.In(time.UTC) + overrideRule, err := rrule.NewRRule(*overrideOptions) + if err != nil { + continue + } + overrideOccurrences := overrideRule.Between(startOfToday.UTC(), endOfToday.UTC(), true) + for _, occ := range overrideOccurrences { + overrideStartTimes[occ.UTC().Format(time.RFC3339Nano)] = struct{}{} + } + } + eventInstances := database.GenerateEventInstances(event, startOfToday, endOfToday) + for _, inst := range eventInstances { + if inst.IsCancelled { + continue + } + localStart := inst.StartTime.In(loc) + localOcc := localStart + if _, isOverride := overrideStartTimes[inst.StartTime.UTC().Format(time.RFC3339Nano)]; !isOverride { + localOcc = time.Date( + localStart.Year(), + localStart.Month(), + localStart.Day(), + canonicalHour, + canonicalMinute, + 0, + 0, + loc, + ) + } + if localOcc.Before(startOfToday) || !localOcc.Before(endOfToday) { + continue + } + facilityName := "" + if class.Facility != nil { + facilityName = class.Facility.Name + } + _, instructorName := models.GetInstructorFromEvents(class.Events) + items = append(items, models.TodaysScheduleItem{ + ClassID: class.ID, + ClassName: class.Name, + InstructorName: instructorName, + FacilityID: class.FacilityID, + FacilityName: facilityName, + EventID: inst.EventID, + Date: localOcc.Format("2006-01-02"), + StartTime: localOcc.Format("15:04"), + Room: inst.Room, + }) + } + } + } + + sort.Slice(items, func(i, j int) bool { + if items[i].Date == items[j].Date { //sorting by date, time, then by name + if items[i].StartTime == items[j].StartTime { + return items[i].ClassName < items[j].ClassName + } + return items[i].StartTime < items[j].StartTime + } + return items[i].Date < items[j].Date + }) + + return items, nil +} + +func (svc *ClassesService) GetProgramClassDetailsForProgram(args *models.QueryContext, programID int) ([]models.ProgramClassDetail, error) { + classes, err := svc.db.GetProgramClassDetailsByID(programID, args) + if err != nil { + return nil, err + } + if len(classes) == 0 { + return classes, nil + } + + classIDs := make([]uint, 0, len(classes)) + for _, cls := range classes { + classIDs = append(classIDs, cls.ID) + } + attendanceByClass, err := svc.db.GetCumulativeAttendanceRatesForClasses(args.Ctx, classIDs) + if err != nil { + return nil, err + } + + for i := range classes { + schedule, room := formatClassScheduleAndRoom(classes[i].Events, args.Timezone) + classes[i].Schedule = schedule + if room != "" { + classes[i].Room = room + } + classes[i].AttendanceRate = attendanceByClass[classes[i].ID] + } + + return classes, nil +} + +func formatClassScheduleAndRoom(events []models.ProgramClassEvent, timezone string) (string, string) { + var event *models.ProgramClassEvent + if len(events) > 0 { + event = &events[0] + } + if event == nil { + return "", "" + } + + roomName := "" + if event.RoomRef != nil { + roomName = event.RoomRef.Name + } + + rule, err := event.GetRRuleWithTimezone(timezone) + if err != nil { + return "", roomName + } + opts := rule.Options + if opts.Dtstart.IsZero() { + return "", roomName + } + + loc := time.UTC + if timezone != "" { + if tz, err := time.LoadLocation(timezone); err == nil { + loc = tz + } + } + + startTime := opts.Dtstart.In(loc) + duration, err := time.ParseDuration(event.Duration) + if err != nil { + return "", roomName + } + endTime := startTime.Add(duration) + + days := formatWeekdays(opts.Byweekday, startTime.Weekday()) + timeRange := fmt.Sprintf("%s - %s", startTime.Format("3:04 PM"), endTime.Format("3:04 PM")) + if len(days) == 0 { + return timeRange, roomName + } + return fmt.Sprintf("%s • %s", strings.Join(days, ", "), timeRange), roomName +} + +func formatWeekdays(days []rrule.Weekday, fallback time.Weekday) []string { + if len(days) == 0 { + return []string{fullWeekdayName(fallback)} + } + unique := make(map[int]rrule.Weekday, len(days)) + for _, day := range days { + unique[day.Day()] = day + } + ordered := make([]rrule.Weekday, 0, len(unique)) + for _, day := range unique { + ordered = append(ordered, day) + } + sort.Slice(ordered, func(i, j int) bool { + return ordered[i].Day() < ordered[j].Day() + }) + names := make([]string, 0, len(ordered)) + for _, day := range ordered { + names = append(names, fullWeekdayNameFromRRule(day.Day())) + } + return names +} + +func fullWeekdayName(day time.Weekday) string { + switch day { + case time.Monday: + return "Monday" + case time.Tuesday: + return "Tuesday" + case time.Wednesday: + return "Wednesday" + case time.Thursday: + return "Thursday" + case time.Friday: + return "Friday" + case time.Saturday: + return "Saturday" + case time.Sunday: + return "Sunday" + default: + return "" + } +} + +func fullWeekdayNameFromRRule(day int) string { + switch day { + case 0: + return "Monday" + case 1: + return "Tuesday" + case 2: + return "Wednesday" + case 3: + return "Thursday" + case 4: + return "Friday" + case 5: + return "Saturday" + case 6: + return "Sunday" + default: + return "" + } +} diff --git a/backend/src/services/users.go b/backend/src/services/users.go new file mode 100644 index 000000000..b283ba0d2 --- /dev/null +++ b/backend/src/services/users.go @@ -0,0 +1,36 @@ +package services + +import ( + "UnlockEdv2/src/database" + "UnlockEdv2/src/models" + "context" + "fmt" + "math" +) + +type UsersService struct { + db *database.DB +} + +func NewUsersService(db *database.DB) *UsersService { + return &UsersService{db: db} +} + +func (svc *UsersService) GetWeeklyAttendanceTrend(ctx context.Context, userID, weeks int) ([]models.WeeklyAttendanceTrend, error) { + rows, err := svc.db.GetUserWeeklyAttendanceRows(ctx, userID, weeks) + if err != nil { + return nil, err + } + trends := make([]models.WeeklyAttendanceTrend, 0, len(rows)) + for _, r := range rows { + rate := 0.0 + if r.TotalCount > 0 { + rate = math.Round(r.PresentCount / r.TotalCount * 100) + } + trends = append(trends, models.WeeklyAttendanceTrend{ + Week: fmt.Sprintf("%s %d", r.WeekStart.Format("Jan"), r.WeekStart.Day()), + Rate: rate, + }) + } + return trends, nil +} diff --git a/backend/tests/integration/attendance_reason_test.go b/backend/tests/integration/attendance_reason_test.go index f757c4754..1d8812812 100644 --- a/backend/tests/integration/attendance_reason_test.go +++ b/backend/tests/integration/attendance_reason_test.go @@ -37,7 +37,7 @@ func TestAttendanceReasons(t *testing.T) { class, err := env.CreateTestClass(program, facility, models.Active, &instructor.ID) require.NoError(t, err) - event, err := env.CreateTestEvent(class.ID, "") + event, err := env.CreateTestEvent(class.ID, "", instructor.ID) require.NoError(t, err) _, err = env.CreateTestEnrollment(class.ID, student.ID, models.Enrolled) diff --git a/backend/tests/integration/attendance_validation_test.go b/backend/tests/integration/attendance_validation_test.go index 3c6518ea3..80e3c989c 100644 --- a/backend/tests/integration/attendance_validation_test.go +++ b/backend/tests/integration/attendance_validation_test.go @@ -43,7 +43,7 @@ func TestAttendanceValidation(t *testing.T) { class, err := env.CreateTestClass(program, facility, models.Active, &instructor.ID) require.NoError(t, err) - event, err := env.CreateTestEvent(class.ID, "") + event, err := env.CreateTestEvent(class.ID, "", instructor.ID) require.NoError(t, err) _, err = env.CreateTestEnrollment(class.ID, enrolledStudent.ID, models.Enrolled) diff --git a/backend/tests/integration/class_date_synchronization_test.go b/backend/tests/integration/class_date_synchronization_test.go index a74a8cb05..b95c214c6 100644 --- a/backend/tests/integration/class_date_synchronization_test.go +++ b/backend/tests/integration/class_date_synchronization_test.go @@ -100,6 +100,7 @@ func runTestOverrideCreationExtendsEndDate(t *testing.T, env *TestEnv, facility "duration": "2h", "room_id": roomID, "recurrence_rule": "DTSTART:20251101T180000Z\nRRULE:FREQ=WEEKLY;BYDAY=TU,TH;UNTIL=20251130T000000Z", + "instructor_id": instructor.ID, } resp := NewRequest[any](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/events", createdClass.ID), eventPayload). @@ -195,6 +196,7 @@ func runTestEventReschedulingExtendsMultipleMonths(t *testing.T, env *TestEnv, f "duration": "2h", "room_id": roomID, "recurrence_rule": "DTSTART:20251104T180000Z\nRRULE:FREQ=WEEKLY;BYDAY=TU,TH;UNTIL=20251130T000000Z", + "instructor_id": instructor.ID, } eventResp := NewRequest[*models.ProgramClassEvent](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/events", createdClass.ID), eventPayload). @@ -210,12 +212,14 @@ func runTestEventReschedulingExtendsMultipleMonths(t *testing.T, env *TestEnv, f "duration": "2h", "room_id": roomID, "recurrence_rule": "DTSTART:20260203T180000Z\nRRULE:FREQ=WEEKLY;BYDAY=TU,TH;UNTIL=20260228T000000Z", + "instructor_id": instructor.ID, }, "closed_event_series": map[string]interface{}{ "id": event.ID, "duration": "2h", "room_id": roomID, "recurrence_rule": "DTSTART:20251104T180000Z\nRRULE:FREQ=WEEKLY;BYDAY=TU,TH;UNTIL=20251130T000000Z", + "instructor_id": instructor.ID, }, } @@ -265,6 +269,7 @@ func runTestMultipleOverridesExtendEndDate(t *testing.T, env *TestEnv, facility "duration": "1h", "room_id": roomID, "recurrence_rule": "DTSTART:20251202T140000Z\nRRULE:FREQ=WEEKLY;BYDAY=TU;UNTIL=20251220T000000Z", + "instructor_id": instructor.ID, } eventResp := NewRequest[*models.ProgramClassEvent](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/events", createdClass.ID), eventPayload). @@ -326,7 +331,7 @@ func runTestCannotModifyEventsForCompletedClasses(t *testing.T, env *TestEnv, fa class, err := env.CreateTestClass(program, facility, models.Scheduled, &instructor.ID) require.NoError(t, err) - _, err = env.CreateTestEvent(class.ID, "") + _, err = env.CreateTestEvent(class.ID, "", instructor.ID) require.NoError(t, err) updateData := map[string]interface{}{ @@ -341,6 +346,7 @@ func runTestCannotModifyEventsForCompletedClasses(t *testing.T, env *TestEnv, fa "duration": "1h", "room_id": roomID, "recurrence_rule": "DTSTART:20240101T100000Z\nRRULE:FREQ=WEEKLY;COUNT=4", + "instructor_id": instructor.ID, } NewRequest[any](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/events", class.ID), eventPayload). @@ -418,6 +424,7 @@ func runTestStartDateUpdatesWhenEventMovedEarlier(t *testing.T, env *TestEnv, fa "duration": "1h30m", "room_id": roomID, "recurrence_rule": "DTSTART:20251220T180000Z\nRRULE:FREQ=WEEKLY;BYDAY=TU,TH;UNTIL=20260131T000000Z", + "instructor_id": instructor.ID, } eventResp := NewRequest[*models.ProgramClassEvent](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/events", createdClass.ID), eventPayload). @@ -485,6 +492,7 @@ func runTestEventCancellationAffectsBoundaries(t *testing.T, env *TestEnv, facil "duration": "1h", "room_id": roomID, "recurrence_rule": "DTSTART:20260108T180000Z\nRRULE:FREQ=WEEKLY;BYDAY=TU,TH;UNTIL=20260228T000000Z", + "instructor_id": instructor.ID, } eventResp := NewRequest[*models.ProgramClassEvent](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/events", createdClass.ID), eventPayload). diff --git a/backend/tests/integration/class_delete_guard_test.go b/backend/tests/integration/class_delete_guard_test.go new file mode 100644 index 000000000..be9bf4dab --- /dev/null +++ b/backend/tests/integration/class_delete_guard_test.go @@ -0,0 +1,294 @@ +package integration + +import ( + "UnlockEdv2/src/handlers" + "UnlockEdv2/src/models" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/suite" +) + +type ClassDeleteGuardTestSuite struct { + suite.Suite + env *TestEnv +} + +func (suite *ClassDeleteGuardTestSuite) SetupSuite() { + suite.env = SetupTestEnv(suite.T()) +} + +func (suite *ClassDeleteGuardTestSuite) TearDownSuite() { + suite.env.CleanupTestEnv() +} + +func (suite *ClassDeleteGuardTestSuite) SetupTest() { + suite.env.DB.Exec("DELETE FROM program_class_event_attendance") + suite.env.DB.Exec("DELETE FROM program_class_events") + suite.env.DB.Exec("DELETE FROM program_class_enrollments") + suite.env.DB.Exec("DELETE FROM program_completions") + suite.env.DB.Exec("DELETE FROM change_log_entries") + suite.env.DB.Exec("DELETE FROM program_classes_history") + suite.env.DB.Exec("DELETE FROM program_classes") + suite.env.DB.Exec("DELETE FROM facilities_programs") + suite.env.DB.Exec("DELETE FROM programs") + suite.env.DB.Exec("DELETE FROM rooms") + suite.env.DB.Exec("DELETE FROM facilities") + suite.env.DB.Exec("DELETE FROM users WHERE role != 'system_admin'") +} + +// seedEmptyClass returns (admin, class) with an empty program_class — no children. +func (suite *ClassDeleteGuardTestSuite) seedEmptyClass() (*models.User, *models.ProgramClass) { + timestamp := fmt.Sprintf("%d", time.Now().UnixNano()) + facility := &models.Facility{Name: "Test Facility " + timestamp} + suite.env.DB.Create(facility) + + admin := &models.User{ + NameFirst: "Sys", NameLast: "Admin", + Username: "sys" + timestamp, Email: "sys" + timestamp + "@t.test", + Role: models.SystemAdmin, FacilityID: facility.ID, + } + suite.env.DB.Create(admin) + + program := &models.Program{Name: "Prog " + timestamp, IsActive: true} + suite.env.DB.Create(program) + suite.env.DB.Create(&models.FacilitiesPrograms{FacilityID: facility.ID, ProgramID: program.ID}) + + class := &models.ProgramClass{ + ProgramID: program.ID, FacilityID: facility.ID, + Status: models.Scheduled, Name: "Class " + timestamp, + Capacity: 10, Description: "x", + } + suite.env.DB.Create(class) + return admin, class +} + +func (suite *ClassDeleteGuardTestSuite) TestDelete_EmptyClass_Succeeds() { + admin, class := suite.seedEmptyClass() + + NewRequest[any](suite.env.Client, suite.T(), http.MethodDelete, + fmt.Sprintf("/api/program-classes/%d", class.ID), nil). + WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, UserID: admin.ID, FacilityID: 0}). + Do(). + ExpectStatus(http.StatusNoContent) +} + +func (suite *ClassDeleteGuardTestSuite) TestDelete_BlockedByEnrollment() { + admin, class := suite.seedEmptyClass() + + student := &models.User{ + NameFirst: "Stu", NameLast: "Dent", + Username: fmt.Sprintf("stu%d", time.Now().UnixNano()), + Email: fmt.Sprintf("stu%d@t.test", time.Now().UnixNano()), + Role: models.Student, FacilityID: class.FacilityID, + } + suite.env.DB.Create(student) + + enrollment := &models.ProgramClassEnrollment{ + ClassID: class.ID, + UserID: student.ID, + EnrollmentStatus: models.Enrolled, + } + suite.env.DB.Create(enrollment) + + resp := NewRequest[models.DeleteBlockingChildren](suite.env.Client, suite.T(), http.MethodDelete, + fmt.Sprintf("/api/program-classes/%d", class.ID), nil). + WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, UserID: admin.ID, FacilityID: 0}). + Do(). + ExpectStatus(http.StatusConflict) + + got := resp.GetData() + suite.Equal(int64(1), got.Enrollments, "should report 1 blocking enrollment") + suite.Equal(int64(0), got.Events) + suite.Equal(int64(0), got.Completions) + + // Verify the class was NOT deleted. + var count int64 + suite.env.DB.Model(&models.ProgramClass{}).Where("id = ?", class.ID).Count(&count) + suite.Equal(int64(1), count, "class should still exist after blocked delete") +} + +func (suite *ClassDeleteGuardTestSuite) TestDelete_BlockedByCompletedEnrollment() { + admin, class := suite.seedEmptyClass() + student := suite.makeStudent(class.FacilityID) + + now := time.Now() + suite.env.DB.Create(&models.ProgramClassEnrollment{ + ClassID: class.ID, + UserID: student.ID, + EnrollmentStatus: models.EnrollmentCompleted, + EnrollmentEndedAt: &now, + }) + + resp := NewRequest[models.DeleteBlockingChildren](suite.env.Client, suite.T(), http.MethodDelete, + fmt.Sprintf("/api/program-classes/%d", class.ID), nil). + WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, UserID: admin.ID, FacilityID: 0}). + Do(). + ExpectStatus(http.StatusConflict) + + got := resp.GetData() + suite.Equal(int64(1), got.Enrollments, "should report 1 blocking completed enrollment") + suite.Equal(int64(0), got.Events) + suite.Equal(int64(0), got.Completions) + + var count int64 + suite.env.DB.Model(&models.ProgramClass{}).Where("id = ?", class.ID).Count(&count) + suite.Equal(int64(1), count, "class should still exist after blocked delete") +} + +func (suite *ClassDeleteGuardTestSuite) TestDelete_BlockedByCancelledEnrollment() { + admin, class := suite.seedEmptyClass() + student := suite.makeStudent(class.FacilityID) + + now := time.Now() + suite.env.DB.Create(&models.ProgramClassEnrollment{ + ClassID: class.ID, + UserID: student.ID, + EnrollmentStatus: models.EnrollmentCancelled, + EnrollmentEndedAt: &now, + }) + + resp := NewRequest[models.DeleteBlockingChildren](suite.env.Client, suite.T(), http.MethodDelete, + fmt.Sprintf("/api/program-classes/%d", class.ID), nil). + WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, UserID: admin.ID, FacilityID: 0}). + Do(). + ExpectStatus(http.StatusConflict) + + got := resp.GetData() + suite.Equal(int64(1), got.Enrollments, "should report 1 blocking cancelled enrollment") + suite.Equal(int64(0), got.Events) + suite.Equal(int64(0), got.Completions) + + var count int64 + suite.env.DB.Model(&models.ProgramClass{}).Where("id = ?", class.ID).Count(&count) + suite.Equal(int64(1), count, "class should still exist after blocked delete") +} + +func (suite *ClassDeleteGuardTestSuite) TestDelete_EventsOnly_Succeeds() { + admin, class := suite.seedEmptyClass() + + room := &models.Room{FacilityID: class.FacilityID, Name: "101"} + suite.env.DB.Create(room) + instructor := &models.User{ + NameFirst: "Inst", NameLast: "Ructor", + Username: fmt.Sprintf("inst%d", time.Now().UnixNano()), + Email: fmt.Sprintf("inst%d@t.test", time.Now().UnixNano()), + Role: models.FacilityAdmin, FacilityID: class.FacilityID, + } + suite.env.DB.Create(instructor) + suite.env.DB.Create(&models.ProgramClassEvent{ + ClassID: class.ID, + Duration: "1h0m0s", + RecurrenceRule: "DTSTART:20260302T093000Z\nRRULE:FREQ=WEEKLY;COUNT=1", + RoomID: &room.ID, + InstructorID: &instructor.ID, + }) + + NewRequest[any](suite.env.Client, suite.T(), http.MethodDelete, + fmt.Sprintf("/api/program-classes/%d", class.ID), nil). + WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, UserID: admin.ID, FacilityID: 0}). + Do(). + ExpectStatus(http.StatusNoContent) + + var liveEvents int64 + suite.env.DB.Model(&models.ProgramClassEvent{}). + Where("class_id = ? AND deleted_at IS NULL", class.ID). + Count(&liveEvents) + suite.Equal(int64(0), liveEvents, "events should be soft-deleted alongside the class") +} + +func (suite *ClassDeleteGuardTestSuite) TestDelete_BlockedByCompletion() { + admin, class := suite.seedEmptyClass() + student := suite.makeStudent(class.FacilityID) + + suite.env.DB.Create(&models.ProgramCompletion{ + UserID: student.ID, + ProgramClassID: class.ID, + ProgramID: class.ProgramID, + FacilityName: "Test Facility", + CreditType: "Completion", + AdminEmail: "admin@t.test", + ProgramOwner: "Owner", + ProgramName: "Prog", + }) + + resp := NewRequest[models.DeleteBlockingChildren](suite.env.Client, suite.T(), http.MethodDelete, + fmt.Sprintf("/api/program-classes/%d", class.ID), nil). + WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, UserID: admin.ID, FacilityID: 0}). + Do(). + ExpectStatus(http.StatusConflict) + + got := resp.GetData() + suite.Equal(int64(0), got.Enrollments) + suite.Equal(int64(0), got.Events) + suite.Equal(int64(1), got.Completions, "should report 1 blocking completion") + + var count int64 + suite.env.DB.Model(&models.ProgramClass{}).Where("id = ?", class.ID).Count(&count) + suite.Equal(int64(1), count, "class should still exist after blocked delete") +} + +func (suite *ClassDeleteGuardTestSuite) TestDelete_NonScheduledStatus_Blocked() { + for _, status := range []models.ClassStatus{models.Active, models.Completed, models.Cancelled, models.Paused} { + admin, class := suite.seedEmptyClass() + suite.env.DB.Model(&models.ProgramClass{}).Where("id = ?", class.ID).Update("status", status) + + resp := NewRequest[models.DeleteBlockingChildren](suite.env.Client, suite.T(), http.MethodDelete, + fmt.Sprintf("/api/program-classes/%d", class.ID), nil). + WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, UserID: admin.ID, FacilityID: 0}). + Do(). + ExpectStatus(http.StatusConflict) + + got := resp.GetData() + suite.Equal(string(status), got.NonDeletableStatus, "blocker should report the class status") + + var count int64 + suite.env.DB.Model(&models.ProgramClass{}).Where("id = ?", class.ID).Count(&count) + suite.Equal(int64(1), count, "class with status %s should still exist after blocked delete", status) + } +} + +func (suite *ClassDeleteGuardTestSuite) TestDelete_HistoryOnly_Succeeds() { + admin, class := suite.seedEmptyClass() + + suite.env.DB.Create(models.NewChangeLogEntry( + "program_classes", "name", + models.StringPtr("old"), models.StringPtr("new"), + class.ID, admin.ID, + )) + + NewRequest[any](suite.env.Client, suite.T(), http.MethodDelete, + fmt.Sprintf("/api/program-classes/%d", class.ID), nil). + WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, UserID: admin.ID, FacilityID: 0}). + Do(). + ExpectStatus(http.StatusNoContent) + + var changeLogCount int64 + suite.env.DB.Table("change_log_entries"). + Where("table_name = 'program_classes' AND parent_ref_id = ?", class.ID). + Count(&changeLogCount) + suite.Equal(int64(0), changeLogCount, "change_log_entries for the class should be cleaned up") + + var historyCount int64 + suite.env.DB.Table("program_classes_history"). + Where("table_name = 'program_classes' AND parent_ref_id = ?", class.ID). + Count(&historyCount) + suite.Equal(int64(0), historyCount, "program_classes_history rows for the class should be cleaned up") +} + +func (suite *ClassDeleteGuardTestSuite) makeStudent(facilityID uint) *models.User { + ts := fmt.Sprintf("%d", time.Now().UnixNano()) + u := &models.User{ + NameFirst: "Stu", NameLast: "Dent", + Username: "stu" + ts, Email: "stu" + ts + "@t.test", + Role: models.Student, FacilityID: facilityID, + } + suite.env.DB.Create(u) + return u +} + +func TestClassDeleteGuardTestSuite(t *testing.T) { + suite.Run(t, new(ClassDeleteGuardTestSuite)) +} diff --git a/backend/tests/integration/classes_test.go b/backend/tests/integration/classes_test.go index 89013b06d..07559a4c2 100644 --- a/backend/tests/integration/classes_test.go +++ b/backend/tests/integration/classes_test.go @@ -74,15 +74,6 @@ func runCreateClassTest(t *testing.T, env *TestEnv, facility *models.Facility, f require.NotZero(t, got.ID) require.Equal(t, class.Name, got.Name) require.Equal(t, class.InstructorID, got.InstructorID) - require.NotEmpty(t, got.InstructorName) - // Backend populates InstructorName from the instructor user (name_first || ' ' || name_last, fallback to username) - expectedInstructorName := fmt.Sprintf("%s %s", instructor.NameFirst, instructor.NameLast) - if expectedInstructorName == " " { - expectedInstructorName = instructor.Username - } - require.Equal(t, expectedInstructorName, got.InstructorName) - - // Note: The instructor relationship is not populated in the response, only InstructorName is filled require.Equal(t, class.Description, got.Description) require.WithinDuration(t, class.StartDt, got.StartDt, time.Millisecond) require.WithinDuration(t, *class.EndDt, *got.EndDt, time.Millisecond) @@ -263,7 +254,7 @@ func runUpdateActiveClassToCancelledTest(t *testing.T, env *TestEnv, facility *m class, err := env.CreateTestClass(program, facility, models.Active, &instructor.ID) require.NoError(t, err) - _, err = env.CreateTestEvent(class.ID, "") + _, err = env.CreateTestEvent(class.ID, "", instructor.ID) require.NoError(t, err) // Create test users @@ -321,7 +312,7 @@ func runUpdatePausedClassToCancelledTest(t *testing.T, env *TestEnv, facility *m class, err := env.CreateTestClass(program, facility, models.Active, &instructor.ID) require.NoError(t, err) - _, err = env.CreateTestEvent(class.ID, "") + _, err = env.CreateTestEvent(class.ID, "", instructor.ID) require.NoError(t, err) // Create test users @@ -457,7 +448,7 @@ func runUpdateActiveClassToCompletedTest(t *testing.T, env *TestEnv, facility *m class, err := env.CreateTestClass(program, facility, models.Active, &instructor.ID) require.NoError(t, err) - _, err = env.CreateTestEvent(class.ID, "") + _, err = env.CreateTestEvent(class.ID, "", instructor.ID) require.NoError(t, err) // Create test users diff --git a/backend/tests/integration/client.go b/backend/tests/integration/client.go index 002cec29f..d857795a8 100644 --- a/backend/tests/integration/client.go +++ b/backend/tests/integration/client.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/url" + "strings" "testing" "github.com/stretchr/testify/require" @@ -113,7 +114,9 @@ func (r *Request[T]) Do() *Response[T] { var decoded bool rawStr := string(rawBytes) - if r.asJson && resp.StatusCode < 400 && resp.StatusCode != http.StatusNoContent { + contentType := resp.Header.Get("Content-Type") + isJSON := strings.Contains(contentType, "application/json") + if r.asJson && resp.StatusCode != http.StatusNoContent && isJSON { require.NoError(r.t, json.Unmarshal(rawBytes, &env), "failed to unmarshal response body") parsed = new(T) diff --git a/backend/tests/integration/conflict_detection_test.go b/backend/tests/integration/conflict_detection_test.go index 68ea8873f..f9a7abfa8 100644 --- a/backend/tests/integration/conflict_detection_test.go +++ b/backend/tests/integration/conflict_detection_test.go @@ -50,6 +50,7 @@ func TestSchedulingConflictDetection(t *testing.T) { Duration: "1h", RecurrenceRule: "FREQ=WEEKLY;BYDAY=MO,WE,FR;DTSTART=20240101T100000Z", // UTC RoomID: &room.ID, + InstructorID: &instructorA.ID, } err = env.DB.Create(&eventA).Error require.NoError(t, err) @@ -60,6 +61,7 @@ func TestSchedulingConflictDetection(t *testing.T) { Duration: "1h", RecurrenceRule: "FREQ=WEEKLY;BYDAY=MO,WE,FR;DTSTART=20240101T103000Z", // UTC, overlaps by 30 mins RoomID: &room.ID, + InstructorID: &instructorB.ID, } err = env.DB.Create(&eventB).Error require.NoError(t, err) diff --git a/backend/tests/integration/program_delete_guard_test.go b/backend/tests/integration/program_delete_guard_test.go new file mode 100644 index 000000000..a0ff3ce83 --- /dev/null +++ b/backend/tests/integration/program_delete_guard_test.go @@ -0,0 +1,203 @@ +package integration + +import ( + "UnlockEdv2/src/handlers" + "UnlockEdv2/src/models" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/suite" +) + +type ProgramDeleteGuardTestSuite struct { + suite.Suite + env *TestEnv +} + +func (suite *ProgramDeleteGuardTestSuite) SetupSuite() { + suite.env = SetupTestEnv(suite.T()) +} + +func (suite *ProgramDeleteGuardTestSuite) TearDownSuite() { + suite.env.CleanupTestEnv() +} + +func (suite *ProgramDeleteGuardTestSuite) SetupTest() { + suite.env.DB.Exec("DELETE FROM program_class_event_attendance") + suite.env.DB.Exec("DELETE FROM program_class_events") + suite.env.DB.Exec("DELETE FROM program_class_enrollments") + suite.env.DB.Exec("DELETE FROM program_completions") + suite.env.DB.Exec("DELETE FROM change_log_entries") + suite.env.DB.Exec("DELETE FROM program_classes_history") + suite.env.DB.Exec("DELETE FROM program_classes") + suite.env.DB.Exec("DELETE FROM facilities_programs") + suite.env.DB.Exec("DELETE FROM programs") + suite.env.DB.Exec("DELETE FROM rooms") + suite.env.DB.Exec("DELETE FROM facilities") + suite.env.DB.Exec("DELETE FROM users WHERE role != 'system_admin'") +} + +func (suite *ProgramDeleteGuardTestSuite) seedEmptyProgram() (*models.User, *models.Program, *models.Facility) { + ts := fmt.Sprintf("%d", time.Now().UnixNano()) + facility := &models.Facility{Name: "F " + ts} + suite.env.DB.Create(facility) + admin := &models.User{ + NameFirst: "A", NameLast: "A", + Username: "a" + ts, Email: "a" + ts + "@t.test", + Role: models.SystemAdmin, FacilityID: facility.ID, + } + suite.env.DB.Create(admin) + program := &models.Program{Name: "P " + ts, IsActive: true, Description: "x"} + suite.env.DB.Create(program) + suite.env.DB.Create(&models.FacilitiesPrograms{FacilityID: facility.ID, ProgramID: program.ID}) + return admin, program, facility +} + +func (suite *ProgramDeleteGuardTestSuite) TestDelete_EmptyProgram_Succeeds() { + admin, program, _ := suite.seedEmptyProgram() + NewRequest[any](suite.env.Client, suite.T(), http.MethodDelete, + fmt.Sprintf("/api/programs/%d", program.ID), nil). + WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, UserID: admin.ID}). + Do(). + ExpectStatus(http.StatusNoContent) +} + +func (suite *ProgramDeleteGuardTestSuite) TestDelete_ProgramWithAnyClass_Blocked() { + admin, program, facility := suite.seedEmptyProgram() + class := &models.ProgramClass{ + ProgramID: program.ID, FacilityID: facility.ID, + Status: models.Scheduled, Name: "Empty", Capacity: 10, Description: "x", + } + suite.env.DB.Create(class) + + resp := NewRequest[models.DeleteBlockingChildren](suite.env.Client, suite.T(), + http.MethodDelete, fmt.Sprintf("/api/programs/%d", program.ID), nil). + WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, UserID: admin.ID}). + Do(). + ExpectStatus(http.StatusConflict) + + blockers := resp.GetData() + suite.Equal(int64(1), blockers.Classes, "any class under the program blocks delete") + + var programCount int64 + suite.env.DB.Model(&models.Program{}).Where("id = ?", program.ID).Count(&programCount) + suite.Equal(int64(1), programCount, "program should NOT be deleted") +} + +func (suite *ProgramDeleteGuardTestSuite) TestDelete_BlockedByClassWithEnrollment() { + admin, program, facility := suite.seedEmptyProgram() + class := &models.ProgramClass{ + ProgramID: program.ID, FacilityID: facility.ID, + Status: models.Active, Name: "C", Capacity: 10, Description: "x", + } + suite.env.DB.Create(class) + student := &models.User{ + NameFirst: "S", NameLast: "S", + Username: fmt.Sprintf("s%d", time.Now().UnixNano()), + Email: fmt.Sprintf("s%d@t.test", time.Now().UnixNano()), + Role: models.Student, FacilityID: facility.ID, + } + suite.env.DB.Create(student) + suite.env.DB.Create(&models.ProgramClassEnrollment{ + ClassID: class.ID, UserID: student.ID, EnrollmentStatus: models.Enrolled, + }) + + resp := NewRequest[models.DeleteBlockingChildren](suite.env.Client, suite.T(), http.MethodDelete, + fmt.Sprintf("/api/programs/%d", program.ID), nil). + WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, UserID: admin.ID}). + Do(). + ExpectStatus(http.StatusConflict) + + got := resp.GetData() + suite.Equal(int64(1), got.Classes, "should report 1 blocking class") + suite.Equal(int64(1), got.Enrollments) + + var count int64 + suite.env.DB.Model(&models.Program{}).Where("id = ?", program.ID).Count(&count) + suite.Equal(int64(1), count, "program should still exist after blocked delete") +} + +func (suite *ProgramDeleteGuardTestSuite) TestDelete_BlockedAcrossMultipleClasses() { + admin, program, facility := suite.seedEmptyProgram() + c1 := &models.ProgramClass{ + ProgramID: program.ID, FacilityID: facility.ID, + Status: models.Active, Name: "C1", Capacity: 5, Description: "x", + } + c2 := &models.ProgramClass{ + ProgramID: program.ID, FacilityID: facility.ID, + Status: models.Scheduled, Name: "C2", Capacity: 5, Description: "x", + } + suite.env.DB.Create(c1) + suite.env.DB.Create(c2) + + student := &models.User{ + NameFirst: "S", NameLast: "S", + Username: fmt.Sprintf("s%d", time.Now().UnixNano()), + Email: fmt.Sprintf("s%d@t.test", time.Now().UnixNano()), + Role: models.Student, FacilityID: facility.ID, + } + suite.env.DB.Create(student) + suite.env.DB.Create(&models.ProgramClassEnrollment{ + ClassID: c1.ID, UserID: student.ID, EnrollmentStatus: models.Enrolled, + }) + + room := &models.Room{FacilityID: facility.ID, Name: "101"} + suite.env.DB.Create(room) + instructor := &models.User{ + NameFirst: "Inst", NameLast: "Ructor", + Username: fmt.Sprintf("inst%d", time.Now().UnixNano()), + Email: fmt.Sprintf("inst%d@t.test", time.Now().UnixNano()), + Role: models.FacilityAdmin, FacilityID: facility.ID, + } + suite.env.DB.Create(instructor) + suite.env.DB.Create(&models.ProgramClassEvent{ + ClassID: c2.ID, + Duration: "1h0m0s", + RecurrenceRule: "DTSTART:20260302T093000Z\nRRULE:FREQ=WEEKLY;COUNT=1", + RoomID: &room.ID, + InstructorID: &instructor.ID, + }) + + resp := NewRequest[models.DeleteBlockingChildren](suite.env.Client, suite.T(), http.MethodDelete, + fmt.Sprintf("/api/programs/%d", program.ID), nil). + WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, UserID: admin.ID}). + Do(). + ExpectStatus(http.StatusConflict) + + got := resp.GetData() + suite.Equal(int64(2), got.Classes) + suite.Equal(int64(1), got.Enrollments) + suite.Equal(int64(1), got.Events) + + var count int64 + suite.env.DB.Model(&models.Program{}).Where("id = ?", program.ID).Count(&count) + suite.Equal(int64(1), count, "program should still exist after blocked delete") +} + +func (suite *ProgramDeleteGuardTestSuite) TestDelete_HistoryOnly_Succeeds() { + admin, program, _ := suite.seedEmptyProgram() + + suite.env.DB.Create(models.NewChangeLogEntry( + "programs", "name", + models.StringPtr("old"), models.StringPtr("new"), + program.ID, admin.ID, + )) + + NewRequest[any](suite.env.Client, suite.T(), http.MethodDelete, + fmt.Sprintf("/api/programs/%d", program.ID), nil). + WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, UserID: admin.ID}). + Do(). + ExpectStatus(http.StatusNoContent) + + var changeLogCount int64 + suite.env.DB.Table("change_log_entries"). + Where("table_name = 'programs' AND parent_ref_id = ?", program.ID). + Count(&changeLogCount) + suite.Equal(int64(0), changeLogCount, "change_log_entries for the program should be cleaned up") +} + +func TestProgramDeleteGuardTestSuite(t *testing.T) { + suite.Run(t, new(ProgramDeleteGuardTestSuite)) +} diff --git a/backend/tests/integration/program_outcomes_report_test.go b/backend/tests/integration/program_outcomes_report_test.go index 93c097f69..7c0aec4c8 100644 --- a/backend/tests/integration/program_outcomes_report_test.go +++ b/backend/tests/integration/program_outcomes_report_test.go @@ -387,11 +387,16 @@ func (suite *ProgramOutcomesReportTestSuite) createEvent(classID uint, eventDate if err != nil { suite.T().Fatalf("failed to create test room: %v", err) } + instructorID, err := suite.env.getOrCreateInstructorForClass(classID) + if err != nil { + suite.T().Fatalf("failed to get instructor: %v", err) + } event := &models.ProgramClassEvent{ ClassID: classID, Duration: "2h", RecurrenceRule: rrule, RoomID: &roomID, + InstructorID: &instructorID, } suite.env.DB.Create(event) return event diff --git a/backend/tests/integration/room_conflict_test.go b/backend/tests/integration/room_conflict_test.go index a36b4337e..afe582fa1 100644 --- a/backend/tests/integration/room_conflict_test.go +++ b/backend/tests/integration/room_conflict_test.go @@ -105,6 +105,7 @@ func testDetectsOverlappingBookings(t *testing.T, env *TestEnv, facility *models "duration": "2h", "room_id": roomID, "recurrence_rule": "DTSTART:20260302T090000Z\nRRULE:FREQ=DAILY;UNTIL=20260331T000000Z", + "instructor_id": instructor.ID, } NewRequest[any](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/events", createdClass1.ID), event1Payload). @@ -155,6 +156,7 @@ func testExcludesOwnEventID(t *testing.T, env *TestEnv, facility *models.Facilit "duration": "2h", "room_id": roomID, "recurrence_rule": "DTSTART:20260402T140000Z\nRRULE:FREQ=DAILY;UNTIL=20260430T000000Z", + "instructor_id": instructor.ID, } NewRequest[any](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/events", createdClass.ID), eventPayload). @@ -218,6 +220,7 @@ func testNoConflictForDifferentTimes(t *testing.T, env *TestEnv, facility *model "duration": "2h", "room_id": roomID, "recurrence_rule": "DTSTART:20260504T090000Z\nRRULE:FREQ=DAILY;UNTIL=20260531T000000Z", + "instructor_id": instructor.ID, } NewRequest[any](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/events", createdClass.ID), eventPayload). @@ -270,6 +273,7 @@ func testHandlerReturns409OnConflict(t *testing.T, env *TestEnv, facility *model "duration": "2h", "room_id": roomID, "recurrence_rule": "DTSTART:20260601T100000Z\nRRULE:FREQ=DAILY;UNTIL=20260630T000000Z", + "instructor_id": instructor1.ID, } NewRequest[any](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/events", createdClass1.ID), event1Payload). @@ -301,6 +305,7 @@ func testHandlerReturns409OnConflict(t *testing.T, env *TestEnv, facility *model "duration": "1h", "room_id": roomID, "recurrence_rule": "DTSTART:20260601T103000Z\nRRULE:FREQ=DAILY;UNTIL=20260610T000000Z", + "instructor_id": instructor2.ID, } NewRequest[any](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/events", createdClass2.ID), conflictingEventPayload). @@ -341,6 +346,7 @@ func testRejectsRoomFromDifferentFacility(t *testing.T, env *TestEnv, facility * "duration": "2h", "room_id": otherFacilityRoomID, "recurrence_rule": "DTSTART:20260702T100000Z\nRRULE:FREQ=DAILY;UNTIL=20260731T000000Z", + "instructor_id": instructor.ID, } NewRequest[any](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/events", createdClass.ID), eventPayload). @@ -389,6 +395,7 @@ func testDetectsConflictsWithTimezoneAwareRRules(t *testing.T, env *TestEnv, fac "duration": "1h", "room_id": roomID, "recurrence_rule": "DTSTART;TZID=America/New_York:20250703T100000\nRRULE:FREQ=WEEKLY;BYDAY=TH", + "instructor_id": instructor.ID, } NewRequest[any](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/events", createdClass1.ID), event1Payload). @@ -456,6 +463,7 @@ func testBackwardsCompatibleUTCRules(t *testing.T, env *TestEnv, facility *model "duration": "1h", "room_id": roomID, "recurrence_rule": "DTSTART:20260806T140000Z\nRRULE:FREQ=WEEKLY;BYDAY=TH", + "instructor_id": instructor.ID, } NewRequest[any](env.Client, t, http.MethodPost, fmt.Sprintf("/api/program-classes/%d/events", createdClass1.ID), event1Payload). diff --git a/backend/tests/integration/rrule_until_integration_test.go b/backend/tests/integration/rrule_until_integration_test.go index 7b5ab2c24..13bb32560 100644 --- a/backend/tests/integration/rrule_until_integration_test.go +++ b/backend/tests/integration/rrule_until_integration_test.go @@ -36,7 +36,7 @@ func TestClassCompletedAddsUntilDate(t *testing.T) { require.NoError(t, err) indefiniteRRule := "DTSTART:20240101T100000Z\nRRULE:FREQ=WEEKLY;BYDAY=TU,TH" - event, err := env.CreateTestEventWithRRule(class.ID, indefiniteRRule) + event, err := env.CreateTestEventWithRRule(class.ID, indefiniteRRule, instructor.ID) require.NoError(t, err) originalRule, err := env.GetEventRecurrenceRule(event.ID) @@ -94,7 +94,7 @@ func TestClassCancelledAddsUntilDate(t *testing.T) { require.NoError(t, err) indefiniteRRule := "DTSTART:20240715T150000Z\nRRULE:FREQ=DAILY;INTERVAL=2" - event, err := env.CreateTestEventWithRRule(class.ID, indefiniteRRule) + event, err := env.CreateTestEventWithRRule(class.ID, indefiniteRRule, instructor.ID) require.NoError(t, err) originalRule, err := env.GetEventRecurrenceRule(event.ID) @@ -151,10 +151,10 @@ func TestMultipleEventsGetUntilDate(t *testing.T) { class, err := env.CreateTestClass(program, facility, models.Active, &instructor.ID) require.NoError(t, err) - event1, err := env.CreateTestEventWithRRule(class.ID, "DTSTART:20240721T100000Z\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR") + event1, err := env.CreateTestEventWithRRule(class.ID, "DTSTART:20240721T100000Z\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR", instructor.ID) require.NoError(t, err) - event2, err := env.CreateTestEventWithRRule(class.ID, "DTSTART:20240722T140000Z\nRRULE:FREQ=WEEKLY;BYDAY=TU,TH") + event2, err := env.CreateTestEventWithRRule(class.ID, "DTSTART:20240722T140000Z\nRRULE:FREQ=WEEKLY;BYDAY=TU,TH", instructor.ID) require.NoError(t, err) rule1, err := env.GetEventRecurrenceRule(event1.ID) @@ -226,7 +226,7 @@ func TestExistingUntilDateReplaced(t *testing.T) { existingUntilDate := src.FormatDateForUntil(futureDate) ruleWithExistingUntil := fmt.Sprintf("DTSTART:20240721T100000Z\nRRULE:FREQ=WEEKLY;BYDAY=TU,TH;UNTIL=%s", existingUntilDate) - event, err := env.CreateTestEventWithRRule(class.ID, ruleWithExistingUntil) + event, err := env.CreateTestEventWithRRule(class.ID, ruleWithExistingUntil, instructor.ID) require.NoError(t, err) originalRule, err := env.GetEventRecurrenceRule(event.ID) diff --git a/backend/tests/integration/testenv.go b/backend/tests/integration/testenv.go index 1df6f4abc..c5e7260d9 100644 --- a/backend/tests/integration/testenv.go +++ b/backend/tests/integration/testenv.go @@ -249,7 +249,7 @@ func (env *TestEnv) GetEnrollmentTimestamps(enrollmentID uint) (enrolledAt, ende return enrollment.EnrolledAt, enrollment.EnrollmentEndedAt, nil } -func (env *TestEnv) CreateTestEvent(classID uint, rrule string) (*models.ProgramClassEvent, error) { +func (env *TestEnv) CreateTestEvent(classID uint, rrule string, instructorID uint) (*models.ProgramClassEvent, error) { if rrule == "" { startDate := time.Now().AddDate(0, 0, -2) // 2 days ago rrule = fmt.Sprintf("DTSTART:%s\nRRULE:FREQ=DAILY;COUNT=10", startDate.Format("20060102T090000Z")) @@ -264,6 +264,7 @@ func (env *TestEnv) CreateTestEvent(classID uint, rrule string) (*models.Program Duration: "2h", RecurrenceRule: rrule, RoomID: &roomID, + InstructorID: &instructorID, } if err := env.DB.Create(event).Error; err != nil { @@ -281,7 +282,7 @@ func (env *TestEnv) GetEventRecurrenceRule(eventID uint) (string, error) { return event.RecurrenceRule, nil } -func (env *TestEnv) CreateTestEventWithRRule(classID uint, customRRule string) (*models.ProgramClassEvent, error) { +func (env *TestEnv) CreateTestEventWithRRule(classID uint, customRRule string, instructorID uint) (*models.ProgramClassEvent, error) { roomID, err := env.getOrCreateTestRoom(classID) if err != nil { return nil, err @@ -291,6 +292,7 @@ func (env *TestEnv) CreateTestEventWithRRule(classID uint, customRRule string) ( Duration: "2h", RecurrenceRule: customRRule, RoomID: &roomID, + InstructorID: &instructorID, } if err := env.DB.Create(event).Error; err != nil { return nil, err @@ -357,6 +359,24 @@ func (env *TestEnv) CreateTestEnrollmentWithDates(classID, userID uint, status m return enrollment, nil } +func (env *TestEnv) getOrCreateInstructorForClass(classID uint) (uint, error) { + var class models.ProgramClass + if err := env.DB.First(&class, "id = ?", classID).Error; err != nil { + return 0, err + } + var instructor models.User + result := env.DB.Where("facility_id = ? AND role = ?", class.FacilityID, models.FacilityAdmin). + Order("id ASC").First(&instructor) + if result.Error == nil { + return instructor.ID, nil + } + created, err := env.CreateTestInstructor(class.FacilityID, fmt.Sprintf("auto%d", time.Now().UnixNano())) + if err != nil { + return 0, err + } + return created.ID, nil +} + func (env *TestEnv) getOrCreateTestRoom(classID uint) (uint, error) { var class models.ProgramClass if err := env.DB.First(&class, "id = ?", classID).Error; err != nil { diff --git a/backend/tests/integration/user_attendance_export_test.go b/backend/tests/integration/user_attendance_export_test.go index de11f6ed2..1fa88f4c7 100644 --- a/backend/tests/integration/user_attendance_export_test.go +++ b/backend/tests/integration/user_attendance_export_test.go @@ -50,7 +50,7 @@ func TestExportResidentAttendanceCSV(t *testing.T) { class1, err := env.CreateTestClass(program, facility1, models.Active, &instructor1.ID) require.NoError(t, err) - event1, err := env.CreateTestEvent(class1.ID, "") + event1, err := env.CreateTestEvent(class1.ID, "", instructor1.ID) require.NoError(t, err) date1 := time.Now().AddDate(0, 0, -5).Format("2006-01-02") @@ -222,7 +222,7 @@ func TestExportResidentAttendanceCSV(t *testing.T) { class2, err := env.CreateTestClass(program, facility1, models.Active, &instructor2.ID) require.NoError(t, err) - event2, err := env.CreateTestEvent(class2.ID, "") + event2, err := env.CreateTestEvent(class2.ID, "", instructor2.ID) require.NoError(t, err) enrollDate := time.Now().UTC().Truncate(24*time.Hour).AddDate(0, 0, -10) @@ -310,10 +310,10 @@ func TestExportResidentAttendanceCSV(t *testing.T) { class4, err := env.CreateTestClass(program, facility1, models.Active, &instructor4.ID) require.NoError(t, err) - event3, err := env.CreateTestEvent(class3.ID, "") + event3, err := env.CreateTestEvent(class3.ID, "", instructor3.ID) require.NoError(t, err) - event4, err := env.CreateTestEvent(class4.ID, "") + event4, err := env.CreateTestEvent(class4.ID, "", instructor4.ID) require.NoError(t, err) enrollDate := time.Now().AddDate(0, 0, -10) diff --git a/frontend/.gitignore b/frontend/.gitignore deleted file mode 100644 index f57574f68..000000000 --- a/frontend/.gitignore +++ /dev/null @@ -1,36 +0,0 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.js -.yarn/install-state.gz - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build -/dist -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# local env files -.env*.local - -# vercel -.vercel - -# typescript -*.tsbuildinfo -next-env.d.ts diff --git a/frontend/.husky/pre-commit b/frontend/.husky/pre-commit deleted file mode 100644 index 4f7433676..000000000 --- a/frontend/.husky/pre-commit +++ /dev/null @@ -1,36 +0,0 @@ -git diff --cached --name-only --diff-filter=ACMRTUXB >frontend/.husky/staged-files -cd frontend -FRONTEND_FILES=$(cat .husky/staged-files | grep '^frontend/.*\.\(css\|ts\|json\|html\|js\|tsx\)$' | sed 's/^frontend\///') -if [ -z "$FRONTEND_FILES" ]; then - echo "No frontend files to check" -else - echo "🎀 running prettier 🎀 ..." - npx prettier -w $FRONTEND_FILES - echo "running front-end linters ..." - yarn lint-staged - yarn build - git add $FRONTEND_FILES -fi - -echo " 🔎 checking backend ..............." -BACKEND_FILES=$(cat .husky/staged-files | grep '^backend/.*\.\(go\)$' | sed 's/^backend\///') -if [ -z "$BACKEND_FILES" ]; then - echo "🎉 All checks passed! 🎉" -else - echo " 🔎 running golangci-lint 🔎 ......" - cd ../backend - go mod tidy -v - echo "running go fmt ...." - go fmt ./... - echo "Running Go tests .." - go test ./... - - DUPLICATE_MIGRATIONS=$(ls -1 migrations | grep '.*\.sql' | cut -c1-5 | sort | uniq -d) - if [ -z "$DUPLICATE_MIGRATIONS" ]; then - echo "🎉 All checks passed! 🎉" - else - echo "🚨 Duplicate migration files found 🚨" - echo "Migration number: $DUPLICATE_MIGRATIONS is a duplicate\n and needs to be renamed" - exit 1 - fi -fi diff --git a/frontend/.prettierignore b/frontend/.prettierignore deleted file mode 100644 index 3f79f2789..000000000 --- a/frontend/.prettierignore +++ /dev/null @@ -1,5 +0,0 @@ -/node_modules -/public/fonts/fonts.css -/dist -/public/build -/.yarn diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 02fa3caf7..388e597d2 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,8 +1,8 @@ -FROM node:21-alpine3.19 as builder +FROM node:22-alpine3.19 as builder WORKDIR /app/ COPY . . -# VITE args and envrionment variables +# VITE args and environment variables ARG VITE_PUBLIC_POSTHOG_KEY ARG VITE_PUBLIC_POSTHOG_HOST ARG VITE_DEPLOYMENT diff --git a/frontend/README.md b/frontend/README.md deleted file mode 100644 index 16d7e6950..000000000 --- a/frontend/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# React + TypeScript + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: - -- Configure the top-level `parserOptions` property like this: - -```js -export default { - // other rules... - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - project: ['./tsconfig.json', './tsconfig.node.json'], - tsconfigRootDir: __dirname - } -}; -``` - -- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` -- Optionally add `plugin:@typescript-eslint/stylistic-type-checked` -- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list diff --git a/frontend/dev.Dockerfile b/frontend/dev.Dockerfile index 6826543a1..d97df93b2 100644 --- a/frontend/dev.Dockerfile +++ b/frontend/dev.Dockerfile @@ -6,4 +6,4 @@ RUN yarn install COPY . . EXPOSE 5173 -ENTRYPOINT ["yarn", "dev"] \ No newline at end of file +ENTRYPOINT ["yarn", "dev"] diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 982052b1d..dfd3cf2ca 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -1,20 +1,44 @@ // @ts-check import eslint from '@eslint/js'; import tseslint from 'typescript-eslint'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import globals from 'globals'; export default tseslint.config( - { - ignores: ['eslint.config.mjs', 'tailwind.config.cjs', 'postcss.config.cjs', 'vite.config.ts', 'dist/**'], - }, - { - languageOptions: { - parserOptions: { - projectService: true, - tsconfigRootDir: import.meta.dirname, - }, - }, - }, - eslint.configs.recommended, - ...tseslint.configs.recommendedTypeChecked, - ...tseslint.configs.stylisticTypeChecked, + { + ignores: [ + 'eslint.config.mjs', + 'vite.config.ts', + 'postcss.config.cjs', + 'tailwind.config.cjs', + 'dist/**' + ] + }, + { + languageOptions: { + globals: globals.browser, + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname + } + } + }, + eslint.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + ...tseslint.configs.stylisticTypeChecked, + { + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true } + ], + 'no-console': 'error' + } + } ); diff --git a/frontend/index.html b/frontend/index.html index b08f29362..0ff8f6240 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,11 +2,11 @@
+- {classInfo - ? 'Unable to retrieve class history' - : 'Unable to retrieve account history'} -
- )} - {!isLoading && - !activityHistoryError && - activityHistory != undefined && ( - <> -|
-- {course.provider_name} - {finalDateStr} -
- {coursePill} - {outcomePills} -- {course.description} -
-No residents enrolled
; - return ( -{residentName}
; - })} -{event.program_name}
- )} -{event.title}
- )} -{event.instructor_name}
-{event.room}
-- {event.start.toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit' - })}{' '} - -{' '} - {event.end.toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit' - })} -
-{event.frequency}
-- Select an event to view details, make changes, or cancel - a session. -
- )} - {event && ( -{classInfo.description}
-- {classInfo.start_dt && - textMonthLocalDate(classInfo.start_dt)}{' '} - –{' '} - {classInfo.end_dt - ? textMonthLocalDate(classInfo.end_dt) - : 'No end date scheduled'} -
-- {classInfo.events[0].room_ref?.name ?? 'TBD'} -
-{classInfo.instructor_name}
-- Next scheduled class:{' '} - {getNextOccurrenceDateAsStr()} -
-- These flags highlight residents with attendance - patterns that may need follow-up: -
-- No attendance yet – Resident is enrolled - but has not attended any sessions since the - class began. -
-- Multiple absences – Resident has missed 3 - or more class sessions with unexcused absences - after attending at least once. -
-| - Resident Name - | -- Resident ID - | -- Flag - | -
|---|---|---|
| - {item.name_last},{' '} - {item.name_first} - | -- {item.doc_id} - | -- {item.flag_type === - AttendanceFlagType.NoAttendance - ? 'no attendance yet' - : 'multiple absences'} - | -
{props.title}
- {props.onActionButtonText && props.onActionButtonClick && ( - - )} -|
-- {course.provider_platform_name} - {finalDateStr} -
-{'Kiwix'}
-- {library?.description} -
- {AdminRoles.includes(role) && ( -- {library.description} -
-{text}
} -
- Last updated: {formattedDate}
-
- Includes residents only
-
- This program has active or scheduled classes in{' '} - {facilityCount} {facilityLabel}. You must - complete or cancel all associated classes before - archiving. -
-Status unavailable
- )} -- Error retrieving programs for selected resident -
- ) : ( -| - Program Name - | -Class Name | -Enrollment Status | -Start Date | -End Date | -- Attendance % - | -
|---|
- {useToLocaleString ? formatNumber(number) : number} - - {label[0].toUpperCase() + label.slice(1)} - -
-- {event.description} -
-| Resident Name: | -- {resident?.user.name_last} - {', '} - {resident?.user.name_first} - | -
| Resident ID: | -{resident?.user.doc_id} | -
| - Current Facility: - | -{resident?.transfer_from} | -
| New Facility: | -{resident?.transfer_to} | -
- You are about to transfer this resident's account. -
-
- - {video.channel_title} - {toMinutes(video.duration)} -
-
- - {video.channel_title} - {toMinutes(video.duration)} -
-- {videoIsAvailable(video) - ? video.description - : getVideoErrorMessage(video)} -
-