diff --git a/.env.example b/.env.example index 2a51030e9..9544ea1f6 100644 --- a/.env.example +++ b/.env.example @@ -40,3 +40,14 @@ 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. +# The first committed import after adding the global watermark must be a manual +# bootstrap with this flag plus the exact SHA-256 from a preceding dry run. +# Destructive runs also require the exact SHA-256 and candidate count observed +# in a preceding dry run. +# STATIC_LOADER_BOOTSTRAP_IMPORT_STATE="false" +# 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..c22c0db49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,6 +109,7 @@ jobs: -v "${report_dir}:/tmp/static-loader-report" \ -e DATABASE_URL="postgresql://postgres:postgres@${postgres}:5432/life_ustc_static_loader" \ -e STATIC_LOADER_DRY_RUN=true \ + -e STATIC_LOADER_BOOTSTRAP_IMPORT_STATE=true \ -e STATIC_LOADER_MIN_SEMESTER=401 \ -e STATIC_LOADER_STATS_FILE=/tmp/static-loader-report/report.json \ life-ustc-static-loader:check @@ -119,6 +120,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 c5f744a89..984bd2b4b 100644 --- a/.github/workflows/static-sync.yml +++ b/.github/workflows/static-sync.yml @@ -10,6 +10,24 @@ on: required: false default: false type: boolean + bootstrap_import_state: + description: "Initialize the global import watermark (manual only; committed runs require the exact snapshot SHA below)" + 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 bootstrap/retirement 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: production-database-writes @@ -55,6 +73,10 @@ 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_BOOTSTRAP_IMPORT_STATE: ${{ github.event_name == 'workflow_dispatch' && inputs.bootstrap_import_state || false }} + 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/Dockerfile b/Dockerfile index f782715f2..1e1a76854 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,7 @@ COPY package.json prisma.config.ts ./ COPY prisma ./prisma RUN DATABASE_URL="postgresql://localhost:5432/life_ustc" bun run db:generate COPY src/static-loader ./src/static-loader +COPY src/lib/db/section-lifecycle-lock.ts ./src/lib/db/section-lifecycle-lock.ts COPY scripts/load-static-sqlite.sh ./scripts/load-static-sqlite.sh COPY docker-entrypoint.load.sh /usr/local/bin/docker-entrypoint.load.sh diff --git a/docker-compose.load.yml b/docker-compose.load.yml index b0580b7ae..d38ab0037 100644 --- a/docker-compose.load.yml +++ b/docker-compose.load.yml @@ -8,6 +8,10 @@ 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_BOOTSTRAP_IMPORT_STATE: ${STATIC_LOADER_BOOTSTRAP_IMPORT_STATE:-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..14e9eb59f 100644 --- a/docs/contracts/section.json +++ b/docs/contracts/section.json @@ -8,6 +8,11 @@ }, "rules": { "semester-offering": "A section is a specific teaching class for a course in a given semester.", + "source-lifecycle": "Before any model write, static sync rejects generated_at values more than 15 minutes in the future and compares each import with one transactionally persisted global generated_at/SHA-256 watermark. Older snapshots and same-time/different-SHA snapshots fail. It records sourceLastSeenAt from the validated time. A missing in-scope Section may set retiredAt only after completeness and mapped-jwId validation; reappearing rows clear retiredAt only when seen after retirement.", + "import-state-bootstrap": "When no global import watermark exists, scheduled and ordinary manual writes fail closed. A manual bootstrap must keep Section retirement disabled; a committed bootstrap also requires the exact snapshot SHA-256 observed in a preceding dry run. The watermark update commits in the same advisory-locked transaction as all imported models.", + "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 +35,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 +49,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 +62,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 +99,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/docs/contracts/subscription.json b/docs/contracts/subscription.json index c08fc4a4f..97d2d16e4 100644 --- a/docs/contracts/subscription.json +++ b/docs/contracts/subscription.json @@ -98,8 +98,8 @@ "notes": [ "Preferred mutation endpoint for batch subscribe, unsubscribe, and replace/update flows.", "Accepts action plus sectionIds and/or mixed section/course codes.", - "action=set requires semesterId and replaces only the requested semester, preserving subscriptions from every other semester.", - "A semester-scoped action=set may omit sectionIds/codes to clear only that semester." + "action=set requires semesterId, replaces only that semester (an empty target clears it), and preserves every other semester.", + "action=set preserves a retired Section only when it is already subscribed and its ID is explicitly included; omitted or newly requested retired Sections remain unsubscribed." ] }, { @@ -114,7 +114,8 @@ "returns": "{ subscription: CalendarSubscription }", "notes": [ "Compatibility replace endpoint for ID-only callers.", - "subscription.sections[] is the user's new set of subscribed sections." + "subscription.sections[] is the user's new set of subscribed sections.", + "A retired Section is preserved only when it is already subscribed and its ID is explicitly included; retired Sections cannot be newly subscribed." ] }, { 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/migrations/20260718110000_add_static_import_state/migration.sql b/prisma/migrations/20260718110000_add_static_import_state/migration.sql new file mode 100644 index 000000000..4b91faeee --- /dev/null +++ b/prisma/migrations/20260718110000_add_static_import_state/migration.sql @@ -0,0 +1,11 @@ +CREATE TABLE "StaticImportState" ( + "id" TEXT NOT NULL, + "snapshotGeneratedAt" TIMESTAMP(3) NOT NULL, + "snapshotSha256" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "StaticImportState_pkey" PRIMARY KEY ("id"), + CONSTRAINT "StaticImportState_snapshotSha256_check" + CHECK ("snapshotSha256" ~ '^[0-9a-f]{64}$') +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8b6d87b24..6ac40dd14 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -452,6 +452,16 @@ model Section { @@index([teachLanguageId]) } +model StaticImportState { + id String @id + + snapshotGeneratedAt DateTime + snapshotSha256 String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + model Exam { id Int @id @default(autoincrement()) jwId Int @unique 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-page-public-summary.ts b/src/features/dashboard/server/dashboard-page-public-summary.ts index 91640710a..09b62e3e0 100644 --- a/src/features/dashboard/server/dashboard-page-public-summary.ts +++ b/src/features/dashboard/server/dashboard-page-public-summary.ts @@ -27,7 +27,7 @@ export async function loadDashboardPublicSummary( await Promise.all([ prisma.semester.count(), prisma.course.count(), - prisma.section.count(), + prisma.section.count({ where: { retiredAt: null } }), prisma.semester.findMany({ select: { id: true, 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 };