From ba340b82660fc919692cdd1b379f951bbe7efd91 Mon Sep 17 00:00:00 2001 From: TianKai Ma Date: Sat, 18 Jul 2026 02:53:53 +0800 Subject: [PATCH 01/12] feat(db): add section lifecycle storage --- .../migration.sql | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 prisma/migrations/20260718023000_add_section_source_lifecycle/migration.sql diff --git a/prisma/migrations/20260718023000_add_section_source_lifecycle/migration.sql b/prisma/migrations/20260718023000_add_section_source_lifecycle/migration.sql new file mode 100644 index 000000000..cc9c398d3 --- /dev/null +++ b/prisma/migrations/20260718023000_add_section_source_lifecycle/migration.sql @@ -0,0 +1,9 @@ +ALTER TYPE "AuditAction" ADD VALUE 'section_retire'; +ALTER TYPE "AuditAction" ADD VALUE 'section_reactivate'; + +ALTER TABLE "Section" +ADD COLUMN "sourceLastSeenAt" TIMESTAMP(3), +ADD COLUMN "retiredAt" TIMESTAMP(3); + +CREATE INDEX "Section_semesterId_retiredAt_idx" +ON "Section"("semesterId", "retiredAt"); From e0bcbc515db500497e1e1045d9514ec26520a0a3 Mon Sep 17 00:00:00 2001 From: TianKai Ma Date: Sat, 18 Jul 2026 03:16:20 +0800 Subject: [PATCH 02/12] feat(static-loader): add reversible section lifecycle --- .env.example | 8 + .github/workflows/ci.yml | 5 + .github/workflows/static-sync.yml | 16 + docker-compose.load.yml | 3 + docs/contracts/_audit.json | 10 +- docs/contracts/calendar.json | 1 + docs/contracts/course.json | 1 + docs/contracts/section.json | 18 +- docs/contracts/subscribed-sections.json | 1 + messages/en-us.json | 2 + messages/zh-cn.json | 2 + prisma/schema.prisma | 6 + public/openapi.generated.json | 288 +++++++++++++++++ .../calendar/server/calendar-event-sources.ts | 9 +- .../calendar/server/calendar-export-data.ts | 2 + src/features/catalog/lib/schedule-filters.ts | 12 +- .../catalog/server/academic-query-includes.ts | 2 +- .../catalog/server/public-catalog-data.ts | 9 +- .../catalog/server/schedule-read-model.ts | 4 +- .../server/section-code-match-query.ts | 1 + .../server/section-code-match-suggestions.ts | 1 + .../catalog/server/section-query-filters.ts | 2 +- .../assistant-dashboard-snapshot-helpers.ts | 1 + .../server/compact-overview-read-model.ts | 4 +- .../dashboard/server/dashboard-nav-stats.ts | 15 +- .../server/dashboard-user-context.ts | 3 +- .../components/SectionDetailDialogs.svelte | 2 +- .../components/SectionDetailHeader.svelte | 11 + .../SectionDetailMainContent.svelte | 1 + .../SectionDetailPrimaryActions.svelte | 7 +- .../components/section-detail-dialog-types.ts | 4 + .../lib/section-detail-controller-types.ts | 3 + .../server/section-detail-page-server.ts | 3 +- .../server/section-page-select.ts | 1 + ...bscription-dashboard-section-read-model.ts | 9 +- .../server/subscription-read-model-shared.ts | 19 ++ .../server/subscription-read-model.ts | 1 + .../subscription-schedule-exam-read-model.ts | 5 + .../server/subscription-section-resolver.ts | 4 + .../server/subscription-write-model.ts | 38 ++- .../academic-section-base-response-schemas.ts | 2 + src/routes/sitemap.xml/+server.ts | 5 +- src/static-loader/cli.ts | 28 +- src/static-loader/import.ts | 63 +++- src/static-loader/section-lifecycle.ts | 242 ++++++++++++++ src/static-loader/validation.ts | 118 ++++++- tests/e2e/src/app/sections/[jwId]/test.ts | 45 +++ .../static-section-lifecycle.test.ts | 235 ++++++++++++++ tests/unit/course-section-queries.test.ts | 5 +- tests/unit/retired-section-discovery.test.ts | 138 ++++++++ tests/unit/schedule-queries.test.ts | 11 + tests/unit/section-lifecycle-queries.test.ts | 123 +++++++ tests/unit/static-loader-validation.test.ts | 121 ++++++- tests/unit/static-section-lifecycle.test.ts | 306 ++++++++++++++++++ .../subscription-semester-filters.test.ts | 2 + tests/unit/viewer-page-services.test.ts | 8 +- 56 files changed, 1930 insertions(+), 56 deletions(-) create mode 100644 src/static-loader/section-lifecycle.ts create mode 100644 tests/integration/static-section-lifecycle.test.ts create mode 100644 tests/unit/retired-section-discovery.test.ts create mode 100644 tests/unit/section-lifecycle-queries.test.ts create mode 100644 tests/unit/static-section-lifecycle.test.ts diff --git a/.env.example b/.env.example index 2a51030e9..3452a4fbb 100644 --- a/.env.example +++ b/.env.example @@ -40,3 +40,11 @@ AUTH_OIDC_CLIENT_SECRET="" # When E2E_DEBUG_AUTH=1 (e.g. Playwright), set both — no defaults in non-dev NODE_ENV: # DEV_DEBUG_PASSWORD="..." # DEV_ADMIN_PASSWORD="..." + +# Static loader only. Missing Sections are never retired unless this is set to +# true after the production backup/restore prerequisite has been verified. +# Destructive runs also require the exact SHA-256 and candidate count observed +# in a preceding dry run. +# STATIC_LOADER_RETIRE_MISSING_SECTIONS="false" +# STATIC_LOADER_EXPECTED_SNAPSHOT_SHA256="" +# STATIC_LOADER_EXPECTED_SECTION_RETIREMENT_CANDIDATES="" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 709f81f67..40020a8c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,6 +119,11 @@ jobs: report.mode !== "dry-run" || report.outcome !== "rolled-back" || report.databaseRecordCounts !== null || + report.reconciliation?.sectionLifecycle?.enabled !== false || + !Number.isInteger( + report.reconciliation?.sectionLifecycle + ?.retirementCandidateCount, + ) || !/^[a-f0-9]{64}$/.test(report.snapshot?.sha256 ?? "") ) { throw new Error("Static loader report has invalid dry-run semantics"); diff --git a/.github/workflows/static-sync.yml b/.github/workflows/static-sync.yml index a12ecd467..fe1bc94f3 100644 --- a/.github/workflows/static-sync.yml +++ b/.github/workflows/static-sync.yml @@ -10,6 +10,19 @@ on: required: false default: false type: boolean + retire_missing_sections: + description: "Retire missing Sections (manual runs only; requires both approval fields below)" + required: false + default: false + type: boolean + expected_snapshot_sha256: + description: "Exact snapshot SHA-256 approved from a preceding dry run" + required: false + type: string + expected_section_retirement_candidates: + description: "Exact missing-Section candidate count approved from a preceding dry run" + required: false + type: string concurrency: group: static-sync @@ -54,6 +67,9 @@ jobs: STATIC_SNAPSHOT_URL: ${{ vars.STATIC_SNAPSHOT_URL || 'https://static.life-ustc.tiankaima.dev/life-ustc-static.sqlite' }} STATIC_LOADER_MIN_SEMESTER: ${{ vars.STATIC_LOADER_MIN_SEMESTER || '401' }} STATIC_LOADER_DRY_RUN: ${{ inputs.dry-run }} + STATIC_LOADER_RETIRE_MISSING_SECTIONS: ${{ github.event_name == 'workflow_dispatch' && inputs.retire_missing_sections || false }} + STATIC_LOADER_EXPECTED_SNAPSHOT_SHA256: ${{ github.event_name == 'workflow_dispatch' && inputs.expected_snapshot_sha256 || '' }} + STATIC_LOADER_EXPECTED_SECTION_RETIREMENT_CANDIDATES: ${{ github.event_name == 'workflow_dispatch' && inputs.expected_section_retirement_candidates || '' }} STATIC_LOADER_STATS_FILE: /tmp/static-load-stats.json run: scripts/load-static-sqlite.sh diff --git a/docker-compose.load.yml b/docker-compose.load.yml index b0580b7ae..5cbea6e96 100644 --- a/docker-compose.load.yml +++ b/docker-compose.load.yml @@ -8,6 +8,9 @@ services: STATIC_SNAPSHOT_PATH: ${STATIC_SNAPSHOT_PATH:-} STATIC_LOADER_MIN_SEMESTER: ${STATIC_LOADER_MIN_SEMESTER:-401} STATIC_LOADER_DRY_RUN: ${STATIC_LOADER_DRY_RUN:-false} + STATIC_LOADER_RETIRE_MISSING_SECTIONS: ${STATIC_LOADER_RETIRE_MISSING_SECTIONS:-false} + STATIC_LOADER_EXPECTED_SNAPSHOT_SHA256: ${STATIC_LOADER_EXPECTED_SNAPSHOT_SHA256:-} + STATIC_LOADER_EXPECTED_SECTION_RETIREMENT_CANDIDATES: ${STATIC_LOADER_EXPECTED_SECTION_RETIREMENT_CANDIDATES:-} # To use a local snapshot file instead of downloading, set STATIC_SNAPSHOT_PATH # and mount the host file to the same path inside the container, for example: # volumes: diff --git a/docs/contracts/_audit.json b/docs/contracts/_audit.json index c1d23b3d8..ade5c5f57 100644 --- a/docs/contracts/_audit.json +++ b/docs/contracts/_audit.json @@ -24,6 +24,14 @@ "trigger": "DELETE /api/uploads/[id] or MCP delete_my_upload", "description": "User deletes an upload they own. MCP-originated writes include metadata.source=mcp. After storage deletion succeeds, metadata deletion and the audit row are committed together." }, + "section_retire": { + "trigger": "Reserved", + "description": "The importer marks one missing in-scope Section retired without deleting the row or children. The same transaction records the Section target, source snapshot SHA-256, observation time, jwId, and previous sourceLastSeenAt." + }, + "section_reactivate": { + "trigger": "Reserved", + "description": "The importer clears retiredAt and refreshes sourceLastSeenAt. The same transaction records the Section target, source snapshot SHA-256, observation time, jwId, and previous retiredAt." + }, "admin_user_suspend": { "trigger": "POST /api/admin/suspensions", "description": "Admin suspends a user account. The suspension change and audit row are committed together." @@ -41,5 +49,5 @@ "description": "Admin updates description content from the moderation workflow. The description row, edit history, and audit row are committed together." } }, - "writer": "writeAuditLog records audit rows and Cloudflare Analytics Engine datapoints. Required domain/security trails await writeAuditLog and use the same Prisma transaction as DB-only mutations where practical. fireAuditLog is reserved for non-critical telemetry and logs failures without blocking the request." + "writer": "writeAuditLog records app audit rows and Cloudflare Analytics Engine datapoints. The Node-only static loader writes Section lifecycle rows directly with its import transaction. Required trails are awaited in the same transaction where practical; fireAuditLog remains non-critical telemetry." } diff --git a/docs/contracts/calendar.json b/docs/contracts/calendar.json index 1d4c07403..cdfab6760 100644 --- a/docs/contracts/calendar.json +++ b/docs/contracts/calendar.json @@ -8,6 +8,7 @@ }, "rules": { "views": "Provides selectable semester, month, and week views.", + "retired-sections": "Current/public collection calendars, personal calendar views, feeds, and calendar event tools exclude retired Sections. A direct single-Section detail/calendar URL remains available as a historical reference, and retirement does not delete underlying schedules, exams, homeworks, or subscription relationships.", "mobile-agenda-first": "At mobile widths, the personal calendar presents the selected week as a vertically readable agenda with time, title, type, and available location context. Previous week, today, next week, and iCal actions remain directly accessible without page-level horizontal scrolling; desktop retains the selectable grid views.", "week-starts-sunday": "The week view starts on Sunday.", "event-card-types": "Calendar event cards uniformly carry course, exam, homework, and todo events." diff --git a/docs/contracts/course.json b/docs/contracts/course.json index 63dfe3aff..0afb0f855 100644 --- a/docs/contracts/course.json +++ b/docs/contracts/course.json @@ -12,6 +12,7 @@ "static-section-course-link": "Each imported Section resolves the exact Course semantic identity attached to its lesson parent. Static sync assigns that link when inserting a Section but never silently rebinds courseId on an existing Section; missing identity mappings abort the transaction.", "static-legacy-course-merge": "After resolving current snapshot identities, static sync merges only stale Course rows absent from the snapshot that have exactly one compatible canonical match with the same code, Chinese name, and complete classification metadata. The merge preserves Sections, Comments, compatible Description history and latest edit metadata, and the stale jwId as a CourseAlias; ambiguous matches, conflicting descriptions, and alias collisions abort the transaction.", "course-jwid-alias": "Legacy course jwIds remain valid for REST, MCP, comment and description targets. Course and CourseAlias jwId namespaces are globally disjoint, and collisions abort instead of resolving ambiguously. Web detail routes permanently redirect aliases to the canonical jwId, while serialized course data always reports the canonical jwId.", + "retired-section-history": "Direct Course detail retains retired Section offerings as historical references; public Section discovery excludes them.", "jwid-url-only": "jwId is used only for URLs, APIs, MCP, and backend logic; it should not be directly displayed in ordinary course UI.", "education-level-display-casing": "English education-level filter labels uppercase only their initial character for consistent display while preserving all remaining source casing; non-English labels remain unchanged.", "public-list-cache": "Anonymous course list REST responses and server-side list data may use short public runtime/CDN caching because course fact data is read-only; localized HTML pages must not use shared edge caching unless the cache key varies by locale.", diff --git a/docs/contracts/section.json b/docs/contracts/section.json index 587bd6618..5500b3e9e 100644 --- a/docs/contracts/section.json +++ b/docs/contracts/section.json @@ -8,6 +8,10 @@ }, "rules": { "semester-offering": "A section is a specific teaching class for a course in a given semester.", + "source-lifecycle": "Static sync records sourceLastSeenAt from the validated snapshot generated_at timestamp and may set retiredAt for a missing in-scope Section only after snapshot completeness and mapped-jwId validation. Reappearing rows clear retiredAt only when the observing snapshot is newer than the retirement.", + "retirement-approval": "Scheduled production sync can never enable retirement. A manual run must explicitly set STATIC_LOADER_RETIRE_MISSING_SECTIONS=true and provide the exact snapshot SHA-256 plus exact candidate count observed in a preceding dry run; any mismatch aborts before lifecycle writes. Semesters with zero source lessons are excluded from destructive scope.", + "retirement-preserves-data": "Retirement never deletes the Section or its comments, description, homeworks, subscription relationships, schedules, exams, or audit history. Each retirement/reactivation writes a Section-targeted audit row and the import report includes candidate, before/after, retired, and reactivated counts.", + "retired-read-semantics": "Public Section/search/match and public/current calendar collection queries exclude retired rows. Direct Section detail and single-Section calendar access remain available for historical references; the detail page labels the record historical and does not offer a new subscription. Course/Teacher detail histories and the owner's subscribed Sections list also retain retired rows. New subscription discovery/add operations reject retired rows, while an existing owner can still remove one.", "subscription-not-enrollment": "Pages involving subscription actions must clearly state this is not official academic course enrollment.", "jwid-url-only": "jwId is used only for routing and API endpoints; it should not be directly displayed in ordinary section UI.", "public-list-cache": "Anonymous section list REST responses and server-side list data may use short public runtime/CDN caching because section fact data is read-only; localized HTML pages must not use shared edge caching unless the cache key varies by locale.", @@ -30,7 +34,8 @@ "path": "/api/sections", "returns": "PaginatedResponse", "notes": [ - "REST pageSize accepts 1-100; the deprecated limit alias remains accepted and pageSize wins when both are supplied. Out-of-range values are rejected instead of clamped." + "REST pageSize accepts 1-100; the deprecated limit alias remains accepted and pageSize wins when both are supplied. Out-of-range values are rejected instead of clamped.", + "Retired Sections are excluded." ] } ] @@ -43,7 +48,8 @@ "rest_equivalent": "GET /api/sections", "mcp_equivalent": "search_sections", "notes": [ - "PageInput defaults to page=1 and pageSize=20; pageSize must remain within 1-100." + "PageInput defaults to page=1 and pageSize=20; pageSize must remain within 1-100.", + "Retired Sections are excluded." ] } ] @@ -55,7 +61,8 @@ "returns": "PaginatedResponse", "rest_equivalent": "GET /api/sections", "notes": [ - "MCP limit accepts 1-100 and rejects out-of-range values instead of clamping." + "MCP limit accepts 1-100 and rejects out-of-range values instead of clamping.", + "Retired Sections are excluded." ] } ] @@ -91,7 +98,10 @@ "routes": [ { "path": "/api/sections/[jwId]", - "returns": "SectionDetail (schedules without room/teacher expansion)" + "returns": "SectionDetail (schedules without room/teacher expansion)", + "notes": [ + "Direct historical lookup remains available for retired Sections; retiredAt identifies retirement and sourceLastSeenAt records the last successful source observation." + ] } ] }, diff --git a/docs/contracts/subscribed-sections.json b/docs/contracts/subscribed-sections.json index 1de361b23..d34ffbbce 100644 --- a/docs/contracts/subscribed-sections.json +++ b/docs/contracts/subscribed-sections.json @@ -8,6 +8,7 @@ }, "rules": { "grouped-by-semester": "The list is grouped by semester rather than presented as a single flat list.", + "retired-history": "Existing subscription relationships to retired Sections remain visible in the owner's subscribed Sections list and can be removed. Retired Sections cannot be newly discovered or added and are omitted from current personal calendar events/feeds.", "section-codes-promoted": "In the 'subscribed context', users manage specific section relationships, so section codes are intentionally promoted.", "legacy-sections-route": "/dashboard/subscriptions/sections is a legacy redirect-only route to /dashboard/subscriptions and must not expose page actions." }, diff --git a/messages/en-us.json b/messages/en-us.json index 48e52eee3..5c9971482 100644 --- a/messages/en-us.json +++ b/messages/en-us.json @@ -1052,6 +1052,8 @@ "courseCode": "Course Code", "sectionCode": "Section Code", "teachingSection": "Teaching section", + "historicalSectionLabel": "Historical section", + "historicalSectionDescription": "This section is absent from the latest academic snapshot. Its history remains available, but it cannot be newly followed.", "semester": "Semester", "campus": "Campus", "credits": "Credits", diff --git a/messages/zh-cn.json b/messages/zh-cn.json index 15f085b31..26ecbbd3c 100644 --- a/messages/zh-cn.json +++ b/messages/zh-cn.json @@ -1018,6 +1018,8 @@ "courseCode": "课程代码", "sectionCode": "班级代码", "teachingSection": "授课班级", + "historicalSectionLabel": "历史班级", + "historicalSectionDescription": "该班级已不在最新教务快照中,保留此页面用于查看历史信息;不能新增关注。", "semester": "学期", "campus": "校区", "credits": "学分", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e2049b605..98a8c74f7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -50,6 +50,8 @@ enum AuditAction { comment_react description_edit upload_delete + section_retire + section_reactivate admin_user_suspend admin_user_unsuspend admin_comment_moderate @@ -375,6 +377,9 @@ model Section { id Int @id @default(autoincrement()) jwId Int @unique + sourceLastSeenAt DateTime? + retiredAt DateTime? + code String bizTypeId Int? credits Float? @@ -439,6 +444,7 @@ model Section { @@index([graduateAndPostgraduate]) @@index([courseId]) @@index([semesterId]) + @@index([semesterId, retiredAt]) @@index([examModeId]) @@index([campusId]) @@index([openDepartmentId]) diff --git a/public/openapi.generated.json b/public/openapi.generated.json index 0ddfe1475..a11ccd35b 100644 --- a/public/openapi.generated.json +++ b/public/openapi.generated.json @@ -8840,6 +8840,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -9545,6 +9557,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -10262,6 +10286,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -12046,6 +12082,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -12881,6 +12929,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -14283,6 +14343,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -17662,6 +17734,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -18349,6 +18433,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -19048,6 +19144,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -19803,6 +19911,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -20460,6 +20580,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -21117,6 +21249,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -21776,6 +21920,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -22562,6 +22718,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -23780,6 +23948,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -24355,6 +24535,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -25579,6 +25771,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -26188,6 +26392,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -26817,6 +27033,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -27155,6 +27383,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -28554,6 +28794,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -29371,6 +29623,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -31380,6 +31644,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, @@ -32433,6 +32709,18 @@ "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "sourceLastSeenAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "retiredAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, "code": { "type": "string" }, diff --git a/src/features/calendar/server/calendar-event-sources.ts b/src/features/calendar/server/calendar-event-sources.ts index 40661c4d0..23e4c08b2 100644 --- a/src/features/calendar/server/calendar-event-sources.ts +++ b/src/features/calendar/server/calendar-event-sources.ts @@ -1,6 +1,6 @@ import { withHomeworkItemState } from "@/features/homeworks/server/homework-item-state"; import { - getSubscribedSectionIds, + getActiveSubscribedSectionIds, listSubscribedExams, listSubscribedHomeworks, listSubscribedSchedules, @@ -26,9 +26,10 @@ export async function loadCalendarEventSources({ windowEnd: Date; windowStart: Date; }) { - const scopedSectionIds = sectionIds - ? Array.from(sectionIds) - : await getSubscribedSectionIds(userId); + const scopedSectionIds = await getActiveSubscribedSectionIds( + userId, + sectionIds, + ); const [schedules, homeworks, exams, todos] = await Promise.all([ listSubscribedSchedules(userId, { diff --git a/src/features/calendar/server/calendar-export-data.ts b/src/features/calendar/server/calendar-export-data.ts index 93722c5e0..02f2079b8 100644 --- a/src/features/calendar/server/calendar-export-data.ts +++ b/src/features/calendar/server/calendar-export-data.ts @@ -69,6 +69,7 @@ export async function getSectionsForCalendar(sectionIds: number[]) { id: { in: sectionIds, }, + retiredAt: null, }, include: sectionCalendarInclude, }); @@ -79,6 +80,7 @@ export async function getUserCalendarRecord(userId: string) { where: { id: userId }, include: { subscribedSections: { + where: { retiredAt: null }, include: sectionCalendarInclude, }, todos: { diff --git a/src/features/catalog/lib/schedule-filters.ts b/src/features/catalog/lib/schedule-filters.ts index 0f1fd934c..b54b37b03 100644 --- a/src/features/catalog/lib/schedule-filters.ts +++ b/src/features/catalog/lib/schedule-filters.ts @@ -32,7 +32,10 @@ export function buildScheduleDateWhere( : {}; } -export function buildScheduleListWhere(filters: ScheduleListFilters) { +export function buildScheduleListWhere( + filters: ScheduleListFilters, + options: { excludeRetiredSections?: boolean } = {}, +) { const { sectionId, sectionJwId, @@ -49,8 +52,11 @@ export function buildScheduleListWhere(filters: ScheduleListFilters) { applyIntegerFilter(where, "sectionId", sectionId); const sectionFilter = buildRelatedFilter("jwId", sectionJwId, sectionCode); - if (sectionFilter) { - where.section = sectionFilter; + if (sectionFilter || options.excludeRetiredSections) { + where.section = { + ...(sectionFilter ?? {}), + ...(options.excludeRetiredSections ? { retiredAt: null } : {}), + }; } const teacherFilter = buildRelatedFilter("id", teacherId, teacherCode); diff --git a/src/features/catalog/server/academic-query-includes.ts b/src/features/catalog/server/academic-query-includes.ts index 1fdebd522..a5e57c1c8 100644 --- a/src/features/catalog/server/academic-query-includes.ts +++ b/src/features/catalog/server/academic-query-includes.ts @@ -128,7 +128,7 @@ export const teacherListInclude = { teacherTitle: true, _count: { select: { - sections: true, + sections: { where: { retiredAt: null } }, }, }, } satisfies Prisma.TeacherInclude; diff --git a/src/features/catalog/server/public-catalog-data.ts b/src/features/catalog/server/public-catalog-data.ts index f7cecb988..1e3aad541 100644 --- a/src/features/catalog/server/public-catalog-data.ts +++ b/src/features/catalog/server/public-catalog-data.ts @@ -13,10 +13,13 @@ export async function getPublicCatalog(locale = "zh-cn") { code: true, nameCn: true, nameEn: true, - _count: { select: { sections: true } }, + _count: { + select: { sections: { where: { retiredAt: null } } }, + }, }, }), prisma.section.findMany({ + where: { retiredAt: null }, take: CATALOG_HOME_LIST_LIMIT, orderBy: [{ id: "desc" }], select: { @@ -35,7 +38,9 @@ export async function getPublicCatalog(locale = "zh-cn") { nameCn: true, nameEn: true, department: { select: { nameCn: true, nameEn: true } }, - _count: { select: { sections: true } }, + _count: { + select: { sections: { where: { retiredAt: null } } }, + }, }, }), ]); diff --git a/src/features/catalog/server/schedule-read-model.ts b/src/features/catalog/server/schedule-read-model.ts index a8e16f2f1..19f0273a8 100644 --- a/src/features/catalog/server/schedule-read-model.ts +++ b/src/features/catalog/server/schedule-read-model.ts @@ -118,7 +118,9 @@ export async function listPublicSchedules(input: { pageSize?: number; }) { const prisma = getPrisma(input.locale ?? DEFAULT_LOCALE); - const where = buildScheduleListWhere(input.filters); + const where = buildScheduleListWhere(input.filters, { + excludeRetiredSections: true, + }); const result = await paginatedQuery( (skip, take) => prisma.schedule.findMany({ diff --git a/src/features/catalog/server/section-code-match-query.ts b/src/features/catalog/server/section-code-match-query.ts index e56b3fc9e..c612fbeb6 100644 --- a/src/features/catalog/server/section-code-match-query.ts +++ b/src/features/catalog/server/section-code-match-query.ts @@ -18,6 +18,7 @@ export async function findSectionCodeMatches( const sections = await getPrisma(locale).section.findMany({ where: { code: { in: codes }, + retiredAt: null, semesterId: semester.id, }, include: sectionCompactInclude, diff --git a/src/features/catalog/server/section-code-match-suggestions.ts b/src/features/catalog/server/section-code-match-suggestions.ts index 57f7a18db..e522f7fd6 100644 --- a/src/features/catalog/server/section-code-match-suggestions.ts +++ b/src/features/catalog/server/section-code-match-suggestions.ts @@ -32,6 +32,7 @@ export async function buildSectionCodeSuggestions({ await prisma.section.findMany({ where: { semesterId, + retiredAt: null, OR: batchedPrefixes.map((prefix) => ({ code: ilike(prefix), })), diff --git a/src/features/catalog/server/section-query-filters.ts b/src/features/catalog/server/section-query-filters.ts index 5644f86a0..7ff946101 100644 --- a/src/features/catalog/server/section-query-filters.ts +++ b/src/features/catalog/server/section-query-filters.ts @@ -24,7 +24,7 @@ export function buildSectionListQuery(filters: SectionListFilters): { jwIds, search, } = filters; - const where: Prisma.SectionWhereInput = {}; + const where: Prisma.SectionWhereInput = { retiredAt: null }; applyIntegerFilter(where, "courseId", courseId); diff --git a/src/features/dashboard/server/assistant-dashboard-snapshot-helpers.ts b/src/features/dashboard/server/assistant-dashboard-snapshot-helpers.ts index 09686da17..046df287e 100644 --- a/src/features/dashboard/server/assistant-dashboard-snapshot-helpers.ts +++ b/src/features/dashboard/server/assistant-dashboard-snapshot-helpers.ts @@ -50,6 +50,7 @@ export async function listAssistantCurrentSemesterSections({ ? localizedPrisma.section.findMany({ where: { id: { in: sectionIds }, + retiredAt: null, semesterId: currentSemesterId, }, orderBy: [{ code: "asc" }], diff --git a/src/features/dashboard/server/compact-overview-read-model.ts b/src/features/dashboard/server/compact-overview-read-model.ts index 9a6b7b007..4b846581c 100644 --- a/src/features/dashboard/server/compact-overview-read-model.ts +++ b/src/features/dashboard/server/compact-overview-read-model.ts @@ -1,7 +1,7 @@ import { withHomeworkItemState } from "@/features/homeworks/server/homework-item-state"; import { countUpcomingSubscribedExams, - getSubscribedSectionIds, + getActiveSubscribedSectionIds, listSubscribedHomeworks, listSubscribedSchedules, listUpcomingSubscribedExams, @@ -56,7 +56,7 @@ export async function getCompactOverview( where: { id: userId }, select: { id: true, image: true, isAdmin: true, name: true }, }), - getSubscribedSectionIds(userId), + getActiveSubscribedSectionIds(userId), listTodoSummary({ filters: { completed: false }, now, diff --git a/src/features/dashboard/server/dashboard-nav-stats.ts b/src/features/dashboard/server/dashboard-nav-stats.ts index c7c75e299..351935f01 100644 --- a/src/features/dashboard/server/dashboard-nav-stats.ts +++ b/src/features/dashboard/server/dashboard-nav-stats.ts @@ -40,12 +40,17 @@ export async function getDashboardNavStats( const pendingTodosCountPromise = countIncompleteTodos(user.id); - if (subscribedSections.length === 0) { + const activeSubscribedSections = subscribedSections.filter( + (section) => section.retiredAt === null || section.retiredAt === undefined, + ); + if (activeSubscribedSections.length === 0) { const pendingTodosCount = await pendingTodosCountPromise; return emptyDashboardNavStats({ pendingTodosCount, user }); } - const scopedSectionIds = subscribedSections.map((section) => section.id); + const scopedSectionIds = activeSubscribedSections.map( + (section) => section.id, + ); const [ pendingTodosCount, pendingHomeworksCount, @@ -78,7 +83,11 @@ export async function getDashboardNavStats( atTime: referenceNow.toDate(), sectionIds: scopedSectionIds, }), - getDashboardCalendarItemsCount(user.id, subscribedSections, referenceNow), + getDashboardCalendarItemsCount( + user.id, + activeSubscribedSections, + referenceNow, + ), ]); return { diff --git a/src/features/dashboard/server/dashboard-user-context.ts b/src/features/dashboard/server/dashboard-user-context.ts index 265449967..36422e259 100644 --- a/src/features/dashboard/server/dashboard-user-context.ts +++ b/src/features/dashboard/server/dashboard-user-context.ts @@ -8,6 +8,7 @@ export type DashboardUserSummary = { export type DashboardSubscribedSection = { id: number; + retiredAt?: Date | null; semesterId: number | null; }; @@ -28,7 +29,7 @@ export async function getDashboardUserContext( username: true, calendarFeedToken: true, subscribedSections: { - select: { id: true, semesterId: true }, + select: { id: true, retiredAt: true, semesterId: true }, }, }, }); diff --git a/src/features/section-detail/components/SectionDetailDialogs.svelte b/src/features/section-detail/components/SectionDetailDialogs.svelte index 8fe28a5ca..7cd929dc9 100644 --- a/src/features/section-detail/components/SectionDetailDialogs.svelte +++ b/src/features/section-detail/components/SectionDetailDialogs.svelte @@ -144,7 +144,7 @@ export let applyEditStartNow: SectionDetailDialogsProps["applyEditStartNow"]; {subscriptionCalendarUrl} /> -{#if showSubscribeDialog} +{#if showSubscribeDialog && data.section.retiredAt == null} + {#if section.retiredAt != null} + + {sectionCopy.historicalSectionLabel} + {sectionCopy.historicalSectionDescription} + + {/if} + {#if formError} {formError} diff --git a/src/features/section-detail/components/SectionDetailMainContent.svelte b/src/features/section-detail/components/SectionDetailMainContent.svelte index a7b794d9c..43520658f 100644 --- a/src/features/section-detail/components/SectionDetailMainContent.svelte +++ b/src/features/section-detail/components/SectionDetailMainContent.svelte @@ -265,6 +265,7 @@ $: activeNavItem = void; export let onOpenSubscribe: () => void; +export let retired = false; export let sectionCopy: SectionActionsCopy; export let stretched = false; export let subscriptionAction: ( @@ -28,7 +29,9 @@ export let viewer: { isSubscribed?: boolean };