Openstatus www.openstatus.dev

Status page api (#1800)

* feat(proto): add status page service proto definitions

Add Protocol Buffer definitions for the status page service including:
- StatusPage and StatusPageSummary messages
- PageComponent and PageComponentGroup messages
- PageSubscriber message
- Full CRUD service definition with 17 RPC methods

* feat(server): implement status page RPC service

Add ConnectRPC service implementation for status pages including:
- Page CRUD operations (create, get, list, update, delete)
- Component management (add monitor/external, remove, update)
- Component groups (create, delete, update)
- Subscriber management (subscribe, unsubscribe, list)
- Content and status endpoints (getStatusPageContent, getOverallStatus)

Includes converters, error handling, and comprehensive test suite with 68 tests.

* feat(server): add workspace limits check for status pages

Add limit validation when creating status pages:
- Check status page count against workspace plan limit
- Add helper functions for feature limits (custom domain, password protection, email domain protection)
- Add test case for limit exceeded scenario using free plan workspace

* refactor(proto): rename token to id in UnsubscribeFromPageRequest

- Rename 'token' field to 'id' in the proto definition
- Update server implementation to use subscriber ID instead of token
- Update tests to use the new field name

* fix(server): improve code quality in status page service

- Fix type mismatch by removing redundant undefined assignment
- Use Drizzle's count() function instead of raw SQL
- Standardize ID validation with trim pattern
- Fix duplicate ternary expressions in error messages
- Wrap subscriber reactivation in transaction for atomicity
- Standardize string coercion patterns using nullish coalescing

* feat(server): add public access validation and maintenance support

- Add public access checks for unpublished and protected pages
- Implement maintenance querying in getStatusPageContent
- Add proper overall status calculation based on reports and maintenances
- Calculate individual component statuses (degraded/maintenance/operational)
- Add new error types for access denied scenarios
- Add tests for public access restrictions

* small fix

* small fix

authored by

Thibault Le Ouay and committed by
GitHub
30bc1de0 72ffa4a3

+5863 -1
+4 -1
apps/server/src/routes/rpc/router.ts
··· 1 1 import { createConnectRouter } from "@connectrpc/connect"; 2 2 import { HealthService } from "@openstatus/proto/health/v1"; 3 3 import { MonitorService } from "@openstatus/proto/monitor/v1"; 4 + import { StatusPageService } from "@openstatus/proto/status_page/v1"; 4 5 import { StatusReportService } from "@openstatus/proto/status_report/v1"; 5 6 6 7 import { ··· 11 12 } from "./interceptors"; 12 13 import { healthServiceImpl } from "./services/health"; 13 14 import { monitorServiceImpl } from "./services/monitor"; 15 + import { statusPageServiceImpl } from "./services/status-page"; 14 16 import { statusReportServiceImpl } from "./services/status-report"; 15 17 16 18 /** ··· 31 33 }) 32 34 .service(MonitorService, monitorServiceImpl) 33 35 .service(HealthService, healthServiceImpl) 34 - .service(StatusReportService, statusReportServiceImpl); 36 + .service(StatusReportService, statusReportServiceImpl) 37 + .service(StatusPageService, statusPageServiceImpl);
+1530
apps/server/src/routes/rpc/services/status-page/__tests__/status-page.test.ts
··· 1 + import { afterAll, beforeAll, describe, expect, test } from "bun:test"; 2 + import { db, eq } from "@openstatus/db"; 3 + import { 4 + monitor, 5 + page, 6 + pageComponent, 7 + pageComponentGroup, 8 + pageSubscriber, 9 + statusReport, 10 + statusReportsToPageComponents, 11 + } from "@openstatus/db/src/schema"; 12 + 13 + import { app } from "@/index"; 14 + 15 + /** 16 + * Helper to make ConnectRPC requests using the Connect protocol (JSON). 17 + * Connect uses POST with JSON body at /rpc/<service>/<method> 18 + */ 19 + async function connectRequest( 20 + method: string, 21 + body: Record<string, unknown> = {}, 22 + headers: Record<string, string> = {}, 23 + ) { 24 + return app.request( 25 + `/rpc/openstatus.status_page.v1.StatusPageService/${method}`, 26 + { 27 + method: "POST", 28 + headers: { 29 + "Content-Type": "application/json", 30 + ...headers, 31 + }, 32 + body: JSON.stringify(body), 33 + }, 34 + ); 35 + } 36 + 37 + const TEST_PREFIX = "rpc-status-page-test"; 38 + let testPageId: number; 39 + let testPageSlug: string; 40 + let testPageToDeleteId: number; 41 + let testPageToUpdateId: number; 42 + let testComponentId: number; 43 + let testComponentToDeleteId: number; 44 + let testComponentToUpdateId: number; 45 + let testGroupId: number; 46 + let testGroupToDeleteId: number; 47 + let testGroupToUpdateId: number; 48 + let testMonitorId: number; 49 + let testSubscriberId: number; 50 + 51 + beforeAll(async () => { 52 + // Clean up any existing test data 53 + await db 54 + .delete(pageSubscriber) 55 + .where(eq(pageSubscriber.email, `${TEST_PREFIX}@example.com`)); 56 + await db 57 + .delete(pageComponent) 58 + .where(eq(pageComponent.name, `${TEST_PREFIX}-component`)); 59 + await db 60 + .delete(pageComponent) 61 + .where(eq(pageComponent.name, `${TEST_PREFIX}-component-to-delete`)); 62 + await db 63 + .delete(pageComponent) 64 + .where(eq(pageComponent.name, `${TEST_PREFIX}-component-to-update`)); 65 + await db 66 + .delete(pageComponentGroup) 67 + .where(eq(pageComponentGroup.name, `${TEST_PREFIX}-group`)); 68 + await db 69 + .delete(pageComponentGroup) 70 + .where(eq(pageComponentGroup.name, `${TEST_PREFIX}-group-to-delete`)); 71 + await db 72 + .delete(pageComponentGroup) 73 + .where(eq(pageComponentGroup.name, `${TEST_PREFIX}-group-to-update`)); 74 + await db.delete(page).where(eq(page.slug, `${TEST_PREFIX}-slug`)); 75 + await db.delete(page).where(eq(page.slug, `${TEST_PREFIX}-slug-to-delete`)); 76 + await db.delete(page).where(eq(page.slug, `${TEST_PREFIX}-slug-to-update`)); 77 + await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-monitor`)); 78 + 79 + // Create a test monitor for component tests 80 + const testMonitor = await db 81 + .insert(monitor) 82 + .values({ 83 + workspaceId: 1, 84 + name: `${TEST_PREFIX}-monitor`, 85 + url: "https://example.com", 86 + periodicity: "1m", 87 + active: true, 88 + jobType: "http", 89 + }) 90 + .returning() 91 + .get(); 92 + testMonitorId = testMonitor.id; 93 + 94 + // Create a test page (published and public for testing public access) 95 + const testPage = await db 96 + .insert(page) 97 + .values({ 98 + workspaceId: 1, 99 + title: `${TEST_PREFIX}-page`, 100 + slug: `${TEST_PREFIX}-slug`, 101 + description: "Test page for status page tests", 102 + customDomain: "", 103 + published: true, 104 + accessType: "public", 105 + }) 106 + .returning() 107 + .get(); 108 + testPageId = testPage.id; 109 + testPageSlug = testPage.slug; 110 + 111 + // Create page to delete 112 + const pageToDelete = await db 113 + .insert(page) 114 + .values({ 115 + workspaceId: 1, 116 + title: `${TEST_PREFIX}-page-to-delete`, 117 + slug: `${TEST_PREFIX}-slug-to-delete`, 118 + description: "Test page to delete", 119 + customDomain: "", 120 + }) 121 + .returning() 122 + .get(); 123 + testPageToDeleteId = pageToDelete.id; 124 + 125 + // Create page to update 126 + const pageToUpdate = await db 127 + .insert(page) 128 + .values({ 129 + workspaceId: 1, 130 + title: `${TEST_PREFIX}-page-to-update`, 131 + slug: `${TEST_PREFIX}-slug-to-update`, 132 + description: "Test page to update", 133 + customDomain: "", 134 + }) 135 + .returning() 136 + .get(); 137 + testPageToUpdateId = pageToUpdate.id; 138 + 139 + // Create a test component group 140 + const testGroup = await db 141 + .insert(pageComponentGroup) 142 + .values({ 143 + workspaceId: 1, 144 + pageId: testPageId, 145 + name: `${TEST_PREFIX}-group`, 146 + }) 147 + .returning() 148 + .get(); 149 + testGroupId = testGroup.id; 150 + 151 + // Create group to delete 152 + const groupToDelete = await db 153 + .insert(pageComponentGroup) 154 + .values({ 155 + workspaceId: 1, 156 + pageId: testPageId, 157 + name: `${TEST_PREFIX}-group-to-delete`, 158 + }) 159 + .returning() 160 + .get(); 161 + testGroupToDeleteId = groupToDelete.id; 162 + 163 + // Create group to update 164 + const groupToUpdate = await db 165 + .insert(pageComponentGroup) 166 + .values({ 167 + workspaceId: 1, 168 + pageId: testPageId, 169 + name: `${TEST_PREFIX}-group-to-update`, 170 + }) 171 + .returning() 172 + .get(); 173 + testGroupToUpdateId = groupToUpdate.id; 174 + 175 + // Create a test component 176 + const testComponent = await db 177 + .insert(pageComponent) 178 + .values({ 179 + workspaceId: 1, 180 + pageId: testPageId, 181 + type: "static", 182 + name: `${TEST_PREFIX}-component`, 183 + description: "Test component", 184 + order: 100, 185 + }) 186 + .returning() 187 + .get(); 188 + testComponentId = testComponent.id; 189 + 190 + // Create component to delete 191 + const componentToDelete = await db 192 + .insert(pageComponent) 193 + .values({ 194 + workspaceId: 1, 195 + pageId: testPageId, 196 + type: "static", 197 + name: `${TEST_PREFIX}-component-to-delete`, 198 + description: "Test component to delete", 199 + order: 101, 200 + }) 201 + .returning() 202 + .get(); 203 + testComponentToDeleteId = componentToDelete.id; 204 + 205 + // Create component to update 206 + const componentToUpdate = await db 207 + .insert(pageComponent) 208 + .values({ 209 + workspaceId: 1, 210 + pageId: testPageId, 211 + type: "static", 212 + name: `${TEST_PREFIX}-component-to-update`, 213 + description: "Test component to update", 214 + order: 102, 215 + }) 216 + .returning() 217 + .get(); 218 + testComponentToUpdateId = componentToUpdate.id; 219 + 220 + // Create a test subscriber 221 + const testSubscriber = await db 222 + .insert(pageSubscriber) 223 + .values({ 224 + pageId: testPageId, 225 + email: `${TEST_PREFIX}@example.com`, 226 + token: `${TEST_PREFIX}-token`, 227 + acceptedAt: new Date(), 228 + }) 229 + .returning() 230 + .get(); 231 + testSubscriberId = testSubscriber.id; 232 + }); 233 + 234 + afterAll(async () => { 235 + // Clean up test data in proper order 236 + await db 237 + .delete(pageSubscriber) 238 + .where(eq(pageSubscriber.email, `${TEST_PREFIX}@example.com`)); 239 + await db 240 + .delete(pageSubscriber) 241 + .where(eq(pageSubscriber.email, `${TEST_PREFIX}-subscribe@example.com`)); 242 + 243 + await db 244 + .delete(pageComponent) 245 + .where(eq(pageComponent.name, `${TEST_PREFIX}-component`)); 246 + await db 247 + .delete(pageComponent) 248 + .where(eq(pageComponent.name, `${TEST_PREFIX}-component-to-delete`)); 249 + await db 250 + .delete(pageComponent) 251 + .where(eq(pageComponent.name, `${TEST_PREFIX}-component-to-update`)); 252 + 253 + await db 254 + .delete(pageComponentGroup) 255 + .where(eq(pageComponentGroup.name, `${TEST_PREFIX}-group`)); 256 + await db 257 + .delete(pageComponentGroup) 258 + .where(eq(pageComponentGroup.name, `${TEST_PREFIX}-group-to-delete`)); 259 + await db 260 + .delete(pageComponentGroup) 261 + .where(eq(pageComponentGroup.name, `${TEST_PREFIX}-group-to-update`)); 262 + 263 + await db.delete(page).where(eq(page.slug, `${TEST_PREFIX}-slug`)); 264 + await db.delete(page).where(eq(page.slug, `${TEST_PREFIX}-slug-to-delete`)); 265 + await db.delete(page).where(eq(page.slug, `${TEST_PREFIX}-slug-to-update`)); 266 + await db.delete(page).where(eq(page.slug, `${TEST_PREFIX}-created-slug`)); 267 + 268 + await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-monitor`)); 269 + }); 270 + 271 + // ========================================================================== 272 + // Page CRUD 273 + // ========================================================================== 274 + 275 + describe("StatusPageService.CreateStatusPage", () => { 276 + test("creates a new status page", async () => { 277 + const res = await connectRequest( 278 + "CreateStatusPage", 279 + { 280 + title: `${TEST_PREFIX}-created`, 281 + description: "A new test page", 282 + slug: `${TEST_PREFIX}-created-slug`, 283 + }, 284 + { "x-openstatus-key": "1" }, 285 + ); 286 + 287 + expect(res.status).toBe(200); 288 + 289 + const data = await res.json(); 290 + expect(data).toHaveProperty("statusPage"); 291 + expect(data.statusPage.title).toBe(`${TEST_PREFIX}-created`); 292 + expect(data.statusPage.description).toBe("A new test page"); 293 + expect(data.statusPage.slug).toBe(`${TEST_PREFIX}-created-slug`); 294 + 295 + // Clean up 296 + await db.delete(page).where(eq(page.id, Number(data.statusPage.id))); 297 + }); 298 + 299 + test("returns 401 when no auth key provided", async () => { 300 + const res = await connectRequest("CreateStatusPage", { 301 + title: "Unauthorized test", 302 + slug: "unauthorized-slug", 303 + }); 304 + 305 + expect(res.status).toBe(401); 306 + }); 307 + 308 + test("returns error when slug already exists", async () => { 309 + const res = await connectRequest( 310 + "CreateStatusPage", 311 + { 312 + title: "Duplicate slug test", 313 + slug: testPageSlug, // Already exists 314 + }, 315 + { "x-openstatus-key": "1" }, 316 + ); 317 + 318 + expect(res.status).toBe(409); // AlreadyExists 319 + }); 320 + 321 + test("returns 403 when status page limit is exceeded", async () => { 322 + // Workspace 2 is on free plan with status-pages limit of 1 323 + // First, create a page for workspace 2 to hit the limit 324 + const firstPage = await db 325 + .insert(page) 326 + .values({ 327 + workspaceId: 2, 328 + title: `${TEST_PREFIX}-limit-test`, 329 + slug: `${TEST_PREFIX}-limit-test-slug`, 330 + description: "First page for limit test", 331 + customDomain: "", 332 + }) 333 + .returning() 334 + .get(); 335 + 336 + try { 337 + // Try to create a second page - should fail with PermissionDenied 338 + const res = await connectRequest( 339 + "CreateStatusPage", 340 + { 341 + title: `${TEST_PREFIX}-limit-exceeded`, 342 + description: "Should fail due to limit", 343 + slug: `${TEST_PREFIX}-limit-exceeded-slug`, 344 + }, 345 + { "x-openstatus-key": "2" }, 346 + ); 347 + 348 + expect(res.status).toBe(403); // PermissionDenied 349 + 350 + const data = await res.json(); 351 + expect(data.message).toContain("Upgrade for more status pages"); 352 + } finally { 353 + // Clean up 354 + await db.delete(page).where(eq(page.id, firstPage.id)); 355 + } 356 + }); 357 + }); 358 + 359 + describe("StatusPageService.GetStatusPage", () => { 360 + test("returns status page by ID", async () => { 361 + const res = await connectRequest( 362 + "GetStatusPage", 363 + { id: String(testPageId) }, 364 + { "x-openstatus-key": "1" }, 365 + ); 366 + 367 + expect(res.status).toBe(200); 368 + 369 + const data = await res.json(); 370 + expect(data).toHaveProperty("statusPage"); 371 + expect(data.statusPage.id).toBe(String(testPageId)); 372 + expect(data.statusPage.slug).toBe(testPageSlug); 373 + }); 374 + 375 + test("returns 401 when no auth key provided", async () => { 376 + const res = await connectRequest("GetStatusPage", { 377 + id: String(testPageId), 378 + }); 379 + 380 + expect(res.status).toBe(401); 381 + }); 382 + 383 + test("returns 404 for non-existent status page", async () => { 384 + const res = await connectRequest( 385 + "GetStatusPage", 386 + { id: "99999" }, 387 + { "x-openstatus-key": "1" }, 388 + ); 389 + 390 + expect(res.status).toBe(404); 391 + }); 392 + 393 + test("returns error when ID is empty", async () => { 394 + const res = await connectRequest( 395 + "GetStatusPage", 396 + { id: "" }, 397 + { "x-openstatus-key": "1" }, 398 + ); 399 + 400 + expect(res.status).toBe(400); 401 + }); 402 + 403 + test("returns 404 for status page in different workspace", async () => { 404 + // Create page in workspace 2 405 + const otherPage = await db 406 + .insert(page) 407 + .values({ 408 + workspaceId: 2, 409 + title: `${TEST_PREFIX}-other-workspace`, 410 + slug: `${TEST_PREFIX}-other-workspace-slug`, 411 + description: "Other workspace page", 412 + customDomain: "", 413 + }) 414 + .returning() 415 + .get(); 416 + 417 + try { 418 + const res = await connectRequest( 419 + "GetStatusPage", 420 + { id: String(otherPage.id) }, 421 + { "x-openstatus-key": "1" }, 422 + ); 423 + 424 + expect(res.status).toBe(404); 425 + } finally { 426 + await db.delete(page).where(eq(page.id, otherPage.id)); 427 + } 428 + }); 429 + }); 430 + 431 + describe("StatusPageService.ListStatusPages", () => { 432 + test("returns status pages for authenticated workspace", async () => { 433 + const res = await connectRequest( 434 + "ListStatusPages", 435 + {}, 436 + { "x-openstatus-key": "1" }, 437 + ); 438 + 439 + expect(res.status).toBe(200); 440 + 441 + const data = await res.json(); 442 + expect(data).toHaveProperty("statusPages"); 443 + expect(Array.isArray(data.statusPages)).toBe(true); 444 + expect(data).toHaveProperty("totalSize"); 445 + }); 446 + 447 + test("returns 401 when no auth key provided", async () => { 448 + const res = await connectRequest("ListStatusPages", {}); 449 + 450 + expect(res.status).toBe(401); 451 + }); 452 + 453 + test("respects limit parameter", async () => { 454 + const res = await connectRequest( 455 + "ListStatusPages", 456 + { limit: 1 }, 457 + { "x-openstatus-key": "1" }, 458 + ); 459 + 460 + expect(res.status).toBe(200); 461 + 462 + const data = await res.json(); 463 + expect(data.statusPages?.length || 0).toBeLessThanOrEqual(1); 464 + }); 465 + 466 + test("respects offset parameter", async () => { 467 + // Get first page 468 + const res1 = await connectRequest( 469 + "ListStatusPages", 470 + { limit: 1, offset: 0 }, 471 + { "x-openstatus-key": "1" }, 472 + ); 473 + const data1 = await res1.json(); 474 + 475 + // Get second page 476 + const res2 = await connectRequest( 477 + "ListStatusPages", 478 + { limit: 1, offset: 1 }, 479 + { "x-openstatus-key": "1" }, 480 + ); 481 + const data2 = await res2.json(); 482 + 483 + // Should have different pages if multiple exist 484 + if (data1.statusPages?.length > 0 && data2.statusPages?.length > 0) { 485 + expect(data1.statusPages[0].id).not.toBe(data2.statusPages[0].id); 486 + } 487 + }); 488 + }); 489 + 490 + describe("StatusPageService.UpdateStatusPage", () => { 491 + test("updates status page title", async () => { 492 + const res = await connectRequest( 493 + "UpdateStatusPage", 494 + { 495 + id: String(testPageToUpdateId), 496 + title: `${TEST_PREFIX}-updated-title`, 497 + }, 498 + { "x-openstatus-key": "1" }, 499 + ); 500 + 501 + expect(res.status).toBe(200); 502 + 503 + const data = await res.json(); 504 + expect(data).toHaveProperty("statusPage"); 505 + expect(data.statusPage.title).toBe(`${TEST_PREFIX}-updated-title`); 506 + 507 + // Restore original title 508 + await db 509 + .update(page) 510 + .set({ title: `${TEST_PREFIX}-page-to-update` }) 511 + .where(eq(page.id, testPageToUpdateId)); 512 + }); 513 + 514 + test("returns 401 when no auth key provided", async () => { 515 + const res = await connectRequest("UpdateStatusPage", { 516 + id: String(testPageToUpdateId), 517 + title: "Unauthorized update", 518 + }); 519 + 520 + expect(res.status).toBe(401); 521 + }); 522 + 523 + test("returns 404 for non-existent status page", async () => { 524 + const res = await connectRequest( 525 + "UpdateStatusPage", 526 + { id: "99999", title: "Non-existent update" }, 527 + { "x-openstatus-key": "1" }, 528 + ); 529 + 530 + expect(res.status).toBe(404); 531 + }); 532 + 533 + test("returns error when slug conflicts with another page", async () => { 534 + const res = await connectRequest( 535 + "UpdateStatusPage", 536 + { 537 + id: String(testPageToUpdateId), 538 + slug: testPageSlug, // Already exists on another page 539 + }, 540 + { "x-openstatus-key": "1" }, 541 + ); 542 + 543 + expect(res.status).toBe(409); 544 + }); 545 + }); 546 + 547 + describe("StatusPageService.DeleteStatusPage", () => { 548 + test("successfully deletes existing status page", async () => { 549 + const res = await connectRequest( 550 + "DeleteStatusPage", 551 + { id: String(testPageToDeleteId) }, 552 + { "x-openstatus-key": "1" }, 553 + ); 554 + 555 + expect(res.status).toBe(200); 556 + 557 + const data = await res.json(); 558 + expect(data.success).toBe(true); 559 + 560 + // Verify it's deleted 561 + const deleted = await db 562 + .select() 563 + .from(page) 564 + .where(eq(page.id, testPageToDeleteId)) 565 + .get(); 566 + expect(deleted).toBeUndefined(); 567 + }); 568 + 569 + test("returns 401 when no auth key provided", async () => { 570 + const res = await connectRequest("DeleteStatusPage", { id: "1" }); 571 + 572 + expect(res.status).toBe(401); 573 + }); 574 + 575 + test("returns 404 for non-existent status page", async () => { 576 + const res = await connectRequest( 577 + "DeleteStatusPage", 578 + { id: "99999" }, 579 + { "x-openstatus-key": "1" }, 580 + ); 581 + 582 + expect(res.status).toBe(404); 583 + }); 584 + }); 585 + 586 + // ========================================================================== 587 + // Component Management 588 + // ========================================================================== 589 + 590 + describe("StatusPageService.AddMonitorComponent", () => { 591 + test("adds monitor component to page", async () => { 592 + const res = await connectRequest( 593 + "AddMonitorComponent", 594 + { 595 + pageId: String(testPageId), 596 + monitorId: String(testMonitorId), 597 + name: `${TEST_PREFIX}-monitor-component`, 598 + order: 200, 599 + }, 600 + { "x-openstatus-key": "1" }, 601 + ); 602 + 603 + expect(res.status).toBe(200); 604 + 605 + const data = await res.json(); 606 + expect(data).toHaveProperty("component"); 607 + expect(data.component.name).toBe(`${TEST_PREFIX}-monitor-component`); 608 + expect(data.component.type).toBe("PAGE_COMPONENT_TYPE_MONITOR"); 609 + expect(data.component.monitorId).toBe(String(testMonitorId)); 610 + 611 + // Clean up 612 + await db 613 + .delete(pageComponent) 614 + .where(eq(pageComponent.id, Number(data.component.id))); 615 + }); 616 + 617 + test("returns 401 when no auth key provided", async () => { 618 + const res = await connectRequest("AddMonitorComponent", { 619 + pageId: String(testPageId), 620 + monitorId: String(testMonitorId), 621 + }); 622 + 623 + expect(res.status).toBe(401); 624 + }); 625 + 626 + test("returns 404 for non-existent page", async () => { 627 + const res = await connectRequest( 628 + "AddMonitorComponent", 629 + { 630 + pageId: "99999", 631 + monitorId: String(testMonitorId), 632 + }, 633 + { "x-openstatus-key": "1" }, 634 + ); 635 + 636 + expect(res.status).toBe(404); 637 + }); 638 + 639 + test("returns 404 for non-existent monitor", async () => { 640 + const res = await connectRequest( 641 + "AddMonitorComponent", 642 + { 643 + pageId: String(testPageId), 644 + monitorId: "99999", 645 + }, 646 + { "x-openstatus-key": "1" }, 647 + ); 648 + 649 + expect(res.status).toBe(404); 650 + }); 651 + 652 + test("adds component with group", async () => { 653 + const res = await connectRequest( 654 + "AddMonitorComponent", 655 + { 656 + pageId: String(testPageId), 657 + monitorId: String(testMonitorId), 658 + name: `${TEST_PREFIX}-monitor-component-grouped`, 659 + groupId: String(testGroupId), 660 + }, 661 + { "x-openstatus-key": "1" }, 662 + ); 663 + 664 + expect(res.status).toBe(200); 665 + 666 + const data = await res.json(); 667 + expect(data.component.groupId).toBe(String(testGroupId)); 668 + 669 + // Clean up 670 + await db 671 + .delete(pageComponent) 672 + .where(eq(pageComponent.id, Number(data.component.id))); 673 + }); 674 + }); 675 + 676 + describe("StatusPageService.AddStaticComponent", () => { 677 + test("adds static component to page", async () => { 678 + const res = await connectRequest( 679 + "AddStaticComponent", 680 + { 681 + pageId: String(testPageId), 682 + name: `${TEST_PREFIX}-static-component`, 683 + description: "Static service", 684 + order: 300, 685 + }, 686 + { "x-openstatus-key": "1" }, 687 + ); 688 + 689 + expect(res.status).toBe(200); 690 + 691 + const data = await res.json(); 692 + expect(data).toHaveProperty("component"); 693 + expect(data.component.name).toBe(`${TEST_PREFIX}-static-component`); 694 + expect(data.component.type).toBe("PAGE_COMPONENT_TYPE_STATIC"); 695 + expect(data.component.description).toBe("Static service"); 696 + 697 + // Clean up 698 + await db 699 + .delete(pageComponent) 700 + .where(eq(pageComponent.id, Number(data.component.id))); 701 + }); 702 + 703 + test("returns 401 when no auth key provided", async () => { 704 + const res = await connectRequest("AddStaticComponent", { 705 + pageId: String(testPageId), 706 + name: "Unauthorized component", 707 + }); 708 + 709 + expect(res.status).toBe(401); 710 + }); 711 + 712 + test("returns 404 for non-existent page", async () => { 713 + const res = await connectRequest( 714 + "AddStaticComponent", 715 + { 716 + pageId: "99999", 717 + name: "Component for non-existent page", 718 + }, 719 + { "x-openstatus-key": "1" }, 720 + ); 721 + 722 + expect(res.status).toBe(404); 723 + }); 724 + }); 725 + 726 + describe("StatusPageService.RemoveComponent", () => { 727 + test("successfully removes component", async () => { 728 + const res = await connectRequest( 729 + "RemoveComponent", 730 + { id: String(testComponentToDeleteId) }, 731 + { "x-openstatus-key": "1" }, 732 + ); 733 + 734 + expect(res.status).toBe(200); 735 + 736 + const data = await res.json(); 737 + expect(data.success).toBe(true); 738 + 739 + // Verify it's deleted 740 + const deleted = await db 741 + .select() 742 + .from(pageComponent) 743 + .where(eq(pageComponent.id, testComponentToDeleteId)) 744 + .get(); 745 + expect(deleted).toBeUndefined(); 746 + }); 747 + 748 + test("returns 401 when no auth key provided", async () => { 749 + const res = await connectRequest("RemoveComponent", { id: "1" }); 750 + 751 + expect(res.status).toBe(401); 752 + }); 753 + 754 + test("returns 404 for non-existent component", async () => { 755 + const res = await connectRequest( 756 + "RemoveComponent", 757 + { id: "99999" }, 758 + { "x-openstatus-key": "1" }, 759 + ); 760 + 761 + expect(res.status).toBe(404); 762 + }); 763 + }); 764 + 765 + describe("StatusPageService.UpdateComponent", () => { 766 + test("updates component name", async () => { 767 + const res = await connectRequest( 768 + "UpdateComponent", 769 + { 770 + id: String(testComponentToUpdateId), 771 + name: `${TEST_PREFIX}-component-updated`, 772 + }, 773 + { "x-openstatus-key": "1" }, 774 + ); 775 + 776 + expect(res.status).toBe(200); 777 + 778 + const data = await res.json(); 779 + expect(data).toHaveProperty("component"); 780 + expect(data.component.name).toBe(`${TEST_PREFIX}-component-updated`); 781 + 782 + // Restore original name 783 + await db 784 + .update(pageComponent) 785 + .set({ name: `${TEST_PREFIX}-component-to-update` }) 786 + .where(eq(pageComponent.id, testComponentToUpdateId)); 787 + }); 788 + 789 + test("updates component group", async () => { 790 + const res = await connectRequest( 791 + "UpdateComponent", 792 + { 793 + id: String(testComponentToUpdateId), 794 + groupId: String(testGroupId), 795 + }, 796 + { "x-openstatus-key": "1" }, 797 + ); 798 + 799 + expect(res.status).toBe(200); 800 + 801 + const data = await res.json(); 802 + expect(data.component.groupId).toBe(String(testGroupId)); 803 + 804 + // Remove from group 805 + await db 806 + .update(pageComponent) 807 + .set({ groupId: null }) 808 + .where(eq(pageComponent.id, testComponentToUpdateId)); 809 + }); 810 + 811 + test("returns 401 when no auth key provided", async () => { 812 + const res = await connectRequest("UpdateComponent", { 813 + id: String(testComponentToUpdateId), 814 + name: "Unauthorized update", 815 + }); 816 + 817 + expect(res.status).toBe(401); 818 + }); 819 + 820 + test("returns 404 for non-existent component", async () => { 821 + const res = await connectRequest( 822 + "UpdateComponent", 823 + { id: "99999", name: "Non-existent update" }, 824 + { "x-openstatus-key": "1" }, 825 + ); 826 + 827 + expect(res.status).toBe(404); 828 + }); 829 + 830 + test("returns 404 for non-existent group", async () => { 831 + const res = await connectRequest( 832 + "UpdateComponent", 833 + { 834 + id: String(testComponentToUpdateId), 835 + groupId: "99999", 836 + }, 837 + { "x-openstatus-key": "1" }, 838 + ); 839 + 840 + expect(res.status).toBe(404); 841 + }); 842 + }); 843 + 844 + // ========================================================================== 845 + // Component Groups 846 + // ========================================================================== 847 + 848 + describe("StatusPageService.CreateComponentGroup", () => { 849 + test("creates a new component group", async () => { 850 + const res = await connectRequest( 851 + "CreateComponentGroup", 852 + { 853 + pageId: String(testPageId), 854 + name: `${TEST_PREFIX}-new-group`, 855 + }, 856 + { "x-openstatus-key": "1" }, 857 + ); 858 + 859 + expect(res.status).toBe(200); 860 + 861 + const data = await res.json(); 862 + expect(data).toHaveProperty("group"); 863 + expect(data.group.name).toBe(`${TEST_PREFIX}-new-group`); 864 + expect(data.group.pageId).toBe(String(testPageId)); 865 + 866 + // Clean up 867 + await db 868 + .delete(pageComponentGroup) 869 + .where(eq(pageComponentGroup.id, Number(data.group.id))); 870 + }); 871 + 872 + test("returns 401 when no auth key provided", async () => { 873 + const res = await connectRequest("CreateComponentGroup", { 874 + pageId: String(testPageId), 875 + name: "Unauthorized group", 876 + }); 877 + 878 + expect(res.status).toBe(401); 879 + }); 880 + 881 + test("returns 404 for non-existent page", async () => { 882 + const res = await connectRequest( 883 + "CreateComponentGroup", 884 + { 885 + pageId: "99999", 886 + name: "Group for non-existent page", 887 + }, 888 + { "x-openstatus-key": "1" }, 889 + ); 890 + 891 + expect(res.status).toBe(404); 892 + }); 893 + }); 894 + 895 + describe("StatusPageService.DeleteComponentGroup", () => { 896 + test("successfully deletes component group", async () => { 897 + const res = await connectRequest( 898 + "DeleteComponentGroup", 899 + { id: String(testGroupToDeleteId) }, 900 + { "x-openstatus-key": "1" }, 901 + ); 902 + 903 + expect(res.status).toBe(200); 904 + 905 + const data = await res.json(); 906 + expect(data.success).toBe(true); 907 + 908 + // Verify it's deleted 909 + const deleted = await db 910 + .select() 911 + .from(pageComponentGroup) 912 + .where(eq(pageComponentGroup.id, testGroupToDeleteId)) 913 + .get(); 914 + expect(deleted).toBeUndefined(); 915 + }); 916 + 917 + test("returns 401 when no auth key provided", async () => { 918 + const res = await connectRequest("DeleteComponentGroup", { id: "1" }); 919 + 920 + expect(res.status).toBe(401); 921 + }); 922 + 923 + test("returns 404 for non-existent group", async () => { 924 + const res = await connectRequest( 925 + "DeleteComponentGroup", 926 + { id: "99999" }, 927 + { "x-openstatus-key": "1" }, 928 + ); 929 + 930 + expect(res.status).toBe(404); 931 + }); 932 + }); 933 + 934 + describe("StatusPageService.UpdateComponentGroup", () => { 935 + test("updates component group name", async () => { 936 + const res = await connectRequest( 937 + "UpdateComponentGroup", 938 + { 939 + id: String(testGroupToUpdateId), 940 + name: `${TEST_PREFIX}-group-updated`, 941 + }, 942 + { "x-openstatus-key": "1" }, 943 + ); 944 + 945 + expect(res.status).toBe(200); 946 + 947 + const data = await res.json(); 948 + expect(data).toHaveProperty("group"); 949 + expect(data.group.name).toBe(`${TEST_PREFIX}-group-updated`); 950 + 951 + // Restore original name 952 + await db 953 + .update(pageComponentGroup) 954 + .set({ name: `${TEST_PREFIX}-group-to-update` }) 955 + .where(eq(pageComponentGroup.id, testGroupToUpdateId)); 956 + }); 957 + 958 + test("returns 401 when no auth key provided", async () => { 959 + const res = await connectRequest("UpdateComponentGroup", { 960 + id: String(testGroupToUpdateId), 961 + name: "Unauthorized update", 962 + }); 963 + 964 + expect(res.status).toBe(401); 965 + }); 966 + 967 + test("returns 404 for non-existent group", async () => { 968 + const res = await connectRequest( 969 + "UpdateComponentGroup", 970 + { id: "99999", name: "Non-existent update" }, 971 + { "x-openstatus-key": "1" }, 972 + ); 973 + 974 + expect(res.status).toBe(404); 975 + }); 976 + }); 977 + 978 + // ========================================================================== 979 + // Subscribers 980 + // ========================================================================== 981 + 982 + describe("StatusPageService.SubscribeToPage", () => { 983 + test("subscribes new user to page", async () => { 984 + const res = await connectRequest( 985 + "SubscribeToPage", 986 + { 987 + pageId: String(testPageId), 988 + email: `${TEST_PREFIX}-subscribe@example.com`, 989 + }, 990 + { "x-openstatus-key": "1" }, 991 + ); 992 + 993 + expect(res.status).toBe(200); 994 + 995 + const data = await res.json(); 996 + expect(data).toHaveProperty("subscriber"); 997 + expect(data.subscriber.email).toBe(`${TEST_PREFIX}-subscribe@example.com`); 998 + expect(data.subscriber.pageId).toBe(String(testPageId)); 999 + 1000 + // Clean up 1001 + await db 1002 + .delete(pageSubscriber) 1003 + .where(eq(pageSubscriber.id, Number(data.subscriber.id))); 1004 + }); 1005 + 1006 + test("returns existing subscriber when already subscribed", async () => { 1007 + const res = await connectRequest( 1008 + "SubscribeToPage", 1009 + { 1010 + pageId: String(testPageId), 1011 + email: `${TEST_PREFIX}@example.com`, // Already exists 1012 + }, 1013 + { "x-openstatus-key": "1" }, 1014 + ); 1015 + 1016 + expect(res.status).toBe(200); 1017 + 1018 + const data = await res.json(); 1019 + expect(data).toHaveProperty("subscriber"); 1020 + expect(data.subscriber.id).toBe(String(testSubscriberId)); 1021 + }); 1022 + 1023 + test("returns 401 when no auth key provided", async () => { 1024 + const res = await connectRequest("SubscribeToPage", { 1025 + pageId: String(testPageId), 1026 + email: "unauthorized@example.com", 1027 + }); 1028 + 1029 + expect(res.status).toBe(401); 1030 + }); 1031 + 1032 + test("returns 404 for non-existent page", async () => { 1033 + const res = await connectRequest( 1034 + "SubscribeToPage", 1035 + { 1036 + pageId: "99999", 1037 + email: "test@example.com", 1038 + }, 1039 + { "x-openstatus-key": "1" }, 1040 + ); 1041 + 1042 + expect(res.status).toBe(404); 1043 + }); 1044 + }); 1045 + 1046 + describe("StatusPageService.UnsubscribeFromPage", () => { 1047 + test("unsubscribes by email", async () => { 1048 + // First subscribe a new user 1049 + const subscribeRes = await connectRequest( 1050 + "SubscribeToPage", 1051 + { 1052 + pageId: String(testPageId), 1053 + email: `${TEST_PREFIX}-unsub-email@example.com`, 1054 + }, 1055 + { "x-openstatus-key": "1" }, 1056 + ); 1057 + const subscribeData = await subscribeRes.json(); 1058 + 1059 + // Then unsubscribe 1060 + const res = await connectRequest( 1061 + "UnsubscribeFromPage", 1062 + { 1063 + pageId: String(testPageId), 1064 + email: `${TEST_PREFIX}-unsub-email@example.com`, 1065 + }, 1066 + { "x-openstatus-key": "1" }, 1067 + ); 1068 + 1069 + expect(res.status).toBe(200); 1070 + 1071 + const data = await res.json(); 1072 + expect(data.success).toBe(true); 1073 + 1074 + // Verify unsubscribedAt is set 1075 + const subscriber = await db 1076 + .select() 1077 + .from(pageSubscriber) 1078 + .where(eq(pageSubscriber.id, Number(subscribeData.subscriber.id))) 1079 + .get(); 1080 + expect(subscriber?.unsubscribedAt).not.toBeNull(); 1081 + 1082 + // Clean up 1083 + await db 1084 + .delete(pageSubscriber) 1085 + .where(eq(pageSubscriber.id, Number(subscribeData.subscriber.id))); 1086 + }); 1087 + 1088 + test("unsubscribes by id", async () => { 1089 + // First subscribe a new user 1090 + const subscribeRes = await connectRequest( 1091 + "SubscribeToPage", 1092 + { 1093 + pageId: String(testPageId), 1094 + email: `${TEST_PREFIX}-unsub-id@example.com`, 1095 + }, 1096 + { "x-openstatus-key": "1" }, 1097 + ); 1098 + const subscribeData = await subscribeRes.json(); 1099 + 1100 + // Then unsubscribe by id 1101 + const res = await connectRequest( 1102 + "UnsubscribeFromPage", 1103 + { 1104 + pageId: String(testPageId), 1105 + id: subscribeData.subscriber.id, 1106 + }, 1107 + { "x-openstatus-key": "1" }, 1108 + ); 1109 + 1110 + expect(res.status).toBe(200); 1111 + 1112 + const data = await res.json(); 1113 + expect(data.success).toBe(true); 1114 + 1115 + // Clean up 1116 + await db 1117 + .delete(pageSubscriber) 1118 + .where(eq(pageSubscriber.id, Number(subscribeData.subscriber.id))); 1119 + }); 1120 + 1121 + test("returns 401 when no auth key provided", async () => { 1122 + const res = await connectRequest("UnsubscribeFromPage", { 1123 + pageId: String(testPageId), 1124 + email: "test@example.com", 1125 + }); 1126 + 1127 + expect(res.status).toBe(401); 1128 + }); 1129 + 1130 + test("returns 404 for non-existent subscriber", async () => { 1131 + const res = await connectRequest( 1132 + "UnsubscribeFromPage", 1133 + { 1134 + pageId: String(testPageId), 1135 + email: "nonexistent@example.com", 1136 + }, 1137 + { "x-openstatus-key": "1" }, 1138 + ); 1139 + 1140 + expect(res.status).toBe(404); 1141 + }); 1142 + 1143 + test("returns error when no identifier provided", async () => { 1144 + const res = await connectRequest( 1145 + "UnsubscribeFromPage", 1146 + { 1147 + pageId: String(testPageId), 1148 + }, 1149 + { "x-openstatus-key": "1" }, 1150 + ); 1151 + 1152 + expect(res.status).toBe(400); 1153 + }); 1154 + }); 1155 + 1156 + describe("StatusPageService.ListSubscribers", () => { 1157 + test("returns subscribers for page", async () => { 1158 + const res = await connectRequest( 1159 + "ListSubscribers", 1160 + { pageId: String(testPageId) }, 1161 + { "x-openstatus-key": "1" }, 1162 + ); 1163 + 1164 + expect(res.status).toBe(200); 1165 + 1166 + const data = await res.json(); 1167 + expect(data).toHaveProperty("subscribers"); 1168 + expect(Array.isArray(data.subscribers)).toBe(true); 1169 + expect(data).toHaveProperty("totalSize"); 1170 + }); 1171 + 1172 + test("returns 401 when no auth key provided", async () => { 1173 + const res = await connectRequest("ListSubscribers", { 1174 + pageId: String(testPageId), 1175 + }); 1176 + 1177 + expect(res.status).toBe(401); 1178 + }); 1179 + 1180 + test("returns 404 for non-existent page", async () => { 1181 + const res = await connectRequest( 1182 + "ListSubscribers", 1183 + { pageId: "99999" }, 1184 + { "x-openstatus-key": "1" }, 1185 + ); 1186 + 1187 + expect(res.status).toBe(404); 1188 + }); 1189 + 1190 + test("filters out unsubscribed by default", async () => { 1191 + // Create an unsubscribed subscriber 1192 + const unsubscriber = await db 1193 + .insert(pageSubscriber) 1194 + .values({ 1195 + pageId: testPageId, 1196 + email: `${TEST_PREFIX}-unsubbed@example.com`, 1197 + token: `${TEST_PREFIX}-unsubbed-token`, 1198 + unsubscribedAt: new Date(), 1199 + }) 1200 + .returning() 1201 + .get(); 1202 + 1203 + try { 1204 + const res = await connectRequest( 1205 + "ListSubscribers", 1206 + { pageId: String(testPageId) }, 1207 + { "x-openstatus-key": "1" }, 1208 + ); 1209 + 1210 + expect(res.status).toBe(200); 1211 + 1212 + const data = await res.json(); 1213 + const subscriberEmails = (data.subscribers || []).map( 1214 + (s: { email: string }) => s.email, 1215 + ); 1216 + expect(subscriberEmails).not.toContain( 1217 + `${TEST_PREFIX}-unsubbed@example.com`, 1218 + ); 1219 + } finally { 1220 + await db 1221 + .delete(pageSubscriber) 1222 + .where(eq(pageSubscriber.id, unsubscriber.id)); 1223 + } 1224 + }); 1225 + 1226 + test("includes unsubscribed when flag is true", async () => { 1227 + // Create an unsubscribed subscriber 1228 + const unsubscriber = await db 1229 + .insert(pageSubscriber) 1230 + .values({ 1231 + pageId: testPageId, 1232 + email: `${TEST_PREFIX}-unsubbed2@example.com`, 1233 + token: `${TEST_PREFIX}-unsubbed2-token`, 1234 + unsubscribedAt: new Date(), 1235 + }) 1236 + .returning() 1237 + .get(); 1238 + 1239 + try { 1240 + const res = await connectRequest( 1241 + "ListSubscribers", 1242 + { pageId: String(testPageId), includeUnsubscribed: true }, 1243 + { "x-openstatus-key": "1" }, 1244 + ); 1245 + 1246 + expect(res.status).toBe(200); 1247 + 1248 + const data = await res.json(); 1249 + const subscriberEmails = (data.subscribers || []).map( 1250 + (s: { email: string }) => s.email, 1251 + ); 1252 + expect(subscriberEmails).toContain( 1253 + `${TEST_PREFIX}-unsubbed2@example.com`, 1254 + ); 1255 + } finally { 1256 + await db 1257 + .delete(pageSubscriber) 1258 + .where(eq(pageSubscriber.id, unsubscriber.id)); 1259 + } 1260 + }); 1261 + }); 1262 + 1263 + // ========================================================================== 1264 + // Full Content & Status 1265 + // ========================================================================== 1266 + 1267 + describe("StatusPageService.GetStatusPageContent", () => { 1268 + test("returns full content by ID", async () => { 1269 + const res = await connectRequest( 1270 + "GetStatusPageContent", 1271 + { id: String(testPageId) }, 1272 + { "x-openstatus-key": "1" }, 1273 + ); 1274 + 1275 + expect(res.status).toBe(200); 1276 + 1277 + const data = await res.json(); 1278 + expect(data).toHaveProperty("statusPage"); 1279 + expect(data).toHaveProperty("components"); 1280 + expect(data).toHaveProperty("groups"); 1281 + // statusReports may be undefined/empty if there are no active reports 1282 + expect(data.statusPage.id).toBe(String(testPageId)); 1283 + }); 1284 + 1285 + test("returns full content by slug", async () => { 1286 + const res = await connectRequest( 1287 + "GetStatusPageContent", 1288 + { slug: testPageSlug }, 1289 + { "x-openstatus-key": "1" }, 1290 + ); 1291 + 1292 + expect(res.status).toBe(200); 1293 + 1294 + const data = await res.json(); 1295 + expect(data).toHaveProperty("statusPage"); 1296 + expect(data.statusPage.slug).toBe(testPageSlug); 1297 + }); 1298 + 1299 + test("returns 401 when no auth key provided", async () => { 1300 + const res = await connectRequest("GetStatusPageContent", { 1301 + id: String(testPageId), 1302 + }); 1303 + 1304 + expect(res.status).toBe(401); 1305 + }); 1306 + 1307 + test("returns 404 for non-existent page", async () => { 1308 + const res = await connectRequest( 1309 + "GetStatusPageContent", 1310 + { id: "99999" }, 1311 + { "x-openstatus-key": "1" }, 1312 + ); 1313 + 1314 + expect(res.status).toBe(404); 1315 + }); 1316 + 1317 + test("returns 404 for unpublished page accessed by slug", async () => { 1318 + // Create an unpublished page 1319 + const unpublishedPage = await db 1320 + .insert(page) 1321 + .values({ 1322 + workspaceId: 1, 1323 + title: `${TEST_PREFIX}-unpublished`, 1324 + slug: `${TEST_PREFIX}-unpublished-slug`, 1325 + description: "Unpublished page", 1326 + customDomain: "", 1327 + published: false, 1328 + accessType: "public", 1329 + }) 1330 + .returning() 1331 + .get(); 1332 + 1333 + try { 1334 + const res = await connectRequest( 1335 + "GetStatusPageContent", 1336 + { slug: unpublishedPage.slug }, 1337 + { "x-openstatus-key": "1" }, 1338 + ); 1339 + 1340 + expect(res.status).toBe(404); 1341 + } finally { 1342 + await db.delete(page).where(eq(page.id, unpublishedPage.id)); 1343 + } 1344 + }); 1345 + 1346 + test("returns 403 for password-protected page accessed by slug", async () => { 1347 + // Create a password-protected page 1348 + const protectedPage = await db 1349 + .insert(page) 1350 + .values({ 1351 + workspaceId: 1, 1352 + title: `${TEST_PREFIX}-protected`, 1353 + slug: `${TEST_PREFIX}-protected-slug`, 1354 + description: "Password protected page", 1355 + customDomain: "", 1356 + published: true, 1357 + accessType: "password", 1358 + }) 1359 + .returning() 1360 + .get(); 1361 + 1362 + try { 1363 + const res = await connectRequest( 1364 + "GetStatusPageContent", 1365 + { slug: protectedPage.slug }, 1366 + { "x-openstatus-key": "1" }, 1367 + ); 1368 + 1369 + expect(res.status).toBe(403); 1370 + } finally { 1371 + await db.delete(page).where(eq(page.id, protectedPage.id)); 1372 + } 1373 + }); 1374 + 1375 + test("allows workspace owner to access unpublished page by ID", async () => { 1376 + // Create an unpublished page 1377 + const unpublishedPage = await db 1378 + .insert(page) 1379 + .values({ 1380 + workspaceId: 1, 1381 + title: `${TEST_PREFIX}-unpublished-by-id`, 1382 + slug: `${TEST_PREFIX}-unpublished-by-id-slug`, 1383 + description: "Unpublished page accessible by ID", 1384 + customDomain: "", 1385 + published: false, 1386 + accessType: "public", 1387 + }) 1388 + .returning() 1389 + .get(); 1390 + 1391 + try { 1392 + const res = await connectRequest( 1393 + "GetStatusPageContent", 1394 + { id: String(unpublishedPage.id) }, 1395 + { "x-openstatus-key": "1" }, 1396 + ); 1397 + 1398 + // Workspace owner can access their own unpublished pages by ID 1399 + expect(res.status).toBe(200); 1400 + } finally { 1401 + await db.delete(page).where(eq(page.id, unpublishedPage.id)); 1402 + } 1403 + }); 1404 + 1405 + test("includes active status reports", async () => { 1406 + // Create an active status report for the test page 1407 + const report = await db 1408 + .insert(statusReport) 1409 + .values({ 1410 + workspaceId: 1, 1411 + pageId: testPageId, 1412 + title: `${TEST_PREFIX}-active-report`, 1413 + status: "investigating", 1414 + }) 1415 + .returning() 1416 + .get(); 1417 + 1418 + await db.insert(statusReportsToPageComponents).values({ 1419 + statusReportId: report.id, 1420 + pageComponentId: testComponentId, 1421 + }); 1422 + 1423 + try { 1424 + const res = await connectRequest( 1425 + "GetStatusPageContent", 1426 + { id: String(testPageId) }, 1427 + { "x-openstatus-key": "1" }, 1428 + ); 1429 + 1430 + expect(res.status).toBe(200); 1431 + 1432 + const data = await res.json(); 1433 + expect(data.statusReports.length).toBeGreaterThan(0); 1434 + 1435 + const testReport = data.statusReports.find( 1436 + (r: { title: string }) => r.title === `${TEST_PREFIX}-active-report`, 1437 + ); 1438 + expect(testReport).toBeDefined(); 1439 + } finally { 1440 + await db 1441 + .delete(statusReportsToPageComponents) 1442 + .where(eq(statusReportsToPageComponents.statusReportId, report.id)); 1443 + await db.delete(statusReport).where(eq(statusReport.id, report.id)); 1444 + } 1445 + }); 1446 + }); 1447 + 1448 + describe("StatusPageService.GetOverallStatus", () => { 1449 + test("returns overall status by ID", async () => { 1450 + const res = await connectRequest( 1451 + "GetOverallStatus", 1452 + { id: String(testPageId) }, 1453 + { "x-openstatus-key": "1" }, 1454 + ); 1455 + 1456 + expect(res.status).toBe(200); 1457 + 1458 + const data = await res.json(); 1459 + expect(data).toHaveProperty("overallStatus"); 1460 + expect(data).toHaveProperty("componentStatuses"); 1461 + }); 1462 + 1463 + test("returns overall status by slug", async () => { 1464 + const res = await connectRequest( 1465 + "GetOverallStatus", 1466 + { slug: testPageSlug }, 1467 + { "x-openstatus-key": "1" }, 1468 + ); 1469 + 1470 + expect(res.status).toBe(200); 1471 + 1472 + const data = await res.json(); 1473 + expect(data).toHaveProperty("overallStatus"); 1474 + }); 1475 + 1476 + test("returns 401 when no auth key provided", async () => { 1477 + const res = await connectRequest("GetOverallStatus", { 1478 + id: String(testPageId), 1479 + }); 1480 + 1481 + expect(res.status).toBe(401); 1482 + }); 1483 + 1484 + test("returns 404 for non-existent page", async () => { 1485 + const res = await connectRequest( 1486 + "GetOverallStatus", 1487 + { id: "99999" }, 1488 + { "x-openstatus-key": "1" }, 1489 + ); 1490 + 1491 + expect(res.status).toBe(404); 1492 + }); 1493 + 1494 + test("returns degraded status when there are active incidents", async () => { 1495 + // Create an active status report for the test page 1496 + const report = await db 1497 + .insert(statusReport) 1498 + .values({ 1499 + workspaceId: 1, 1500 + pageId: testPageId, 1501 + title: `${TEST_PREFIX}-incident-report`, 1502 + status: "investigating", 1503 + }) 1504 + .returning() 1505 + .get(); 1506 + 1507 + await db.insert(statusReportsToPageComponents).values({ 1508 + statusReportId: report.id, 1509 + pageComponentId: testComponentId, 1510 + }); 1511 + 1512 + try { 1513 + const res = await connectRequest( 1514 + "GetOverallStatus", 1515 + { id: String(testPageId) }, 1516 + { "x-openstatus-key": "1" }, 1517 + ); 1518 + 1519 + expect(res.status).toBe(200); 1520 + 1521 + const data = await res.json(); 1522 + expect(data.overallStatus).toBe("OVERALL_STATUS_DEGRADED"); 1523 + } finally { 1524 + await db 1525 + .delete(statusReportsToPageComponents) 1526 + .where(eq(statusReportsToPageComponents.statusReportId, report.id)); 1527 + await db.delete(statusReport).where(eq(statusReport.id, report.id)); 1528 + } 1529 + }); 1530 + });
+266
apps/server/src/routes/rpc/services/status-page/converters.ts
··· 1 + import type { 2 + PageComponent, 3 + PageComponentGroup, 4 + PageSubscriber, 5 + StatusPage, 6 + StatusPageSummary, 7 + } from "@openstatus/proto/status_page/v1"; 8 + import { 9 + OverallStatus, 10 + PageAccessType, 11 + PageComponentType, 12 + PageTheme, 13 + } from "@openstatus/proto/status_page/v1"; 14 + 15 + /** 16 + * Database types 17 + */ 18 + type DBPage = { 19 + id: number; 20 + title: string; 21 + description: string; 22 + slug: string; 23 + customDomain: string; 24 + published: boolean | null; 25 + forceTheme: "system" | "light" | "dark"; 26 + accessType: "public" | "password" | "email-domain" | null; 27 + homepageUrl: string | null; 28 + contactUrl: string | null; 29 + icon: string | null; 30 + createdAt: Date | null; 31 + updatedAt: Date | null; 32 + }; 33 + 34 + type DBPageComponent = { 35 + id: number; 36 + pageId: number; 37 + name: string; 38 + description: string | null; 39 + type: "static" | "monitor"; 40 + monitorId: number | null; 41 + order: number | null; 42 + groupId: number | null; 43 + groupOrder: number | null; 44 + createdAt: Date | null; 45 + updatedAt: Date | null; 46 + }; 47 + 48 + type DBPageComponentGroup = { 49 + id: number; 50 + pageId: number; 51 + name: string; 52 + createdAt: Date | null; 53 + updatedAt: Date | null; 54 + }; 55 + 56 + type DBPageSubscriber = { 57 + id: number; 58 + pageId: number; 59 + email: string; 60 + acceptedAt: Date | null; 61 + unsubscribedAt: Date | null; 62 + createdAt: Date | null; 63 + updatedAt: Date | null; 64 + }; 65 + 66 + /** 67 + * Convert DB access type string to proto enum. 68 + */ 69 + export function dbAccessTypeToProto( 70 + accessType: "public" | "password" | "email-domain" | null, 71 + ): PageAccessType { 72 + switch (accessType) { 73 + case "public": 74 + return PageAccessType.PUBLIC; 75 + case "password": 76 + return PageAccessType.PASSWORD_PROTECTED; 77 + case "email-domain": 78 + return PageAccessType.AUTHENTICATED; 79 + default: 80 + return PageAccessType.PUBLIC; 81 + } 82 + } 83 + 84 + /** 85 + * Convert proto access type enum to DB string. 86 + */ 87 + export function protoAccessTypeToDb( 88 + accessType: PageAccessType, 89 + ): "public" | "password" | "email-domain" { 90 + switch (accessType) { 91 + case PageAccessType.PUBLIC: 92 + return "public"; 93 + case PageAccessType.PASSWORD_PROTECTED: 94 + return "password"; 95 + case PageAccessType.AUTHENTICATED: 96 + return "email-domain"; 97 + default: 98 + return "public"; 99 + } 100 + } 101 + 102 + /** 103 + * Convert DB theme string to proto enum. 104 + */ 105 + export function dbThemeToProto(theme: "system" | "light" | "dark"): PageTheme { 106 + switch (theme) { 107 + case "system": 108 + return PageTheme.SYSTEM; 109 + case "light": 110 + return PageTheme.LIGHT; 111 + case "dark": 112 + return PageTheme.DARK; 113 + default: 114 + return PageTheme.SYSTEM; 115 + } 116 + } 117 + 118 + /** 119 + * Convert proto theme enum to DB string. 120 + */ 121 + export function protoThemeToDb(theme: PageTheme): "system" | "light" | "dark" { 122 + switch (theme) { 123 + case PageTheme.SYSTEM: 124 + return "system"; 125 + case PageTheme.LIGHT: 126 + return "light"; 127 + case PageTheme.DARK: 128 + return "dark"; 129 + default: 130 + return "system"; 131 + } 132 + } 133 + 134 + /** 135 + * Convert DB component type string to proto enum. 136 + */ 137 + export function dbComponentTypeToProto( 138 + type: "static" | "monitor", 139 + ): PageComponentType { 140 + switch (type) { 141 + case "monitor": 142 + return PageComponentType.MONITOR; 143 + case "static": 144 + return PageComponentType.STATIC; 145 + default: 146 + return PageComponentType.UNSPECIFIED; 147 + } 148 + } 149 + 150 + /** 151 + * Convert proto component type enum to DB string. 152 + */ 153 + export function protoComponentTypeToDb( 154 + type: PageComponentType, 155 + ): "static" | "monitor" { 156 + switch (type) { 157 + case PageComponentType.MONITOR: 158 + return "monitor"; 159 + case PageComponentType.STATIC: 160 + return "static"; 161 + default: 162 + return "static"; 163 + } 164 + } 165 + 166 + /** 167 + * Convert a DB status page to full proto format. 168 + */ 169 + export function dbPageToProto(page: DBPage): StatusPage { 170 + return { 171 + $typeName: "openstatus.status_page.v1.StatusPage" as const, 172 + id: String(page.id), 173 + title: page.title, 174 + description: page.description, 175 + slug: page.slug, 176 + customDomain: page.customDomain ?? "", 177 + published: page.published ?? false, 178 + accessType: dbAccessTypeToProto(page.accessType), 179 + theme: dbThemeToProto(page.forceTheme), 180 + homepageUrl: page.homepageUrl ?? "", 181 + contactUrl: page.contactUrl ?? "", 182 + icon: page.icon ?? "", 183 + createdAt: page.createdAt?.toISOString() ?? "", 184 + updatedAt: page.updatedAt?.toISOString() ?? "", 185 + }; 186 + } 187 + 188 + /** 189 + * Convert a DB status page to summary proto format. 190 + */ 191 + export function dbPageToProtoSummary(page: DBPage): StatusPageSummary { 192 + return { 193 + $typeName: "openstatus.status_page.v1.StatusPageSummary" as const, 194 + id: String(page.id), 195 + title: page.title, 196 + slug: page.slug, 197 + published: page.published ?? false, 198 + createdAt: page.createdAt?.toISOString() ?? "", 199 + updatedAt: page.updatedAt?.toISOString() ?? "", 200 + }; 201 + } 202 + 203 + /** 204 + * Convert a DB page component to proto format. 205 + */ 206 + export function dbComponentToProto(component: DBPageComponent): PageComponent { 207 + return { 208 + $typeName: "openstatus.status_page.v1.PageComponent" as const, 209 + id: String(component.id), 210 + pageId: String(component.pageId), 211 + name: component.name, 212 + description: component.description ?? "", 213 + type: dbComponentTypeToProto(component.type), 214 + monitorId: component.monitorId != null ? String(component.monitorId) : "", 215 + order: component.order ?? 0, 216 + groupId: component.groupId != null ? String(component.groupId) : "", 217 + groupOrder: component.groupOrder ?? 0, 218 + createdAt: component.createdAt?.toISOString() ?? "", 219 + updatedAt: component.updatedAt?.toISOString() ?? "", 220 + }; 221 + } 222 + 223 + /** 224 + * Convert a DB component group to proto format. 225 + */ 226 + export function dbGroupToProto( 227 + group: DBPageComponentGroup, 228 + ): PageComponentGroup { 229 + return { 230 + $typeName: "openstatus.status_page.v1.PageComponentGroup" as const, 231 + id: String(group.id), 232 + pageId: String(group.pageId), 233 + name: group.name, 234 + createdAt: group.createdAt?.toISOString() ?? "", 235 + updatedAt: group.updatedAt?.toISOString() ?? "", 236 + }; 237 + } 238 + 239 + /** 240 + * Convert a DB subscriber to proto format. 241 + */ 242 + export function dbSubscriberToProto( 243 + subscriber: DBPageSubscriber, 244 + ): PageSubscriber { 245 + return { 246 + $typeName: "openstatus.status_page.v1.PageSubscriber" as const, 247 + id: String(subscriber.id), 248 + pageId: String(subscriber.pageId), 249 + email: subscriber.email, 250 + acceptedAt: subscriber.acceptedAt?.toISOString() ?? "", 251 + unsubscribedAt: subscriber.unsubscribedAt?.toISOString() ?? "", 252 + createdAt: subscriber.createdAt?.toISOString() ?? "", 253 + updatedAt: subscriber.updatedAt?.toISOString() ?? "", 254 + }; 255 + } 256 + 257 + /** 258 + * Get overall status based on component statuses. 259 + * This is a placeholder - the actual implementation would look at 260 + * monitor statuses, active incidents, and maintenance windows. 261 + */ 262 + export function getOverallStatusValue(): OverallStatus { 263 + // Default to operational - in a real implementation this would 264 + // aggregate status from monitors and incidents 265 + return OverallStatus.OPERATIONAL; 266 + }
+256
apps/server/src/routes/rpc/services/status-page/errors.ts
··· 1 + import { Code, ConnectError } from "@connectrpc/connect"; 2 + 3 + /** 4 + * Error reasons for structured error handling. 5 + */ 6 + export const ErrorReason = { 7 + STATUS_PAGE_NOT_FOUND: "STATUS_PAGE_NOT_FOUND", 8 + STATUS_PAGE_ID_REQUIRED: "STATUS_PAGE_ID_REQUIRED", 9 + STATUS_PAGE_CREATE_FAILED: "STATUS_PAGE_CREATE_FAILED", 10 + STATUS_PAGE_UPDATE_FAILED: "STATUS_PAGE_UPDATE_FAILED", 11 + STATUS_PAGE_NOT_PUBLISHED: "STATUS_PAGE_NOT_PUBLISHED", 12 + STATUS_PAGE_ACCESS_DENIED: "STATUS_PAGE_ACCESS_DENIED", 13 + SLUG_ALREADY_EXISTS: "SLUG_ALREADY_EXISTS", 14 + PAGE_COMPONENT_NOT_FOUND: "PAGE_COMPONENT_NOT_FOUND", 15 + PAGE_COMPONENT_CREATE_FAILED: "PAGE_COMPONENT_CREATE_FAILED", 16 + PAGE_COMPONENT_UPDATE_FAILED: "PAGE_COMPONENT_UPDATE_FAILED", 17 + COMPONENT_GROUP_NOT_FOUND: "COMPONENT_GROUP_NOT_FOUND", 18 + COMPONENT_GROUP_CREATE_FAILED: "COMPONENT_GROUP_CREATE_FAILED", 19 + COMPONENT_GROUP_UPDATE_FAILED: "COMPONENT_GROUP_UPDATE_FAILED", 20 + MONITOR_NOT_FOUND: "MONITOR_NOT_FOUND", 21 + SUBSCRIBER_NOT_FOUND: "SUBSCRIBER_NOT_FOUND", 22 + SUBSCRIBER_CREATE_FAILED: "SUBSCRIBER_CREATE_FAILED", 23 + IDENTIFIER_REQUIRED: "IDENTIFIER_REQUIRED", 24 + } as const; 25 + 26 + export type ErrorReason = (typeof ErrorReason)[keyof typeof ErrorReason]; 27 + 28 + const DOMAIN = "openstatus.dev"; 29 + 30 + /** 31 + * Creates a ConnectError with structured metadata. 32 + */ 33 + function createError( 34 + message: string, 35 + code: Code, 36 + reason: ErrorReason, 37 + metadata?: Record<string, string>, 38 + ): ConnectError { 39 + const headers = new Headers({ 40 + "error-domain": DOMAIN, 41 + "error-reason": reason, 42 + }); 43 + 44 + if (metadata) { 45 + for (const [key, value] of Object.entries(metadata)) { 46 + headers.set(`error-${key}`, value); 47 + } 48 + } 49 + 50 + return new ConnectError(message, code, headers); 51 + } 52 + 53 + /** 54 + * Creates a "status page not found" error. 55 + */ 56 + export function statusPageNotFoundError(pageId: string): ConnectError { 57 + return createError( 58 + "Status page not found", 59 + Code.NotFound, 60 + ErrorReason.STATUS_PAGE_NOT_FOUND, 61 + { "page-id": pageId }, 62 + ); 63 + } 64 + 65 + /** 66 + * Creates a "status page ID required" error. 67 + */ 68 + export function statusPageIdRequiredError(): ConnectError { 69 + return createError( 70 + "Status page ID is required", 71 + Code.InvalidArgument, 72 + ErrorReason.STATUS_PAGE_ID_REQUIRED, 73 + ); 74 + } 75 + 76 + /** 77 + * Creates a "failed to create status page" error. 78 + */ 79 + export function statusPageCreateFailedError(): ConnectError { 80 + return createError( 81 + "Failed to create status page", 82 + Code.Internal, 83 + ErrorReason.STATUS_PAGE_CREATE_FAILED, 84 + ); 85 + } 86 + 87 + /** 88 + * Creates a "failed to update status page" error. 89 + */ 90 + export function statusPageUpdateFailedError(pageId: string): ConnectError { 91 + return createError( 92 + "Failed to update status page", 93 + Code.Internal, 94 + ErrorReason.STATUS_PAGE_UPDATE_FAILED, 95 + { "page-id": pageId }, 96 + ); 97 + } 98 + 99 + /** 100 + * Creates a "slug already exists" error. 101 + */ 102 + export function slugAlreadyExistsError(slug: string): ConnectError { 103 + return createError( 104 + "A status page with this slug already exists", 105 + Code.AlreadyExists, 106 + ErrorReason.SLUG_ALREADY_EXISTS, 107 + { slug }, 108 + ); 109 + } 110 + 111 + /** 112 + * Creates a "status page not published" error. 113 + * Used when trying to access an unpublished page via public slug. 114 + */ 115 + export function statusPageNotPublishedError(slug: string): ConnectError { 116 + return createError( 117 + "Status page is not published", 118 + Code.NotFound, 119 + ErrorReason.STATUS_PAGE_NOT_PUBLISHED, 120 + { slug }, 121 + ); 122 + } 123 + 124 + /** 125 + * Creates a "status page access denied" error. 126 + * Used when trying to access a protected page without proper authentication. 127 + */ 128 + export function statusPageAccessDeniedError( 129 + slug: string, 130 + accessType: string, 131 + ): ConnectError { 132 + return createError( 133 + `Status page requires ${accessType} access`, 134 + Code.PermissionDenied, 135 + ErrorReason.STATUS_PAGE_ACCESS_DENIED, 136 + { slug, "access-type": accessType }, 137 + ); 138 + } 139 + 140 + /** 141 + * Creates a "page component not found" error. 142 + */ 143 + export function pageComponentNotFoundError(componentId: string): ConnectError { 144 + return createError( 145 + "Page component not found", 146 + Code.NotFound, 147 + ErrorReason.PAGE_COMPONENT_NOT_FOUND, 148 + { "component-id": componentId }, 149 + ); 150 + } 151 + 152 + /** 153 + * Creates a "failed to create page component" error. 154 + */ 155 + export function pageComponentCreateFailedError(): ConnectError { 156 + return createError( 157 + "Failed to create page component", 158 + Code.Internal, 159 + ErrorReason.PAGE_COMPONENT_CREATE_FAILED, 160 + ); 161 + } 162 + 163 + /** 164 + * Creates a "failed to update page component" error. 165 + */ 166 + export function pageComponentUpdateFailedError( 167 + componentId: string, 168 + ): ConnectError { 169 + return createError( 170 + "Failed to update page component", 171 + Code.Internal, 172 + ErrorReason.PAGE_COMPONENT_UPDATE_FAILED, 173 + { "component-id": componentId }, 174 + ); 175 + } 176 + 177 + /** 178 + * Creates a "component group not found" error. 179 + */ 180 + export function componentGroupNotFoundError(groupId: string): ConnectError { 181 + return createError( 182 + "Component group not found", 183 + Code.NotFound, 184 + ErrorReason.COMPONENT_GROUP_NOT_FOUND, 185 + { "group-id": groupId }, 186 + ); 187 + } 188 + 189 + /** 190 + * Creates a "failed to create component group" error. 191 + */ 192 + export function componentGroupCreateFailedError(): ConnectError { 193 + return createError( 194 + "Failed to create component group", 195 + Code.Internal, 196 + ErrorReason.COMPONENT_GROUP_CREATE_FAILED, 197 + ); 198 + } 199 + 200 + /** 201 + * Creates a "failed to update component group" error. 202 + */ 203 + export function componentGroupUpdateFailedError(groupId: string): ConnectError { 204 + return createError( 205 + "Failed to update component group", 206 + Code.Internal, 207 + ErrorReason.COMPONENT_GROUP_UPDATE_FAILED, 208 + { "group-id": groupId }, 209 + ); 210 + } 211 + 212 + /** 213 + * Creates a "monitor not found" error. 214 + */ 215 + export function monitorNotFoundError(monitorId: string): ConnectError { 216 + return createError( 217 + "Monitor not found", 218 + Code.NotFound, 219 + ErrorReason.MONITOR_NOT_FOUND, 220 + { "monitor-id": monitorId }, 221 + ); 222 + } 223 + 224 + /** 225 + * Creates a "subscriber not found" error. 226 + */ 227 + export function subscriberNotFoundError(identifier: string): ConnectError { 228 + return createError( 229 + "Subscriber not found", 230 + Code.NotFound, 231 + ErrorReason.SUBSCRIBER_NOT_FOUND, 232 + { identifier }, 233 + ); 234 + } 235 + 236 + /** 237 + * Creates a "failed to create subscriber" error. 238 + */ 239 + export function subscriberCreateFailedError(): ConnectError { 240 + return createError( 241 + "Failed to create subscriber", 242 + Code.Internal, 243 + ErrorReason.SUBSCRIBER_CREATE_FAILED, 244 + ); 245 + } 246 + 247 + /** 248 + * Creates an "identifier required" error. 249 + */ 250 + export function identifierRequiredError(): ConnectError { 251 + return createError( 252 + "Either email or token is required to identify the subscriber", 253 + Code.InvalidArgument, 254 + ErrorReason.IDENTIFIER_REQUIRED, 255 + ); 256 + }
+1093
apps/server/src/routes/rpc/services/status-page/index.ts
··· 1 + import type { ServiceImpl } from "@connectrpc/connect"; 2 + import { 3 + and, 4 + count, 5 + db, 6 + desc, 7 + eq, 8 + gte, 9 + inArray, 10 + isNull, 11 + lte, 12 + } from "@openstatus/db"; 13 + import { 14 + maintenance, 15 + maintenancesToPageComponents, 16 + monitor, 17 + page, 18 + pageComponent, 19 + pageComponentGroup, 20 + pageSubscriber, 21 + statusReport, 22 + statusReportUpdate, 23 + statusReportsToPageComponents, 24 + } from "@openstatus/db/src/schema"; 25 + import type { StatusPageService } from "@openstatus/proto/status_page/v1"; 26 + import { OverallStatus } from "@openstatus/proto/status_page/v1"; 27 + import { nanoid } from "nanoid"; 28 + 29 + import { getRpcContext } from "../../interceptors"; 30 + import { 31 + dbComponentToProto, 32 + dbGroupToProto, 33 + dbPageToProto, 34 + dbPageToProtoSummary, 35 + dbSubscriberToProto, 36 + } from "./converters"; 37 + import { 38 + componentGroupCreateFailedError, 39 + componentGroupNotFoundError, 40 + componentGroupUpdateFailedError, 41 + identifierRequiredError, 42 + monitorNotFoundError, 43 + pageComponentCreateFailedError, 44 + pageComponentNotFoundError, 45 + pageComponentUpdateFailedError, 46 + slugAlreadyExistsError, 47 + statusPageAccessDeniedError, 48 + statusPageCreateFailedError, 49 + statusPageIdRequiredError, 50 + statusPageNotFoundError, 51 + statusPageNotPublishedError, 52 + statusPageUpdateFailedError, 53 + subscriberCreateFailedError, 54 + subscriberNotFoundError, 55 + } from "./errors"; 56 + import { checkStatusPageLimits } from "./limits"; 57 + 58 + /** 59 + * Helper to get a status page by ID with workspace scope. 60 + */ 61 + async function getPageById(id: number, workspaceId: number) { 62 + return db 63 + .select() 64 + .from(page) 65 + .where(and(eq(page.id, id), eq(page.workspaceId, workspaceId))) 66 + .get(); 67 + } 68 + 69 + /** 70 + * Helper to get a status page by slug. 71 + * Normalizes the slug to lowercase before querying. 72 + */ 73 + async function getPageBySlug(slug: string) { 74 + const normalizedSlug = slug.toLowerCase(); 75 + return db.select().from(page).where(eq(page.slug, normalizedSlug)).get(); 76 + } 77 + 78 + /** 79 + * Validates public access to a status page. 80 + * Checks that the page is published and has public access type. 81 + * Throws appropriate errors if access is denied. 82 + */ 83 + function validatePublicAccess( 84 + pageData: { published: boolean | null; accessType: string | null }, 85 + slug: string, 86 + ): void { 87 + // Check if page is published 88 + if (!pageData.published) { 89 + throw statusPageNotPublishedError(slug); 90 + } 91 + 92 + // Check access type - only public pages are accessible without authentication 93 + if (pageData.accessType && pageData.accessType !== "public") { 94 + throw statusPageAccessDeniedError(slug, pageData.accessType); 95 + } 96 + } 97 + 98 + /** 99 + * Helper to get a component by ID with workspace scope. 100 + */ 101 + async function getComponentById(id: number, workspaceId: number) { 102 + return db 103 + .select() 104 + .from(pageComponent) 105 + .where( 106 + and(eq(pageComponent.id, id), eq(pageComponent.workspaceId, workspaceId)), 107 + ) 108 + .get(); 109 + } 110 + 111 + /** 112 + * Helper to get a component group by ID with workspace scope. 113 + */ 114 + async function getGroupById(id: number, workspaceId: number) { 115 + return db 116 + .select() 117 + .from(pageComponentGroup) 118 + .where( 119 + and( 120 + eq(pageComponentGroup.id, id), 121 + eq(pageComponentGroup.workspaceId, workspaceId), 122 + ), 123 + ) 124 + .get(); 125 + } 126 + 127 + /** 128 + * Helper to get a monitor by ID with workspace scope. 129 + */ 130 + async function getMonitorById(id: number, workspaceId: number) { 131 + return db 132 + .select() 133 + .from(monitor) 134 + .where(and(eq(monitor.id, id), eq(monitor.workspaceId, workspaceId))) 135 + .get(); 136 + } 137 + 138 + /** 139 + * Status page service implementation for ConnectRPC. 140 + */ 141 + export const statusPageServiceImpl: ServiceImpl<typeof StatusPageService> = { 142 + // ========================================================================== 143 + // Page CRUD 144 + // ========================================================================== 145 + 146 + async createStatusPage(req, ctx) { 147 + const rpcCtx = getRpcContext(ctx); 148 + const workspaceId = rpcCtx.workspace.id; 149 + const limits = rpcCtx.workspace.limits; 150 + 151 + // Check workspace limits for status pages 152 + await checkStatusPageLimits(workspaceId, limits); 153 + 154 + // Check if slug already exists 155 + const existingPage = await getPageBySlug(req.slug); 156 + if (existingPage) { 157 + throw slugAlreadyExistsError(req.slug); 158 + } 159 + 160 + // Create the status page 161 + const newPage = await db 162 + .insert(page) 163 + .values({ 164 + workspaceId, 165 + title: req.title, 166 + description: req.description ?? "", 167 + slug: req.slug, 168 + customDomain: "", 169 + published: false, 170 + homepageUrl: req.homepageUrl ?? null, 171 + contactUrl: req.contactUrl ?? null, 172 + }) 173 + .returning() 174 + .get(); 175 + 176 + if (!newPage) { 177 + throw statusPageCreateFailedError(); 178 + } 179 + 180 + return { 181 + statusPage: dbPageToProto(newPage), 182 + }; 183 + }, 184 + 185 + async getStatusPage(req, ctx) { 186 + const rpcCtx = getRpcContext(ctx); 187 + const workspaceId = rpcCtx.workspace.id; 188 + 189 + const id = req.id?.trim(); 190 + if (!id) { 191 + throw statusPageIdRequiredError(); 192 + } 193 + 194 + const pageData = await getPageById(Number(id), workspaceId); 195 + if (!pageData) { 196 + throw statusPageNotFoundError(id); 197 + } 198 + 199 + return { 200 + statusPage: dbPageToProto(pageData), 201 + }; 202 + }, 203 + 204 + async listStatusPages(req, ctx) { 205 + const rpcCtx = getRpcContext(ctx); 206 + const workspaceId = rpcCtx.workspace.id; 207 + 208 + const limit = Math.min(Math.max(req.limit ?? 50, 1), 100); 209 + const offset = req.offset ?? 0; 210 + 211 + // Get total count 212 + const countResult = await db 213 + .select({ count: count() }) 214 + .from(page) 215 + .where(eq(page.workspaceId, workspaceId)) 216 + .get(); 217 + 218 + const totalCount = countResult?.count ?? 0; 219 + 220 + // Get pages 221 + const pages = await db 222 + .select() 223 + .from(page) 224 + .where(eq(page.workspaceId, workspaceId)) 225 + .orderBy(desc(page.createdAt)) 226 + .limit(limit) 227 + .offset(offset) 228 + .all(); 229 + 230 + return { 231 + statusPages: pages.map(dbPageToProtoSummary), 232 + totalSize: totalCount, 233 + }; 234 + }, 235 + 236 + async updateStatusPage(req, ctx) { 237 + const rpcCtx = getRpcContext(ctx); 238 + const workspaceId = rpcCtx.workspace.id; 239 + 240 + const id = req.id?.trim(); 241 + if (!id) { 242 + throw statusPageIdRequiredError(); 243 + } 244 + 245 + const pageData = await getPageById(Number(id), workspaceId); 246 + if (!pageData) { 247 + throw statusPageNotFoundError(id); 248 + } 249 + 250 + // Check if new slug conflicts with another page 251 + if (req.slug && req.slug !== pageData.slug) { 252 + const existingPage = await getPageBySlug(req.slug); 253 + if (existingPage && existingPage.id !== pageData.id) { 254 + throw slugAlreadyExistsError(req.slug); 255 + } 256 + } 257 + 258 + // Build update values 259 + const updateValues: Record<string, unknown> = { 260 + updatedAt: new Date(), 261 + }; 262 + 263 + if (req.title !== undefined && req.title !== "") { 264 + updateValues.title = req.title; 265 + } 266 + if (req.description !== undefined) { 267 + updateValues.description = req.description; 268 + } 269 + if (req.slug !== undefined && req.slug !== "") { 270 + updateValues.slug = req.slug; 271 + } 272 + if (req.homepageUrl !== undefined) { 273 + updateValues.homepageUrl = req.homepageUrl || null; 274 + } 275 + if (req.contactUrl !== undefined) { 276 + updateValues.contactUrl = req.contactUrl || null; 277 + } 278 + 279 + const updatedPage = await db 280 + .update(page) 281 + .set(updateValues) 282 + .where(eq(page.id, pageData.id)) 283 + .returning() 284 + .get(); 285 + 286 + if (!updatedPage) { 287 + throw statusPageUpdateFailedError(req.id); 288 + } 289 + 290 + return { 291 + statusPage: dbPageToProto(updatedPage), 292 + }; 293 + }, 294 + 295 + async deleteStatusPage(req, ctx) { 296 + const rpcCtx = getRpcContext(ctx); 297 + const workspaceId = rpcCtx.workspace.id; 298 + 299 + const id = req.id?.trim(); 300 + if (!id) { 301 + throw statusPageIdRequiredError(); 302 + } 303 + 304 + const pageData = await getPageById(Number(id), workspaceId); 305 + if (!pageData) { 306 + throw statusPageNotFoundError(id); 307 + } 308 + 309 + // Delete the page (cascade will delete components, groups, subscribers) 310 + await db.delete(page).where(eq(page.id, pageData.id)); 311 + 312 + return { success: true }; 313 + }, 314 + 315 + // ========================================================================== 316 + // Component Management 317 + // ========================================================================== 318 + 319 + async addMonitorComponent(req, ctx) { 320 + const rpcCtx = getRpcContext(ctx); 321 + const workspaceId = rpcCtx.workspace.id; 322 + 323 + // Verify page exists and belongs to workspace 324 + const pageData = await getPageById(Number(req.pageId), workspaceId); 325 + if (!pageData) { 326 + throw statusPageNotFoundError(req.pageId); 327 + } 328 + 329 + // Verify monitor exists and belongs to workspace 330 + const monitorData = await getMonitorById( 331 + Number(req.monitorId), 332 + workspaceId, 333 + ); 334 + if (!monitorData) { 335 + throw monitorNotFoundError(req.monitorId); 336 + } 337 + 338 + // Validate group exists if provided 339 + if (req.groupId) { 340 + const group = await getGroupById(Number(req.groupId), workspaceId); 341 + if (!group) { 342 + throw componentGroupNotFoundError(req.groupId); 343 + } 344 + } 345 + 346 + // Create the component 347 + const newComponent = await db 348 + .insert(pageComponent) 349 + .values({ 350 + workspaceId, 351 + pageId: pageData.id, 352 + type: "monitor", 353 + monitorId: monitorData.id, 354 + name: req.name ?? monitorData.name, 355 + description: req.description ?? null, 356 + order: req.order ?? 0, 357 + groupId: req.groupId ? Number(req.groupId) : null, 358 + }) 359 + .returning() 360 + .get(); 361 + 362 + if (!newComponent) { 363 + throw pageComponentCreateFailedError(); 364 + } 365 + 366 + return { 367 + component: dbComponentToProto(newComponent), 368 + }; 369 + }, 370 + 371 + async addStaticComponent(req, ctx) { 372 + const rpcCtx = getRpcContext(ctx); 373 + const workspaceId = rpcCtx.workspace.id; 374 + 375 + // Verify page exists and belongs to workspace 376 + const pageData = await getPageById(Number(req.pageId), workspaceId); 377 + if (!pageData) { 378 + throw statusPageNotFoundError(req.pageId); 379 + } 380 + 381 + // Validate group exists if provided 382 + if (req.groupId) { 383 + const group = await getGroupById(Number(req.groupId), workspaceId); 384 + if (!group) { 385 + throw componentGroupNotFoundError(req.groupId); 386 + } 387 + } 388 + 389 + // Create the component 390 + const newComponent = await db 391 + .insert(pageComponent) 392 + .values({ 393 + workspaceId, 394 + pageId: pageData.id, 395 + type: "static", 396 + monitorId: null, 397 + name: req.name, 398 + description: req.description ?? null, 399 + order: req.order ?? 0, 400 + groupId: req.groupId ? Number(req.groupId) : null, 401 + }) 402 + .returning() 403 + .get(); 404 + 405 + if (!newComponent) { 406 + throw pageComponentCreateFailedError(); 407 + } 408 + 409 + return { 410 + component: dbComponentToProto(newComponent), 411 + }; 412 + }, 413 + 414 + async removeComponent(req, ctx) { 415 + const rpcCtx = getRpcContext(ctx); 416 + const workspaceId = rpcCtx.workspace.id; 417 + 418 + const id = req.id?.trim(); 419 + if (!id) { 420 + throw pageComponentNotFoundError(req.id); 421 + } 422 + 423 + const component = await getComponentById(Number(id), workspaceId); 424 + if (!component) { 425 + throw pageComponentNotFoundError(id); 426 + } 427 + 428 + // Delete the component 429 + await db.delete(pageComponent).where(eq(pageComponent.id, component.id)); 430 + 431 + return { success: true }; 432 + }, 433 + 434 + async updateComponent(req, ctx) { 435 + const rpcCtx = getRpcContext(ctx); 436 + const workspaceId = rpcCtx.workspace.id; 437 + 438 + const id = req.id?.trim(); 439 + if (!id) { 440 + throw pageComponentNotFoundError(req.id); 441 + } 442 + 443 + const component = await getComponentById(Number(id), workspaceId); 444 + if (!component) { 445 + throw pageComponentNotFoundError(id); 446 + } 447 + 448 + // Validate group exists if provided 449 + if (req.groupId !== undefined && req.groupId !== "") { 450 + const group = await getGroupById(Number(req.groupId), workspaceId); 451 + if (!group) { 452 + throw componentGroupNotFoundError(req.groupId); 453 + } 454 + } 455 + 456 + // Build update values 457 + const updateValues: Record<string, unknown> = { 458 + updatedAt: new Date(), 459 + }; 460 + 461 + if (req.name !== undefined && req.name !== "") { 462 + updateValues.name = req.name; 463 + } 464 + if (req.description !== undefined) { 465 + updateValues.description = req.description || null; 466 + } 467 + if (req.order !== undefined) { 468 + updateValues.order = req.order; 469 + } 470 + if (req.groupId !== undefined) { 471 + // Empty string means remove from group 472 + updateValues.groupId = req.groupId === "" ? null : Number(req.groupId); 473 + } 474 + if (req.groupOrder !== undefined) { 475 + updateValues.groupOrder = req.groupOrder; 476 + } 477 + 478 + const updatedComponent = await db 479 + .update(pageComponent) 480 + .set(updateValues) 481 + .where(eq(pageComponent.id, component.id)) 482 + .returning() 483 + .get(); 484 + 485 + if (!updatedComponent) { 486 + throw pageComponentUpdateFailedError(req.id); 487 + } 488 + 489 + return { 490 + component: dbComponentToProto(updatedComponent), 491 + }; 492 + }, 493 + 494 + // ========================================================================== 495 + // Component Groups 496 + // ========================================================================== 497 + 498 + async createComponentGroup(req, ctx) { 499 + const rpcCtx = getRpcContext(ctx); 500 + const workspaceId = rpcCtx.workspace.id; 501 + 502 + // Verify page exists and belongs to workspace 503 + const pageData = await getPageById(Number(req.pageId), workspaceId); 504 + if (!pageData) { 505 + throw statusPageNotFoundError(req.pageId); 506 + } 507 + 508 + // Create the group 509 + const newGroup = await db 510 + .insert(pageComponentGroup) 511 + .values({ 512 + workspaceId, 513 + pageId: pageData.id, 514 + name: req.name, 515 + }) 516 + .returning() 517 + .get(); 518 + 519 + if (!newGroup) { 520 + throw componentGroupCreateFailedError(); 521 + } 522 + 523 + return { 524 + group: dbGroupToProto(newGroup), 525 + }; 526 + }, 527 + 528 + async deleteComponentGroup(req, ctx) { 529 + const rpcCtx = getRpcContext(ctx); 530 + const workspaceId = rpcCtx.workspace.id; 531 + 532 + const id = req.id?.trim(); 533 + if (!id) { 534 + throw componentGroupNotFoundError(req.id); 535 + } 536 + 537 + const group = await getGroupById(Number(id), workspaceId); 538 + if (!group) { 539 + throw componentGroupNotFoundError(id); 540 + } 541 + 542 + // Delete the group (components will have groupId set to null due to FK constraint) 543 + await db 544 + .delete(pageComponentGroup) 545 + .where(eq(pageComponentGroup.id, group.id)); 546 + 547 + return { success: true }; 548 + }, 549 + 550 + async updateComponentGroup(req, ctx) { 551 + const rpcCtx = getRpcContext(ctx); 552 + const workspaceId = rpcCtx.workspace.id; 553 + 554 + const id = req.id?.trim(); 555 + if (!id) { 556 + throw componentGroupNotFoundError(req.id); 557 + } 558 + 559 + const group = await getGroupById(Number(id), workspaceId); 560 + if (!group) { 561 + throw componentGroupNotFoundError(id); 562 + } 563 + 564 + // Build update values 565 + const updateValues: Record<string, unknown> = { 566 + updatedAt: new Date(), 567 + }; 568 + 569 + if (req.name !== undefined && req.name !== "") { 570 + updateValues.name = req.name; 571 + } 572 + 573 + const updatedGroup = await db 574 + .update(pageComponentGroup) 575 + .set(updateValues) 576 + .where(eq(pageComponentGroup.id, group.id)) 577 + .returning() 578 + .get(); 579 + 580 + if (!updatedGroup) { 581 + throw componentGroupUpdateFailedError(req.id); 582 + } 583 + 584 + return { 585 + group: dbGroupToProto(updatedGroup), 586 + }; 587 + }, 588 + 589 + // ========================================================================== 590 + // Subscribers 591 + // ========================================================================== 592 + 593 + async subscribeToPage(req, ctx) { 594 + const rpcCtx = getRpcContext(ctx); 595 + const workspaceId = rpcCtx.workspace.id; 596 + 597 + // Verify page exists and belongs to workspace 598 + const pageData = await getPageById(Number(req.pageId), workspaceId); 599 + if (!pageData) { 600 + throw statusPageNotFoundError(req.pageId); 601 + } 602 + 603 + // Check if already subscribed 604 + const existingSubscriber = await db 605 + .select() 606 + .from(pageSubscriber) 607 + .where( 608 + and( 609 + eq(pageSubscriber.pageId, pageData.id), 610 + eq(pageSubscriber.email, req.email), 611 + ), 612 + ) 613 + .get(); 614 + 615 + if (existingSubscriber) { 616 + // If unsubscribed, resubscribe within a transaction to ensure atomicity 617 + if (existingSubscriber.unsubscribedAt) { 618 + const updatedSubscriber = await db.transaction(async (tx) => { 619 + const result = await tx 620 + .update(pageSubscriber) 621 + .set({ 622 + unsubscribedAt: null, 623 + updatedAt: new Date(), 624 + token: nanoid(), 625 + }) 626 + .where(eq(pageSubscriber.id, existingSubscriber.id)) 627 + .returning() 628 + .get(); 629 + 630 + if (!result) { 631 + throw subscriberCreateFailedError(); 632 + } 633 + 634 + return result; 635 + }); 636 + 637 + return { 638 + subscriber: dbSubscriberToProto(updatedSubscriber), 639 + }; 640 + } 641 + 642 + // Already subscribed, return existing 643 + return { 644 + subscriber: dbSubscriberToProto(existingSubscriber), 645 + }; 646 + } 647 + 648 + // Create new subscriber 649 + const newSubscriber = await db 650 + .insert(pageSubscriber) 651 + .values({ 652 + pageId: pageData.id, 653 + email: req.email, 654 + token: nanoid(), 655 + }) 656 + .returning() 657 + .get(); 658 + 659 + if (!newSubscriber) { 660 + throw subscriberCreateFailedError(); 661 + } 662 + 663 + return { 664 + subscriber: dbSubscriberToProto(newSubscriber), 665 + }; 666 + }, 667 + 668 + async unsubscribeFromPage(req, ctx) { 669 + const rpcCtx = getRpcContext(ctx); 670 + const workspaceId = rpcCtx.workspace.id; 671 + 672 + // Verify page exists and belongs to workspace 673 + const pageData = await getPageById(Number(req.pageId), workspaceId); 674 + if (!pageData) { 675 + throw statusPageNotFoundError(req.pageId); 676 + } 677 + 678 + // Find subscriber based on identifier type 679 + if (req.identifier.case === "email") { 680 + const subscriber = await db 681 + .select() 682 + .from(pageSubscriber) 683 + .where( 684 + and( 685 + eq(pageSubscriber.pageId, pageData.id), 686 + eq(pageSubscriber.email, req.identifier.value), 687 + ), 688 + ) 689 + .get(); 690 + 691 + if (!subscriber) { 692 + throw subscriberNotFoundError(req.identifier.value); 693 + } 694 + 695 + await db 696 + .update(pageSubscriber) 697 + .set({ unsubscribedAt: new Date(), updatedAt: new Date() }) 698 + .where(eq(pageSubscriber.id, subscriber.id)); 699 + 700 + return { success: true }; 701 + } 702 + 703 + if (req.identifier.case === "id") { 704 + const subscriber = await db 705 + .select() 706 + .from(pageSubscriber) 707 + .where( 708 + and( 709 + eq(pageSubscriber.pageId, pageData.id), 710 + eq(pageSubscriber.id, Number(req.identifier.value)), 711 + ), 712 + ) 713 + .get(); 714 + 715 + if (!subscriber) { 716 + throw subscriberNotFoundError(req.identifier.value); 717 + } 718 + 719 + await db 720 + .update(pageSubscriber) 721 + .set({ unsubscribedAt: new Date(), updatedAt: new Date() }) 722 + .where(eq(pageSubscriber.id, subscriber.id)); 723 + 724 + return { success: true }; 725 + } 726 + 727 + throw identifierRequiredError(); 728 + }, 729 + 730 + async listSubscribers(req, ctx) { 731 + const rpcCtx = getRpcContext(ctx); 732 + const workspaceId = rpcCtx.workspace.id; 733 + 734 + // Verify page exists and belongs to workspace 735 + const pageData = await getPageById(Number(req.pageId), workspaceId); 736 + if (!pageData) { 737 + throw statusPageNotFoundError(req.pageId); 738 + } 739 + 740 + const limit = Math.min(Math.max(req.limit ?? 50, 1), 100); 741 + const offset = req.offset ?? 0; 742 + 743 + // Build conditions 744 + const conditions = [eq(pageSubscriber.pageId, pageData.id)]; 745 + if (!req.includeUnsubscribed) { 746 + conditions.push(isNull(pageSubscriber.unsubscribedAt)); 747 + } 748 + 749 + // Get total count 750 + const countResult = await db 751 + .select({ count: count() }) 752 + .from(pageSubscriber) 753 + .where(and(...conditions)) 754 + .get(); 755 + 756 + const totalCount = countResult?.count ?? 0; 757 + 758 + // Get subscribers 759 + const subscribers = await db 760 + .select() 761 + .from(pageSubscriber) 762 + .where(and(...conditions)) 763 + .orderBy(desc(pageSubscriber.createdAt)) 764 + .limit(limit) 765 + .offset(offset) 766 + .all(); 767 + 768 + return { 769 + subscribers: subscribers.map(dbSubscriberToProto), 770 + totalSize: totalCount, 771 + }; 772 + }, 773 + 774 + // ========================================================================== 775 + // Full Content & Status 776 + // ========================================================================== 777 + 778 + async getStatusPageContent(req, ctx) { 779 + // Note: This endpoint may be used publicly, so we need to handle 780 + // the case where we look up by slug without workspace scope 781 + type PageData = Awaited<ReturnType<typeof getPageById>>; 782 + let pageData: PageData; 783 + let identifierValue: string; 784 + let isPublicAccess = false; 785 + 786 + if (req.identifier.case === "id") { 787 + const rpcCtx = getRpcContext(ctx); 788 + const workspaceId = rpcCtx.workspace.id; 789 + identifierValue = req.identifier.value; 790 + pageData = await getPageById(Number(identifierValue), workspaceId); 791 + } else if (req.identifier.case === "slug") { 792 + identifierValue = req.identifier.value; 793 + pageData = await getPageBySlug(identifierValue); 794 + isPublicAccess = true; 795 + } else { 796 + throw statusPageIdRequiredError(); 797 + } 798 + 799 + if (!pageData) { 800 + throw statusPageNotFoundError(identifierValue); 801 + } 802 + 803 + // Access control differs based on how the page is accessed: 804 + // - By slug (public): Validates page is published and publicly accessible 805 + // - By ID (workspace): Allows workspace members to preview unpublished pages 806 + if (isPublicAccess) { 807 + validatePublicAccess(pageData, identifierValue); 808 + } 809 + 810 + // Get components 811 + const components = await db 812 + .select() 813 + .from(pageComponent) 814 + .where(eq(pageComponent.pageId, pageData.id)) 815 + .orderBy(pageComponent.order) 816 + .all(); 817 + 818 + // Get groups 819 + const groups = await db 820 + .select() 821 + .from(pageComponentGroup) 822 + .where(eq(pageComponentGroup.pageId, pageData.id)) 823 + .all(); 824 + 825 + // Get active status reports (not resolved) 826 + const activeReports = await db 827 + .select() 828 + .from(statusReport) 829 + .where( 830 + and( 831 + eq(statusReport.pageId, pageData.id), 832 + inArray(statusReport.status, [ 833 + "investigating", 834 + "identified", 835 + "monitoring", 836 + ]), 837 + ), 838 + ) 839 + .orderBy(desc(statusReport.createdAt)) 840 + .all(); 841 + 842 + // Get status report updates for active reports 843 + const reportIds = activeReports.map((r) => r.id); 844 + const reportUpdates = 845 + reportIds.length > 0 846 + ? await db 847 + .select() 848 + .from(statusReportUpdate) 849 + .where(inArray(statusReportUpdate.statusReportId, reportIds)) 850 + .orderBy(desc(statusReportUpdate.date)) 851 + .all() 852 + : []; 853 + 854 + // Get page component IDs for each report 855 + const reportComponents = 856 + reportIds.length > 0 857 + ? await db 858 + .select() 859 + .from(statusReportsToPageComponents) 860 + .where( 861 + inArray(statusReportsToPageComponents.statusReportId, reportIds), 862 + ) 863 + .all() 864 + : []; 865 + 866 + // Import the converter from status-report service 867 + const { dbStatusToProto } = await import("../status-report/converters"); 868 + 869 + // Convert reports to proto format 870 + const statusReports = activeReports.map((report) => { 871 + const updates = reportUpdates.filter( 872 + (u) => u.statusReportId === report.id, 873 + ); 874 + const componentIds = reportComponents 875 + .filter((rc) => rc.statusReportId === report.id) 876 + .map((rc) => String(rc.pageComponentId)); 877 + 878 + return { 879 + $typeName: "openstatus.status_report.v1.StatusReport" as const, 880 + id: String(report.id), 881 + status: dbStatusToProto(report.status), 882 + title: report.title, 883 + pageComponentIds: componentIds, 884 + updates: updates.map((u) => ({ 885 + $typeName: "openstatus.status_report.v1.StatusReportUpdate" as const, 886 + id: String(u.id), 887 + status: dbStatusToProto(u.status), 888 + date: u.date.toISOString(), 889 + message: u.message, 890 + createdAt: u.createdAt?.toISOString() ?? "", 891 + })), 892 + createdAt: report.createdAt?.toISOString() ?? "", 893 + updatedAt: report.updatedAt?.toISOString() ?? "", 894 + }; 895 + }); 896 + 897 + // Get maintenances for the page (upcoming and recent) 898 + const pageMaintenances = await db 899 + .select() 900 + .from(maintenance) 901 + .where(eq(maintenance.pageId, pageData.id)) 902 + .orderBy(desc(maintenance.from)) 903 + .all(); 904 + 905 + // Get component associations for maintenances 906 + const maintenanceIds = pageMaintenances.map((m) => m.id); 907 + const maintenanceComponents = 908 + maintenanceIds.length > 0 909 + ? await db 910 + .select() 911 + .from(maintenancesToPageComponents) 912 + .where( 913 + inArray( 914 + maintenancesToPageComponents.maintenanceId, 915 + maintenanceIds, 916 + ), 917 + ) 918 + .all() 919 + : []; 920 + 921 + // Convert maintenances to proto format 922 + const maintenancesProto = pageMaintenances.map((m) => { 923 + const componentIds = maintenanceComponents 924 + .filter((mc) => mc.maintenanceId === m.id) 925 + .map((mc) => String(mc.pageComponentId)); 926 + 927 + return { 928 + $typeName: "openstatus.status_page.v1.Maintenance" as const, 929 + id: String(m.id), 930 + title: m.title, 931 + message: m.message, 932 + from: m.from.toISOString(), 933 + to: m.to.toISOString(), 934 + pageComponentIds: componentIds, 935 + createdAt: m.createdAt?.toISOString() ?? "", 936 + updatedAt: m.updatedAt?.toISOString() ?? "", 937 + }; 938 + }); 939 + 940 + return { 941 + statusPage: dbPageToProto(pageData), 942 + components: components.map(dbComponentToProto), 943 + groups: groups.map(dbGroupToProto), 944 + statusReports, 945 + maintenances: maintenancesProto, 946 + }; 947 + }, 948 + 949 + async getOverallStatus(req, ctx) { 950 + type PageData = Awaited<ReturnType<typeof getPageById>>; 951 + let pageData: PageData; 952 + let identifierValue: string; 953 + let isPublicAccess = false; 954 + 955 + if (req.identifier.case === "id") { 956 + const rpcCtx = getRpcContext(ctx); 957 + const workspaceId = rpcCtx.workspace.id; 958 + identifierValue = req.identifier.value; 959 + pageData = await getPageById(Number(identifierValue), workspaceId); 960 + } else if (req.identifier.case === "slug") { 961 + identifierValue = req.identifier.value; 962 + pageData = await getPageBySlug(identifierValue); 963 + isPublicAccess = true; 964 + } else { 965 + throw statusPageIdRequiredError(); 966 + } 967 + 968 + if (!pageData) { 969 + throw statusPageNotFoundError(identifierValue); 970 + } 971 + 972 + // Access control differs based on how the page is accessed: 973 + // - By slug (public): Validates page is published and publicly accessible 974 + // - By ID (workspace): Allows workspace members to preview unpublished pages 975 + if (isPublicAccess) { 976 + validatePublicAccess(pageData, identifierValue); 977 + } 978 + 979 + // Get components 980 + const components = await db 981 + .select() 982 + .from(pageComponent) 983 + .where(eq(pageComponent.pageId, pageData.id)) 984 + .all(); 985 + 986 + const componentIds = components.map((c) => c.id); 987 + const now = new Date(); 988 + 989 + // Check for active status reports (degraded state) 990 + let hasActiveStatusReport = false; 991 + const componentReportStatus = new Map<number, boolean>(); 992 + 993 + if (componentIds.length > 0) { 994 + const activeReports = await db 995 + .select({ 996 + componentId: statusReportsToPageComponents.pageComponentId, 997 + }) 998 + .from(statusReportsToPageComponents) 999 + .innerJoin( 1000 + statusReport, 1001 + eq(statusReportsToPageComponents.statusReportId, statusReport.id), 1002 + ) 1003 + .where( 1004 + and( 1005 + inArray( 1006 + statusReportsToPageComponents.pageComponentId, 1007 + componentIds, 1008 + ), 1009 + inArray(statusReport.status, [ 1010 + "investigating", 1011 + "identified", 1012 + "monitoring", 1013 + ]), 1014 + ), 1015 + ) 1016 + .all(); 1017 + 1018 + hasActiveStatusReport = activeReports.length > 0; 1019 + 1020 + // Track which components have active reports 1021 + for (const report of activeReports) { 1022 + componentReportStatus.set(report.componentId, true); 1023 + } 1024 + } 1025 + 1026 + // Check for active maintenances (info state - current time between from and to) 1027 + let hasActiveMaintenance = false; 1028 + const componentMaintenanceStatus = new Map<number, boolean>(); 1029 + 1030 + const activeMaintenances = await db 1031 + .select() 1032 + .from(maintenance) 1033 + .where( 1034 + and( 1035 + eq(maintenance.pageId, pageData.id), 1036 + lte(maintenance.from, now), 1037 + gte(maintenance.to, now), 1038 + ), 1039 + ) 1040 + .all(); 1041 + 1042 + hasActiveMaintenance = activeMaintenances.length > 0; 1043 + 1044 + // Get component associations for active maintenances 1045 + if (activeMaintenances.length > 0) { 1046 + const maintenanceIds = activeMaintenances.map((m) => m.id); 1047 + const maintenanceComponentAssocs = await db 1048 + .select() 1049 + .from(maintenancesToPageComponents) 1050 + .where( 1051 + inArray(maintenancesToPageComponents.maintenanceId, maintenanceIds), 1052 + ) 1053 + .all(); 1054 + 1055 + // Track which components are under maintenance 1056 + for (const assoc of maintenanceComponentAssocs) { 1057 + componentMaintenanceStatus.set(assoc.pageComponentId, true); 1058 + } 1059 + } 1060 + 1061 + // Determine overall status based on priority: degraded > maintenance > operational 1062 + // Note: In the existing codebase, status reports indicate "degraded" state 1063 + // and maintenances indicate "info/maintenance" state 1064 + const overallStatus = hasActiveStatusReport 1065 + ? OverallStatus.DEGRADED 1066 + : hasActiveMaintenance 1067 + ? OverallStatus.MAINTENANCE 1068 + : OverallStatus.OPERATIONAL; 1069 + 1070 + // Build component statuses based on their individual state 1071 + const componentStatuses = components.map((c) => { 1072 + const hasReport = componentReportStatus.get(c.id) ?? false; 1073 + const hasMaintenance = componentMaintenanceStatus.get(c.id) ?? false; 1074 + 1075 + const status = hasReport 1076 + ? OverallStatus.DEGRADED 1077 + : hasMaintenance 1078 + ? OverallStatus.MAINTENANCE 1079 + : OverallStatus.OPERATIONAL; 1080 + 1081 + return { 1082 + $typeName: "openstatus.status_page.v1.ComponentStatus" as const, 1083 + componentId: String(c.id), 1084 + status, 1085 + }; 1086 + }); 1087 + 1088 + return { 1089 + overallStatus, 1090 + componentStatuses, 1091 + }; 1092 + }, 1093 + };
+64
apps/server/src/routes/rpc/services/status-page/limits.ts
··· 1 + import { Code, ConnectError } from "@connectrpc/connect"; 2 + import { count, db, eq } from "@openstatus/db"; 3 + import { page } from "@openstatus/db/src/schema"; 4 + import type { Limits } from "@openstatus/db/src/schema/plan/schema"; 5 + 6 + /** 7 + * Check workspace limits for creating a new status page. 8 + * Throws ConnectError with PermissionDenied if limit is exceeded. 9 + */ 10 + export async function checkStatusPageLimits( 11 + workspaceId: number, 12 + limits: Limits, 13 + ): Promise<void> { 14 + // Check status page count limit 15 + const countResult = await db 16 + .select({ count: count() }) 17 + .from(page) 18 + .where(eq(page.workspaceId, workspaceId)) 19 + .get(); 20 + 21 + const currentCount = countResult?.count ?? 0; 22 + if (currentCount >= limits["status-pages"]) { 23 + throw new ConnectError( 24 + "Upgrade for more status pages", 25 + Code.PermissionDenied, 26 + ); 27 + } 28 + } 29 + 30 + /** 31 + * Check if custom domain feature is available on the workspace plan. 32 + * Throws ConnectError with PermissionDenied if not available. 33 + */ 34 + export function checkCustomDomainLimit(limits: Limits): void { 35 + if (!limits["custom-domain"]) { 36 + throw new ConnectError("Upgrade for custom domains", Code.PermissionDenied); 37 + } 38 + } 39 + 40 + /** 41 + * Check if password protection feature is available on the workspace plan. 42 + * Throws ConnectError with PermissionDenied if not available. 43 + */ 44 + export function checkPasswordProtectionLimit(limits: Limits): void { 45 + if (!limits["password-protection"]) { 46 + throw new ConnectError( 47 + "Upgrade for password protection", 48 + Code.PermissionDenied, 49 + ); 50 + } 51 + } 52 + 53 + /** 54 + * Check if email domain protection feature is available on the workspace plan. 55 + * Throws ConnectError with PermissionDenied if not available. 56 + */ 57 + export function checkEmailDomainProtectionLimit(limits: Limits): void { 58 + if (!limits["email-domain-protection"]) { 59 + throw new ConnectError( 60 + "Upgrade for email domain protection", 61 + Code.PermissionDenied, 62 + ); 63 + } 64 + }
+66
packages/proto/api/openstatus/status_page/v1/page_component.proto
··· 1 + syntax = "proto3"; 2 + 3 + package openstatus.status_page.v1; 4 + 5 + option go_package = "github.com/openstatushq/openstatus/packages/proto/openstatus/status_page/v1;statuspagev1"; 6 + 7 + // PageComponentType defines the type of a component on a status page. 8 + enum PageComponentType { 9 + PAGE_COMPONENT_TYPE_UNSPECIFIED = 0; 10 + PAGE_COMPONENT_TYPE_MONITOR = 1; 11 + PAGE_COMPONENT_TYPE_STATIC = 2; 12 + } 13 + 14 + // PageComponent represents a component displayed on a status page. 15 + message PageComponent { 16 + // Unique identifier for the component. 17 + string id = 1; 18 + 19 + // ID of the status page this component belongs to. 20 + string page_id = 2; 21 + 22 + // Display name of the component. 23 + string name = 3; 24 + 25 + // Description of the component (optional). 26 + string description = 4; 27 + 28 + // Type of the component (monitor or static). 29 + PageComponentType type = 5; 30 + 31 + // ID of the monitor if type is MONITOR (optional). 32 + string monitor_id = 6; 33 + 34 + // Display order of the component. 35 + int32 order = 7; 36 + 37 + // ID of the group this component belongs to (optional). 38 + string group_id = 8; 39 + 40 + // Order within the group if grouped. 41 + int32 group_order = 9; 42 + 43 + // Timestamp when the component was created (RFC 3339 format). 44 + string created_at = 10; 45 + 46 + // Timestamp when the component was last updated (RFC 3339 format). 47 + string updated_at = 11; 48 + } 49 + 50 + // PageComponentGroup represents a group of components on a status page. 51 + message PageComponentGroup { 52 + // Unique identifier for the group. 53 + string id = 1; 54 + 55 + // ID of the status page this group belongs to. 56 + string page_id = 2; 57 + 58 + // Display name of the group. 59 + string name = 3; 60 + 61 + // Timestamp when the group was created (RFC 3339 format). 62 + string created_at = 4; 63 + 64 + // Timestamp when the group was last updated (RFC 3339 format). 65 + string updated_at = 5; 66 + }
+29
packages/proto/api/openstatus/status_page/v1/page_subscriber.proto
··· 1 + syntax = "proto3"; 2 + 3 + package openstatus.status_page.v1; 4 + 5 + option go_package = "github.com/openstatushq/openstatus/packages/proto/openstatus/status_page/v1;statuspagev1"; 6 + 7 + // PageSubscriber represents a subscriber to a status page. 8 + message PageSubscriber { 9 + // Unique identifier for the subscriber. 10 + string id = 1; 11 + 12 + // ID of the status page the user is subscribed to. 13 + string page_id = 2; 14 + 15 + // Email address of the subscriber. 16 + string email = 3; 17 + 18 + // Timestamp when the subscription was accepted/confirmed (RFC 3339 format, optional). 19 + string accepted_at = 4; 20 + 21 + // Timestamp when the user unsubscribed (RFC 3339 format, optional). 22 + string unsubscribed_at = 5; 23 + 24 + // Timestamp when the subscription was created (RFC 3339 format). 25 + string created_at = 6; 26 + 27 + // Timestamp when the subscription was last updated (RFC 3339 format). 28 + string updated_at = 7; 29 + }
+466
packages/proto/api/openstatus/status_page/v1/service.proto
··· 1 + syntax = "proto3"; 2 + 3 + package openstatus.status_page.v1; 4 + 5 + import "buf/validate/validate.proto"; 6 + import "openstatus/status_page/v1/page_component.proto"; 7 + import "openstatus/status_page/v1/page_subscriber.proto"; 8 + import "openstatus/status_page/v1/status_page.proto"; 9 + import "openstatus/status_report/v1/status_report.proto"; 10 + 11 + option go_package = "github.com/openstatushq/openstatus/packages/proto/openstatus/status_page/v1;statuspagev1"; 12 + 13 + // StatusPageService provides CRUD and management operations for status pages. 14 + service StatusPageService { 15 + // --- Page CRUD --- 16 + 17 + // CreateStatusPage creates a new status page. 18 + rpc CreateStatusPage(CreateStatusPageRequest) returns (CreateStatusPageResponse); 19 + 20 + // GetStatusPage retrieves a specific status page by ID. 21 + rpc GetStatusPage(GetStatusPageRequest) returns (GetStatusPageResponse); 22 + 23 + // ListStatusPages returns all status pages for the workspace. 24 + rpc ListStatusPages(ListStatusPagesRequest) returns (ListStatusPagesResponse); 25 + 26 + // UpdateStatusPage updates an existing status page. 27 + rpc UpdateStatusPage(UpdateStatusPageRequest) returns (UpdateStatusPageResponse); 28 + 29 + // DeleteStatusPage removes a status page. 30 + rpc DeleteStatusPage(DeleteStatusPageRequest) returns (DeleteStatusPageResponse); 31 + 32 + // --- Component Management --- 33 + 34 + // AddMonitorComponent adds a monitor-based component to a status page. 35 + rpc AddMonitorComponent(AddMonitorComponentRequest) returns (AddMonitorComponentResponse); 36 + 37 + // AddStaticComponent adds a static component to a status page. 38 + rpc AddStaticComponent(AddStaticComponentRequest) returns (AddStaticComponentResponse); 39 + 40 + // RemoveComponent removes a component from a status page. 41 + rpc RemoveComponent(RemoveComponentRequest) returns (RemoveComponentResponse); 42 + 43 + // UpdateComponent updates an existing component. 44 + rpc UpdateComponent(UpdateComponentRequest) returns (UpdateComponentResponse); 45 + 46 + // --- Component Groups --- 47 + 48 + // CreateComponentGroup creates a new component group. 49 + rpc CreateComponentGroup(CreateComponentGroupRequest) returns (CreateComponentGroupResponse); 50 + 51 + // DeleteComponentGroup removes a component group. 52 + rpc DeleteComponentGroup(DeleteComponentGroupRequest) returns (DeleteComponentGroupResponse); 53 + 54 + // UpdateComponentGroup updates an existing component group. 55 + rpc UpdateComponentGroup(UpdateComponentGroupRequest) returns (UpdateComponentGroupResponse); 56 + 57 + // --- Subscribers --- 58 + 59 + // SubscribeToPage subscribes an email to a status page. 60 + rpc SubscribeToPage(SubscribeToPageRequest) returns (SubscribeToPageResponse); 61 + 62 + // UnsubscribeFromPage removes a subscription from a status page. 63 + rpc UnsubscribeFromPage(UnsubscribeFromPageRequest) returns (UnsubscribeFromPageResponse); 64 + 65 + // ListSubscribers returns all subscribers for a status page. 66 + rpc ListSubscribers(ListSubscribersRequest) returns (ListSubscribersResponse); 67 + 68 + // --- Full Content & Status --- 69 + 70 + // GetStatusPageContent retrieves the full content of a status page including components and reports. 71 + rpc GetStatusPageContent(GetStatusPageContentRequest) returns (GetStatusPageContentResponse); 72 + 73 + // GetOverallStatus returns the aggregated status of a status page. 74 + rpc GetOverallStatus(GetOverallStatusRequest) returns (GetOverallStatusResponse); 75 + } 76 + 77 + // ============================================================================= 78 + // Page CRUD Messages 79 + // ============================================================================= 80 + 81 + // --- Create Status Page --- 82 + 83 + message CreateStatusPageRequest { 84 + // Title of the status page (required). 85 + string title = 1 [ 86 + (buf.validate.field).string.min_len = 1, 87 + (buf.validate.field).string.max_len = 256 88 + ]; 89 + 90 + // Description of the status page (optional). 91 + optional string description = 2 [(buf.validate.field).string.max_len = 1024]; 92 + 93 + // URL-friendly slug for the status page (required). 94 + string slug = 3 [ 95 + (buf.validate.field).string.min_len = 1, 96 + (buf.validate.field).string.max_len = 256, 97 + (buf.validate.field).string.pattern = "^[a-z0-9]+(?:-[a-z0-9]+)*$" 98 + ]; 99 + 100 + // URL to the homepage (optional). 101 + optional string homepage_url = 4; 102 + 103 + // URL to the contact page (optional). 104 + optional string contact_url = 5; 105 + } 106 + 107 + message CreateStatusPageResponse { 108 + // The created status page. 109 + StatusPage status_page = 1; 110 + } 111 + 112 + // --- Get Status Page --- 113 + 114 + message GetStatusPageRequest { 115 + // ID of the status page to retrieve (required). 116 + string id = 1 [(buf.validate.field).string.min_len = 1]; 117 + } 118 + 119 + message GetStatusPageResponse { 120 + // The requested status page. 121 + StatusPage status_page = 1; 122 + } 123 + 124 + // --- List Status Pages --- 125 + 126 + message ListStatusPagesRequest { 127 + // Maximum number of pages to return (1-100, defaults to 50). 128 + optional int32 limit = 1 [(buf.validate.field).int32 = { 129 + gte: 1 130 + lte: 100 131 + }]; 132 + 133 + // Number of pages to skip for pagination (defaults to 0). 134 + optional int32 offset = 2 [(buf.validate.field).int32.gte = 0]; 135 + } 136 + 137 + message ListStatusPagesResponse { 138 + // List of status pages (metadata only). 139 + repeated StatusPageSummary status_pages = 1; 140 + 141 + // Total number of status pages. 142 + int32 total_size = 2; 143 + } 144 + 145 + // --- Update Status Page --- 146 + 147 + message UpdateStatusPageRequest { 148 + // ID of the status page to update (required). 149 + string id = 1 [(buf.validate.field).string.min_len = 1]; 150 + 151 + // New title for the status page (optional). 152 + optional string title = 2 [(buf.validate.field).string = { 153 + min_len: 1 154 + max_len: 256 155 + }]; 156 + 157 + // New description for the status page (optional). 158 + optional string description = 3 [(buf.validate.field).string.max_len = 1024]; 159 + 160 + // New slug for the status page (optional). 161 + optional string slug = 4 [(buf.validate.field).string = { 162 + min_len: 1 163 + max_len: 256 164 + pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$" 165 + }]; 166 + 167 + // New homepage URL (optional). 168 + optional string homepage_url = 5; 169 + 170 + // New contact URL (optional). 171 + optional string contact_url = 6; 172 + } 173 + 174 + message UpdateStatusPageResponse { 175 + // The updated status page. 176 + StatusPage status_page = 1; 177 + } 178 + 179 + // --- Delete Status Page --- 180 + 181 + message DeleteStatusPageRequest { 182 + // ID of the status page to delete (required). 183 + string id = 1 [(buf.validate.field).string.min_len = 1]; 184 + } 185 + 186 + message DeleteStatusPageResponse { 187 + // Whether the deletion was successful. 188 + bool success = 1; 189 + } 190 + 191 + // ============================================================================= 192 + // Component Management Messages 193 + // ============================================================================= 194 + 195 + // --- Add Monitor Component --- 196 + 197 + message AddMonitorComponentRequest { 198 + // ID of the status page to add the component to (required). 199 + string page_id = 1 [(buf.validate.field).string.min_len = 1]; 200 + 201 + // ID of the monitor to associate with this component (required). 202 + string monitor_id = 2 [(buf.validate.field).string.min_len = 1]; 203 + 204 + // Display name for the component (optional, defaults to monitor name). 205 + optional string name = 3 [(buf.validate.field).string.max_len = 256]; 206 + 207 + // Description of the component (optional). 208 + optional string description = 4 [(buf.validate.field).string.max_len = 1024]; 209 + 210 + // Display order of the component (optional). 211 + optional int32 order = 5; 212 + 213 + // ID of the group to add this component to (optional). 214 + optional string group_id = 6; 215 + } 216 + 217 + message AddMonitorComponentResponse { 218 + // The created component. 219 + PageComponent component = 1; 220 + } 221 + 222 + // --- Add Static Component --- 223 + 224 + message AddStaticComponentRequest { 225 + // ID of the status page to add the component to (required). 226 + string page_id = 1 [(buf.validate.field).string.min_len = 1]; 227 + 228 + // Display name for the component (required). 229 + string name = 2 [ 230 + (buf.validate.field).string.min_len = 1, 231 + (buf.validate.field).string.max_len = 256 232 + ]; 233 + 234 + // Description of the component (optional). 235 + optional string description = 3 [(buf.validate.field).string.max_len = 1024]; 236 + 237 + // Display order of the component (optional). 238 + optional int32 order = 4; 239 + 240 + // ID of the group to add this component to (optional). 241 + optional string group_id = 5; 242 + } 243 + 244 + message AddStaticComponentResponse { 245 + // The created component. 246 + PageComponent component = 1; 247 + } 248 + 249 + // --- Remove Component --- 250 + 251 + message RemoveComponentRequest { 252 + // ID of the component to remove (required). 253 + string id = 1 [(buf.validate.field).string.min_len = 1]; 254 + } 255 + 256 + message RemoveComponentResponse { 257 + // Whether the removal was successful. 258 + bool success = 1; 259 + } 260 + 261 + // --- Update Component --- 262 + 263 + message UpdateComponentRequest { 264 + // ID of the component to update (required). 265 + string id = 1 [(buf.validate.field).string.min_len = 1]; 266 + 267 + // New display name for the component (optional). 268 + optional string name = 2 [(buf.validate.field).string.max_len = 256]; 269 + 270 + // New description for the component (optional). 271 + optional string description = 3 [(buf.validate.field).string.max_len = 1024]; 272 + 273 + // New display order (optional). 274 + optional int32 order = 4; 275 + 276 + // New group ID (optional, set to empty string to remove from group). 277 + optional string group_id = 5; 278 + 279 + // New order within the group (optional). 280 + optional int32 group_order = 6; 281 + } 282 + 283 + message UpdateComponentResponse { 284 + // The updated component. 285 + PageComponent component = 1; 286 + } 287 + 288 + // ============================================================================= 289 + // Component Group Messages 290 + // ============================================================================= 291 + 292 + // --- Create Component Group --- 293 + 294 + message CreateComponentGroupRequest { 295 + // ID of the status page to create the group in (required). 296 + string page_id = 1 [(buf.validate.field).string.min_len = 1]; 297 + 298 + // Display name for the group (required). 299 + string name = 2 [ 300 + (buf.validate.field).string.min_len = 1, 301 + (buf.validate.field).string.max_len = 256 302 + ]; 303 + } 304 + 305 + message CreateComponentGroupResponse { 306 + // The created component group. 307 + PageComponentGroup group = 1; 308 + } 309 + 310 + // --- Delete Component Group --- 311 + 312 + message DeleteComponentGroupRequest { 313 + // ID of the component group to delete (required). 314 + string id = 1 [(buf.validate.field).string.min_len = 1]; 315 + } 316 + 317 + message DeleteComponentGroupResponse { 318 + // Whether the deletion was successful. 319 + bool success = 1; 320 + } 321 + 322 + // --- Update Component Group --- 323 + 324 + message UpdateComponentGroupRequest { 325 + // ID of the component group to update (required). 326 + string id = 1 [(buf.validate.field).string.min_len = 1]; 327 + 328 + // New display name for the group (optional). 329 + optional string name = 2 [(buf.validate.field).string = { 330 + min_len: 1 331 + max_len: 256 332 + }]; 333 + } 334 + 335 + message UpdateComponentGroupResponse { 336 + // The updated component group. 337 + PageComponentGroup group = 1; 338 + } 339 + 340 + // ============================================================================= 341 + // Subscriber Messages 342 + // ============================================================================= 343 + 344 + // --- Subscribe To Page --- 345 + 346 + message SubscribeToPageRequest { 347 + // ID of the status page to subscribe to (required). 348 + string page_id = 1 [(buf.validate.field).string.min_len = 1]; 349 + 350 + // Email address to subscribe (required). 351 + string email = 2 [(buf.validate.field).string.email = true]; 352 + } 353 + 354 + message SubscribeToPageResponse { 355 + // The created subscriber. 356 + PageSubscriber subscriber = 1; 357 + } 358 + 359 + // --- Unsubscribe From Page --- 360 + 361 + message UnsubscribeFromPageRequest { 362 + // ID of the status page to unsubscribe from (required). 363 + string page_id = 1 [(buf.validate.field).string.min_len = 1]; 364 + 365 + // Identifier for the subscription (either email or id). 366 + oneof identifier { 367 + // Email address to unsubscribe. 368 + string email = 2; 369 + // Subscriber ID. 370 + string id = 3; 371 + } 372 + } 373 + 374 + message UnsubscribeFromPageResponse { 375 + // Whether the unsubscription was successful. 376 + bool success = 1; 377 + } 378 + 379 + // --- List Subscribers --- 380 + 381 + message ListSubscribersRequest { 382 + // ID of the status page to list subscribers for (required). 383 + string page_id = 1 [(buf.validate.field).string.min_len = 1]; 384 + 385 + // Maximum number of subscribers to return (1-100, defaults to 50). 386 + optional int32 limit = 2 [(buf.validate.field).int32 = { 387 + gte: 1 388 + lte: 100 389 + }]; 390 + 391 + // Number of subscribers to skip for pagination (defaults to 0). 392 + optional int32 offset = 3 [(buf.validate.field).int32.gte = 0]; 393 + 394 + // Whether to include unsubscribed users (defaults to false). 395 + optional bool include_unsubscribed = 4; 396 + } 397 + 398 + message ListSubscribersResponse { 399 + // List of subscribers. 400 + repeated PageSubscriber subscribers = 1; 401 + 402 + // Total number of subscribers matching the filter. 403 + int32 total_size = 2; 404 + } 405 + 406 + // ============================================================================= 407 + // Full Content & Status Messages 408 + // ============================================================================= 409 + 410 + // --- Get Status Page Content --- 411 + 412 + message GetStatusPageContentRequest { 413 + // Identifier for the status page (either id or slug). 414 + oneof identifier { 415 + // ID of the status page. 416 + string id = 1; 417 + // Slug of the status page. 418 + string slug = 2; 419 + } 420 + } 421 + 422 + message GetStatusPageContentResponse { 423 + // The status page details. 424 + StatusPage status_page = 1; 425 + 426 + // Components on the status page. 427 + repeated PageComponent components = 2; 428 + 429 + // Component groups on the status page. 430 + repeated PageComponentGroup groups = 3; 431 + 432 + // Active and recent status reports. 433 + repeated openstatus.status_report.v1.StatusReport status_reports = 4; 434 + 435 + // Scheduled maintenances. 436 + repeated Maintenance maintenances = 5; 437 + } 438 + 439 + // --- Get Overall Status --- 440 + 441 + message GetOverallStatusRequest { 442 + // Identifier for the status page (either id or slug). 443 + oneof identifier { 444 + // ID of the status page. 445 + string id = 1; 446 + // Slug of the status page. 447 + string slug = 2; 448 + } 449 + } 450 + 451 + // ComponentStatus represents the status of a single component. 452 + message ComponentStatus { 453 + // ID of the component. 454 + string component_id = 1; 455 + 456 + // Current status of the component. 457 + OverallStatus status = 2; 458 + } 459 + 460 + message GetOverallStatusResponse { 461 + // Aggregated status across all components. 462 + OverallStatus overall_status = 1; 463 + 464 + // Status of individual components. 465 + repeated ComponentStatus component_statuses = 2; 466 + }
+122
packages/proto/api/openstatus/status_page/v1/status_page.proto
··· 1 + syntax = "proto3"; 2 + 3 + package openstatus.status_page.v1; 4 + 5 + option go_package = "github.com/openstatushq/openstatus/packages/proto/openstatus/status_page/v1;statuspagev1"; 6 + 7 + // PageAccessType defines who can access the status page. 8 + enum PageAccessType { 9 + PAGE_ACCESS_TYPE_UNSPECIFIED = 0; 10 + PAGE_ACCESS_TYPE_PUBLIC = 1; 11 + PAGE_ACCESS_TYPE_PASSWORD_PROTECTED = 2; 12 + PAGE_ACCESS_TYPE_AUTHENTICATED = 3; 13 + } 14 + 15 + // PageTheme defines the visual theme of the status page. 16 + enum PageTheme { 17 + PAGE_THEME_UNSPECIFIED = 0; 18 + PAGE_THEME_SYSTEM = 1; 19 + PAGE_THEME_LIGHT = 2; 20 + PAGE_THEME_DARK = 3; 21 + } 22 + 23 + // OverallStatus represents the aggregated status of all components on a page. 24 + enum OverallStatus { 25 + OVERALL_STATUS_UNSPECIFIED = 0; 26 + OVERALL_STATUS_OPERATIONAL = 1; 27 + OVERALL_STATUS_DEGRADED = 2; 28 + OVERALL_STATUS_PARTIAL_OUTAGE = 3; 29 + OVERALL_STATUS_MAJOR_OUTAGE = 4; 30 + OVERALL_STATUS_MAINTENANCE = 5; 31 + OVERALL_STATUS_UNKNOWN = 6; 32 + } 33 + 34 + // StatusPage represents a full status page with all details. 35 + message StatusPage { 36 + // Unique identifier for the status page. 37 + string id = 1; 38 + 39 + // Title of the status page. 40 + string title = 2; 41 + 42 + // Description of the status page. 43 + string description = 3; 44 + 45 + // URL-friendly slug for the status page. 46 + string slug = 4; 47 + 48 + // Custom domain for the status page (optional). 49 + string custom_domain = 5; 50 + 51 + // Whether the status page is published and visible. 52 + bool published = 6; 53 + 54 + // Access type for the status page. 55 + PageAccessType access_type = 7; 56 + 57 + // Visual theme for the status page. 58 + PageTheme theme = 8; 59 + 60 + // URL to the homepage (optional). 61 + string homepage_url = 9; 62 + 63 + // URL to the contact page (optional). 64 + string contact_url = 10; 65 + 66 + // Icon URL for the status page (optional). 67 + string icon = 11; 68 + 69 + // Timestamp when the page was created (RFC 3339 format). 70 + string created_at = 12; 71 + 72 + // Timestamp when the page was last updated (RFC 3339 format). 73 + string updated_at = 13; 74 + } 75 + 76 + // StatusPageSummary represents metadata for a status page (used in list responses). 77 + message StatusPageSummary { 78 + // Unique identifier for the status page. 79 + string id = 1; 80 + 81 + // Title of the status page. 82 + string title = 2; 83 + 84 + // URL-friendly slug for the status page. 85 + string slug = 3; 86 + 87 + // Whether the status page is published and visible. 88 + bool published = 4; 89 + 90 + // Timestamp when the page was created (RFC 3339 format). 91 + string created_at = 5; 92 + 93 + // Timestamp when the page was last updated (RFC 3339 format). 94 + string updated_at = 6; 95 + } 96 + 97 + // Maintenance represents a scheduled maintenance window. 98 + message Maintenance { 99 + // Unique identifier for the maintenance. 100 + string id = 1; 101 + 102 + // Title of the maintenance. 103 + string title = 2; 104 + 105 + // Message describing the maintenance. 106 + string message = 3; 107 + 108 + // Start time of the maintenance window (RFC 3339 format). 109 + string from = 4; 110 + 111 + // End time of the maintenance window (RFC 3339 format). 112 + string to = 5; 113 + 114 + // IDs of affected page components. 115 + repeated string page_component_ids = 6; 116 + 117 + // Timestamp when the maintenance was created (RFC 3339 format). 118 + string created_at = 7; 119 + 120 + // Timestamp when the maintenance was last updated (RFC 3339 format). 121 + string updated_at = 8; 122 + }
packages/proto/api/openstatus/status_report/PLAN.md packages/proto/plan/PLAN.md
+1
packages/proto/gen/ts/index.ts
··· 2 2 export * from "./openstatus/monitor/v1/index.js"; 3 3 export * from "./openstatus/health/v1/index.js"; 4 4 export * from "./openstatus/status_report/v1/index.js"; 5 + export * from "./openstatus/status_page/v1/index.js";
+5
packages/proto/gen/ts/openstatus/status_page/v1/index.ts
··· 1 + // Status page service exports 2 + export * from "./status_page_pb.js"; 3 + export * from "./page_component_pb.js"; 4 + export * from "./page_subscriber_pb.js"; 5 + export * from "./service_pb.js";
+182
packages/proto/gen/ts/openstatus/status_page/v1/page_component_pb.ts
··· 1 + // @generated by protoc-gen-es v2.10.2 with parameter "target=ts,import_extension=.ts" 2 + // @generated from file openstatus/status_page/v1/page_component.proto (package openstatus.status_page.v1, syntax proto3) 3 + /* eslint-disable */ 4 + 5 + import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; 6 + import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; 7 + import type { Message } from "@bufbuild/protobuf"; 8 + 9 + /** 10 + * Describes the file openstatus/status_page/v1/page_component.proto. 11 + */ 12 + export const file_openstatus_status_page_v1_page_component: GenFile = /*@__PURE__*/ 13 + fileDesc("Ci5vcGVuc3RhdHVzL3N0YXR1c19wYWdlL3YxL3BhZ2VfY29tcG9uZW50LnByb3RvEhlvcGVuc3RhdHVzLnN0YXR1c19wYWdlLnYxIv0BCg1QYWdlQ29tcG9uZW50EgoKAmlkGAEgASgJEg8KB3BhZ2VfaWQYAiABKAkSDAoEbmFtZRgDIAEoCRITCgtkZXNjcmlwdGlvbhgEIAEoCRI6CgR0eXBlGAUgASgOMiwub3BlbnN0YXR1cy5zdGF0dXNfcGFnZS52MS5QYWdlQ29tcG9uZW50VHlwZRISCgptb25pdG9yX2lkGAYgASgJEg0KBW9yZGVyGAcgASgFEhAKCGdyb3VwX2lkGAggASgJEhMKC2dyb3VwX29yZGVyGAkgASgFEhIKCmNyZWF0ZWRfYXQYCiABKAkSEgoKdXBkYXRlZF9hdBgLIAEoCSJnChJQYWdlQ29tcG9uZW50R3JvdXASCgoCaWQYASABKAkSDwoHcGFnZV9pZBgCIAEoCRIMCgRuYW1lGAMgASgJEhIKCmNyZWF0ZWRfYXQYBCABKAkSEgoKdXBkYXRlZF9hdBgFIAEoCSp5ChFQYWdlQ29tcG9uZW50VHlwZRIjCh9QQUdFX0NPTVBPTkVOVF9UWVBFX1VOU1BFQ0lGSUVEEAASHwobUEFHRV9DT01QT05FTlRfVFlQRV9NT05JVE9SEAESHgoaUEFHRV9DT01QT05FTlRfVFlQRV9TVEFUSUMQAkJaWlhnaXRodWIuY29tL29wZW5zdGF0dXNocS9vcGVuc3RhdHVzL3BhY2thZ2VzL3Byb3RvL29wZW5zdGF0dXMvc3RhdHVzX3BhZ2UvdjE7c3RhdHVzcGFnZXYxYgZwcm90bzM"); 14 + 15 + /** 16 + * PageComponent represents a component displayed on a status page. 17 + * 18 + * @generated from message openstatus.status_page.v1.PageComponent 19 + */ 20 + export type PageComponent = Message<"openstatus.status_page.v1.PageComponent"> & { 21 + /** 22 + * Unique identifier for the component. 23 + * 24 + * @generated from field: string id = 1; 25 + */ 26 + id: string; 27 + 28 + /** 29 + * ID of the status page this component belongs to. 30 + * 31 + * @generated from field: string page_id = 2; 32 + */ 33 + pageId: string; 34 + 35 + /** 36 + * Display name of the component. 37 + * 38 + * @generated from field: string name = 3; 39 + */ 40 + name: string; 41 + 42 + /** 43 + * Description of the component (optional). 44 + * 45 + * @generated from field: string description = 4; 46 + */ 47 + description: string; 48 + 49 + /** 50 + * Type of the component (monitor or static). 51 + * 52 + * @generated from field: openstatus.status_page.v1.PageComponentType type = 5; 53 + */ 54 + type: PageComponentType; 55 + 56 + /** 57 + * ID of the monitor if type is MONITOR (optional). 58 + * 59 + * @generated from field: string monitor_id = 6; 60 + */ 61 + monitorId: string; 62 + 63 + /** 64 + * Display order of the component. 65 + * 66 + * @generated from field: int32 order = 7; 67 + */ 68 + order: number; 69 + 70 + /** 71 + * ID of the group this component belongs to (optional). 72 + * 73 + * @generated from field: string group_id = 8; 74 + */ 75 + groupId: string; 76 + 77 + /** 78 + * Order within the group if grouped. 79 + * 80 + * @generated from field: int32 group_order = 9; 81 + */ 82 + groupOrder: number; 83 + 84 + /** 85 + * Timestamp when the component was created (RFC 3339 format). 86 + * 87 + * @generated from field: string created_at = 10; 88 + */ 89 + createdAt: string; 90 + 91 + /** 92 + * Timestamp when the component was last updated (RFC 3339 format). 93 + * 94 + * @generated from field: string updated_at = 11; 95 + */ 96 + updatedAt: string; 97 + }; 98 + 99 + /** 100 + * Describes the message openstatus.status_page.v1.PageComponent. 101 + * Use `create(PageComponentSchema)` to create a new message. 102 + */ 103 + export const PageComponentSchema: GenMessage<PageComponent> = /*@__PURE__*/ 104 + messageDesc(file_openstatus_status_page_v1_page_component, 0); 105 + 106 + /** 107 + * PageComponentGroup represents a group of components on a status page. 108 + * 109 + * @generated from message openstatus.status_page.v1.PageComponentGroup 110 + */ 111 + export type PageComponentGroup = Message<"openstatus.status_page.v1.PageComponentGroup"> & { 112 + /** 113 + * Unique identifier for the group. 114 + * 115 + * @generated from field: string id = 1; 116 + */ 117 + id: string; 118 + 119 + /** 120 + * ID of the status page this group belongs to. 121 + * 122 + * @generated from field: string page_id = 2; 123 + */ 124 + pageId: string; 125 + 126 + /** 127 + * Display name of the group. 128 + * 129 + * @generated from field: string name = 3; 130 + */ 131 + name: string; 132 + 133 + /** 134 + * Timestamp when the group was created (RFC 3339 format). 135 + * 136 + * @generated from field: string created_at = 4; 137 + */ 138 + createdAt: string; 139 + 140 + /** 141 + * Timestamp when the group was last updated (RFC 3339 format). 142 + * 143 + * @generated from field: string updated_at = 5; 144 + */ 145 + updatedAt: string; 146 + }; 147 + 148 + /** 149 + * Describes the message openstatus.status_page.v1.PageComponentGroup. 150 + * Use `create(PageComponentGroupSchema)` to create a new message. 151 + */ 152 + export const PageComponentGroupSchema: GenMessage<PageComponentGroup> = /*@__PURE__*/ 153 + messageDesc(file_openstatus_status_page_v1_page_component, 1); 154 + 155 + /** 156 + * PageComponentType defines the type of a component on a status page. 157 + * 158 + * @generated from enum openstatus.status_page.v1.PageComponentType 159 + */ 160 + export enum PageComponentType { 161 + /** 162 + * @generated from enum value: PAGE_COMPONENT_TYPE_UNSPECIFIED = 0; 163 + */ 164 + UNSPECIFIED = 0, 165 + 166 + /** 167 + * @generated from enum value: PAGE_COMPONENT_TYPE_MONITOR = 1; 168 + */ 169 + MONITOR = 1, 170 + 171 + /** 172 + * @generated from enum value: PAGE_COMPONENT_TYPE_STATIC = 2; 173 + */ 174 + STATIC = 2, 175 + } 176 + 177 + /** 178 + * Describes the enum openstatus.status_page.v1.PageComponentType. 179 + */ 180 + export const PageComponentTypeSchema: GenEnum<PageComponentType> = /*@__PURE__*/ 181 + enumDesc(file_openstatus_status_page_v1_page_component, 0); 182 +
+77
packages/proto/gen/ts/openstatus/status_page/v1/page_subscriber_pb.ts
··· 1 + // @generated by protoc-gen-es v2.10.2 with parameter "target=ts,import_extension=.ts" 2 + // @generated from file openstatus/status_page/v1/page_subscriber.proto (package openstatus.status_page.v1, syntax proto3) 3 + /* eslint-disable */ 4 + 5 + import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; 6 + import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; 7 + import type { Message } from "@bufbuild/protobuf"; 8 + 9 + /** 10 + * Describes the file openstatus/status_page/v1/page_subscriber.proto. 11 + */ 12 + export const file_openstatus_status_page_v1_page_subscriber: GenFile = /*@__PURE__*/ 13 + fileDesc("Ci9vcGVuc3RhdHVzL3N0YXR1c19wYWdlL3YxL3BhZ2Vfc3Vic2NyaWJlci5wcm90bxIZb3BlbnN0YXR1cy5zdGF0dXNfcGFnZS52MSKSAQoOUGFnZVN1YnNjcmliZXISCgoCaWQYASABKAkSDwoHcGFnZV9pZBgCIAEoCRINCgVlbWFpbBgDIAEoCRITCgthY2NlcHRlZF9hdBgEIAEoCRIXCg91bnN1YnNjcmliZWRfYXQYBSABKAkSEgoKY3JlYXRlZF9hdBgGIAEoCRISCgp1cGRhdGVkX2F0GAcgASgJQlpaWGdpdGh1Yi5jb20vb3BlbnN0YXR1c2hxL29wZW5zdGF0dXMvcGFja2FnZXMvcHJvdG8vb3BlbnN0YXR1cy9zdGF0dXNfcGFnZS92MTtzdGF0dXNwYWdldjFiBnByb3RvMw"); 14 + 15 + /** 16 + * PageSubscriber represents a subscriber to a status page. 17 + * 18 + * @generated from message openstatus.status_page.v1.PageSubscriber 19 + */ 20 + export type PageSubscriber = Message<"openstatus.status_page.v1.PageSubscriber"> & { 21 + /** 22 + * Unique identifier for the subscriber. 23 + * 24 + * @generated from field: string id = 1; 25 + */ 26 + id: string; 27 + 28 + /** 29 + * ID of the status page the user is subscribed to. 30 + * 31 + * @generated from field: string page_id = 2; 32 + */ 33 + pageId: string; 34 + 35 + /** 36 + * Email address of the subscriber. 37 + * 38 + * @generated from field: string email = 3; 39 + */ 40 + email: string; 41 + 42 + /** 43 + * Timestamp when the subscription was accepted/confirmed (RFC 3339 format, optional). 44 + * 45 + * @generated from field: string accepted_at = 4; 46 + */ 47 + acceptedAt: string; 48 + 49 + /** 50 + * Timestamp when the user unsubscribed (RFC 3339 format, optional). 51 + * 52 + * @generated from field: string unsubscribed_at = 5; 53 + */ 54 + unsubscribedAt: string; 55 + 56 + /** 57 + * Timestamp when the subscription was created (RFC 3339 format). 58 + * 59 + * @generated from field: string created_at = 6; 60 + */ 61 + createdAt: string; 62 + 63 + /** 64 + * Timestamp when the subscription was last updated (RFC 3339 format). 65 + * 66 + * @generated from field: string updated_at = 7; 67 + */ 68 + updatedAt: string; 69 + }; 70 + 71 + /** 72 + * Describes the message openstatus.status_page.v1.PageSubscriber. 73 + * Use `create(PageSubscriberSchema)` to create a new message. 74 + */ 75 + export const PageSubscriberSchema: GenMessage<PageSubscriber> = /*@__PURE__*/ 76 + messageDesc(file_openstatus_status_page_v1_page_subscriber, 0); 77 +
+1192
packages/proto/gen/ts/openstatus/status_page/v1/service_pb.ts
··· 1 + // @generated by protoc-gen-es v2.10.2 with parameter "target=ts,import_extension=.ts" 2 + // @generated from file openstatus/status_page/v1/service.proto (package openstatus.status_page.v1, syntax proto3) 3 + /* eslint-disable */ 4 + 5 + import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; 6 + import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; 7 + import { file_buf_validate_validate } from "../../../buf/validate/validate_pb.ts"; 8 + import type { PageComponent, PageComponentGroup } from "./page_component_pb.ts"; 9 + import { file_openstatus_status_page_v1_page_component } from "./page_component_pb.ts"; 10 + import type { PageSubscriber } from "./page_subscriber_pb.ts"; 11 + import { file_openstatus_status_page_v1_page_subscriber } from "./page_subscriber_pb.ts"; 12 + import type { Maintenance, OverallStatus, StatusPage, StatusPageSummary } from "./status_page_pb.ts"; 13 + import { file_openstatus_status_page_v1_status_page } from "./status_page_pb.ts"; 14 + import type { StatusReport } from "../../status_report/v1/status_report_pb.ts"; 15 + import { file_openstatus_status_report_v1_status_report } from "../../status_report/v1/status_report_pb.ts"; 16 + import type { Message } from "@bufbuild/protobuf"; 17 + 18 + /** 19 + * Describes the file openstatus/status_page/v1/service.proto. 20 + */ 21 + export const file_openstatus_status_page_v1_service: GenFile = /*@__PURE__*/ 22 + fileDesc("CidvcGVuc3RhdHVzL3N0YXR1c19wYWdlL3YxL3NlcnZpY2UucHJvdG8SGW9wZW5zdGF0dXMuc3RhdHVzX3BhZ2UudjEi9AEKF0NyZWF0ZVN0YXR1c1BhZ2VSZXF1ZXN0EhkKBXRpdGxlGAEgASgJQgq6SAdyBRABGIACEiIKC2Rlc2NyaXB0aW9uGAIgASgJQgi6SAVyAxiACEgAiAEBEjQKBHNsdWcYAyABKAlCJrpII3IhEAEYgAIyGl5bYS16MC05XSsoPzotW2EtejAtOV0rKSokEhkKDGhvbWVwYWdlX3VybBgEIAEoCUgBiAEBEhgKC2NvbnRhY3RfdXJsGAUgASgJSAKIAQFCDgoMX2Rlc2NyaXB0aW9uQg8KDV9ob21lcGFnZV91cmxCDgoMX2NvbnRhY3RfdXJsIlYKGENyZWF0ZVN0YXR1c1BhZ2VSZXNwb25zZRI6CgtzdGF0dXNfcGFnZRgBIAEoCzIlLm9wZW5zdGF0dXMuc3RhdHVzX3BhZ2UudjEuU3RhdHVzUGFnZSIrChRHZXRTdGF0dXNQYWdlUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQASJTChVHZXRTdGF0dXNQYWdlUmVzcG9uc2USOgoLc3RhdHVzX3BhZ2UYASABKAsyJS5vcGVuc3RhdHVzLnN0YXR1c19wYWdlLnYxLlN0YXR1c1BhZ2UiagoWTGlzdFN0YXR1c1BhZ2VzUmVxdWVzdBIdCgVsaW1pdBgBIAEoBUIJukgGGgQYZCgBSACIAQESHAoGb2Zmc2V0GAIgASgFQge6SAQaAigASAGIAQFCCAoGX2xpbWl0QgkKB19vZmZzZXQicQoXTGlzdFN0YXR1c1BhZ2VzUmVzcG9uc2USQgoMc3RhdHVzX3BhZ2VzGAEgAygLMiwub3BlbnN0YXR1cy5zdGF0dXNfcGFnZS52MS5TdGF0dXNQYWdlU3VtbWFyeRISCgp0b3RhbF9zaXplGAIgASgFIqYCChdVcGRhdGVTdGF0dXNQYWdlUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQARIeCgV0aXRsZRgCIAEoCUIKukgHcgUQARiAAkgAiAEBEiIKC2Rlc2NyaXB0aW9uGAMgASgJQgi6SAVyAxiACEgBiAEBEjkKBHNsdWcYBCABKAlCJrpII3IhEAEYgAIyGl5bYS16MC05XSsoPzotW2EtejAtOV0rKSokSAKIAQESGQoMaG9tZXBhZ2VfdXJsGAUgASgJSAOIAQESGAoLY29udGFjdF91cmwYBiABKAlIBIgBAUIICgZfdGl0bGVCDgoMX2Rlc2NyaXB0aW9uQgcKBV9zbHVnQg8KDV9ob21lcGFnZV91cmxCDgoMX2NvbnRhY3RfdXJsIlYKGFVwZGF0ZVN0YXR1c1BhZ2VSZXNwb25zZRI6CgtzdGF0dXNfcGFnZRgBIAEoCzIlLm9wZW5zdGF0dXMuc3RhdHVzX3BhZ2UudjEuU3RhdHVzUGFnZSIuChdEZWxldGVTdGF0dXNQYWdlUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQASIrChhEZWxldGVTdGF0dXNQYWdlUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCCLvAQoaQWRkTW9uaXRvckNvbXBvbmVudFJlcXVlc3QSGAoHcGFnZV9pZBgBIAEoCUIHukgEcgIQARIbCgptb25pdG9yX2lkGAIgASgJQge6SARyAhABEhsKBG5hbWUYAyABKAlCCLpIBXIDGIACSACIAQESIgoLZGVzY3JpcHRpb24YBCABKAlCCLpIBXIDGIAISAGIAQESEgoFb3JkZXIYBSABKAVIAogBARIVCghncm91cF9pZBgGIAEoCUgDiAEBQgcKBV9uYW1lQg4KDF9kZXNjcmlwdGlvbkIICgZfb3JkZXJCCwoJX2dyb3VwX2lkIloKG0FkZE1vbml0b3JDb21wb25lbnRSZXNwb25zZRI7Cgljb21wb25lbnQYASABKAsyKC5vcGVuc3RhdHVzLnN0YXR1c19wYWdlLnYxLlBhZ2VDb21wb25lbnQixQEKGUFkZFN0YXRpY0NvbXBvbmVudFJlcXVlc3QSGAoHcGFnZV9pZBgBIAEoCUIHukgEcgIQARIYCgRuYW1lGAIgASgJQgq6SAdyBRABGIACEiIKC2Rlc2NyaXB0aW9uGAMgASgJQgi6SAVyAxiACEgAiAEBEhIKBW9yZGVyGAQgASgFSAGIAQESFQoIZ3JvdXBfaWQYBSABKAlIAogBAUIOCgxfZGVzY3JpcHRpb25CCAoGX29yZGVyQgsKCV9ncm91cF9pZCJZChpBZGRTdGF0aWNDb21wb25lbnRSZXNwb25zZRI7Cgljb21wb25lbnQYASABKAsyKC5vcGVuc3RhdHVzLnN0YXR1c19wYWdlLnYxLlBhZ2VDb21wb25lbnQiLQoWUmVtb3ZlQ29tcG9uZW50UmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQASIqChdSZW1vdmVDb21wb25lbnRSZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIIvMBChZVcGRhdGVDb21wb25lbnRSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABEhsKBG5hbWUYAiABKAlCCLpIBXIDGIACSACIAQESIgoLZGVzY3JpcHRpb24YAyABKAlCCLpIBXIDGIAISAGIAQESEgoFb3JkZXIYBCABKAVIAogBARIVCghncm91cF9pZBgFIAEoCUgDiAEBEhgKC2dyb3VwX29yZGVyGAYgASgFSASIAQFCBwoFX25hbWVCDgoMX2Rlc2NyaXB0aW9uQggKBl9vcmRlckILCglfZ3JvdXBfaWRCDgoMX2dyb3VwX29yZGVyIlYKF1VwZGF0ZUNvbXBvbmVudFJlc3BvbnNlEjsKCWNvbXBvbmVudBgBIAEoCzIoLm9wZW5zdGF0dXMuc3RhdHVzX3BhZ2UudjEuUGFnZUNvbXBvbmVudCJRChtDcmVhdGVDb21wb25lbnRHcm91cFJlcXVlc3QSGAoHcGFnZV9pZBgBIAEoCUIHukgEcgIQARIYCgRuYW1lGAIgASgJQgq6SAdyBRABGIACIlwKHENyZWF0ZUNvbXBvbmVudEdyb3VwUmVzcG9uc2USPAoFZ3JvdXAYASABKAsyLS5vcGVuc3RhdHVzLnN0YXR1c19wYWdlLnYxLlBhZ2VDb21wb25lbnRHcm91cCIyChtEZWxldGVDb21wb25lbnRHcm91cFJlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAEiLwocRGVsZXRlQ29tcG9uZW50R3JvdXBSZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIIloKG1VwZGF0ZUNvbXBvbmVudEdyb3VwUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQARIdCgRuYW1lGAIgASgJQgq6SAdyBRABGIACSACIAQFCBwoFX25hbWUiXAocVXBkYXRlQ29tcG9uZW50R3JvdXBSZXNwb25zZRI8CgVncm91cBgBIAEoCzItLm9wZW5zdGF0dXMuc3RhdHVzX3BhZ2UudjEuUGFnZUNvbXBvbmVudEdyb3VwIkoKFlN1YnNjcmliZVRvUGFnZVJlcXVlc3QSGAoHcGFnZV9pZBgBIAEoCUIHukgEcgIQARIWCgVlbWFpbBgCIAEoCUIHukgEcgJgASJYChdTdWJzY3JpYmVUb1BhZ2VSZXNwb25zZRI9CgpzdWJzY3JpYmVyGAEgASgLMikub3BlbnN0YXR1cy5zdGF0dXNfcGFnZS52MS5QYWdlU3Vic2NyaWJlciJjChpVbnN1YnNjcmliZUZyb21QYWdlUmVxdWVzdBIYCgdwYWdlX2lkGAEgASgJQge6SARyAhABEg8KBWVtYWlsGAIgASgJSAASDAoCaWQYAyABKAlIAEIMCgppZGVudGlmaWVyIi4KG1Vuc3Vic2NyaWJlRnJvbVBhZ2VSZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIIsABChZMaXN0U3Vic2NyaWJlcnNSZXF1ZXN0EhgKB3BhZ2VfaWQYASABKAlCB7pIBHICEAESHQoFbGltaXQYAiABKAVCCbpIBhoEGGQoAUgAiAEBEhwKBm9mZnNldBgDIAEoBUIHukgEGgIoAEgBiAEBEiEKFGluY2x1ZGVfdW5zdWJzY3JpYmVkGAQgASgISAKIAQFCCAoGX2xpbWl0QgkKB19vZmZzZXRCFwoVX2luY2x1ZGVfdW5zdWJzY3JpYmVkIm0KF0xpc3RTdWJzY3JpYmVyc1Jlc3BvbnNlEj4KC3N1YnNjcmliZXJzGAEgAygLMikub3BlbnN0YXR1cy5zdGF0dXNfcGFnZS52MS5QYWdlU3Vic2NyaWJlchISCgp0b3RhbF9zaXplGAIgASgFIkkKG0dldFN0YXR1c1BhZ2VDb250ZW50UmVxdWVzdBIMCgJpZBgBIAEoCUgAEg4KBHNsdWcYAiABKAlIAEIMCgppZGVudGlmaWVyItgCChxHZXRTdGF0dXNQYWdlQ29udGVudFJlc3BvbnNlEjoKC3N0YXR1c19wYWdlGAEgASgLMiUub3BlbnN0YXR1cy5zdGF0dXNfcGFnZS52MS5TdGF0dXNQYWdlEjwKCmNvbXBvbmVudHMYAiADKAsyKC5vcGVuc3RhdHVzLnN0YXR1c19wYWdlLnYxLlBhZ2VDb21wb25lbnQSPQoGZ3JvdXBzGAMgAygLMi0ub3BlbnN0YXR1cy5zdGF0dXNfcGFnZS52MS5QYWdlQ29tcG9uZW50R3JvdXASQQoOc3RhdHVzX3JlcG9ydHMYBCADKAsyKS5vcGVuc3RhdHVzLnN0YXR1c19yZXBvcnQudjEuU3RhdHVzUmVwb3J0EjwKDG1haW50ZW5hbmNlcxgFIAMoCzImLm9wZW5zdGF0dXMuc3RhdHVzX3BhZ2UudjEuTWFpbnRlbmFuY2UiRQoXR2V0T3ZlcmFsbFN0YXR1c1JlcXVlc3QSDAoCaWQYASABKAlIABIOCgRzbHVnGAIgASgJSABCDAoKaWRlbnRpZmllciJhCg9Db21wb25lbnRTdGF0dXMSFAoMY29tcG9uZW50X2lkGAEgASgJEjgKBnN0YXR1cxgCIAEoDjIoLm9wZW5zdGF0dXMuc3RhdHVzX3BhZ2UudjEuT3ZlcmFsbFN0YXR1cyKkAQoYR2V0T3ZlcmFsbFN0YXR1c1Jlc3BvbnNlEkAKDm92ZXJhbGxfc3RhdHVzGAEgASgOMigub3BlbnN0YXR1cy5zdGF0dXNfcGFnZS52MS5PdmVyYWxsU3RhdHVzEkYKEmNvbXBvbmVudF9zdGF0dXNlcxgCIAMoCzIqLm9wZW5zdGF0dXMuc3RhdHVzX3BhZ2UudjEuQ29tcG9uZW50U3RhdHVzMpcRChFTdGF0dXNQYWdlU2VydmljZRJ7ChBDcmVhdGVTdGF0dXNQYWdlEjIub3BlbnN0YXR1cy5zdGF0dXNfcGFnZS52MS5DcmVhdGVTdGF0dXNQYWdlUmVxdWVzdBozLm9wZW5zdGF0dXMuc3RhdHVzX3BhZ2UudjEuQ3JlYXRlU3RhdHVzUGFnZVJlc3BvbnNlEnIKDUdldFN0YXR1c1BhZ2USLy5vcGVuc3RhdHVzLnN0YXR1c19wYWdlLnYxLkdldFN0YXR1c1BhZ2VSZXF1ZXN0GjAub3BlbnN0YXR1cy5zdGF0dXNfcGFnZS52MS5HZXRTdGF0dXNQYWdlUmVzcG9uc2USeAoPTGlzdFN0YXR1c1BhZ2VzEjEub3BlbnN0YXR1cy5zdGF0dXNfcGFnZS52MS5MaXN0U3RhdHVzUGFnZXNSZXF1ZXN0GjIub3BlbnN0YXR1cy5zdGF0dXNfcGFnZS52MS5MaXN0U3RhdHVzUGFnZXNSZXNwb25zZRJ7ChBVcGRhdGVTdGF0dXNQYWdlEjIub3BlbnN0YXR1cy5zdGF0dXNfcGFnZS52MS5VcGRhdGVTdGF0dXNQYWdlUmVxdWVzdBozLm9wZW5zdGF0dXMuc3RhdHVzX3BhZ2UudjEuVXBkYXRlU3RhdHVzUGFnZVJlc3BvbnNlEnsKEERlbGV0ZVN0YXR1c1BhZ2USMi5vcGVuc3RhdHVzLnN0YXR1c19wYWdlLnYxLkRlbGV0ZVN0YXR1c1BhZ2VSZXF1ZXN0GjMub3BlbnN0YXR1cy5zdGF0dXNfcGFnZS52MS5EZWxldGVTdGF0dXNQYWdlUmVzcG9uc2UShAEKE0FkZE1vbml0b3JDb21wb25lbnQSNS5vcGVuc3RhdHVzLnN0YXR1c19wYWdlLnYxLkFkZE1vbml0b3JDb21wb25lbnRSZXF1ZXN0GjYub3BlbnN0YXR1cy5zdGF0dXNfcGFnZS52MS5BZGRNb25pdG9yQ29tcG9uZW50UmVzcG9uc2USgQEKEkFkZFN0YXRpY0NvbXBvbmVudBI0Lm9wZW5zdGF0dXMuc3RhdHVzX3BhZ2UudjEuQWRkU3RhdGljQ29tcG9uZW50UmVxdWVzdBo1Lm9wZW5zdGF0dXMuc3RhdHVzX3BhZ2UudjEuQWRkU3RhdGljQ29tcG9uZW50UmVzcG9uc2USeAoPUmVtb3ZlQ29tcG9uZW50EjEub3BlbnN0YXR1cy5zdGF0dXNfcGFnZS52MS5SZW1vdmVDb21wb25lbnRSZXF1ZXN0GjIub3BlbnN0YXR1cy5zdGF0dXNfcGFnZS52MS5SZW1vdmVDb21wb25lbnRSZXNwb25zZRJ4Cg9VcGRhdGVDb21wb25lbnQSMS5vcGVuc3RhdHVzLnN0YXR1c19wYWdlLnYxLlVwZGF0ZUNvbXBvbmVudFJlcXVlc3QaMi5vcGVuc3RhdHVzLnN0YXR1c19wYWdlLnYxLlVwZGF0ZUNvbXBvbmVudFJlc3BvbnNlEocBChRDcmVhdGVDb21wb25lbnRHcm91cBI2Lm9wZW5zdGF0dXMuc3RhdHVzX3BhZ2UudjEuQ3JlYXRlQ29tcG9uZW50R3JvdXBSZXF1ZXN0Gjcub3BlbnN0YXR1cy5zdGF0dXNfcGFnZS52MS5DcmVhdGVDb21wb25lbnRHcm91cFJlc3BvbnNlEocBChREZWxldGVDb21wb25lbnRHcm91cBI2Lm9wZW5zdGF0dXMuc3RhdHVzX3BhZ2UudjEuRGVsZXRlQ29tcG9uZW50R3JvdXBSZXF1ZXN0Gjcub3BlbnN0YXR1cy5zdGF0dXNfcGFnZS52MS5EZWxldGVDb21wb25lbnRHcm91cFJlc3BvbnNlEocBChRVcGRhdGVDb21wb25lbnRHcm91cBI2Lm9wZW5zdGF0dXMuc3RhdHVzX3BhZ2UudjEuVXBkYXRlQ29tcG9uZW50R3JvdXBSZXF1ZXN0Gjcub3BlbnN0YXR1cy5zdGF0dXNfcGFnZS52MS5VcGRhdGVDb21wb25lbnRHcm91cFJlc3BvbnNlEngKD1N1YnNjcmliZVRvUGFnZRIxLm9wZW5zdGF0dXMuc3RhdHVzX3BhZ2UudjEuU3Vic2NyaWJlVG9QYWdlUmVxdWVzdBoyLm9wZW5zdGF0dXMuc3RhdHVzX3BhZ2UudjEuU3Vic2NyaWJlVG9QYWdlUmVzcG9uc2UShAEKE1Vuc3Vic2NyaWJlRnJvbVBhZ2USNS5vcGVuc3RhdHVzLnN0YXR1c19wYWdlLnYxLlVuc3Vic2NyaWJlRnJvbVBhZ2VSZXF1ZXN0GjYub3BlbnN0YXR1cy5zdGF0dXNfcGFnZS52MS5VbnN1YnNjcmliZUZyb21QYWdlUmVzcG9uc2USeAoPTGlzdFN1YnNjcmliZXJzEjEub3BlbnN0YXR1cy5zdGF0dXNfcGFnZS52MS5MaXN0U3Vic2NyaWJlcnNSZXF1ZXN0GjIub3BlbnN0YXR1cy5zdGF0dXNfcGFnZS52MS5MaXN0U3Vic2NyaWJlcnNSZXNwb25zZRKHAQoUR2V0U3RhdHVzUGFnZUNvbnRlbnQSNi5vcGVuc3RhdHVzLnN0YXR1c19wYWdlLnYxLkdldFN0YXR1c1BhZ2VDb250ZW50UmVxdWVzdBo3Lm9wZW5zdGF0dXMuc3RhdHVzX3BhZ2UudjEuR2V0U3RhdHVzUGFnZUNvbnRlbnRSZXNwb25zZRJ7ChBHZXRPdmVyYWxsU3RhdHVzEjIub3BlbnN0YXR1cy5zdGF0dXNfcGFnZS52MS5HZXRPdmVyYWxsU3RhdHVzUmVxdWVzdBozLm9wZW5zdGF0dXMuc3RhdHVzX3BhZ2UudjEuR2V0T3ZlcmFsbFN0YXR1c1Jlc3BvbnNlQlpaWGdpdGh1Yi5jb20vb3BlbnN0YXR1c2hxL29wZW5zdGF0dXMvcGFja2FnZXMvcHJvdG8vb3BlbnN0YXR1cy9zdGF0dXNfcGFnZS92MTtzdGF0dXNwYWdldjFiBnByb3RvMw", [file_buf_validate_validate, file_openstatus_status_page_v1_page_component, file_openstatus_status_page_v1_page_subscriber, file_openstatus_status_page_v1_status_page, file_openstatus_status_report_v1_status_report]); 23 + 24 + /** 25 + * @generated from message openstatus.status_page.v1.CreateStatusPageRequest 26 + */ 27 + export type CreateStatusPageRequest = Message<"openstatus.status_page.v1.CreateStatusPageRequest"> & { 28 + /** 29 + * Title of the status page (required). 30 + * 31 + * @generated from field: string title = 1; 32 + */ 33 + title: string; 34 + 35 + /** 36 + * Description of the status page (optional). 37 + * 38 + * @generated from field: optional string description = 2; 39 + */ 40 + description?: string; 41 + 42 + /** 43 + * URL-friendly slug for the status page (required). 44 + * 45 + * @generated from field: string slug = 3; 46 + */ 47 + slug: string; 48 + 49 + /** 50 + * URL to the homepage (optional). 51 + * 52 + * @generated from field: optional string homepage_url = 4; 53 + */ 54 + homepageUrl?: string; 55 + 56 + /** 57 + * URL to the contact page (optional). 58 + * 59 + * @generated from field: optional string contact_url = 5; 60 + */ 61 + contactUrl?: string; 62 + }; 63 + 64 + /** 65 + * Describes the message openstatus.status_page.v1.CreateStatusPageRequest. 66 + * Use `create(CreateStatusPageRequestSchema)` to create a new message. 67 + */ 68 + export const CreateStatusPageRequestSchema: GenMessage<CreateStatusPageRequest> = /*@__PURE__*/ 69 + messageDesc(file_openstatus_status_page_v1_service, 0); 70 + 71 + /** 72 + * @generated from message openstatus.status_page.v1.CreateStatusPageResponse 73 + */ 74 + export type CreateStatusPageResponse = Message<"openstatus.status_page.v1.CreateStatusPageResponse"> & { 75 + /** 76 + * The created status page. 77 + * 78 + * @generated from field: openstatus.status_page.v1.StatusPage status_page = 1; 79 + */ 80 + statusPage?: StatusPage; 81 + }; 82 + 83 + /** 84 + * Describes the message openstatus.status_page.v1.CreateStatusPageResponse. 85 + * Use `create(CreateStatusPageResponseSchema)` to create a new message. 86 + */ 87 + export const CreateStatusPageResponseSchema: GenMessage<CreateStatusPageResponse> = /*@__PURE__*/ 88 + messageDesc(file_openstatus_status_page_v1_service, 1); 89 + 90 + /** 91 + * @generated from message openstatus.status_page.v1.GetStatusPageRequest 92 + */ 93 + export type GetStatusPageRequest = Message<"openstatus.status_page.v1.GetStatusPageRequest"> & { 94 + /** 95 + * ID of the status page to retrieve (required). 96 + * 97 + * @generated from field: string id = 1; 98 + */ 99 + id: string; 100 + }; 101 + 102 + /** 103 + * Describes the message openstatus.status_page.v1.GetStatusPageRequest. 104 + * Use `create(GetStatusPageRequestSchema)` to create a new message. 105 + */ 106 + export const GetStatusPageRequestSchema: GenMessage<GetStatusPageRequest> = /*@__PURE__*/ 107 + messageDesc(file_openstatus_status_page_v1_service, 2); 108 + 109 + /** 110 + * @generated from message openstatus.status_page.v1.GetStatusPageResponse 111 + */ 112 + export type GetStatusPageResponse = Message<"openstatus.status_page.v1.GetStatusPageResponse"> & { 113 + /** 114 + * The requested status page. 115 + * 116 + * @generated from field: openstatus.status_page.v1.StatusPage status_page = 1; 117 + */ 118 + statusPage?: StatusPage; 119 + }; 120 + 121 + /** 122 + * Describes the message openstatus.status_page.v1.GetStatusPageResponse. 123 + * Use `create(GetStatusPageResponseSchema)` to create a new message. 124 + */ 125 + export const GetStatusPageResponseSchema: GenMessage<GetStatusPageResponse> = /*@__PURE__*/ 126 + messageDesc(file_openstatus_status_page_v1_service, 3); 127 + 128 + /** 129 + * @generated from message openstatus.status_page.v1.ListStatusPagesRequest 130 + */ 131 + export type ListStatusPagesRequest = Message<"openstatus.status_page.v1.ListStatusPagesRequest"> & { 132 + /** 133 + * Maximum number of pages to return (1-100, defaults to 50). 134 + * 135 + * @generated from field: optional int32 limit = 1; 136 + */ 137 + limit?: number; 138 + 139 + /** 140 + * Number of pages to skip for pagination (defaults to 0). 141 + * 142 + * @generated from field: optional int32 offset = 2; 143 + */ 144 + offset?: number; 145 + }; 146 + 147 + /** 148 + * Describes the message openstatus.status_page.v1.ListStatusPagesRequest. 149 + * Use `create(ListStatusPagesRequestSchema)` to create a new message. 150 + */ 151 + export const ListStatusPagesRequestSchema: GenMessage<ListStatusPagesRequest> = /*@__PURE__*/ 152 + messageDesc(file_openstatus_status_page_v1_service, 4); 153 + 154 + /** 155 + * @generated from message openstatus.status_page.v1.ListStatusPagesResponse 156 + */ 157 + export type ListStatusPagesResponse = Message<"openstatus.status_page.v1.ListStatusPagesResponse"> & { 158 + /** 159 + * List of status pages (metadata only). 160 + * 161 + * @generated from field: repeated openstatus.status_page.v1.StatusPageSummary status_pages = 1; 162 + */ 163 + statusPages: StatusPageSummary[]; 164 + 165 + /** 166 + * Total number of status pages. 167 + * 168 + * @generated from field: int32 total_size = 2; 169 + */ 170 + totalSize: number; 171 + }; 172 + 173 + /** 174 + * Describes the message openstatus.status_page.v1.ListStatusPagesResponse. 175 + * Use `create(ListStatusPagesResponseSchema)` to create a new message. 176 + */ 177 + export const ListStatusPagesResponseSchema: GenMessage<ListStatusPagesResponse> = /*@__PURE__*/ 178 + messageDesc(file_openstatus_status_page_v1_service, 5); 179 + 180 + /** 181 + * @generated from message openstatus.status_page.v1.UpdateStatusPageRequest 182 + */ 183 + export type UpdateStatusPageRequest = Message<"openstatus.status_page.v1.UpdateStatusPageRequest"> & { 184 + /** 185 + * ID of the status page to update (required). 186 + * 187 + * @generated from field: string id = 1; 188 + */ 189 + id: string; 190 + 191 + /** 192 + * New title for the status page (optional). 193 + * 194 + * @generated from field: optional string title = 2; 195 + */ 196 + title?: string; 197 + 198 + /** 199 + * New description for the status page (optional). 200 + * 201 + * @generated from field: optional string description = 3; 202 + */ 203 + description?: string; 204 + 205 + /** 206 + * New slug for the status page (optional). 207 + * 208 + * @generated from field: optional string slug = 4; 209 + */ 210 + slug?: string; 211 + 212 + /** 213 + * New homepage URL (optional). 214 + * 215 + * @generated from field: optional string homepage_url = 5; 216 + */ 217 + homepageUrl?: string; 218 + 219 + /** 220 + * New contact URL (optional). 221 + * 222 + * @generated from field: optional string contact_url = 6; 223 + */ 224 + contactUrl?: string; 225 + }; 226 + 227 + /** 228 + * Describes the message openstatus.status_page.v1.UpdateStatusPageRequest. 229 + * Use `create(UpdateStatusPageRequestSchema)` to create a new message. 230 + */ 231 + export const UpdateStatusPageRequestSchema: GenMessage<UpdateStatusPageRequest> = /*@__PURE__*/ 232 + messageDesc(file_openstatus_status_page_v1_service, 6); 233 + 234 + /** 235 + * @generated from message openstatus.status_page.v1.UpdateStatusPageResponse 236 + */ 237 + export type UpdateStatusPageResponse = Message<"openstatus.status_page.v1.UpdateStatusPageResponse"> & { 238 + /** 239 + * The updated status page. 240 + * 241 + * @generated from field: openstatus.status_page.v1.StatusPage status_page = 1; 242 + */ 243 + statusPage?: StatusPage; 244 + }; 245 + 246 + /** 247 + * Describes the message openstatus.status_page.v1.UpdateStatusPageResponse. 248 + * Use `create(UpdateStatusPageResponseSchema)` to create a new message. 249 + */ 250 + export const UpdateStatusPageResponseSchema: GenMessage<UpdateStatusPageResponse> = /*@__PURE__*/ 251 + messageDesc(file_openstatus_status_page_v1_service, 7); 252 + 253 + /** 254 + * @generated from message openstatus.status_page.v1.DeleteStatusPageRequest 255 + */ 256 + export type DeleteStatusPageRequest = Message<"openstatus.status_page.v1.DeleteStatusPageRequest"> & { 257 + /** 258 + * ID of the status page to delete (required). 259 + * 260 + * @generated from field: string id = 1; 261 + */ 262 + id: string; 263 + }; 264 + 265 + /** 266 + * Describes the message openstatus.status_page.v1.DeleteStatusPageRequest. 267 + * Use `create(DeleteStatusPageRequestSchema)` to create a new message. 268 + */ 269 + export const DeleteStatusPageRequestSchema: GenMessage<DeleteStatusPageRequest> = /*@__PURE__*/ 270 + messageDesc(file_openstatus_status_page_v1_service, 8); 271 + 272 + /** 273 + * @generated from message openstatus.status_page.v1.DeleteStatusPageResponse 274 + */ 275 + export type DeleteStatusPageResponse = Message<"openstatus.status_page.v1.DeleteStatusPageResponse"> & { 276 + /** 277 + * Whether the deletion was successful. 278 + * 279 + * @generated from field: bool success = 1; 280 + */ 281 + success: boolean; 282 + }; 283 + 284 + /** 285 + * Describes the message openstatus.status_page.v1.DeleteStatusPageResponse. 286 + * Use `create(DeleteStatusPageResponseSchema)` to create a new message. 287 + */ 288 + export const DeleteStatusPageResponseSchema: GenMessage<DeleteStatusPageResponse> = /*@__PURE__*/ 289 + messageDesc(file_openstatus_status_page_v1_service, 9); 290 + 291 + /** 292 + * @generated from message openstatus.status_page.v1.AddMonitorComponentRequest 293 + */ 294 + export type AddMonitorComponentRequest = Message<"openstatus.status_page.v1.AddMonitorComponentRequest"> & { 295 + /** 296 + * ID of the status page to add the component to (required). 297 + * 298 + * @generated from field: string page_id = 1; 299 + */ 300 + pageId: string; 301 + 302 + /** 303 + * ID of the monitor to associate with this component (required). 304 + * 305 + * @generated from field: string monitor_id = 2; 306 + */ 307 + monitorId: string; 308 + 309 + /** 310 + * Display name for the component (optional, defaults to monitor name). 311 + * 312 + * @generated from field: optional string name = 3; 313 + */ 314 + name?: string; 315 + 316 + /** 317 + * Description of the component (optional). 318 + * 319 + * @generated from field: optional string description = 4; 320 + */ 321 + description?: string; 322 + 323 + /** 324 + * Display order of the component (optional). 325 + * 326 + * @generated from field: optional int32 order = 5; 327 + */ 328 + order?: number; 329 + 330 + /** 331 + * ID of the group to add this component to (optional). 332 + * 333 + * @generated from field: optional string group_id = 6; 334 + */ 335 + groupId?: string; 336 + }; 337 + 338 + /** 339 + * Describes the message openstatus.status_page.v1.AddMonitorComponentRequest. 340 + * Use `create(AddMonitorComponentRequestSchema)` to create a new message. 341 + */ 342 + export const AddMonitorComponentRequestSchema: GenMessage<AddMonitorComponentRequest> = /*@__PURE__*/ 343 + messageDesc(file_openstatus_status_page_v1_service, 10); 344 + 345 + /** 346 + * @generated from message openstatus.status_page.v1.AddMonitorComponentResponse 347 + */ 348 + export type AddMonitorComponentResponse = Message<"openstatus.status_page.v1.AddMonitorComponentResponse"> & { 349 + /** 350 + * The created component. 351 + * 352 + * @generated from field: openstatus.status_page.v1.PageComponent component = 1; 353 + */ 354 + component?: PageComponent; 355 + }; 356 + 357 + /** 358 + * Describes the message openstatus.status_page.v1.AddMonitorComponentResponse. 359 + * Use `create(AddMonitorComponentResponseSchema)` to create a new message. 360 + */ 361 + export const AddMonitorComponentResponseSchema: GenMessage<AddMonitorComponentResponse> = /*@__PURE__*/ 362 + messageDesc(file_openstatus_status_page_v1_service, 11); 363 + 364 + /** 365 + * @generated from message openstatus.status_page.v1.AddStaticComponentRequest 366 + */ 367 + export type AddStaticComponentRequest = Message<"openstatus.status_page.v1.AddStaticComponentRequest"> & { 368 + /** 369 + * ID of the status page to add the component to (required). 370 + * 371 + * @generated from field: string page_id = 1; 372 + */ 373 + pageId: string; 374 + 375 + /** 376 + * Display name for the component (required). 377 + * 378 + * @generated from field: string name = 2; 379 + */ 380 + name: string; 381 + 382 + /** 383 + * Description of the component (optional). 384 + * 385 + * @generated from field: optional string description = 3; 386 + */ 387 + description?: string; 388 + 389 + /** 390 + * Display order of the component (optional). 391 + * 392 + * @generated from field: optional int32 order = 4; 393 + */ 394 + order?: number; 395 + 396 + /** 397 + * ID of the group to add this component to (optional). 398 + * 399 + * @generated from field: optional string group_id = 5; 400 + */ 401 + groupId?: string; 402 + }; 403 + 404 + /** 405 + * Describes the message openstatus.status_page.v1.AddStaticComponentRequest. 406 + * Use `create(AddStaticComponentRequestSchema)` to create a new message. 407 + */ 408 + export const AddStaticComponentRequestSchema: GenMessage<AddStaticComponentRequest> = /*@__PURE__*/ 409 + messageDesc(file_openstatus_status_page_v1_service, 12); 410 + 411 + /** 412 + * @generated from message openstatus.status_page.v1.AddStaticComponentResponse 413 + */ 414 + export type AddStaticComponentResponse = Message<"openstatus.status_page.v1.AddStaticComponentResponse"> & { 415 + /** 416 + * The created component. 417 + * 418 + * @generated from field: openstatus.status_page.v1.PageComponent component = 1; 419 + */ 420 + component?: PageComponent; 421 + }; 422 + 423 + /** 424 + * Describes the message openstatus.status_page.v1.AddStaticComponentResponse. 425 + * Use `create(AddStaticComponentResponseSchema)` to create a new message. 426 + */ 427 + export const AddStaticComponentResponseSchema: GenMessage<AddStaticComponentResponse> = /*@__PURE__*/ 428 + messageDesc(file_openstatus_status_page_v1_service, 13); 429 + 430 + /** 431 + * @generated from message openstatus.status_page.v1.RemoveComponentRequest 432 + */ 433 + export type RemoveComponentRequest = Message<"openstatus.status_page.v1.RemoveComponentRequest"> & { 434 + /** 435 + * ID of the component to remove (required). 436 + * 437 + * @generated from field: string id = 1; 438 + */ 439 + id: string; 440 + }; 441 + 442 + /** 443 + * Describes the message openstatus.status_page.v1.RemoveComponentRequest. 444 + * Use `create(RemoveComponentRequestSchema)` to create a new message. 445 + */ 446 + export const RemoveComponentRequestSchema: GenMessage<RemoveComponentRequest> = /*@__PURE__*/ 447 + messageDesc(file_openstatus_status_page_v1_service, 14); 448 + 449 + /** 450 + * @generated from message openstatus.status_page.v1.RemoveComponentResponse 451 + */ 452 + export type RemoveComponentResponse = Message<"openstatus.status_page.v1.RemoveComponentResponse"> & { 453 + /** 454 + * Whether the removal was successful. 455 + * 456 + * @generated from field: bool success = 1; 457 + */ 458 + success: boolean; 459 + }; 460 + 461 + /** 462 + * Describes the message openstatus.status_page.v1.RemoveComponentResponse. 463 + * Use `create(RemoveComponentResponseSchema)` to create a new message. 464 + */ 465 + export const RemoveComponentResponseSchema: GenMessage<RemoveComponentResponse> = /*@__PURE__*/ 466 + messageDesc(file_openstatus_status_page_v1_service, 15); 467 + 468 + /** 469 + * @generated from message openstatus.status_page.v1.UpdateComponentRequest 470 + */ 471 + export type UpdateComponentRequest = Message<"openstatus.status_page.v1.UpdateComponentRequest"> & { 472 + /** 473 + * ID of the component to update (required). 474 + * 475 + * @generated from field: string id = 1; 476 + */ 477 + id: string; 478 + 479 + /** 480 + * New display name for the component (optional). 481 + * 482 + * @generated from field: optional string name = 2; 483 + */ 484 + name?: string; 485 + 486 + /** 487 + * New description for the component (optional). 488 + * 489 + * @generated from field: optional string description = 3; 490 + */ 491 + description?: string; 492 + 493 + /** 494 + * New display order (optional). 495 + * 496 + * @generated from field: optional int32 order = 4; 497 + */ 498 + order?: number; 499 + 500 + /** 501 + * New group ID (optional, set to empty string to remove from group). 502 + * 503 + * @generated from field: optional string group_id = 5; 504 + */ 505 + groupId?: string; 506 + 507 + /** 508 + * New order within the group (optional). 509 + * 510 + * @generated from field: optional int32 group_order = 6; 511 + */ 512 + groupOrder?: number; 513 + }; 514 + 515 + /** 516 + * Describes the message openstatus.status_page.v1.UpdateComponentRequest. 517 + * Use `create(UpdateComponentRequestSchema)` to create a new message. 518 + */ 519 + export const UpdateComponentRequestSchema: GenMessage<UpdateComponentRequest> = /*@__PURE__*/ 520 + messageDesc(file_openstatus_status_page_v1_service, 16); 521 + 522 + /** 523 + * @generated from message openstatus.status_page.v1.UpdateComponentResponse 524 + */ 525 + export type UpdateComponentResponse = Message<"openstatus.status_page.v1.UpdateComponentResponse"> & { 526 + /** 527 + * The updated component. 528 + * 529 + * @generated from field: openstatus.status_page.v1.PageComponent component = 1; 530 + */ 531 + component?: PageComponent; 532 + }; 533 + 534 + /** 535 + * Describes the message openstatus.status_page.v1.UpdateComponentResponse. 536 + * Use `create(UpdateComponentResponseSchema)` to create a new message. 537 + */ 538 + export const UpdateComponentResponseSchema: GenMessage<UpdateComponentResponse> = /*@__PURE__*/ 539 + messageDesc(file_openstatus_status_page_v1_service, 17); 540 + 541 + /** 542 + * @generated from message openstatus.status_page.v1.CreateComponentGroupRequest 543 + */ 544 + export type CreateComponentGroupRequest = Message<"openstatus.status_page.v1.CreateComponentGroupRequest"> & { 545 + /** 546 + * ID of the status page to create the group in (required). 547 + * 548 + * @generated from field: string page_id = 1; 549 + */ 550 + pageId: string; 551 + 552 + /** 553 + * Display name for the group (required). 554 + * 555 + * @generated from field: string name = 2; 556 + */ 557 + name: string; 558 + }; 559 + 560 + /** 561 + * Describes the message openstatus.status_page.v1.CreateComponentGroupRequest. 562 + * Use `create(CreateComponentGroupRequestSchema)` to create a new message. 563 + */ 564 + export const CreateComponentGroupRequestSchema: GenMessage<CreateComponentGroupRequest> = /*@__PURE__*/ 565 + messageDesc(file_openstatus_status_page_v1_service, 18); 566 + 567 + /** 568 + * @generated from message openstatus.status_page.v1.CreateComponentGroupResponse 569 + */ 570 + export type CreateComponentGroupResponse = Message<"openstatus.status_page.v1.CreateComponentGroupResponse"> & { 571 + /** 572 + * The created component group. 573 + * 574 + * @generated from field: openstatus.status_page.v1.PageComponentGroup group = 1; 575 + */ 576 + group?: PageComponentGroup; 577 + }; 578 + 579 + /** 580 + * Describes the message openstatus.status_page.v1.CreateComponentGroupResponse. 581 + * Use `create(CreateComponentGroupResponseSchema)` to create a new message. 582 + */ 583 + export const CreateComponentGroupResponseSchema: GenMessage<CreateComponentGroupResponse> = /*@__PURE__*/ 584 + messageDesc(file_openstatus_status_page_v1_service, 19); 585 + 586 + /** 587 + * @generated from message openstatus.status_page.v1.DeleteComponentGroupRequest 588 + */ 589 + export type DeleteComponentGroupRequest = Message<"openstatus.status_page.v1.DeleteComponentGroupRequest"> & { 590 + /** 591 + * ID of the component group to delete (required). 592 + * 593 + * @generated from field: string id = 1; 594 + */ 595 + id: string; 596 + }; 597 + 598 + /** 599 + * Describes the message openstatus.status_page.v1.DeleteComponentGroupRequest. 600 + * Use `create(DeleteComponentGroupRequestSchema)` to create a new message. 601 + */ 602 + export const DeleteComponentGroupRequestSchema: GenMessage<DeleteComponentGroupRequest> = /*@__PURE__*/ 603 + messageDesc(file_openstatus_status_page_v1_service, 20); 604 + 605 + /** 606 + * @generated from message openstatus.status_page.v1.DeleteComponentGroupResponse 607 + */ 608 + export type DeleteComponentGroupResponse = Message<"openstatus.status_page.v1.DeleteComponentGroupResponse"> & { 609 + /** 610 + * Whether the deletion was successful. 611 + * 612 + * @generated from field: bool success = 1; 613 + */ 614 + success: boolean; 615 + }; 616 + 617 + /** 618 + * Describes the message openstatus.status_page.v1.DeleteComponentGroupResponse. 619 + * Use `create(DeleteComponentGroupResponseSchema)` to create a new message. 620 + */ 621 + export const DeleteComponentGroupResponseSchema: GenMessage<DeleteComponentGroupResponse> = /*@__PURE__*/ 622 + messageDesc(file_openstatus_status_page_v1_service, 21); 623 + 624 + /** 625 + * @generated from message openstatus.status_page.v1.UpdateComponentGroupRequest 626 + */ 627 + export type UpdateComponentGroupRequest = Message<"openstatus.status_page.v1.UpdateComponentGroupRequest"> & { 628 + /** 629 + * ID of the component group to update (required). 630 + * 631 + * @generated from field: string id = 1; 632 + */ 633 + id: string; 634 + 635 + /** 636 + * New display name for the group (optional). 637 + * 638 + * @generated from field: optional string name = 2; 639 + */ 640 + name?: string; 641 + }; 642 + 643 + /** 644 + * Describes the message openstatus.status_page.v1.UpdateComponentGroupRequest. 645 + * Use `create(UpdateComponentGroupRequestSchema)` to create a new message. 646 + */ 647 + export const UpdateComponentGroupRequestSchema: GenMessage<UpdateComponentGroupRequest> = /*@__PURE__*/ 648 + messageDesc(file_openstatus_status_page_v1_service, 22); 649 + 650 + /** 651 + * @generated from message openstatus.status_page.v1.UpdateComponentGroupResponse 652 + */ 653 + export type UpdateComponentGroupResponse = Message<"openstatus.status_page.v1.UpdateComponentGroupResponse"> & { 654 + /** 655 + * The updated component group. 656 + * 657 + * @generated from field: openstatus.status_page.v1.PageComponentGroup group = 1; 658 + */ 659 + group?: PageComponentGroup; 660 + }; 661 + 662 + /** 663 + * Describes the message openstatus.status_page.v1.UpdateComponentGroupResponse. 664 + * Use `create(UpdateComponentGroupResponseSchema)` to create a new message. 665 + */ 666 + export const UpdateComponentGroupResponseSchema: GenMessage<UpdateComponentGroupResponse> = /*@__PURE__*/ 667 + messageDesc(file_openstatus_status_page_v1_service, 23); 668 + 669 + /** 670 + * @generated from message openstatus.status_page.v1.SubscribeToPageRequest 671 + */ 672 + export type SubscribeToPageRequest = Message<"openstatus.status_page.v1.SubscribeToPageRequest"> & { 673 + /** 674 + * ID of the status page to subscribe to (required). 675 + * 676 + * @generated from field: string page_id = 1; 677 + */ 678 + pageId: string; 679 + 680 + /** 681 + * Email address to subscribe (required). 682 + * 683 + * @generated from field: string email = 2; 684 + */ 685 + email: string; 686 + }; 687 + 688 + /** 689 + * Describes the message openstatus.status_page.v1.SubscribeToPageRequest. 690 + * Use `create(SubscribeToPageRequestSchema)` to create a new message. 691 + */ 692 + export const SubscribeToPageRequestSchema: GenMessage<SubscribeToPageRequest> = /*@__PURE__*/ 693 + messageDesc(file_openstatus_status_page_v1_service, 24); 694 + 695 + /** 696 + * @generated from message openstatus.status_page.v1.SubscribeToPageResponse 697 + */ 698 + export type SubscribeToPageResponse = Message<"openstatus.status_page.v1.SubscribeToPageResponse"> & { 699 + /** 700 + * The created subscriber. 701 + * 702 + * @generated from field: openstatus.status_page.v1.PageSubscriber subscriber = 1; 703 + */ 704 + subscriber?: PageSubscriber; 705 + }; 706 + 707 + /** 708 + * Describes the message openstatus.status_page.v1.SubscribeToPageResponse. 709 + * Use `create(SubscribeToPageResponseSchema)` to create a new message. 710 + */ 711 + export const SubscribeToPageResponseSchema: GenMessage<SubscribeToPageResponse> = /*@__PURE__*/ 712 + messageDesc(file_openstatus_status_page_v1_service, 25); 713 + 714 + /** 715 + * @generated from message openstatus.status_page.v1.UnsubscribeFromPageRequest 716 + */ 717 + export type UnsubscribeFromPageRequest = Message<"openstatus.status_page.v1.UnsubscribeFromPageRequest"> & { 718 + /** 719 + * ID of the status page to unsubscribe from (required). 720 + * 721 + * @generated from field: string page_id = 1; 722 + */ 723 + pageId: string; 724 + 725 + /** 726 + * Identifier for the subscription (either email or id). 727 + * 728 + * @generated from oneof openstatus.status_page.v1.UnsubscribeFromPageRequest.identifier 729 + */ 730 + identifier: { 731 + /** 732 + * Email address to unsubscribe. 733 + * 734 + * @generated from field: string email = 2; 735 + */ 736 + value: string; 737 + case: "email"; 738 + } | { 739 + /** 740 + * Subscriber ID. 741 + * 742 + * @generated from field: string id = 3; 743 + */ 744 + value: string; 745 + case: "id"; 746 + } | { case: undefined; value?: undefined }; 747 + }; 748 + 749 + /** 750 + * Describes the message openstatus.status_page.v1.UnsubscribeFromPageRequest. 751 + * Use `create(UnsubscribeFromPageRequestSchema)` to create a new message. 752 + */ 753 + export const UnsubscribeFromPageRequestSchema: GenMessage<UnsubscribeFromPageRequest> = /*@__PURE__*/ 754 + messageDesc(file_openstatus_status_page_v1_service, 26); 755 + 756 + /** 757 + * @generated from message openstatus.status_page.v1.UnsubscribeFromPageResponse 758 + */ 759 + export type UnsubscribeFromPageResponse = Message<"openstatus.status_page.v1.UnsubscribeFromPageResponse"> & { 760 + /** 761 + * Whether the unsubscription was successful. 762 + * 763 + * @generated from field: bool success = 1; 764 + */ 765 + success: boolean; 766 + }; 767 + 768 + /** 769 + * Describes the message openstatus.status_page.v1.UnsubscribeFromPageResponse. 770 + * Use `create(UnsubscribeFromPageResponseSchema)` to create a new message. 771 + */ 772 + export const UnsubscribeFromPageResponseSchema: GenMessage<UnsubscribeFromPageResponse> = /*@__PURE__*/ 773 + messageDesc(file_openstatus_status_page_v1_service, 27); 774 + 775 + /** 776 + * @generated from message openstatus.status_page.v1.ListSubscribersRequest 777 + */ 778 + export type ListSubscribersRequest = Message<"openstatus.status_page.v1.ListSubscribersRequest"> & { 779 + /** 780 + * ID of the status page to list subscribers for (required). 781 + * 782 + * @generated from field: string page_id = 1; 783 + */ 784 + pageId: string; 785 + 786 + /** 787 + * Maximum number of subscribers to return (1-100, defaults to 50). 788 + * 789 + * @generated from field: optional int32 limit = 2; 790 + */ 791 + limit?: number; 792 + 793 + /** 794 + * Number of subscribers to skip for pagination (defaults to 0). 795 + * 796 + * @generated from field: optional int32 offset = 3; 797 + */ 798 + offset?: number; 799 + 800 + /** 801 + * Whether to include unsubscribed users (defaults to false). 802 + * 803 + * @generated from field: optional bool include_unsubscribed = 4; 804 + */ 805 + includeUnsubscribed?: boolean; 806 + }; 807 + 808 + /** 809 + * Describes the message openstatus.status_page.v1.ListSubscribersRequest. 810 + * Use `create(ListSubscribersRequestSchema)` to create a new message. 811 + */ 812 + export const ListSubscribersRequestSchema: GenMessage<ListSubscribersRequest> = /*@__PURE__*/ 813 + messageDesc(file_openstatus_status_page_v1_service, 28); 814 + 815 + /** 816 + * @generated from message openstatus.status_page.v1.ListSubscribersResponse 817 + */ 818 + export type ListSubscribersResponse = Message<"openstatus.status_page.v1.ListSubscribersResponse"> & { 819 + /** 820 + * List of subscribers. 821 + * 822 + * @generated from field: repeated openstatus.status_page.v1.PageSubscriber subscribers = 1; 823 + */ 824 + subscribers: PageSubscriber[]; 825 + 826 + /** 827 + * Total number of subscribers matching the filter. 828 + * 829 + * @generated from field: int32 total_size = 2; 830 + */ 831 + totalSize: number; 832 + }; 833 + 834 + /** 835 + * Describes the message openstatus.status_page.v1.ListSubscribersResponse. 836 + * Use `create(ListSubscribersResponseSchema)` to create a new message. 837 + */ 838 + export const ListSubscribersResponseSchema: GenMessage<ListSubscribersResponse> = /*@__PURE__*/ 839 + messageDesc(file_openstatus_status_page_v1_service, 29); 840 + 841 + /** 842 + * @generated from message openstatus.status_page.v1.GetStatusPageContentRequest 843 + */ 844 + export type GetStatusPageContentRequest = Message<"openstatus.status_page.v1.GetStatusPageContentRequest"> & { 845 + /** 846 + * Identifier for the status page (either id or slug). 847 + * 848 + * @generated from oneof openstatus.status_page.v1.GetStatusPageContentRequest.identifier 849 + */ 850 + identifier: { 851 + /** 852 + * ID of the status page. 853 + * 854 + * @generated from field: string id = 1; 855 + */ 856 + value: string; 857 + case: "id"; 858 + } | { 859 + /** 860 + * Slug of the status page. 861 + * 862 + * @generated from field: string slug = 2; 863 + */ 864 + value: string; 865 + case: "slug"; 866 + } | { case: undefined; value?: undefined }; 867 + }; 868 + 869 + /** 870 + * Describes the message openstatus.status_page.v1.GetStatusPageContentRequest. 871 + * Use `create(GetStatusPageContentRequestSchema)` to create a new message. 872 + */ 873 + export const GetStatusPageContentRequestSchema: GenMessage<GetStatusPageContentRequest> = /*@__PURE__*/ 874 + messageDesc(file_openstatus_status_page_v1_service, 30); 875 + 876 + /** 877 + * @generated from message openstatus.status_page.v1.GetStatusPageContentResponse 878 + */ 879 + export type GetStatusPageContentResponse = Message<"openstatus.status_page.v1.GetStatusPageContentResponse"> & { 880 + /** 881 + * The status page details. 882 + * 883 + * @generated from field: openstatus.status_page.v1.StatusPage status_page = 1; 884 + */ 885 + statusPage?: StatusPage; 886 + 887 + /** 888 + * Components on the status page. 889 + * 890 + * @generated from field: repeated openstatus.status_page.v1.PageComponent components = 2; 891 + */ 892 + components: PageComponent[]; 893 + 894 + /** 895 + * Component groups on the status page. 896 + * 897 + * @generated from field: repeated openstatus.status_page.v1.PageComponentGroup groups = 3; 898 + */ 899 + groups: PageComponentGroup[]; 900 + 901 + /** 902 + * Active and recent status reports. 903 + * 904 + * @generated from field: repeated openstatus.status_report.v1.StatusReport status_reports = 4; 905 + */ 906 + statusReports: StatusReport[]; 907 + 908 + /** 909 + * Scheduled maintenances. 910 + * 911 + * @generated from field: repeated openstatus.status_page.v1.Maintenance maintenances = 5; 912 + */ 913 + maintenances: Maintenance[]; 914 + }; 915 + 916 + /** 917 + * Describes the message openstatus.status_page.v1.GetStatusPageContentResponse. 918 + * Use `create(GetStatusPageContentResponseSchema)` to create a new message. 919 + */ 920 + export const GetStatusPageContentResponseSchema: GenMessage<GetStatusPageContentResponse> = /*@__PURE__*/ 921 + messageDesc(file_openstatus_status_page_v1_service, 31); 922 + 923 + /** 924 + * @generated from message openstatus.status_page.v1.GetOverallStatusRequest 925 + */ 926 + export type GetOverallStatusRequest = Message<"openstatus.status_page.v1.GetOverallStatusRequest"> & { 927 + /** 928 + * Identifier for the status page (either id or slug). 929 + * 930 + * @generated from oneof openstatus.status_page.v1.GetOverallStatusRequest.identifier 931 + */ 932 + identifier: { 933 + /** 934 + * ID of the status page. 935 + * 936 + * @generated from field: string id = 1; 937 + */ 938 + value: string; 939 + case: "id"; 940 + } | { 941 + /** 942 + * Slug of the status page. 943 + * 944 + * @generated from field: string slug = 2; 945 + */ 946 + value: string; 947 + case: "slug"; 948 + } | { case: undefined; value?: undefined }; 949 + }; 950 + 951 + /** 952 + * Describes the message openstatus.status_page.v1.GetOverallStatusRequest. 953 + * Use `create(GetOverallStatusRequestSchema)` to create a new message. 954 + */ 955 + export const GetOverallStatusRequestSchema: GenMessage<GetOverallStatusRequest> = /*@__PURE__*/ 956 + messageDesc(file_openstatus_status_page_v1_service, 32); 957 + 958 + /** 959 + * ComponentStatus represents the status of a single component. 960 + * 961 + * @generated from message openstatus.status_page.v1.ComponentStatus 962 + */ 963 + export type ComponentStatus = Message<"openstatus.status_page.v1.ComponentStatus"> & { 964 + /** 965 + * ID of the component. 966 + * 967 + * @generated from field: string component_id = 1; 968 + */ 969 + componentId: string; 970 + 971 + /** 972 + * Current status of the component. 973 + * 974 + * @generated from field: openstatus.status_page.v1.OverallStatus status = 2; 975 + */ 976 + status: OverallStatus; 977 + }; 978 + 979 + /** 980 + * Describes the message openstatus.status_page.v1.ComponentStatus. 981 + * Use `create(ComponentStatusSchema)` to create a new message. 982 + */ 983 + export const ComponentStatusSchema: GenMessage<ComponentStatus> = /*@__PURE__*/ 984 + messageDesc(file_openstatus_status_page_v1_service, 33); 985 + 986 + /** 987 + * @generated from message openstatus.status_page.v1.GetOverallStatusResponse 988 + */ 989 + export type GetOverallStatusResponse = Message<"openstatus.status_page.v1.GetOverallStatusResponse"> & { 990 + /** 991 + * Aggregated status across all components. 992 + * 993 + * @generated from field: openstatus.status_page.v1.OverallStatus overall_status = 1; 994 + */ 995 + overallStatus: OverallStatus; 996 + 997 + /** 998 + * Status of individual components. 999 + * 1000 + * @generated from field: repeated openstatus.status_page.v1.ComponentStatus component_statuses = 2; 1001 + */ 1002 + componentStatuses: ComponentStatus[]; 1003 + }; 1004 + 1005 + /** 1006 + * Describes the message openstatus.status_page.v1.GetOverallStatusResponse. 1007 + * Use `create(GetOverallStatusResponseSchema)` to create a new message. 1008 + */ 1009 + export const GetOverallStatusResponseSchema: GenMessage<GetOverallStatusResponse> = /*@__PURE__*/ 1010 + messageDesc(file_openstatus_status_page_v1_service, 34); 1011 + 1012 + /** 1013 + * StatusPageService provides CRUD and management operations for status pages. 1014 + * 1015 + * --- Page CRUD --- 1016 + * 1017 + * @generated from service openstatus.status_page.v1.StatusPageService 1018 + */ 1019 + export const StatusPageService: GenService<{ 1020 + /** 1021 + * CreateStatusPage creates a new status page. 1022 + * 1023 + * @generated from rpc openstatus.status_page.v1.StatusPageService.CreateStatusPage 1024 + */ 1025 + createStatusPage: { 1026 + methodKind: "unary"; 1027 + input: typeof CreateStatusPageRequestSchema; 1028 + output: typeof CreateStatusPageResponseSchema; 1029 + }, 1030 + /** 1031 + * GetStatusPage retrieves a specific status page by ID. 1032 + * 1033 + * @generated from rpc openstatus.status_page.v1.StatusPageService.GetStatusPage 1034 + */ 1035 + getStatusPage: { 1036 + methodKind: "unary"; 1037 + input: typeof GetStatusPageRequestSchema; 1038 + output: typeof GetStatusPageResponseSchema; 1039 + }, 1040 + /** 1041 + * ListStatusPages returns all status pages for the workspace. 1042 + * 1043 + * @generated from rpc openstatus.status_page.v1.StatusPageService.ListStatusPages 1044 + */ 1045 + listStatusPages: { 1046 + methodKind: "unary"; 1047 + input: typeof ListStatusPagesRequestSchema; 1048 + output: typeof ListStatusPagesResponseSchema; 1049 + }, 1050 + /** 1051 + * UpdateStatusPage updates an existing status page. 1052 + * 1053 + * @generated from rpc openstatus.status_page.v1.StatusPageService.UpdateStatusPage 1054 + */ 1055 + updateStatusPage: { 1056 + methodKind: "unary"; 1057 + input: typeof UpdateStatusPageRequestSchema; 1058 + output: typeof UpdateStatusPageResponseSchema; 1059 + }, 1060 + /** 1061 + * DeleteStatusPage removes a status page. 1062 + * 1063 + * @generated from rpc openstatus.status_page.v1.StatusPageService.DeleteStatusPage 1064 + */ 1065 + deleteStatusPage: { 1066 + methodKind: "unary"; 1067 + input: typeof DeleteStatusPageRequestSchema; 1068 + output: typeof DeleteStatusPageResponseSchema; 1069 + }, 1070 + /** 1071 + * AddMonitorComponent adds a monitor-based component to a status page. 1072 + * 1073 + * @generated from rpc openstatus.status_page.v1.StatusPageService.AddMonitorComponent 1074 + */ 1075 + addMonitorComponent: { 1076 + methodKind: "unary"; 1077 + input: typeof AddMonitorComponentRequestSchema; 1078 + output: typeof AddMonitorComponentResponseSchema; 1079 + }, 1080 + /** 1081 + * AddStaticComponent adds a static component to a status page. 1082 + * 1083 + * @generated from rpc openstatus.status_page.v1.StatusPageService.AddStaticComponent 1084 + */ 1085 + addStaticComponent: { 1086 + methodKind: "unary"; 1087 + input: typeof AddStaticComponentRequestSchema; 1088 + output: typeof AddStaticComponentResponseSchema; 1089 + }, 1090 + /** 1091 + * RemoveComponent removes a component from a status page. 1092 + * 1093 + * @generated from rpc openstatus.status_page.v1.StatusPageService.RemoveComponent 1094 + */ 1095 + removeComponent: { 1096 + methodKind: "unary"; 1097 + input: typeof RemoveComponentRequestSchema; 1098 + output: typeof RemoveComponentResponseSchema; 1099 + }, 1100 + /** 1101 + * UpdateComponent updates an existing component. 1102 + * 1103 + * @generated from rpc openstatus.status_page.v1.StatusPageService.UpdateComponent 1104 + */ 1105 + updateComponent: { 1106 + methodKind: "unary"; 1107 + input: typeof UpdateComponentRequestSchema; 1108 + output: typeof UpdateComponentResponseSchema; 1109 + }, 1110 + /** 1111 + * CreateComponentGroup creates a new component group. 1112 + * 1113 + * @generated from rpc openstatus.status_page.v1.StatusPageService.CreateComponentGroup 1114 + */ 1115 + createComponentGroup: { 1116 + methodKind: "unary"; 1117 + input: typeof CreateComponentGroupRequestSchema; 1118 + output: typeof CreateComponentGroupResponseSchema; 1119 + }, 1120 + /** 1121 + * DeleteComponentGroup removes a component group. 1122 + * 1123 + * @generated from rpc openstatus.status_page.v1.StatusPageService.DeleteComponentGroup 1124 + */ 1125 + deleteComponentGroup: { 1126 + methodKind: "unary"; 1127 + input: typeof DeleteComponentGroupRequestSchema; 1128 + output: typeof DeleteComponentGroupResponseSchema; 1129 + }, 1130 + /** 1131 + * UpdateComponentGroup updates an existing component group. 1132 + * 1133 + * @generated from rpc openstatus.status_page.v1.StatusPageService.UpdateComponentGroup 1134 + */ 1135 + updateComponentGroup: { 1136 + methodKind: "unary"; 1137 + input: typeof UpdateComponentGroupRequestSchema; 1138 + output: typeof UpdateComponentGroupResponseSchema; 1139 + }, 1140 + /** 1141 + * SubscribeToPage subscribes an email to a status page. 1142 + * 1143 + * @generated from rpc openstatus.status_page.v1.StatusPageService.SubscribeToPage 1144 + */ 1145 + subscribeToPage: { 1146 + methodKind: "unary"; 1147 + input: typeof SubscribeToPageRequestSchema; 1148 + output: typeof SubscribeToPageResponseSchema; 1149 + }, 1150 + /** 1151 + * UnsubscribeFromPage removes a subscription from a status page. 1152 + * 1153 + * @generated from rpc openstatus.status_page.v1.StatusPageService.UnsubscribeFromPage 1154 + */ 1155 + unsubscribeFromPage: { 1156 + methodKind: "unary"; 1157 + input: typeof UnsubscribeFromPageRequestSchema; 1158 + output: typeof UnsubscribeFromPageResponseSchema; 1159 + }, 1160 + /** 1161 + * ListSubscribers returns all subscribers for a status page. 1162 + * 1163 + * @generated from rpc openstatus.status_page.v1.StatusPageService.ListSubscribers 1164 + */ 1165 + listSubscribers: { 1166 + methodKind: "unary"; 1167 + input: typeof ListSubscribersRequestSchema; 1168 + output: typeof ListSubscribersResponseSchema; 1169 + }, 1170 + /** 1171 + * GetStatusPageContent retrieves the full content of a status page including components and reports. 1172 + * 1173 + * @generated from rpc openstatus.status_page.v1.StatusPageService.GetStatusPageContent 1174 + */ 1175 + getStatusPageContent: { 1176 + methodKind: "unary"; 1177 + input: typeof GetStatusPageContentRequestSchema; 1178 + output: typeof GetStatusPageContentResponseSchema; 1179 + }, 1180 + /** 1181 + * GetOverallStatus returns the aggregated status of a status page. 1182 + * 1183 + * @generated from rpc openstatus.status_page.v1.StatusPageService.GetOverallStatus 1184 + */ 1185 + getOverallStatus: { 1186 + methodKind: "unary"; 1187 + input: typeof GetOverallStatusRequestSchema; 1188 + output: typeof GetOverallStatusResponseSchema; 1189 + }, 1190 + }> = /*@__PURE__*/ 1191 + serviceDesc(file_openstatus_status_page_v1_service, 0); 1192 +
+359
packages/proto/gen/ts/openstatus/status_page/v1/status_page_pb.ts
··· 1 + // @generated by protoc-gen-es v2.10.2 with parameter "target=ts,import_extension=.ts" 2 + // @generated from file openstatus/status_page/v1/status_page.proto (package openstatus.status_page.v1, syntax proto3) 3 + /* eslint-disable */ 4 + 5 + import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; 6 + import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; 7 + import type { Message } from "@bufbuild/protobuf"; 8 + 9 + /** 10 + * Describes the file openstatus/status_page/v1/status_page.proto. 11 + */ 12 + export const file_openstatus_status_page_v1_status_page: GenFile = /*@__PURE__*/ 13 + fileDesc("CitvcGVuc3RhdHVzL3N0YXR1c19wYWdlL3YxL3N0YXR1c19wYWdlLnByb3RvEhlvcGVuc3RhdHVzLnN0YXR1c19wYWdlLnYxIsoCCgpTdGF0dXNQYWdlEgoKAmlkGAEgASgJEg0KBXRpdGxlGAIgASgJEhMKC2Rlc2NyaXB0aW9uGAMgASgJEgwKBHNsdWcYBCABKAkSFQoNY3VzdG9tX2RvbWFpbhgFIAEoCRIRCglwdWJsaXNoZWQYBiABKAgSPgoLYWNjZXNzX3R5cGUYByABKA4yKS5vcGVuc3RhdHVzLnN0YXR1c19wYWdlLnYxLlBhZ2VBY2Nlc3NUeXBlEjMKBXRoZW1lGAggASgOMiQub3BlbnN0YXR1cy5zdGF0dXNfcGFnZS52MS5QYWdlVGhlbWUSFAoMaG9tZXBhZ2VfdXJsGAkgASgJEhMKC2NvbnRhY3RfdXJsGAogASgJEgwKBGljb24YCyABKAkSEgoKY3JlYXRlZF9hdBgMIAEoCRISCgp1cGRhdGVkX2F0GA0gASgJIncKEVN0YXR1c1BhZ2VTdW1tYXJ5EgoKAmlkGAEgASgJEg0KBXRpdGxlGAIgASgJEgwKBHNsdWcYAyABKAkSEQoJcHVibGlzaGVkGAQgASgIEhIKCmNyZWF0ZWRfYXQYBSABKAkSEgoKdXBkYXRlZF9hdBgGIAEoCSKXAQoLTWFpbnRlbmFuY2USCgoCaWQYASABKAkSDQoFdGl0bGUYAiABKAkSDwoHbWVzc2FnZRgDIAEoCRIMCgRmcm9tGAQgASgJEgoKAnRvGAUgASgJEhoKEnBhZ2VfY29tcG9uZW50X2lkcxgGIAMoCRISCgpjcmVhdGVkX2F0GAcgASgJEhIKCnVwZGF0ZWRfYXQYCCABKAkqnAEKDlBhZ2VBY2Nlc3NUeXBlEiAKHFBBR0VfQUNDRVNTX1RZUEVfVU5TUEVDSUZJRUQQABIbChdQQUdFX0FDQ0VTU19UWVBFX1BVQkxJQxABEicKI1BBR0VfQUNDRVNTX1RZUEVfUEFTU1dPUkRfUFJPVEVDVEVEEAISIgoeUEFHRV9BQ0NFU1NfVFlQRV9BVVRIRU5USUNBVEVEEAMqaQoJUGFnZVRoZW1lEhoKFlBBR0VfVEhFTUVfVU5TUEVDSUZJRUQQABIVChFQQUdFX1RIRU1FX1NZU1RFTRABEhQKEFBBR0VfVEhFTUVfTElHSFQQAhITCg9QQUdFX1RIRU1FX0RBUksQAyrsAQoNT3ZlcmFsbFN0YXR1cxIeChpPVkVSQUxMX1NUQVRVU19VTlNQRUNJRklFRBAAEh4KGk9WRVJBTExfU1RBVFVTX09QRVJBVElPTkFMEAESGwoXT1ZFUkFMTF9TVEFUVVNfREVHUkFERUQQAhIhCh1PVkVSQUxMX1NUQVRVU19QQVJUSUFMX09VVEFHRRADEh8KG09WRVJBTExfU1RBVFVTX01BSk9SX09VVEFHRRAEEh4KGk9WRVJBTExfU1RBVFVTX01BSU5URU5BTkNFEAUSGgoWT1ZFUkFMTF9TVEFUVVNfVU5LTk9XThAGQlpaWGdpdGh1Yi5jb20vb3BlbnN0YXR1c2hxL29wZW5zdGF0dXMvcGFja2FnZXMvcHJvdG8vb3BlbnN0YXR1cy9zdGF0dXNfcGFnZS92MTtzdGF0dXNwYWdldjFiBnByb3RvMw"); 14 + 15 + /** 16 + * StatusPage represents a full status page with all details. 17 + * 18 + * @generated from message openstatus.status_page.v1.StatusPage 19 + */ 20 + export type StatusPage = Message<"openstatus.status_page.v1.StatusPage"> & { 21 + /** 22 + * Unique identifier for the status page. 23 + * 24 + * @generated from field: string id = 1; 25 + */ 26 + id: string; 27 + 28 + /** 29 + * Title of the status page. 30 + * 31 + * @generated from field: string title = 2; 32 + */ 33 + title: string; 34 + 35 + /** 36 + * Description of the status page. 37 + * 38 + * @generated from field: string description = 3; 39 + */ 40 + description: string; 41 + 42 + /** 43 + * URL-friendly slug for the status page. 44 + * 45 + * @generated from field: string slug = 4; 46 + */ 47 + slug: string; 48 + 49 + /** 50 + * Custom domain for the status page (optional). 51 + * 52 + * @generated from field: string custom_domain = 5; 53 + */ 54 + customDomain: string; 55 + 56 + /** 57 + * Whether the status page is published and visible. 58 + * 59 + * @generated from field: bool published = 6; 60 + */ 61 + published: boolean; 62 + 63 + /** 64 + * Access type for the status page. 65 + * 66 + * @generated from field: openstatus.status_page.v1.PageAccessType access_type = 7; 67 + */ 68 + accessType: PageAccessType; 69 + 70 + /** 71 + * Visual theme for the status page. 72 + * 73 + * @generated from field: openstatus.status_page.v1.PageTheme theme = 8; 74 + */ 75 + theme: PageTheme; 76 + 77 + /** 78 + * URL to the homepage (optional). 79 + * 80 + * @generated from field: string homepage_url = 9; 81 + */ 82 + homepageUrl: string; 83 + 84 + /** 85 + * URL to the contact page (optional). 86 + * 87 + * @generated from field: string contact_url = 10; 88 + */ 89 + contactUrl: string; 90 + 91 + /** 92 + * Icon URL for the status page (optional). 93 + * 94 + * @generated from field: string icon = 11; 95 + */ 96 + icon: string; 97 + 98 + /** 99 + * Timestamp when the page was created (RFC 3339 format). 100 + * 101 + * @generated from field: string created_at = 12; 102 + */ 103 + createdAt: string; 104 + 105 + /** 106 + * Timestamp when the page was last updated (RFC 3339 format). 107 + * 108 + * @generated from field: string updated_at = 13; 109 + */ 110 + updatedAt: string; 111 + }; 112 + 113 + /** 114 + * Describes the message openstatus.status_page.v1.StatusPage. 115 + * Use `create(StatusPageSchema)` to create a new message. 116 + */ 117 + export const StatusPageSchema: GenMessage<StatusPage> = /*@__PURE__*/ 118 + messageDesc(file_openstatus_status_page_v1_status_page, 0); 119 + 120 + /** 121 + * StatusPageSummary represents metadata for a status page (used in list responses). 122 + * 123 + * @generated from message openstatus.status_page.v1.StatusPageSummary 124 + */ 125 + export type StatusPageSummary = Message<"openstatus.status_page.v1.StatusPageSummary"> & { 126 + /** 127 + * Unique identifier for the status page. 128 + * 129 + * @generated from field: string id = 1; 130 + */ 131 + id: string; 132 + 133 + /** 134 + * Title of the status page. 135 + * 136 + * @generated from field: string title = 2; 137 + */ 138 + title: string; 139 + 140 + /** 141 + * URL-friendly slug for the status page. 142 + * 143 + * @generated from field: string slug = 3; 144 + */ 145 + slug: string; 146 + 147 + /** 148 + * Whether the status page is published and visible. 149 + * 150 + * @generated from field: bool published = 4; 151 + */ 152 + published: boolean; 153 + 154 + /** 155 + * Timestamp when the page was created (RFC 3339 format). 156 + * 157 + * @generated from field: string created_at = 5; 158 + */ 159 + createdAt: string; 160 + 161 + /** 162 + * Timestamp when the page was last updated (RFC 3339 format). 163 + * 164 + * @generated from field: string updated_at = 6; 165 + */ 166 + updatedAt: string; 167 + }; 168 + 169 + /** 170 + * Describes the message openstatus.status_page.v1.StatusPageSummary. 171 + * Use `create(StatusPageSummarySchema)` to create a new message. 172 + */ 173 + export const StatusPageSummarySchema: GenMessage<StatusPageSummary> = /*@__PURE__*/ 174 + messageDesc(file_openstatus_status_page_v1_status_page, 1); 175 + 176 + /** 177 + * Maintenance represents a scheduled maintenance window. 178 + * 179 + * @generated from message openstatus.status_page.v1.Maintenance 180 + */ 181 + export type Maintenance = Message<"openstatus.status_page.v1.Maintenance"> & { 182 + /** 183 + * Unique identifier for the maintenance. 184 + * 185 + * @generated from field: string id = 1; 186 + */ 187 + id: string; 188 + 189 + /** 190 + * Title of the maintenance. 191 + * 192 + * @generated from field: string title = 2; 193 + */ 194 + title: string; 195 + 196 + /** 197 + * Message describing the maintenance. 198 + * 199 + * @generated from field: string message = 3; 200 + */ 201 + message: string; 202 + 203 + /** 204 + * Start time of the maintenance window (RFC 3339 format). 205 + * 206 + * @generated from field: string from = 4; 207 + */ 208 + from: string; 209 + 210 + /** 211 + * End time of the maintenance window (RFC 3339 format). 212 + * 213 + * @generated from field: string to = 5; 214 + */ 215 + to: string; 216 + 217 + /** 218 + * IDs of affected page components. 219 + * 220 + * @generated from field: repeated string page_component_ids = 6; 221 + */ 222 + pageComponentIds: string[]; 223 + 224 + /** 225 + * Timestamp when the maintenance was created (RFC 3339 format). 226 + * 227 + * @generated from field: string created_at = 7; 228 + */ 229 + createdAt: string; 230 + 231 + /** 232 + * Timestamp when the maintenance was last updated (RFC 3339 format). 233 + * 234 + * @generated from field: string updated_at = 8; 235 + */ 236 + updatedAt: string; 237 + }; 238 + 239 + /** 240 + * Describes the message openstatus.status_page.v1.Maintenance. 241 + * Use `create(MaintenanceSchema)` to create a new message. 242 + */ 243 + export const MaintenanceSchema: GenMessage<Maintenance> = /*@__PURE__*/ 244 + messageDesc(file_openstatus_status_page_v1_status_page, 2); 245 + 246 + /** 247 + * PageAccessType defines who can access the status page. 248 + * 249 + * @generated from enum openstatus.status_page.v1.PageAccessType 250 + */ 251 + export enum PageAccessType { 252 + /** 253 + * @generated from enum value: PAGE_ACCESS_TYPE_UNSPECIFIED = 0; 254 + */ 255 + UNSPECIFIED = 0, 256 + 257 + /** 258 + * @generated from enum value: PAGE_ACCESS_TYPE_PUBLIC = 1; 259 + */ 260 + PUBLIC = 1, 261 + 262 + /** 263 + * @generated from enum value: PAGE_ACCESS_TYPE_PASSWORD_PROTECTED = 2; 264 + */ 265 + PASSWORD_PROTECTED = 2, 266 + 267 + /** 268 + * @generated from enum value: PAGE_ACCESS_TYPE_AUTHENTICATED = 3; 269 + */ 270 + AUTHENTICATED = 3, 271 + } 272 + 273 + /** 274 + * Describes the enum openstatus.status_page.v1.PageAccessType. 275 + */ 276 + export const PageAccessTypeSchema: GenEnum<PageAccessType> = /*@__PURE__*/ 277 + enumDesc(file_openstatus_status_page_v1_status_page, 0); 278 + 279 + /** 280 + * PageTheme defines the visual theme of the status page. 281 + * 282 + * @generated from enum openstatus.status_page.v1.PageTheme 283 + */ 284 + export enum PageTheme { 285 + /** 286 + * @generated from enum value: PAGE_THEME_UNSPECIFIED = 0; 287 + */ 288 + UNSPECIFIED = 0, 289 + 290 + /** 291 + * @generated from enum value: PAGE_THEME_SYSTEM = 1; 292 + */ 293 + SYSTEM = 1, 294 + 295 + /** 296 + * @generated from enum value: PAGE_THEME_LIGHT = 2; 297 + */ 298 + LIGHT = 2, 299 + 300 + /** 301 + * @generated from enum value: PAGE_THEME_DARK = 3; 302 + */ 303 + DARK = 3, 304 + } 305 + 306 + /** 307 + * Describes the enum openstatus.status_page.v1.PageTheme. 308 + */ 309 + export const PageThemeSchema: GenEnum<PageTheme> = /*@__PURE__*/ 310 + enumDesc(file_openstatus_status_page_v1_status_page, 1); 311 + 312 + /** 313 + * OverallStatus represents the aggregated status of all components on a page. 314 + * 315 + * @generated from enum openstatus.status_page.v1.OverallStatus 316 + */ 317 + export enum OverallStatus { 318 + /** 319 + * @generated from enum value: OVERALL_STATUS_UNSPECIFIED = 0; 320 + */ 321 + UNSPECIFIED = 0, 322 + 323 + /** 324 + * @generated from enum value: OVERALL_STATUS_OPERATIONAL = 1; 325 + */ 326 + OPERATIONAL = 1, 327 + 328 + /** 329 + * @generated from enum value: OVERALL_STATUS_DEGRADED = 2; 330 + */ 331 + DEGRADED = 2, 332 + 333 + /** 334 + * @generated from enum value: OVERALL_STATUS_PARTIAL_OUTAGE = 3; 335 + */ 336 + PARTIAL_OUTAGE = 3, 337 + 338 + /** 339 + * @generated from enum value: OVERALL_STATUS_MAJOR_OUTAGE = 4; 340 + */ 341 + MAJOR_OUTAGE = 4, 342 + 343 + /** 344 + * @generated from enum value: OVERALL_STATUS_MAINTENANCE = 5; 345 + */ 346 + MAINTENANCE = 5, 347 + 348 + /** 349 + * @generated from enum value: OVERALL_STATUS_UNKNOWN = 6; 350 + */ 351 + UNKNOWN = 6, 352 + } 353 + 354 + /** 355 + * Describes the enum openstatus.status_page.v1.OverallStatus. 356 + */ 357 + export const OverallStatusSchema: GenEnum<OverallStatus> = /*@__PURE__*/ 358 + enumDesc(file_openstatus_status_page_v1_status_page, 2); 359 +
+4
packages/proto/package.json
··· 20 20 "./status_report/v1": { 21 21 "import": "./gen/ts/openstatus/status_report/v1/index.ts", 22 22 "types": "./gen/ts/openstatus/status_report/v1/index.ts" 23 + }, 24 + "./status_page/v1": { 25 + "import": "./gen/ts/openstatus/status_page/v1/index.ts", 26 + "types": "./gen/ts/openstatus/status_page/v1/index.ts" 23 27 } 24 28 }, 25 29 "scripts": {
+147
packages/proto/plan/api.md
··· 1 + # Status Page Proto Service Implementation Plan 2 + 3 + ## Overview 4 + 5 + Create a new proto service for Status Pages in `packages/proto/api/openstatus/status_page/v1/` following existing patterns from `monitor` and `status_report` services. 6 + 7 + ## File Structure 8 + 9 + ``` 10 + packages/proto/api/openstatus/status_page/v1/ 11 + ├── service.proto # RPC service definitions 12 + ├── status_page.proto # StatusPage message and enums 13 + ├── page_component.proto # PageComponent message and enums 14 + └── page_subscriber.proto # PageSubscriber message 15 + ``` 16 + 17 + ## Proto Definitions 18 + 19 + ### 1. `status_page.proto` - Core Types 20 + 21 + **Enums:** 22 + - `PageAccessType`: PUBLIC, PASSWORD_PROTECTED, AUTHENTICATED 23 + - `PageTheme`: SYSTEM, LIGHT, DARK 24 + - `OverallStatus`: OPERATIONAL, DEGRADED, PARTIAL_OUTAGE, MAJOR_OUTAGE, MAINTENANCE, UNKNOWN 25 + 26 + **Messages:** 27 + - `StatusPage` - Full page details (id, title, description, slug, custom_domain, published, access_type, theme, homepage_url, contact_url, icon, timestamps) 28 + - `StatusPageSummary` - List response (id, title, slug, published, timestamps) 29 + 30 + ### 2. `page_component.proto` - Component Types 31 + 32 + **Enums:** 33 + - `PageComponentType`: MONITOR, STATIC 34 + 35 + **Messages:** 36 + - `PageComponent` - Component details (id, page_id, name, description, type, monitor_id, order, group_id, group_order, timestamps) 37 + - `PageComponentGroup` - Group details (id, page_id, name, timestamps) 38 + 39 + ### 3. `page_subscriber.proto` - Subscriber Types 40 + 41 + **Messages:** 42 + - `PageSubscriber` - Subscriber details (id, page_id, email, accepted_at, unsubscribed_at, timestamps) 43 + 44 + ### 4. `service.proto` - RPC Service 45 + 46 + ```protobuf 47 + service StatusPageService { 48 + // Page CRUD 49 + rpc CreateStatusPage(CreateStatusPageRequest) returns (CreateStatusPageResponse); 50 + rpc GetStatusPage(GetStatusPageRequest) returns (GetStatusPageResponse); 51 + rpc ListStatusPages(ListStatusPagesRequest) returns (ListStatusPagesResponse); 52 + rpc UpdateStatusPage(UpdateStatusPageRequest) returns (UpdateStatusPageResponse); 53 + rpc DeleteStatusPage(DeleteStatusPageRequest) returns (DeleteStatusPageResponse); 54 + 55 + // Components 56 + rpc AddMonitorComponent(AddMonitorComponentRequest) returns (AddMonitorComponentResponse); 57 + rpc AddExternalComponent(AddExternalComponentRequest) returns (AddExternalComponentResponse); 58 + rpc RemoveComponent(RemoveComponentRequest) returns (RemoveComponentResponse); 59 + rpc UpdateComponent(UpdateComponentRequest) returns (UpdateComponentResponse); 60 + 61 + // Component Groups 62 + rpc CreateComponentGroup(CreateComponentGroupRequest) returns (CreateComponentGroupResponse); 63 + rpc DeleteComponentGroup(DeleteComponentGroupRequest) returns (DeleteComponentGroupResponse); 64 + rpc UpdateComponentGroup(UpdateComponentGroupRequest) returns (UpdateComponentGroupResponse); 65 + 66 + // Subscribers 67 + rpc SubscribeToPage(SubscribeToPageRequest) returns (SubscribeToPageResponse); 68 + rpc UnsubscribeFromPage(UnsubscribeFromPageRequest) returns (UnsubscribeFromPageResponse); 69 + rpc ListSubscribers(ListSubscribersRequest) returns (ListSubscribersResponse); 70 + 71 + // Full Content & Status 72 + rpc GetStatusPageContent(GetStatusPageContentRequest) returns (GetStatusPageContentResponse); 73 + rpc GetOverallStatus(GetOverallStatusRequest) returns (GetOverallStatusResponse); 74 + } 75 + ``` 76 + 77 + ## RPC Methods Breakdown 78 + 79 + ### Page CRUD (5 methods) 80 + | Method | Request Fields | Response | 81 + |--------|---------------|----------| 82 + | `CreateStatusPage` | title, description, slug, homepage_url?, contact_url?| StatusPage | 83 + | `GetStatusPage` | id | StatusPage | 84 + | `ListStatusPages` | limit?, offset? | StatusPageSummary[], total_size | 85 + | `UpdateStatusPage` | id, title?, description?, slug?, homepage_url?, contact_url? | StatusPage | 86 + | `DeleteStatusPage` | id | success | 87 + 88 + ### Component Management (4 methods) 89 + | Method | Request Fields | Response | 90 + |--------|---------------|----------| 91 + | `AddMonitorComponent` | page_id, monitor_id, name?, description?, order?, group_id? | PageComponent | 92 + | `AddExternalComponent` | page_id, name, description?, order?, group_id? | PageComponent | 93 + | `RemoveComponent` | id | success | 94 + | `UpdateComponent` | id, name?, description?, order?, group_id?, group_order? | PageComponent | 95 + 96 + ### Component Groups (3 methods) 97 + | Method | Request Fields | Response | 98 + |--------|---------------|----------| 99 + | `CreateComponentGroup` | page_id, name | PageComponentGroup | 100 + | `DeleteComponentGroup` | id | success | 101 + | `UpdateComponentGroup` | id, name? | PageComponentGroup | 102 + 103 + ### Subscriber Management (3 methods) 104 + | Method | Request Fields | Response | 105 + |--------|---------------|----------| 106 + | `SubscribeToPage` | page_id, email | PageSubscriber | 107 + | `UnsubscribeFromPage` | page_id, email or token | success | 108 + | `ListSubscribers` | page_id, limit?, offset?, include_unsubscribed? | PageSubscriber[], total_size | 109 + 110 + ### Full Content & Status (2 methods) 111 + | Method | Request Fields | Response | 112 + |--------|---------------|----------| 113 + | `GetStatusPageContent` | id or slug | StatusPage, components[], groups[], status_reports[], maintenances[] | 114 + | `GetOverallStatus` | id or slug | overall_status, component_statuses[] | 115 + 116 + ## Validation Rules (using buf.validate) 117 + 118 + - `title`: min_len=1, max_len=256 119 + - `description`: max_len=1024 120 + - `slug`: min_len=1, max_len=256, pattern for valid slug 121 + - `email`: email format validation 122 + - `limit`: gte=1, lte=100 123 + - `offset`: gte=0 124 + - Enums: defined_only=true, not_in=[0] where appropriate 125 + 126 + ## Implementation Order 127 + 128 + 1. Create `status_page.proto` with enums and base messages 129 + 2. Create `page_component.proto` with component types 130 + 3. Create `page_subscriber.proto` with subscriber message 131 + 4. Create `service.proto` with all RPC definitions 132 + 5. Update buf.yaml if needed to include new package 133 + 134 + ## Files to Create 135 + 136 + | File | Description | 137 + |------|-------------| 138 + | `packages/proto/api/openstatus/status_page/v1/status_page.proto` | Core enums and StatusPage messages | 139 + | `packages/proto/api/openstatus/status_page/v1/page_component.proto` | PageComponent and PageComponentGroup messages | 140 + | `packages/proto/api/openstatus/status_page/v1/page_subscriber.proto` | PageSubscriber message | 141 + | `packages/proto/api/openstatus/status_page/v1/service.proto` | StatusPageService RPC definitions | 142 + 143 + ## Verification 144 + 145 + 1. Run `pnpm buf lint` to verify proto files are valid 146 + 2. Run `pnpm buf generate` to generate Go/TS code 147 + 3. Check generated code compiles without errors