Openstatus www.openstatus.dev

Status report (#1796)

* docs: add status report proto implementation plan

Add PLAN.md documenting the design for StatusReportService RPC with:
- CRUD operations (Create, Get, List, Update, Delete)
- AddStatusReportUpdate for timeline entries
- Message definitions following existing patterns
- Offset-based pagination and status filtering

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

Add protobuf definitions for StatusReportService with:
- StatusReportStatus enum (investigating, identified, monitoring, resolved)
- StatusReport, StatusReportSummary, StatusReportUpdate messages
- 6 RPC methods: Create, Get, List, Update, Delete, AddUpdate
- buf.validate rules for request validation
- Generated TypeScript bindings and package exports

* feat(server): implement status report RPC handler

Add StatusReportService implementation following monitor handler pattern:
- CreateStatusReport with initial update and page component associations
- GetStatusReport with full update timeline
- ListStatusReports with offset pagination and status filtering
- UpdateStatusReport for metadata changes (title, page components)
- DeleteStatusReport with cascade delete
- AddStatusReportUpdate for timeline entries
- Error helpers and DB-to-proto converters

* test(server): add status report RPC handler tests

Add comprehensive test coverage for StatusReportService:
- 25 tests covering all 6 RPC methods
- Authentication and authorization tests
- Workspace isolation verification
- Pagination and filtering tests
- Input validation error cases

* ci: apply automated fixes

* feat(proto): add pageId field to CreateStatusReport

- Add required pageId field to CreateStatusReportRequest proto
- Make pageComponentIds field optional (was required)
- Update handler to use pageId directly from request
- Update tests to include pageId in requests

* remove .claude

* ci: apply automated fixes

* refactor(status-report): wrap database operations in transactions

Ensure atomicity for create, update, and add-update operations by using
database transactions. This prevents partial writes if any step fails.

* implement pr review

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

authored by

Thibault Le Ouay
autofix-ci[bot]
and committed by
GitHub
77dec981 e23ea3f7

+3558 -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 { StatusReportService } from "@openstatus/proto/status_report/v1"; 4 5 5 6 import { 6 7 authInterceptor, ··· 10 11 } from "./interceptors"; 11 12 import { healthServiceImpl } from "./services/health"; 12 13 import { monitorServiceImpl } from "./services/monitor"; 14 + import { statusReportServiceImpl } from "./services/status-report"; 13 15 14 16 /** 15 17 * Create ConnectRPC router with services. ··· 28 30 ], 29 31 }) 30 32 .service(MonitorService, monitorServiceImpl) 31 - .service(HealthService, healthServiceImpl); 33 + .service(HealthService, healthServiceImpl) 34 + .service(StatusReportService, statusReportServiceImpl);
+1521
apps/server/src/routes/rpc/services/status-report/__tests__/status-report.test.ts
··· 1 + import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test"; 2 + import { db, eq } from "@openstatus/db"; 3 + import { 4 + page, 5 + pageComponent, 6 + pageSubscriber, 7 + statusReport, 8 + statusReportUpdate, 9 + statusReportsToPageComponents, 10 + } from "@openstatus/db/src/schema"; 11 + import { EmailClient } from "@openstatus/emails"; 12 + import { StatusReportStatus } from "@openstatus/proto/status_report/v1"; 13 + 14 + import { app } from "@/index"; 15 + import { protoStatusToDb } from "../converters"; 16 + 17 + // Mock the sendStatusReportUpdate method 18 + const sendStatusReportUpdateMock = spyOn( 19 + EmailClient.prototype, 20 + "sendStatusReportUpdate", 21 + ).mockResolvedValue(undefined); 22 + 23 + /** 24 + * Helper to make ConnectRPC requests using the Connect protocol (JSON). 25 + * Connect uses POST with JSON body at /rpc/<service>/<method> 26 + */ 27 + async function connectRequest( 28 + method: string, 29 + body: Record<string, unknown> = {}, 30 + headers: Record<string, string> = {}, 31 + ) { 32 + return app.request( 33 + `/rpc/openstatus.status_report.v1.StatusReportService/${method}`, 34 + { 35 + method: "POST", 36 + headers: { 37 + "Content-Type": "application/json", 38 + ...headers, 39 + }, 40 + body: JSON.stringify(body), 41 + }, 42 + ); 43 + } 44 + 45 + const TEST_PREFIX = "rpc-status-report-test"; 46 + let testPageComponentId: number; 47 + let testStatusReportId: number; 48 + let testStatusReportToDeleteId: number; 49 + let testStatusReportToUpdateId: number; 50 + let testStatusReportForNotifyId: number; 51 + let testSubscriberId: number; 52 + // For mixed-page validation tests 53 + let testPage2Id: number; 54 + let testPage2ComponentId: number; 55 + 56 + beforeAll(async () => { 57 + // Clean up any existing test data 58 + await db 59 + .delete(statusReport) 60 + .where(eq(statusReport.title, `${TEST_PREFIX}-main`)); 61 + await db 62 + .delete(statusReport) 63 + .where(eq(statusReport.title, `${TEST_PREFIX}-to-delete`)); 64 + await db 65 + .delete(statusReport) 66 + .where(eq(statusReport.title, `${TEST_PREFIX}-to-update`)); 67 + await db 68 + .delete(pageComponent) 69 + .where(eq(pageComponent.name, `${TEST_PREFIX}-component`)); 70 + 71 + // Create a test page component (using existing page 1 from seed) 72 + const component = await db 73 + .insert(pageComponent) 74 + .values({ 75 + workspaceId: 1, 76 + pageId: 1, 77 + type: "external", 78 + name: `${TEST_PREFIX}-component`, 79 + description: "Test component for status report tests", 80 + order: 100, 81 + }) 82 + .returning() 83 + .get(); 84 + testPageComponentId = component.id; 85 + 86 + // Create a second page and component for mixed-page validation tests 87 + const page2 = await db 88 + .insert(page) 89 + .values({ 90 + workspaceId: 1, 91 + title: `${TEST_PREFIX}-page-2`, 92 + slug: `${TEST_PREFIX}-page-2-slug`, 93 + description: "Second test page for mixed-page tests", 94 + customDomain: "", 95 + }) 96 + .returning() 97 + .get(); 98 + testPage2Id = page2.id; 99 + 100 + const component2 = await db 101 + .insert(pageComponent) 102 + .values({ 103 + workspaceId: 1, 104 + pageId: testPage2Id, 105 + type: "external", 106 + name: `${TEST_PREFIX}-component-2`, 107 + description: "Test component on page 2", 108 + order: 100, 109 + }) 110 + .returning() 111 + .get(); 112 + testPage2ComponentId = component2.id; 113 + 114 + // Create test status report 115 + const report = await db 116 + .insert(statusReport) 117 + .values({ 118 + workspaceId: 1, 119 + pageId: 1, 120 + title: `${TEST_PREFIX}-main`, 121 + status: "investigating", 122 + }) 123 + .returning() 124 + .get(); 125 + testStatusReportId = report.id; 126 + 127 + // Create page component association 128 + await db.insert(statusReportsToPageComponents).values({ 129 + statusReportId: report.id, 130 + pageComponentId: testPageComponentId, 131 + }); 132 + 133 + // Create status report updates 134 + await db.insert(statusReportUpdate).values([ 135 + { 136 + statusReportId: report.id, 137 + status: "investigating", 138 + date: new Date(Date.now() - 60 * 60 * 1000), // 1 hour ago 139 + message: "We are investigating the issue.", 140 + }, 141 + { 142 + statusReportId: report.id, 143 + status: "identified", 144 + date: new Date(Date.now() - 30 * 60 * 1000), // 30 min ago 145 + message: "We have identified the root cause.", 146 + }, 147 + ]); 148 + 149 + // Create status report to delete 150 + const deleteReport = await db 151 + .insert(statusReport) 152 + .values({ 153 + workspaceId: 1, 154 + pageId: 1, 155 + title: `${TEST_PREFIX}-to-delete`, 156 + status: "investigating", 157 + }) 158 + .returning() 159 + .get(); 160 + testStatusReportToDeleteId = deleteReport.id; 161 + 162 + // Create status report to update 163 + const updateReport = await db 164 + .insert(statusReport) 165 + .values({ 166 + workspaceId: 1, 167 + pageId: 1, 168 + title: `${TEST_PREFIX}-to-update`, 169 + status: "investigating", 170 + }) 171 + .returning() 172 + .get(); 173 + testStatusReportToUpdateId = updateReport.id; 174 + 175 + await db.insert(statusReportsToPageComponents).values({ 176 + statusReportId: updateReport.id, 177 + pageComponentId: testPageComponentId, 178 + }); 179 + 180 + // Create status report for notify tests 181 + const notifyReport = await db 182 + .insert(statusReport) 183 + .values({ 184 + workspaceId: 1, 185 + pageId: 1, 186 + title: `${TEST_PREFIX}-for-notify`, 187 + status: "investigating", 188 + }) 189 + .returning() 190 + .get(); 191 + testStatusReportForNotifyId = notifyReport.id; 192 + 193 + await db.insert(statusReportsToPageComponents).values({ 194 + statusReportId: notifyReport.id, 195 + pageComponentId: testPageComponentId, 196 + }); 197 + 198 + // Create a verified subscriber for notification tests 199 + const subscriber = await db 200 + .insert(pageSubscriber) 201 + .values({ 202 + pageId: 1, 203 + email: `${TEST_PREFIX}@example.com`, 204 + token: `${TEST_PREFIX}-token`, 205 + acceptedAt: new Date(), 206 + }) 207 + .returning() 208 + .get(); 209 + testSubscriberId = subscriber.id; 210 + }); 211 + 212 + afterAll(async () => { 213 + // Clean up subscriber first (only if it was created) 214 + if (testSubscriberId) { 215 + await db 216 + .delete(pageSubscriber) 217 + .where(eq(pageSubscriber.id, testSubscriberId)); 218 + } 219 + 220 + // Clean up status report updates first (due to foreign key) 221 + await db 222 + .delete(statusReportUpdate) 223 + .where(eq(statusReportUpdate.statusReportId, testStatusReportId)); 224 + await db 225 + .delete(statusReportUpdate) 226 + .where(eq(statusReportUpdate.statusReportId, testStatusReportToUpdateId)); 227 + await db 228 + .delete(statusReportUpdate) 229 + .where(eq(statusReportUpdate.statusReportId, testStatusReportForNotifyId)); 230 + 231 + // Clean up associations 232 + await db 233 + .delete(statusReportsToPageComponents) 234 + .where( 235 + eq(statusReportsToPageComponents.statusReportId, testStatusReportId), 236 + ); 237 + await db 238 + .delete(statusReportsToPageComponents) 239 + .where( 240 + eq( 241 + statusReportsToPageComponents.statusReportId, 242 + testStatusReportToUpdateId, 243 + ), 244 + ); 245 + await db 246 + .delete(statusReportsToPageComponents) 247 + .where( 248 + eq( 249 + statusReportsToPageComponents.statusReportId, 250 + testStatusReportForNotifyId, 251 + ), 252 + ); 253 + 254 + // Clean up status reports 255 + await db 256 + .delete(statusReport) 257 + .where(eq(statusReport.title, `${TEST_PREFIX}-main`)); 258 + await db 259 + .delete(statusReport) 260 + .where(eq(statusReport.title, `${TEST_PREFIX}-to-delete`)); 261 + await db 262 + .delete(statusReport) 263 + .where(eq(statusReport.title, `${TEST_PREFIX}-to-update`)); 264 + await db 265 + .delete(statusReport) 266 + .where(eq(statusReport.title, `${TEST_PREFIX}-for-notify`)); 267 + 268 + // Clean up page component 269 + await db 270 + .delete(pageComponent) 271 + .where(eq(pageComponent.name, `${TEST_PREFIX}-component`)); 272 + 273 + // Clean up second page component and page (for mixed-page tests) 274 + await db 275 + .delete(pageComponent) 276 + .where(eq(pageComponent.name, `${TEST_PREFIX}-component-2`)); 277 + await db.delete(page).where(eq(page.title, `${TEST_PREFIX}-page-2`)); 278 + }); 279 + 280 + describe("StatusReportService.CreateStatusReport", () => { 281 + test("creates a new status report with initial update", async () => { 282 + const res = await connectRequest( 283 + "CreateStatusReport", 284 + { 285 + title: `${TEST_PREFIX}-created`, 286 + status: "STATUS_REPORT_STATUS_INVESTIGATING", 287 + message: "We are looking into this issue.", 288 + date: new Date().toISOString(), 289 + pageId: "1", 290 + pageComponentIds: [String(testPageComponentId)], 291 + }, 292 + { "x-openstatus-key": "1" }, 293 + ); 294 + 295 + expect(res.status).toBe(200); 296 + 297 + const data = await res.json(); 298 + expect(data).toHaveProperty("statusReport"); 299 + expect(data.statusReport.title).toBe(`${TEST_PREFIX}-created`); 300 + expect(data.statusReport.status).toBe("STATUS_REPORT_STATUS_INVESTIGATING"); 301 + expect(data.statusReport.pageComponentIds).toContain( 302 + String(testPageComponentId), 303 + ); 304 + expect(data.statusReport.updates).toHaveLength(1); 305 + expect(data.statusReport.updates[0].message).toBe( 306 + "We are looking into this issue.", 307 + ); 308 + 309 + // Clean up 310 + await db 311 + .delete(statusReportUpdate) 312 + .where( 313 + eq(statusReportUpdate.statusReportId, Number(data.statusReport.id)), 314 + ); 315 + await db 316 + .delete(statusReportsToPageComponents) 317 + .where( 318 + eq( 319 + statusReportsToPageComponents.statusReportId, 320 + Number(data.statusReport.id), 321 + ), 322 + ); 323 + await db 324 + .delete(statusReport) 325 + .where(eq(statusReport.id, Number(data.statusReport.id))); 326 + }); 327 + 328 + test("returns 401 when no auth key provided", async () => { 329 + const res = await connectRequest("CreateStatusReport", { 330 + title: "Unauthorized test", 331 + status: "STATUS_REPORT_STATUS_INVESTIGATING", 332 + message: "Test message", 333 + date: new Date().toISOString(), 334 + pageId: "1", 335 + pageComponentIds: ["1"], 336 + }); 337 + 338 + expect(res.status).toBe(401); 339 + }); 340 + 341 + test("returns error for invalid page component ID", async () => { 342 + const res = await connectRequest( 343 + "CreateStatusReport", 344 + { 345 + title: "Invalid component test", 346 + status: "STATUS_REPORT_STATUS_INVESTIGATING", 347 + message: "Test message", 348 + date: new Date().toISOString(), 349 + pageId: "1", 350 + pageComponentIds: ["99999"], 351 + }, 352 + { "x-openstatus-key": "1" }, 353 + ); 354 + 355 + expect(res.status).toBe(404); 356 + }); 357 + 358 + test("returns error when page components are from different pages", async () => { 359 + const res = await connectRequest( 360 + "CreateStatusReport", 361 + { 362 + title: `${TEST_PREFIX}-mixed-pages`, 363 + status: "STATUS_REPORT_STATUS_INVESTIGATING", 364 + message: "Test message", 365 + date: new Date().toISOString(), 366 + pageId: "1", 367 + pageComponentIds: [ 368 + String(testPageComponentId), 369 + String(testPage2ComponentId), 370 + ], 371 + }, 372 + { "x-openstatus-key": "1" }, 373 + ); 374 + 375 + expect(res.status).toBe(400); 376 + const data = await res.json(); 377 + expect(data.message).toContain( 378 + "All page components must belong to the same page", 379 + ); 380 + }); 381 + 382 + test("derives pageId from components when creating status report", async () => { 383 + const res = await connectRequest( 384 + "CreateStatusReport", 385 + { 386 + title: `${TEST_PREFIX}-derived-pageid`, 387 + status: "STATUS_REPORT_STATUS_INVESTIGATING", 388 + message: "Test deriving pageId from components.", 389 + date: new Date().toISOString(), 390 + pageId: "1", // This is ignored, pageId is derived from components 391 + pageComponentIds: [String(testPage2ComponentId)], 392 + }, 393 + { "x-openstatus-key": "1" }, 394 + ); 395 + 396 + expect(res.status).toBe(200); 397 + 398 + const data = await res.json(); 399 + expect(data).toHaveProperty("statusReport"); 400 + expect(data.statusReport.pageComponentIds).toContain( 401 + String(testPage2ComponentId), 402 + ); 403 + 404 + // Verify the pageId was derived from the component (page 2) 405 + const createdReport = await db 406 + .select() 407 + .from(statusReport) 408 + .where(eq(statusReport.id, Number(data.statusReport.id))) 409 + .get(); 410 + expect(createdReport?.pageId).toBe(testPage2Id); 411 + 412 + // Clean up 413 + await db 414 + .delete(statusReportUpdate) 415 + .where( 416 + eq(statusReportUpdate.statusReportId, Number(data.statusReport.id)), 417 + ); 418 + await db 419 + .delete(statusReportsToPageComponents) 420 + .where( 421 + eq( 422 + statusReportsToPageComponents.statusReportId, 423 + Number(data.statusReport.id), 424 + ), 425 + ); 426 + await db 427 + .delete(statusReport) 428 + .where(eq(statusReport.id, Number(data.statusReport.id))); 429 + }); 430 + 431 + test("sets pageId to null when no components provided", async () => { 432 + const res = await connectRequest( 433 + "CreateStatusReport", 434 + { 435 + title: `${TEST_PREFIX}-null-pageid`, 436 + status: "STATUS_REPORT_STATUS_INVESTIGATING", 437 + message: "Test null pageId when no components.", 438 + date: new Date().toISOString(), 439 + pageId: "1", // This should be ignored 440 + pageComponentIds: [], 441 + }, 442 + { "x-openstatus-key": "1" }, 443 + ); 444 + 445 + expect(res.status).toBe(200); 446 + 447 + const data = await res.json(); 448 + expect(data).toHaveProperty("statusReport"); 449 + 450 + // Verify the pageId is null 451 + const createdReport = await db 452 + .select() 453 + .from(statusReport) 454 + .where(eq(statusReport.id, Number(data.statusReport.id))) 455 + .get(); 456 + expect(createdReport?.pageId).toBeNull(); 457 + 458 + // Clean up 459 + await db 460 + .delete(statusReportUpdate) 461 + .where( 462 + eq(statusReportUpdate.statusReportId, Number(data.statusReport.id)), 463 + ); 464 + await db 465 + .delete(statusReport) 466 + .where(eq(statusReport.id, Number(data.statusReport.id))); 467 + }); 468 + 469 + test("creates status report with empty pageComponentIds", async () => { 470 + const res = await connectRequest( 471 + "CreateStatusReport", 472 + { 473 + title: `${TEST_PREFIX}-no-components`, 474 + status: "STATUS_REPORT_STATUS_INVESTIGATING", 475 + message: "Report without components.", 476 + date: new Date().toISOString(), 477 + pageId: "1", 478 + pageComponentIds: [], 479 + }, 480 + { "x-openstatus-key": "1" }, 481 + ); 482 + 483 + expect(res.status).toBe(200); 484 + 485 + const data = await res.json(); 486 + expect(data).toHaveProperty("statusReport"); 487 + expect(data.statusReport.title).toBe(`${TEST_PREFIX}-no-components`); 488 + // Empty array may be serialized as undefined or empty array in proto 489 + const pageComponentIds = data.statusReport.pageComponentIds ?? []; 490 + expect(pageComponentIds).toHaveLength(0); 491 + 492 + // Clean up 493 + await db 494 + .delete(statusReportUpdate) 495 + .where( 496 + eq(statusReportUpdate.statusReportId, Number(data.statusReport.id)), 497 + ); 498 + await db 499 + .delete(statusReport) 500 + .where(eq(statusReport.id, Number(data.statusReport.id))); 501 + }); 502 + 503 + test("creates status report with notify=true", async () => { 504 + sendStatusReportUpdateMock.mockClear(); 505 + 506 + const res = await connectRequest( 507 + "CreateStatusReport", 508 + { 509 + title: `${TEST_PREFIX}-with-notify`, 510 + status: "STATUS_REPORT_STATUS_INVESTIGATING", 511 + message: "Notifying subscribers about this issue.", 512 + date: new Date().toISOString(), 513 + pageId: "1", 514 + pageComponentIds: [String(testPageComponentId)], 515 + notify: true, 516 + }, 517 + { "x-openstatus-key": "1" }, 518 + ); 519 + 520 + expect(res.status).toBe(200); 521 + 522 + const data = await res.json(); 523 + expect(data).toHaveProperty("statusReport"); 524 + expect(data.statusReport.title).toBe(`${TEST_PREFIX}-with-notify`); 525 + expect(data.statusReport.updates).toHaveLength(1); 526 + 527 + // Verify notification was sent 528 + expect(sendStatusReportUpdateMock).toHaveBeenCalledTimes(1); 529 + const mockCall = sendStatusReportUpdateMock.mock.calls[0][0]; 530 + expect(mockCall.reportTitle).toBe(`${TEST_PREFIX}-with-notify`); 531 + expect(mockCall.status).toBe("investigating"); 532 + expect(mockCall.message).toBe("Notifying subscribers about this issue."); 533 + 534 + // Clean up 535 + await db 536 + .delete(statusReportUpdate) 537 + .where( 538 + eq(statusReportUpdate.statusReportId, Number(data.statusReport.id)), 539 + ); 540 + await db 541 + .delete(statusReportsToPageComponents) 542 + .where( 543 + eq( 544 + statusReportsToPageComponents.statusReportId, 545 + Number(data.statusReport.id), 546 + ), 547 + ); 548 + await db 549 + .delete(statusReport) 550 + .where(eq(statusReport.id, Number(data.statusReport.id))); 551 + }); 552 + 553 + test("creates status report with notify=false (default)", async () => { 554 + sendStatusReportUpdateMock.mockClear(); 555 + 556 + const res = await connectRequest( 557 + "CreateStatusReport", 558 + { 559 + title: `${TEST_PREFIX}-no-notify`, 560 + status: "STATUS_REPORT_STATUS_IDENTIFIED", 561 + message: "No notification for this one.", 562 + date: new Date().toISOString(), 563 + pageId: "1", 564 + pageComponentIds: [], 565 + notify: false, 566 + }, 567 + { "x-openstatus-key": "1" }, 568 + ); 569 + 570 + expect(res.status).toBe(200); 571 + 572 + const data = await res.json(); 573 + expect(data).toHaveProperty("statusReport"); 574 + expect(data.statusReport.title).toBe(`${TEST_PREFIX}-no-notify`); 575 + 576 + // Verify notification was NOT sent 577 + expect(sendStatusReportUpdateMock).not.toHaveBeenCalled(); 578 + 579 + // Clean up 580 + await db 581 + .delete(statusReportUpdate) 582 + .where( 583 + eq(statusReportUpdate.statusReportId, Number(data.statusReport.id)), 584 + ); 585 + await db 586 + .delete(statusReport) 587 + .where(eq(statusReport.id, Number(data.statusReport.id))); 588 + }); 589 + 590 + test("returns error for invalid date format", async () => { 591 + const res = await connectRequest( 592 + "CreateStatusReport", 593 + { 594 + title: `${TEST_PREFIX}-invalid-date`, 595 + status: "STATUS_REPORT_STATUS_INVESTIGATING", 596 + message: "Test with invalid date.", 597 + date: "not-a-valid-date", 598 + pageId: "1", 599 + pageComponentIds: [], 600 + }, 601 + { "x-openstatus-key": "1" }, 602 + ); 603 + 604 + expect(res.status).toBe(400); 605 + const data = await res.json(); 606 + expect(data.message).toContain("date: value does not match regex pattern"); 607 + }); 608 + }); 609 + 610 + describe("StatusReportService.GetStatusReport", () => { 611 + test("returns status report with updates", async () => { 612 + const res = await connectRequest( 613 + "GetStatusReport", 614 + { id: String(testStatusReportId) }, 615 + { "x-openstatus-key": "1" }, 616 + ); 617 + 618 + expect(res.status).toBe(200); 619 + 620 + const data = await res.json(); 621 + expect(data).toHaveProperty("statusReport"); 622 + expect(data.statusReport.id).toBe(String(testStatusReportId)); 623 + expect(data.statusReport.title).toBe(`${TEST_PREFIX}-main`); 624 + expect(data.statusReport.pageComponentIds).toContain( 625 + String(testPageComponentId), 626 + ); 627 + expect(data.statusReport.updates).toHaveLength(2); 628 + expect(data.statusReport).toHaveProperty("createdAt"); 629 + expect(data.statusReport).toHaveProperty("updatedAt"); 630 + }); 631 + 632 + test("returns 401 when no auth key provided", async () => { 633 + const res = await connectRequest("GetStatusReport", { 634 + id: String(testStatusReportId), 635 + }); 636 + 637 + expect(res.status).toBe(401); 638 + }); 639 + 640 + test("returns 404 for non-existent status report", async () => { 641 + const res = await connectRequest( 642 + "GetStatusReport", 643 + { id: "99999" }, 644 + { "x-openstatus-key": "1" }, 645 + ); 646 + 647 + expect(res.status).toBe(404); 648 + }); 649 + 650 + test("returns 404 for status report in different workspace", async () => { 651 + // Create status report in workspace 2 652 + const otherReport = await db 653 + .insert(statusReport) 654 + .values({ 655 + workspaceId: 2, 656 + title: `${TEST_PREFIX}-other-workspace`, 657 + status: "investigating", 658 + }) 659 + .returning() 660 + .get(); 661 + 662 + try { 663 + const res = await connectRequest( 664 + "GetStatusReport", 665 + { id: String(otherReport.id) }, 666 + { "x-openstatus-key": "1" }, 667 + ); 668 + 669 + expect(res.status).toBe(404); 670 + } finally { 671 + await db.delete(statusReport).where(eq(statusReport.id, otherReport.id)); 672 + } 673 + }); 674 + 675 + test("returns error when ID is empty string", async () => { 676 + const res = await connectRequest( 677 + "GetStatusReport", 678 + { id: "" }, 679 + { "x-openstatus-key": "1" }, 680 + ); 681 + 682 + expect(res.status).toBe(400); 683 + }); 684 + 685 + test("returns error when ID is whitespace only", async () => { 686 + const res = await connectRequest( 687 + "GetStatusReport", 688 + { id: " " }, 689 + { "x-openstatus-key": "1" }, 690 + ); 691 + 692 + expect(res.status).toBe(400); 693 + }); 694 + }); 695 + 696 + describe("StatusReportService.ListStatusReports", () => { 697 + test("returns status reports for authenticated workspace", async () => { 698 + const res = await connectRequest( 699 + "ListStatusReports", 700 + {}, 701 + { "x-openstatus-key": "1" }, 702 + ); 703 + 704 + expect(res.status).toBe(200); 705 + 706 + const data = await res.json(); 707 + expect(data).toHaveProperty("statusReports"); 708 + expect(Array.isArray(data.statusReports)).toBe(true); 709 + expect(data).toHaveProperty("totalSize"); 710 + }); 711 + 712 + test("returns status reports with correct structure (summary only)", async () => { 713 + const res = await connectRequest( 714 + "ListStatusReports", 715 + { limit: 100 }, 716 + { "x-openstatus-key": "1" }, 717 + ); 718 + 719 + expect(res.status).toBe(200); 720 + 721 + const data = await res.json(); 722 + const report = data.statusReports?.find( 723 + (r: { id: string }) => r.id === String(testStatusReportId), 724 + ); 725 + 726 + expect(report).toBeDefined(); 727 + expect(report.title).toBe(`${TEST_PREFIX}-main`); 728 + expect(report.pageComponentIds).toBeDefined(); 729 + expect(report.createdAt).toBeDefined(); 730 + expect(report.updatedAt).toBeDefined(); 731 + // Summary should NOT include updates 732 + expect(report.updates).toBeUndefined(); 733 + }); 734 + 735 + test("returns 401 when no auth key provided", async () => { 736 + const res = await connectRequest("ListStatusReports", {}); 737 + 738 + expect(res.status).toBe(401); 739 + }); 740 + 741 + test("respects limit parameter", async () => { 742 + const res = await connectRequest( 743 + "ListStatusReports", 744 + { limit: 1 }, 745 + { "x-openstatus-key": "1" }, 746 + ); 747 + 748 + expect(res.status).toBe(200); 749 + 750 + const data = await res.json(); 751 + expect(data.statusReports?.length || 0).toBeLessThanOrEqual(1); 752 + }); 753 + 754 + test("respects offset parameter", async () => { 755 + // Get total count first 756 + const res1 = await connectRequest( 757 + "ListStatusReports", 758 + {}, 759 + { "x-openstatus-key": "1" }, 760 + ); 761 + const data1 = await res1.json(); 762 + const totalSize = data1.totalSize; 763 + 764 + if (totalSize > 1) { 765 + // Get first page 766 + const res2 = await connectRequest( 767 + "ListStatusReports", 768 + { limit: 1, offset: 0 }, 769 + { "x-openstatus-key": "1" }, 770 + ); 771 + const data2 = await res2.json(); 772 + 773 + // Get second page 774 + const res3 = await connectRequest( 775 + "ListStatusReports", 776 + { limit: 1, offset: 1 }, 777 + { "x-openstatus-key": "1" }, 778 + ); 779 + const data3 = await res3.json(); 780 + 781 + // Should have different reports 782 + if (data2.statusReports?.length > 0 && data3.statusReports?.length > 0) { 783 + expect(data2.statusReports[0].id).not.toBe(data3.statusReports[0].id); 784 + } 785 + } 786 + }); 787 + 788 + test("filters by status", async () => { 789 + const res = await connectRequest( 790 + "ListStatusReports", 791 + { statuses: ["STATUS_REPORT_STATUS_INVESTIGATING"] }, 792 + { "x-openstatus-key": "1" }, 793 + ); 794 + 795 + expect(res.status).toBe(200); 796 + 797 + const data = await res.json(); 798 + // All returned reports should have investigating status 799 + for (const report of data.statusReports || []) { 800 + expect(report.status).toBe("STATUS_REPORT_STATUS_INVESTIGATING"); 801 + } 802 + }); 803 + 804 + test("only returns reports for authenticated workspace", async () => { 805 + // Create status report in workspace 2 806 + const otherReport = await db 807 + .insert(statusReport) 808 + .values({ 809 + workspaceId: 2, 810 + title: `${TEST_PREFIX}-other-workspace-list`, 811 + status: "investigating", 812 + }) 813 + .returning() 814 + .get(); 815 + 816 + try { 817 + const res = await connectRequest( 818 + "ListStatusReports", 819 + { limit: 100 }, 820 + { "x-openstatus-key": "1" }, 821 + ); 822 + 823 + expect(res.status).toBe(200); 824 + 825 + const data = await res.json(); 826 + const reportIds = (data.statusReports || []).map( 827 + (r: { id: string }) => r.id, 828 + ); 829 + 830 + expect(reportIds).not.toContain(String(otherReport.id)); 831 + } finally { 832 + await db.delete(statusReport).where(eq(statusReport.id, otherReport.id)); 833 + } 834 + }); 835 + 836 + test("filters by multiple statuses", async () => { 837 + // Create reports with different statuses 838 + const monitoringReport = await db 839 + .insert(statusReport) 840 + .values({ 841 + workspaceId: 1, 842 + pageId: 1, 843 + title: `${TEST_PREFIX}-monitoring-filter`, 844 + status: "monitoring", 845 + }) 846 + .returning() 847 + .get(); 848 + 849 + const resolvedReport = await db 850 + .insert(statusReport) 851 + .values({ 852 + workspaceId: 1, 853 + pageId: 1, 854 + title: `${TEST_PREFIX}-resolved-filter`, 855 + status: "resolved", 856 + }) 857 + .returning() 858 + .get(); 859 + 860 + try { 861 + const res = await connectRequest( 862 + "ListStatusReports", 863 + { 864 + statuses: [ 865 + "STATUS_REPORT_STATUS_MONITORING", 866 + "STATUS_REPORT_STATUS_RESOLVED", 867 + ], 868 + }, 869 + { "x-openstatus-key": "1" }, 870 + ); 871 + 872 + expect(res.status).toBe(200); 873 + 874 + const data = await res.json(); 875 + // All returned reports should have monitoring or resolved status 876 + for (const report of data.statusReports || []) { 877 + expect([ 878 + "STATUS_REPORT_STATUS_MONITORING", 879 + "STATUS_REPORT_STATUS_RESOLVED", 880 + ]).toContain(report.status); 881 + } 882 + } finally { 883 + await db 884 + .delete(statusReport) 885 + .where(eq(statusReport.id, monitoringReport.id)); 886 + await db 887 + .delete(statusReport) 888 + .where(eq(statusReport.id, resolvedReport.id)); 889 + } 890 + }); 891 + 892 + test("returns all statuses when statuses filter is empty", async () => { 893 + const res = await connectRequest( 894 + "ListStatusReports", 895 + { statuses: [] }, 896 + { "x-openstatus-key": "1" }, 897 + ); 898 + 899 + expect(res.status).toBe(200); 900 + 901 + const data = await res.json(); 902 + expect(data).toHaveProperty("statusReports"); 903 + expect(data).toHaveProperty("totalSize"); 904 + }); 905 + 906 + test("ignores UNSPECIFIED status in filter", async () => { 907 + const res = await connectRequest( 908 + "ListStatusReports", 909 + { 910 + statuses: [ 911 + "STATUS_REPORT_STATUS_UNSPECIFIED", 912 + "STATUS_REPORT_STATUS_INVESTIGATING", 913 + ], 914 + }, 915 + { "x-openstatus-key": "1" }, 916 + ); 917 + 918 + expect(res.status).toBe(200); 919 + 920 + const data = await res.json(); 921 + // Should only return investigating status (UNSPECIFIED is ignored) 922 + for (const report of data.statusReports || []) { 923 + expect(report.status).toBe("STATUS_REPORT_STATUS_INVESTIGATING"); 924 + } 925 + }); 926 + }); 927 + 928 + describe("StatusReportService.UpdateStatusReport", () => { 929 + test("updates status report title", async () => { 930 + const res = await connectRequest( 931 + "UpdateStatusReport", 932 + { 933 + id: String(testStatusReportToUpdateId), 934 + title: `${TEST_PREFIX}-updated-title`, 935 + }, 936 + { "x-openstatus-key": "1" }, 937 + ); 938 + 939 + expect(res.status).toBe(200); 940 + 941 + const data = await res.json(); 942 + expect(data).toHaveProperty("statusReport"); 943 + expect(data.statusReport.title).toBe(`${TEST_PREFIX}-updated-title`); 944 + 945 + // Restore original title 946 + await db 947 + .update(statusReport) 948 + .set({ title: `${TEST_PREFIX}-to-update` }) 949 + .where(eq(statusReport.id, testStatusReportToUpdateId)); 950 + }); 951 + 952 + test("updates page component associations", async () => { 953 + // Use existing seeded page component 1 954 + const res = await connectRequest( 955 + "UpdateStatusReport", 956 + { 957 + id: String(testStatusReportToUpdateId), 958 + pageComponentIds: ["1"], 959 + }, 960 + { "x-openstatus-key": "1" }, 961 + ); 962 + 963 + expect(res.status).toBe(200); 964 + 965 + const data = await res.json(); 966 + expect(data.statusReport.pageComponentIds).toContain("1"); 967 + }); 968 + 969 + test("returns 401 when no auth key provided", async () => { 970 + const res = await connectRequest("UpdateStatusReport", { 971 + id: String(testStatusReportToUpdateId), 972 + title: "Unauthorized update", 973 + }); 974 + 975 + expect(res.status).toBe(401); 976 + }); 977 + 978 + test("returns 404 for non-existent status report", async () => { 979 + const res = await connectRequest( 980 + "UpdateStatusReport", 981 + { id: "99999", title: "Non-existent update" }, 982 + { "x-openstatus-key": "1" }, 983 + ); 984 + 985 + expect(res.status).toBe(404); 986 + }); 987 + 988 + test("returns error when ID is empty string", async () => { 989 + const res = await connectRequest( 990 + "UpdateStatusReport", 991 + { id: "", title: "Empty ID update" }, 992 + { "x-openstatus-key": "1" }, 993 + ); 994 + 995 + expect(res.status).toBe(400); 996 + }); 997 + 998 + test("returns error when ID is whitespace only", async () => { 999 + const res = await connectRequest( 1000 + "UpdateStatusReport", 1001 + { id: " ", title: "Whitespace ID update" }, 1002 + { "x-openstatus-key": "1" }, 1003 + ); 1004 + 1005 + expect(res.status).toBe(400); 1006 + }); 1007 + 1008 + test("returns 404 for status report in different workspace", async () => { 1009 + // Create status report in workspace 2 1010 + const otherReport = await db 1011 + .insert(statusReport) 1012 + .values({ 1013 + workspaceId: 2, 1014 + title: `${TEST_PREFIX}-other-workspace-update`, 1015 + status: "investigating", 1016 + }) 1017 + .returning() 1018 + .get(); 1019 + 1020 + try { 1021 + const res = await connectRequest( 1022 + "UpdateStatusReport", 1023 + { id: String(otherReport.id), title: "Should not update" }, 1024 + { "x-openstatus-key": "1" }, 1025 + ); 1026 + 1027 + expect(res.status).toBe(404); 1028 + } finally { 1029 + await db.delete(statusReport).where(eq(statusReport.id, otherReport.id)); 1030 + } 1031 + }); 1032 + 1033 + test("returns error for invalid page component ID on update", async () => { 1034 + const res = await connectRequest( 1035 + "UpdateStatusReport", 1036 + { 1037 + id: String(testStatusReportToUpdateId), 1038 + pageComponentIds: ["99999"], 1039 + }, 1040 + { "x-openstatus-key": "1" }, 1041 + ); 1042 + 1043 + expect(res.status).toBe(404); 1044 + }); 1045 + 1046 + test("returns error when updating with components from different pages", async () => { 1047 + const res = await connectRequest( 1048 + "UpdateStatusReport", 1049 + { 1050 + id: String(testStatusReportToUpdateId), 1051 + pageComponentIds: [ 1052 + String(testPageComponentId), 1053 + String(testPage2ComponentId), 1054 + ], 1055 + }, 1056 + { "x-openstatus-key": "1" }, 1057 + ); 1058 + 1059 + expect(res.status).toBe(400); 1060 + const data = await res.json(); 1061 + expect(data.message).toContain( 1062 + "All page components must belong to the same page", 1063 + ); 1064 + }); 1065 + 1066 + test("updates pageId when changing components to different page", async () => { 1067 + // First verify the current pageId 1068 + const beforeReport = await db 1069 + .select() 1070 + .from(statusReport) 1071 + .where(eq(statusReport.id, testStatusReportToUpdateId)) 1072 + .get(); 1073 + expect(beforeReport?.pageId).toBe(1); 1074 + 1075 + // Update to use component from page 2 1076 + const res = await connectRequest( 1077 + "UpdateStatusReport", 1078 + { 1079 + id: String(testStatusReportToUpdateId), 1080 + pageComponentIds: [String(testPage2ComponentId)], 1081 + }, 1082 + { "x-openstatus-key": "1" }, 1083 + ); 1084 + 1085 + expect(res.status).toBe(200); 1086 + 1087 + // Verify the pageId was updated to page 2 1088 + const afterReport = await db 1089 + .select() 1090 + .from(statusReport) 1091 + .where(eq(statusReport.id, testStatusReportToUpdateId)) 1092 + .get(); 1093 + expect(afterReport?.pageId).toBe(testPage2Id); 1094 + 1095 + // Restore original component association 1096 + await connectRequest( 1097 + "UpdateStatusReport", 1098 + { 1099 + id: String(testStatusReportToUpdateId), 1100 + pageComponentIds: [String(testPageComponentId)], 1101 + }, 1102 + { "x-openstatus-key": "1" }, 1103 + ); 1104 + }); 1105 + 1106 + test("clears pageId when removing all components", async () => { 1107 + // Create a temporary status report for this test 1108 + const tempReport = await db 1109 + .insert(statusReport) 1110 + .values({ 1111 + workspaceId: 1, 1112 + pageId: 1, 1113 + title: `${TEST_PREFIX}-clear-pageid`, 1114 + status: "investigating", 1115 + }) 1116 + .returning() 1117 + .get(); 1118 + 1119 + await db.insert(statusReportsToPageComponents).values({ 1120 + statusReportId: tempReport.id, 1121 + pageComponentId: testPageComponentId, 1122 + }); 1123 + 1124 + try { 1125 + // Clear all components 1126 + const res = await connectRequest( 1127 + "UpdateStatusReport", 1128 + { 1129 + id: String(tempReport.id), 1130 + pageComponentIds: [], 1131 + }, 1132 + { "x-openstatus-key": "1" }, 1133 + ); 1134 + 1135 + expect(res.status).toBe(200); 1136 + 1137 + // Verify the pageId is now null 1138 + const afterReport = await db 1139 + .select() 1140 + .from(statusReport) 1141 + .where(eq(statusReport.id, tempReport.id)) 1142 + .get(); 1143 + expect(afterReport?.pageId).toBeNull(); 1144 + } finally { 1145 + // Clean up 1146 + await db 1147 + .delete(statusReportsToPageComponents) 1148 + .where(eq(statusReportsToPageComponents.statusReportId, tempReport.id)); 1149 + await db.delete(statusReport).where(eq(statusReport.id, tempReport.id)); 1150 + } 1151 + }); 1152 + }); 1153 + 1154 + describe("StatusReportService.DeleteStatusReport", () => { 1155 + test("successfully deletes existing status report", async () => { 1156 + const res = await connectRequest( 1157 + "DeleteStatusReport", 1158 + { id: String(testStatusReportToDeleteId) }, 1159 + { "x-openstatus-key": "1" }, 1160 + ); 1161 + 1162 + expect(res.status).toBe(200); 1163 + 1164 + const data = await res.json(); 1165 + expect(data.success).toBe(true); 1166 + 1167 + // Verify it's deleted 1168 + const deleted = await db 1169 + .select() 1170 + .from(statusReport) 1171 + .where(eq(statusReport.id, testStatusReportToDeleteId)) 1172 + .get(); 1173 + expect(deleted).toBeUndefined(); 1174 + }); 1175 + 1176 + test("returns 401 when no auth key provided", async () => { 1177 + const res = await connectRequest("DeleteStatusReport", { id: "1" }); 1178 + 1179 + expect(res.status).toBe(401); 1180 + }); 1181 + 1182 + test("returns 404 for non-existent status report", async () => { 1183 + const res = await connectRequest( 1184 + "DeleteStatusReport", 1185 + { id: "99999" }, 1186 + { "x-openstatus-key": "1" }, 1187 + ); 1188 + 1189 + expect(res.status).toBe(404); 1190 + }); 1191 + 1192 + test("returns error when ID is empty string", async () => { 1193 + const res = await connectRequest( 1194 + "DeleteStatusReport", 1195 + { id: "" }, 1196 + { "x-openstatus-key": "1" }, 1197 + ); 1198 + 1199 + expect(res.status).toBe(400); 1200 + }); 1201 + 1202 + test("returns error when ID is whitespace only", async () => { 1203 + const res = await connectRequest( 1204 + "DeleteStatusReport", 1205 + { id: " " }, 1206 + { "x-openstatus-key": "1" }, 1207 + ); 1208 + 1209 + expect(res.status).toBe(400); 1210 + }); 1211 + 1212 + test("returns 404 for status report in different workspace", async () => { 1213 + // Create status report in workspace 2 1214 + const otherReport = await db 1215 + .insert(statusReport) 1216 + .values({ 1217 + workspaceId: 2, 1218 + title: `${TEST_PREFIX}-other-workspace-delete`, 1219 + status: "investigating", 1220 + }) 1221 + .returning() 1222 + .get(); 1223 + 1224 + try { 1225 + const res = await connectRequest( 1226 + "DeleteStatusReport", 1227 + { id: String(otherReport.id) }, 1228 + { "x-openstatus-key": "1" }, 1229 + ); 1230 + 1231 + expect(res.status).toBe(404); 1232 + 1233 + // Verify it wasn't deleted 1234 + const stillExists = await db 1235 + .select() 1236 + .from(statusReport) 1237 + .where(eq(statusReport.id, otherReport.id)) 1238 + .get(); 1239 + expect(stillExists).toBeDefined(); 1240 + } finally { 1241 + await db.delete(statusReport).where(eq(statusReport.id, otherReport.id)); 1242 + } 1243 + }); 1244 + }); 1245 + 1246 + describe("StatusReportService.AddStatusReportUpdate", () => { 1247 + test("adds update to existing status report", async () => { 1248 + const res = await connectRequest( 1249 + "AddStatusReportUpdate", 1250 + { 1251 + statusReportId: String(testStatusReportId), 1252 + status: "STATUS_REPORT_STATUS_MONITORING", 1253 + message: "We are monitoring the fix.", 1254 + date: new Date().toISOString(), 1255 + }, 1256 + { "x-openstatus-key": "1" }, 1257 + ); 1258 + 1259 + expect(res.status).toBe(200); 1260 + 1261 + const data = await res.json(); 1262 + expect(data).toHaveProperty("statusReport"); 1263 + expect(data.statusReport.status).toBe("STATUS_REPORT_STATUS_MONITORING"); 1264 + // Should now have 3 updates (2 initial + 1 new) 1265 + expect(data.statusReport.updates.length).toBeGreaterThanOrEqual(3); 1266 + 1267 + const newUpdate = data.statusReport.updates.find( 1268 + (u: { message: string }) => u.message === "We are monitoring the fix.", 1269 + ); 1270 + expect(newUpdate).toBeDefined(); 1271 + expect(newUpdate.status).toBe("STATUS_REPORT_STATUS_MONITORING"); 1272 + }); 1273 + 1274 + test("uses current time when date is not provided", async () => { 1275 + // Allow 2 second tolerance for timing differences 1276 + const beforeTime = new Date(Date.now() - 2000); 1277 + 1278 + const res = await connectRequest( 1279 + "AddStatusReportUpdate", 1280 + { 1281 + statusReportId: String(testStatusReportId), 1282 + status: "STATUS_REPORT_STATUS_RESOLVED", 1283 + message: "Issue has been resolved.", 1284 + }, 1285 + { "x-openstatus-key": "1" }, 1286 + ); 1287 + 1288 + const afterTime = new Date(Date.now() + 2000); 1289 + 1290 + expect(res.status).toBe(200); 1291 + 1292 + const data = await res.json(); 1293 + expect(data.statusReport.status).toBe("STATUS_REPORT_STATUS_RESOLVED"); 1294 + 1295 + const newUpdate = data.statusReport.updates.find( 1296 + (u: { message: string }) => u.message === "Issue has been resolved.", 1297 + ); 1298 + expect(newUpdate).toBeDefined(); 1299 + 1300 + const updateDate = new Date(newUpdate.date); 1301 + expect(updateDate.getTime()).toBeGreaterThanOrEqual(beforeTime.getTime()); 1302 + expect(updateDate.getTime()).toBeLessThanOrEqual(afterTime.getTime()); 1303 + }); 1304 + 1305 + test("returns 401 when no auth key provided", async () => { 1306 + const res = await connectRequest("AddStatusReportUpdate", { 1307 + statusReportId: String(testStatusReportId), 1308 + status: "STATUS_REPORT_STATUS_RESOLVED", 1309 + message: "Unauthorized update", 1310 + }); 1311 + 1312 + expect(res.status).toBe(401); 1313 + }); 1314 + 1315 + test("returns 404 for non-existent status report", async () => { 1316 + const res = await connectRequest( 1317 + "AddStatusReportUpdate", 1318 + { 1319 + statusReportId: "99999", 1320 + status: "STATUS_REPORT_STATUS_RESOLVED", 1321 + message: "Non-existent report", 1322 + }, 1323 + { "x-openstatus-key": "1" }, 1324 + ); 1325 + 1326 + expect(res.status).toBe(404); 1327 + }); 1328 + 1329 + test("returns error when statusReportId is empty string", async () => { 1330 + const res = await connectRequest( 1331 + "AddStatusReportUpdate", 1332 + { 1333 + statusReportId: "", 1334 + status: "STATUS_REPORT_STATUS_RESOLVED", 1335 + message: "Empty ID update", 1336 + }, 1337 + { "x-openstatus-key": "1" }, 1338 + ); 1339 + 1340 + expect(res.status).toBe(400); 1341 + }); 1342 + 1343 + test("returns error when statusReportId is whitespace only", async () => { 1344 + const res = await connectRequest( 1345 + "AddStatusReportUpdate", 1346 + { 1347 + statusReportId: " ", 1348 + status: "STATUS_REPORT_STATUS_RESOLVED", 1349 + message: "Whitespace ID update", 1350 + }, 1351 + { "x-openstatus-key": "1" }, 1352 + ); 1353 + 1354 + expect(res.status).toBe(400); 1355 + }); 1356 + 1357 + test("returns 404 for status report in different workspace", async () => { 1358 + // Create status report in workspace 2 1359 + const otherReport = await db 1360 + .insert(statusReport) 1361 + .values({ 1362 + workspaceId: 2, 1363 + title: `${TEST_PREFIX}-other-workspace-add-update`, 1364 + status: "investigating", 1365 + }) 1366 + .returning() 1367 + .get(); 1368 + 1369 + try { 1370 + const res = await connectRequest( 1371 + "AddStatusReportUpdate", 1372 + { 1373 + statusReportId: String(otherReport.id), 1374 + status: "STATUS_REPORT_STATUS_RESOLVED", 1375 + message: "Should not add to other workspace", 1376 + }, 1377 + { "x-openstatus-key": "1" }, 1378 + ); 1379 + 1380 + expect(res.status).toBe(404); 1381 + } finally { 1382 + await db.delete(statusReport).where(eq(statusReport.id, otherReport.id)); 1383 + } 1384 + }); 1385 + 1386 + test("adds update with notify=true", async () => { 1387 + sendStatusReportUpdateMock.mockClear(); 1388 + 1389 + const res = await connectRequest( 1390 + "AddStatusReportUpdate", 1391 + { 1392 + statusReportId: String(testStatusReportForNotifyId), 1393 + status: "STATUS_REPORT_STATUS_IDENTIFIED", 1394 + message: "We identified the issue and are notifying subscribers.", 1395 + date: new Date().toISOString(), 1396 + notify: true, 1397 + }, 1398 + { "x-openstatus-key": "1" }, 1399 + ); 1400 + 1401 + expect(res.status).toBe(200); 1402 + 1403 + const data = await res.json(); 1404 + expect(data).toHaveProperty("statusReport"); 1405 + expect(data.statusReport.status).toBe("STATUS_REPORT_STATUS_IDENTIFIED"); 1406 + 1407 + const newUpdate = data.statusReport.updates.find( 1408 + (u: { message: string }) => 1409 + u.message === "We identified the issue and are notifying subscribers.", 1410 + ); 1411 + expect(newUpdate).toBeDefined(); 1412 + 1413 + // Verify notification was sent 1414 + expect(sendStatusReportUpdateMock).toHaveBeenCalledTimes(1); 1415 + const mockCall = sendStatusReportUpdateMock.mock.calls[0][0]; 1416 + expect(mockCall.reportTitle).toBe(`${TEST_PREFIX}-for-notify`); 1417 + expect(mockCall.status).toBe("identified"); 1418 + expect(mockCall.message).toBe( 1419 + "We identified the issue and are notifying subscribers.", 1420 + ); 1421 + }); 1422 + 1423 + test("adds update with notify=false", async () => { 1424 + sendStatusReportUpdateMock.mockClear(); 1425 + 1426 + const res = await connectRequest( 1427 + "AddStatusReportUpdate", 1428 + { 1429 + statusReportId: String(testStatusReportForNotifyId), 1430 + status: "STATUS_REPORT_STATUS_MONITORING", 1431 + message: "Monitoring without notification.", 1432 + date: new Date().toISOString(), 1433 + notify: false, 1434 + }, 1435 + { "x-openstatus-key": "1" }, 1436 + ); 1437 + 1438 + expect(res.status).toBe(200); 1439 + 1440 + const data = await res.json(); 1441 + expect(data).toHaveProperty("statusReport"); 1442 + expect(data.statusReport.status).toBe("STATUS_REPORT_STATUS_MONITORING"); 1443 + 1444 + // Verify notification was NOT sent 1445 + expect(sendStatusReportUpdateMock).not.toHaveBeenCalled(); 1446 + }); 1447 + 1448 + test("updates status report status when adding update", async () => { 1449 + // First verify the current status 1450 + const getRes = await connectRequest( 1451 + "GetStatusReport", 1452 + { id: String(testStatusReportForNotifyId) }, 1453 + { "x-openstatus-key": "1" }, 1454 + ); 1455 + const getData = await getRes.json(); 1456 + const initialStatus = getData.statusReport.status; 1457 + 1458 + // Add update with different status 1459 + const newStatus = 1460 + initialStatus === "STATUS_REPORT_STATUS_RESOLVED" 1461 + ? "STATUS_REPORT_STATUS_INVESTIGATING" 1462 + : "STATUS_REPORT_STATUS_RESOLVED"; 1463 + 1464 + const res = await connectRequest( 1465 + "AddStatusReportUpdate", 1466 + { 1467 + statusReportId: String(testStatusReportForNotifyId), 1468 + status: newStatus, 1469 + message: "Status change test.", 1470 + }, 1471 + { "x-openstatus-key": "1" }, 1472 + ); 1473 + 1474 + expect(res.status).toBe(200); 1475 + 1476 + const data = await res.json(); 1477 + expect(data.statusReport.status).toBe(newStatus); 1478 + }); 1479 + 1480 + test("returns error for invalid date format", async () => { 1481 + const res = await connectRequest( 1482 + "AddStatusReportUpdate", 1483 + { 1484 + statusReportId: String(testStatusReportId), 1485 + status: "STATUS_REPORT_STATUS_MONITORING", 1486 + message: "Test with invalid date.", 1487 + date: "not-a-valid-date", 1488 + }, 1489 + { "x-openstatus-key": "1" }, 1490 + ); 1491 + 1492 + expect(res.status).toBe(400); 1493 + const data = await res.json(); 1494 + expect(data.message).toContain("date: value does not match regex pattern"); 1495 + }); 1496 + }); 1497 + 1498 + describe("protoStatusToDb converter", () => { 1499 + test("converts valid statuses correctly", () => { 1500 + expect(protoStatusToDb(StatusReportStatus.INVESTIGATING)).toBe( 1501 + "investigating", 1502 + ); 1503 + expect(protoStatusToDb(StatusReportStatus.IDENTIFIED)).toBe("identified"); 1504 + expect(protoStatusToDb(StatusReportStatus.MONITORING)).toBe("monitoring"); 1505 + expect(protoStatusToDb(StatusReportStatus.RESOLVED)).toBe("resolved"); 1506 + }); 1507 + 1508 + test("throws error for UNSPECIFIED status", () => { 1509 + expect(() => protoStatusToDb(StatusReportStatus.UNSPECIFIED)).toThrow( 1510 + "Invalid status value", 1511 + ); 1512 + }); 1513 + 1514 + test("throws error for unknown status values", () => { 1515 + // Simulate a new status value being added to the proto 1516 + const unknownStatus = 999 as StatusReportStatus; 1517 + expect(() => protoStatusToDb(unknownStatus)).toThrow( 1518 + "Invalid status value", 1519 + ); 1520 + }); 1521 + });
+123
apps/server/src/routes/rpc/services/status-report/converters.ts
··· 1 + import type { 2 + StatusReport, 3 + StatusReportSummary, 4 + StatusReportUpdate, 5 + } from "@openstatus/proto/status_report/v1"; 6 + import { StatusReportStatus } from "@openstatus/proto/status_report/v1"; 7 + import { invalidStatusError } from "./errors"; 8 + 9 + type DBStatusReport = { 10 + id: number; 11 + status: "investigating" | "identified" | "monitoring" | "resolved"; 12 + title: string; 13 + workspaceId: number | null; 14 + pageId: number | null; 15 + createdAt: Date | null; 16 + updatedAt: Date | null; 17 + }; 18 + 19 + type DBStatusReportUpdate = { 20 + id: number; 21 + status: "investigating" | "identified" | "monitoring" | "resolved"; 22 + date: Date; 23 + message: string; 24 + statusReportId: number; 25 + createdAt: Date | null; 26 + updatedAt: Date | null; 27 + }; 28 + 29 + /** 30 + * Convert DB status string to proto enum. 31 + */ 32 + export function dbStatusToProto( 33 + status: "investigating" | "identified" | "monitoring" | "resolved", 34 + ): StatusReportStatus { 35 + switch (status) { 36 + case "investigating": 37 + return StatusReportStatus.INVESTIGATING; 38 + case "identified": 39 + return StatusReportStatus.IDENTIFIED; 40 + case "monitoring": 41 + return StatusReportStatus.MONITORING; 42 + case "resolved": 43 + return StatusReportStatus.RESOLVED; 44 + default: 45 + return StatusReportStatus.UNSPECIFIED; 46 + } 47 + } 48 + 49 + /** 50 + * Convert proto enum to DB status string. 51 + */ 52 + export function protoStatusToDb( 53 + status: StatusReportStatus, 54 + ): "investigating" | "identified" | "monitoring" | "resolved" { 55 + switch (status) { 56 + case StatusReportStatus.INVESTIGATING: 57 + return "investigating"; 58 + case StatusReportStatus.IDENTIFIED: 59 + return "identified"; 60 + case StatusReportStatus.MONITORING: 61 + return "monitoring"; 62 + case StatusReportStatus.RESOLVED: 63 + return "resolved"; 64 + case StatusReportStatus.UNSPECIFIED: 65 + throw invalidStatusError(status); 66 + default: 67 + throw invalidStatusError(status); 68 + } 69 + } 70 + 71 + /** 72 + * Convert a DB status report update to proto format. 73 + */ 74 + export function dbUpdateToProto( 75 + update: DBStatusReportUpdate, 76 + ): StatusReportUpdate { 77 + return { 78 + $typeName: "openstatus.status_report.v1.StatusReportUpdate" as const, 79 + id: String(update.id), 80 + status: dbStatusToProto(update.status), 81 + date: update.date.toISOString(), 82 + message: update.message, 83 + createdAt: update.createdAt?.toISOString() ?? "", 84 + }; 85 + } 86 + 87 + /** 88 + * Convert a DB status report to proto summary format (metadata only). 89 + */ 90 + export function dbReportToProtoSummary( 91 + report: DBStatusReport, 92 + pageComponentIds: string[], 93 + ): StatusReportSummary { 94 + return { 95 + $typeName: "openstatus.status_report.v1.StatusReportSummary" as const, 96 + id: String(report.id), 97 + status: dbStatusToProto(report.status), 98 + title: report.title, 99 + pageComponentIds, 100 + createdAt: report.createdAt?.toISOString() ?? "", 101 + updatedAt: report.updatedAt?.toISOString() ?? "", 102 + }; 103 + } 104 + 105 + /** 106 + * Convert a DB status report to full proto format (with updates). 107 + */ 108 + export function dbReportToProto( 109 + report: DBStatusReport, 110 + pageComponentIds: string[], 111 + updates: DBStatusReportUpdate[], 112 + ): StatusReport { 113 + return { 114 + $typeName: "openstatus.status_report.v1.StatusReport" as const, 115 + id: String(report.id), 116 + status: dbStatusToProto(report.status), 117 + title: report.title, 118 + pageComponentIds, 119 + updates: updates.map(dbUpdateToProto), 120 + createdAt: report.createdAt?.toISOString() ?? "", 121 + updatedAt: report.updatedAt?.toISOString() ?? "", 122 + }; 123 + }
+141
apps/server/src/routes/rpc/services/status-report/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_REPORT_NOT_FOUND: "STATUS_REPORT_NOT_FOUND", 8 + STATUS_REPORT_ID_REQUIRED: "STATUS_REPORT_ID_REQUIRED", 9 + STATUS_REPORT_CREATE_FAILED: "STATUS_REPORT_CREATE_FAILED", 10 + STATUS_REPORT_UPDATE_FAILED: "STATUS_REPORT_UPDATE_FAILED", 11 + PAGE_COMPONENT_NOT_FOUND: "PAGE_COMPONENT_NOT_FOUND", 12 + PAGE_COMPONENTS_MIXED_PAGES: "PAGE_COMPONENTS_MIXED_PAGES", 13 + INVALID_DATE_FORMAT: "INVALID_DATE_FORMAT", 14 + INVALID_STATUS: "INVALID_STATUS", 15 + } as const; 16 + 17 + export type ErrorReason = (typeof ErrorReason)[keyof typeof ErrorReason]; 18 + 19 + const DOMAIN = "openstatus.dev"; 20 + 21 + /** 22 + * Creates a ConnectError with structured metadata. 23 + */ 24 + function createError( 25 + message: string, 26 + code: Code, 27 + reason: ErrorReason, 28 + metadata?: Record<string, string>, 29 + ): ConnectError { 30 + const headers = new Headers({ 31 + "error-domain": DOMAIN, 32 + "error-reason": reason, 33 + }); 34 + 35 + if (metadata) { 36 + for (const [key, value] of Object.entries(metadata)) { 37 + headers.set(`error-${key}`, value); 38 + } 39 + } 40 + 41 + return new ConnectError(message, code, headers); 42 + } 43 + 44 + /** 45 + * Creates a "status report not found" error. 46 + */ 47 + export function statusReportNotFoundError( 48 + statusReportId: string, 49 + ): ConnectError { 50 + return createError( 51 + "Status report not found", 52 + Code.NotFound, 53 + ErrorReason.STATUS_REPORT_NOT_FOUND, 54 + { "status-report-id": statusReportId }, 55 + ); 56 + } 57 + 58 + /** 59 + * Creates a "status report ID required" error. 60 + */ 61 + export function statusReportIdRequiredError(): ConnectError { 62 + return createError( 63 + "Status report ID is required", 64 + Code.InvalidArgument, 65 + ErrorReason.STATUS_REPORT_ID_REQUIRED, 66 + ); 67 + } 68 + 69 + /** 70 + * Creates a "failed to create status report" error. 71 + */ 72 + export function statusReportCreateFailedError(): ConnectError { 73 + return createError( 74 + "Failed to create status report", 75 + Code.Internal, 76 + ErrorReason.STATUS_REPORT_CREATE_FAILED, 77 + ); 78 + } 79 + 80 + /** 81 + * Creates a "failed to update status report" error. 82 + */ 83 + export function statusReportUpdateFailedError( 84 + statusReportId: string, 85 + ): ConnectError { 86 + return createError( 87 + "Failed to update status report", 88 + Code.Internal, 89 + ErrorReason.STATUS_REPORT_UPDATE_FAILED, 90 + { "status-report-id": statusReportId }, 91 + ); 92 + } 93 + 94 + /** 95 + * Creates a "page component not found" error. 96 + */ 97 + export function pageComponentNotFoundError( 98 + pageComponentId: string, 99 + ): ConnectError { 100 + return createError( 101 + "Page component not found", 102 + Code.NotFound, 103 + ErrorReason.PAGE_COMPONENT_NOT_FOUND, 104 + { "page-component-id": pageComponentId }, 105 + ); 106 + } 107 + 108 + /** 109 + * Creates a "page components from mixed pages" error. 110 + */ 111 + export function pageComponentsMixedPagesError(): ConnectError { 112 + return createError( 113 + "All page components must belong to the same page", 114 + Code.InvalidArgument, 115 + ErrorReason.PAGE_COMPONENTS_MIXED_PAGES, 116 + ); 117 + } 118 + 119 + /** 120 + * Creates an "invalid date format" error. 121 + */ 122 + export function invalidDateFormatError(dateValue: string): ConnectError { 123 + return createError( 124 + "Invalid date format. Expected RFC 3339 format (e.g., 2024-01-15T10:30:00Z)", 125 + Code.InvalidArgument, 126 + ErrorReason.INVALID_DATE_FORMAT, 127 + { "date-value": dateValue }, 128 + ); 129 + } 130 + 131 + /** 132 + * Creates an "invalid status" error. 133 + */ 134 + export function invalidStatusError(statusValue: number): ConnectError { 135 + return createError( 136 + `Invalid status value: ${statusValue}. Expected INVESTIGATING, IDENTIFIED, MONITORING, or RESOLVED`, 137 + Code.InvalidArgument, 138 + ErrorReason.INVALID_STATUS, 139 + { "status-value": String(statusValue) }, 140 + ); 141 + }
+589
apps/server/src/routes/rpc/services/status-report/index.ts
··· 1 + import type { ServiceImpl } from "@connectrpc/connect"; 2 + import { 3 + and, 4 + db, 5 + desc, 6 + eq, 7 + inArray, 8 + isNotNull, 9 + isNull, 10 + sql, 11 + } from "@openstatus/db"; 12 + 13 + // Type that works with both db instance and transaction 14 + type DB = typeof db; 15 + type Transaction = Parameters<Parameters<DB["transaction"]>[0]>[0]; 16 + import { 17 + page, 18 + pageComponent, 19 + pageSubscriber, 20 + statusReport, 21 + statusReportUpdate, 22 + statusReportsToPageComponents, 23 + } from "@openstatus/db/src/schema"; 24 + import type { Limits } from "@openstatus/db/src/schema/plan/schema"; 25 + import { EmailClient } from "@openstatus/emails"; 26 + import type { StatusReportService } from "@openstatus/proto/status_report/v1"; 27 + import { StatusReportStatus } from "@openstatus/proto/status_report/v1"; 28 + 29 + import { env } from "@/env"; 30 + import { getRpcContext } from "../../interceptors"; 31 + import { 32 + dbReportToProto, 33 + dbReportToProtoSummary, 34 + protoStatusToDb, 35 + } from "./converters"; 36 + import { 37 + invalidDateFormatError, 38 + pageComponentNotFoundError, 39 + pageComponentsMixedPagesError, 40 + statusReportCreateFailedError, 41 + statusReportIdRequiredError, 42 + statusReportNotFoundError, 43 + statusReportUpdateFailedError, 44 + } from "./errors"; 45 + 46 + const emailClient = new EmailClient({ apiKey: env.RESEND_API_KEY }); 47 + 48 + /** 49 + * Helper to send status report notifications to page subscribers. 50 + */ 51 + async function sendStatusReportNotification(params: { 52 + statusReportId: number; 53 + pageId: number; 54 + reportTitle: string; 55 + status: "investigating" | "identified" | "monitoring" | "resolved"; 56 + message: string; 57 + date: Date; 58 + limits: Limits; 59 + }) { 60 + const { statusReportId, pageId, reportTitle, status, message, date, limits } = 61 + params; 62 + 63 + // Check if workspace has status-subscribers feature enabled 64 + if (!limits["status-subscribers"]) { 65 + return; 66 + } 67 + 68 + // Get page info 69 + const pageInfo = await db.query.page.findFirst({ 70 + where: eq(page.id, pageId), 71 + }); 72 + 73 + if (!pageInfo) { 74 + return; 75 + } 76 + 77 + // Get verified subscribers who haven't unsubscribed 78 + const subscribers = await db 79 + .select() 80 + .from(pageSubscriber) 81 + .where( 82 + and( 83 + eq(pageSubscriber.pageId, pageId), 84 + isNotNull(pageSubscriber.acceptedAt), 85 + isNull(pageSubscriber.unsubscribedAt), 86 + ), 87 + ) 88 + .all(); 89 + 90 + const validSubscribers = subscribers.filter( 91 + (s): s is typeof s & { token: string } => 92 + s.token !== null && s.acceptedAt !== null && s.unsubscribedAt === null, 93 + ); 94 + 95 + if (validSubscribers.length === 0) { 96 + return; 97 + } 98 + 99 + // Get page components for this status report 100 + const statusReportWithComponents = await db.query.statusReport.findFirst({ 101 + where: eq(statusReport.id, statusReportId), 102 + with: { 103 + statusReportsToPageComponents: { 104 + with: { pageComponent: true }, 105 + }, 106 + }, 107 + }); 108 + 109 + const pageComponents = 110 + statusReportWithComponents?.statusReportsToPageComponents.map( 111 + (i) => i.pageComponent.name, 112 + ) ?? []; 113 + 114 + // Send notification emails 115 + await emailClient.sendStatusReportUpdate({ 116 + subscribers: validSubscribers.map((subscriber) => ({ 117 + email: subscriber.email, 118 + token: subscriber.token, 119 + })), 120 + pageTitle: pageInfo.title, 121 + pageSlug: pageInfo.slug, 122 + customDomain: pageInfo.customDomain, 123 + reportTitle, 124 + status, 125 + message, 126 + date: date.toISOString(), 127 + pageComponents, 128 + }); 129 + } 130 + 131 + /** 132 + * Helper to get a status report by ID with workspace scope. 133 + */ 134 + async function getStatusReportById(id: number, workspaceId: number) { 135 + return db 136 + .select() 137 + .from(statusReport) 138 + .where( 139 + and(eq(statusReport.id, id), eq(statusReport.workspaceId, workspaceId)), 140 + ) 141 + .get(); 142 + } 143 + 144 + /** 145 + * Helper to get page component IDs for a status report. 146 + */ 147 + async function getPageComponentIdsForReport(statusReportId: number) { 148 + const components = await db 149 + .select({ pageComponentId: statusReportsToPageComponents.pageComponentId }) 150 + .from(statusReportsToPageComponents) 151 + .where(eq(statusReportsToPageComponents.statusReportId, statusReportId)) 152 + .all(); 153 + 154 + return components.map((c) => String(c.pageComponentId)); 155 + } 156 + 157 + /** 158 + * Helper to get updates for a status report, ordered by date descending. 159 + */ 160 + async function getUpdatesForReport(statusReportId: number) { 161 + return db 162 + .select() 163 + .from(statusReportUpdate) 164 + .where(eq(statusReportUpdate.statusReportId, statusReportId)) 165 + .orderBy(desc(statusReportUpdate.date)) 166 + .all(); 167 + } 168 + 169 + /** 170 + * Result of validating page component IDs. 171 + */ 172 + interface ValidatedPageComponents { 173 + componentIds: number[]; 174 + pageId: number | null; 175 + } 176 + 177 + /** 178 + * Helper to validate page component IDs belong to the workspace and same page. 179 + * Accepts an optional transaction to ensure atomicity with subsequent operations. 180 + */ 181 + async function validatePageComponentIds( 182 + pageComponentIds: string[], 183 + workspaceId: number, 184 + tx: DB | Transaction = db, 185 + ): Promise<ValidatedPageComponents> { 186 + if (pageComponentIds.length === 0) { 187 + return { componentIds: [], pageId: null }; 188 + } 189 + 190 + const numericIds = pageComponentIds.map((id) => Number(id)); 191 + 192 + const validComponents = await tx 193 + .select({ id: pageComponent.id, pageId: pageComponent.pageId }) 194 + .from(pageComponent) 195 + .where( 196 + and( 197 + inArray(pageComponent.id, numericIds), 198 + eq(pageComponent.workspaceId, workspaceId), 199 + ), 200 + ) 201 + .all(); 202 + 203 + const validComponentsMap = new Map( 204 + validComponents.map((c) => [c.id, c.pageId]), 205 + ); 206 + 207 + // Check all requested IDs exist 208 + for (const id of numericIds) { 209 + if (!validComponentsMap.has(id)) { 210 + throw pageComponentNotFoundError(String(id)); 211 + } 212 + } 213 + 214 + // Validate all components belong to the same page 215 + const pageIds = new Set(validComponents.map((c) => c.pageId)); 216 + if (pageIds.size > 1) { 217 + throw pageComponentsMixedPagesError(); 218 + } 219 + 220 + const pageId = validComponents[0]?.pageId ?? null; 221 + 222 + return { componentIds: numericIds, pageId }; 223 + } 224 + 225 + /** 226 + * Helper to update page component associations for a status report. 227 + * Accepts an optional transaction to ensure atomicity. 228 + */ 229 + async function updatePageComponentAssociations( 230 + statusReportId: number, 231 + pageComponentIds: number[], 232 + tx: DB | Transaction = db, 233 + ) { 234 + // Delete existing associations 235 + await tx 236 + .delete(statusReportsToPageComponents) 237 + .where(eq(statusReportsToPageComponents.statusReportId, statusReportId)); 238 + 239 + // Insert new associations 240 + if (pageComponentIds.length > 0) { 241 + await tx.insert(statusReportsToPageComponents).values( 242 + pageComponentIds.map((pageComponentId) => ({ 243 + statusReportId, 244 + pageComponentId, 245 + })), 246 + ); 247 + } 248 + } 249 + 250 + /** 251 + * Parses and validates a date string. 252 + * Throws invalidDateFormatError if the date is invalid. 253 + */ 254 + function parseDate(dateString: string): Date { 255 + const date = new Date(dateString); 256 + if (Number.isNaN(date.getTime())) { 257 + throw invalidDateFormatError(dateString); 258 + } 259 + return date; 260 + } 261 + 262 + /** 263 + * Status report service implementation for ConnectRPC. 264 + */ 265 + export const statusReportServiceImpl: ServiceImpl<typeof StatusReportService> = 266 + { 267 + async createStatusReport(req, ctx) { 268 + const rpcCtx = getRpcContext(ctx); 269 + const workspaceId = rpcCtx.workspace.id; 270 + 271 + // Parse and validate the date before the transaction 272 + const date = parseDate(req.date); 273 + 274 + // Create status report, associations, and initial update in a transaction 275 + const { report: newReport, pageId } = await db.transaction(async (tx) => { 276 + // Validate page component IDs inside transaction to prevent TOCTOU race condition 277 + const validatedComponents = await validatePageComponentIds( 278 + req.pageComponentIds, 279 + workspaceId, 280 + tx, 281 + ); 282 + 283 + // Derive pageId from validated components (ensures consistency) 284 + const pageId = validatedComponents.pageId; 285 + 286 + // Create the status report 287 + const report = await tx 288 + .insert(statusReport) 289 + .values({ 290 + workspaceId, 291 + pageId, 292 + title: req.title, 293 + status: protoStatusToDb(req.status), 294 + }) 295 + .returning() 296 + .get(); 297 + 298 + if (!report) { 299 + throw statusReportCreateFailedError(); 300 + } 301 + 302 + // Create page component associations 303 + await updatePageComponentAssociations( 304 + report.id, 305 + validatedComponents.componentIds, 306 + tx, 307 + ); 308 + 309 + // Create the initial update 310 + const newUpdate = await tx 311 + .insert(statusReportUpdate) 312 + .values({ 313 + statusReportId: report.id, 314 + status: protoStatusToDb(req.status), 315 + date, 316 + message: req.message, 317 + }) 318 + .returning() 319 + .get(); 320 + 321 + if (!newUpdate) { 322 + throw statusReportCreateFailedError(); 323 + } 324 + 325 + return { report, pageId }; 326 + }); 327 + 328 + // Send notifications if requested (outside transaction) 329 + if (req.notify) { 330 + await sendStatusReportNotification({ 331 + statusReportId: newReport.id, 332 + pageId, 333 + reportTitle: newReport.title, 334 + status: protoStatusToDb(req.status), 335 + message: req.message, 336 + date, 337 + limits: rpcCtx.workspace.limits, 338 + }); 339 + } 340 + 341 + // Fetch the updates for the response 342 + const updates = await getUpdatesForReport(newReport.id); 343 + 344 + return { 345 + statusReport: dbReportToProto(newReport, req.pageComponentIds, updates), 346 + }; 347 + }, 348 + 349 + async getStatusReport(req, ctx) { 350 + const rpcCtx = getRpcContext(ctx); 351 + const workspaceId = rpcCtx.workspace.id; 352 + 353 + if (!req.id || req.id.trim() === "") { 354 + throw statusReportIdRequiredError(); 355 + } 356 + 357 + const report = await getStatusReportById(Number(req.id), workspaceId); 358 + if (!report) { 359 + throw statusReportNotFoundError(req.id); 360 + } 361 + 362 + const pageComponentIds = await getPageComponentIdsForReport(report.id); 363 + const updates = await getUpdatesForReport(report.id); 364 + 365 + return { 366 + statusReport: dbReportToProto(report, pageComponentIds, updates), 367 + }; 368 + }, 369 + 370 + async listStatusReports(req, ctx) { 371 + const rpcCtx = getRpcContext(ctx); 372 + const workspaceId = rpcCtx.workspace.id; 373 + 374 + const limit = Math.min(Math.max(req.limit ?? 50, 1), 100); 375 + const offset = req.offset ?? 0; 376 + 377 + // Build conditions 378 + const conditions = [eq(statusReport.workspaceId, workspaceId)]; 379 + 380 + // Add status filter if provided 381 + if (req.statuses.length > 0) { 382 + const dbStatuses = req.statuses 383 + .filter((s) => s !== StatusReportStatus.UNSPECIFIED) 384 + .map(protoStatusToDb); 385 + if (dbStatuses.length > 0) { 386 + conditions.push(inArray(statusReport.status, dbStatuses)); 387 + } 388 + } 389 + 390 + // Get total count 391 + const countResult = await db 392 + .select({ count: sql<number>`count(*)` }) 393 + .from(statusReport) 394 + .where(and(...conditions)) 395 + .get(); 396 + 397 + const totalCount = countResult?.count ?? 0; 398 + 399 + // Get status reports 400 + const reports = await db 401 + .select() 402 + .from(statusReport) 403 + .where(and(...conditions)) 404 + .orderBy(desc(statusReport.createdAt)) 405 + .limit(limit) 406 + .offset(offset) 407 + .all(); 408 + 409 + // Get page component IDs for each report 410 + const statusReports = await Promise.all( 411 + reports.map(async (report) => { 412 + const pageComponentIds = await getPageComponentIdsForReport( 413 + report.id, 414 + ); 415 + return dbReportToProtoSummary(report, pageComponentIds); 416 + }), 417 + ); 418 + 419 + return { 420 + statusReports, 421 + totalSize: totalCount, 422 + }; 423 + }, 424 + 425 + async updateStatusReport(req, ctx) { 426 + const rpcCtx = getRpcContext(ctx); 427 + const workspaceId = rpcCtx.workspace.id; 428 + 429 + if (!req.id || req.id.trim() === "") { 430 + throw statusReportIdRequiredError(); 431 + } 432 + 433 + const report = await getStatusReportById(Number(req.id), workspaceId); 434 + if (!report) { 435 + throw statusReportNotFoundError(req.id); 436 + } 437 + 438 + // Update report, associations in a transaction 439 + const updatedReport = await db.transaction(async (tx) => { 440 + // Validate page component IDs inside transaction to prevent TOCTOU race condition 441 + // Allows empty array to clear associations; ensures all components belong to same page 442 + const validatedComponents = await validatePageComponentIds( 443 + req.pageComponentIds, 444 + workspaceId, 445 + tx, 446 + ); 447 + 448 + // Build update values 449 + const updateValues: Record<string, unknown> = { 450 + updatedAt: new Date(), 451 + // Set pageId from validated components (null if no components) 452 + pageId: validatedComponents.pageId, 453 + }; 454 + 455 + if (req.title !== undefined && req.title !== "") { 456 + updateValues.title = req.title; 457 + } 458 + 459 + // Always update page component associations (empty array clears all) 460 + await updatePageComponentAssociations( 461 + report.id, 462 + validatedComponents.componentIds, 463 + tx, 464 + ); 465 + 466 + // Update the report 467 + const updated = await tx 468 + .update(statusReport) 469 + .set(updateValues) 470 + .where(eq(statusReport.id, report.id)) 471 + .returning() 472 + .get(); 473 + 474 + if (!updated) { 475 + throw statusReportUpdateFailedError(req.id); 476 + } 477 + 478 + return updated; 479 + }); 480 + 481 + // Fetch updated data 482 + const pageComponentIds = await getPageComponentIdsForReport( 483 + updatedReport.id, 484 + ); 485 + const updates = await getUpdatesForReport(updatedReport.id); 486 + 487 + return { 488 + statusReport: dbReportToProto(updatedReport, pageComponentIds, updates), 489 + }; 490 + }, 491 + 492 + async deleteStatusReport(req, ctx) { 493 + const rpcCtx = getRpcContext(ctx); 494 + const workspaceId = rpcCtx.workspace.id; 495 + 496 + if (!req.id || req.id.trim() === "") { 497 + throw statusReportIdRequiredError(); 498 + } 499 + 500 + const report = await getStatusReportById(Number(req.id), workspaceId); 501 + if (!report) { 502 + throw statusReportNotFoundError(req.id); 503 + } 504 + 505 + // Delete the status report (cascade will delete updates and associations) 506 + await db.delete(statusReport).where(eq(statusReport.id, report.id)); 507 + 508 + return { success: true }; 509 + }, 510 + 511 + async addStatusReportUpdate(req, ctx) { 512 + const rpcCtx = getRpcContext(ctx); 513 + const workspaceId = rpcCtx.workspace.id; 514 + 515 + if (!req.statusReportId || req.statusReportId.trim() === "") { 516 + throw statusReportIdRequiredError(); 517 + } 518 + 519 + const report = await getStatusReportById( 520 + Number(req.statusReportId), 521 + workspaceId, 522 + ); 523 + if (!report) { 524 + throw statusReportNotFoundError(req.statusReportId); 525 + } 526 + 527 + // Parse and validate the date or use current time 528 + const date = req.date ? parseDate(req.date) : new Date(); 529 + 530 + // Create update and update status report in a transaction 531 + const updatedReport = await db.transaction(async (tx) => { 532 + // Create the update 533 + const newUpdate = await tx 534 + .insert(statusReportUpdate) 535 + .values({ 536 + statusReportId: report.id, 537 + status: protoStatusToDb(req.status), 538 + date, 539 + message: req.message, 540 + }) 541 + .returning() 542 + .get(); 543 + 544 + if (!newUpdate) { 545 + throw statusReportUpdateFailedError(req.statusReportId); 546 + } 547 + 548 + // Update the status report's status and updatedAt 549 + const updated = await tx 550 + .update(statusReport) 551 + .set({ 552 + status: protoStatusToDb(req.status), 553 + updatedAt: new Date(), 554 + }) 555 + .where(eq(statusReport.id, report.id)) 556 + .returning() 557 + .get(); 558 + 559 + if (!updated) { 560 + throw statusReportUpdateFailedError(req.statusReportId); 561 + } 562 + 563 + return updated; 564 + }); 565 + 566 + // Send notifications if requested (outside transaction) 567 + if (req.notify && updatedReport.pageId) { 568 + await sendStatusReportNotification({ 569 + statusReportId: updatedReport.id, 570 + pageId: updatedReport.pageId, 571 + reportTitle: updatedReport.title, 572 + status: protoStatusToDb(req.status), 573 + message: req.message, 574 + date, 575 + limits: rpcCtx.workspace.limits, 576 + }); 577 + } 578 + 579 + // Fetch all updates 580 + const pageComponentIds = await getPageComponentIdsForReport( 581 + updatedReport.id, 582 + ); 583 + const updates = await getUpdatesForReport(updatedReport.id); 584 + 585 + return { 586 + statusReport: dbReportToProto(updatedReport, pageComponentIds, updates), 587 + }; 588 + }, 589 + };
+307
packages/proto/api/openstatus/status_report/PLAN.md
··· 1 + # Status Report Proto Implementation Plan 2 + 3 + ## Overview 4 + 5 + Create proto definitions for Status Report CRUD operations, following the existing patterns in `packages/proto/api/openstatus/monitor/v1/`. 6 + 7 + ## Directory Structure 8 + 9 + ``` 10 + packages/proto/api/openstatus/status_report/ 11 + └── v1/ 12 + ├── status_report.proto # Message definitions 13 + └── service.proto # Service and RPC definitions 14 + ``` 15 + 16 + ## Database Schema Reference 17 + 18 + From `packages/db/src/schema/status_reports/status_reports.ts`: 19 + 20 + ### StatusReport Table 21 + | Field | Type | Constraints | 22 + |-------|------|-------------| 23 + | id | integer | primary key | 24 + | status | enum | "investigating", "identified", "monitoring", "resolved" | 25 + | title | text(256) | not null | 26 + | workspaceId | integer | foreign key | 27 + | pageId | integer | foreign key (cascade delete) | 28 + | createdAt | timestamp | default now | 29 + | updatedAt | timestamp | default now | 30 + 31 + ### StatusReportUpdate Table 32 + | Field | Type | Constraints | 33 + |-------|------|-------------| 34 + | id | integer | primary key | 35 + | status | enum | same as above | 36 + | date | timestamp | not null | 37 + | message | text | not null | 38 + | statusReportId | integer | foreign key, not null (cascade delete) | 39 + | createdAt | timestamp | default now | 40 + | updatedAt | timestamp | default now | 41 + 42 + ### Relationships 43 + - StatusReport has many StatusReportUpdates 44 + - StatusReport belongs to Workspace 45 + - StatusReport can be linked to multiple PageComponents (via `statusReportsToPageComponents` junction table) 46 + - Legacy: StatusReport belongs to Page (deprecated, use page components instead) 47 + - Legacy: StatusReport can be linked to Monitors (via `monitorsToStatusReport`, deprecated) 48 + 49 + --- 50 + 51 + ## Proto Definitions 52 + 53 + ### File 1: `status_report.proto` 54 + 55 + ```protobuf 56 + syntax = "proto3"; 57 + 58 + package openstatus.status_report.v1; 59 + 60 + option go_package = "github.com/openstatushq/openstatus/packages/proto/openstatus/status_report/v1;statusreportv1"; 61 + 62 + // StatusReportStatus represents the current state of a status report. 63 + enum StatusReportStatus { 64 + STATUS_REPORT_STATUS_UNSPECIFIED = 0; 65 + STATUS_REPORT_STATUS_INVESTIGATING = 1; 66 + STATUS_REPORT_STATUS_IDENTIFIED = 2; 67 + STATUS_REPORT_STATUS_MONITORING = 3; 68 + STATUS_REPORT_STATUS_RESOLVED = 4; 69 + } 70 + 71 + // StatusReportUpdate represents a single update entry in a status report timeline. 72 + message StatusReportUpdate { 73 + // Unique identifier for the update. 74 + string id = 1; 75 + 76 + // Status at the time of this update. 77 + StatusReportStatus status = 2; 78 + 79 + // Timestamp when this update occurred (RFC 3339 format). 80 + string date = 3; 81 + 82 + // Message describing the update. 83 + string message = 4; 84 + 85 + // Timestamp when the update was created (RFC 3339 format). 86 + string created_at = 5; 87 + } 88 + 89 + // StatusReportSummary represents metadata for a status report (used in list responses). 90 + message StatusReportSummary { 91 + // Unique identifier for the status report. 92 + string id = 1; 93 + 94 + // Current status of the report. 95 + StatusReportStatus status = 2; 96 + 97 + // Title of the status report. 98 + string title = 3; 99 + 100 + // IDs of affected page components. 101 + repeated string page_component_ids = 4; 102 + 103 + // Timestamp when the report was created (RFC 3339 format). 104 + string created_at = 5; 105 + 106 + // Timestamp when the report was last updated (RFC 3339 format). 107 + string updated_at = 6; 108 + } 109 + 110 + // StatusReport represents an incident or maintenance report with full details. 111 + message StatusReport { 112 + // Unique identifier for the status report. 113 + string id = 1; 114 + 115 + // Current status of the report. 116 + StatusReportStatus status = 2; 117 + 118 + // Title of the status report. 119 + string title = 3; 120 + 121 + // IDs of affected page components. 122 + repeated string page_component_ids = 4; 123 + 124 + // Timeline of updates for this report (only included in GetStatusReport). 125 + repeated StatusReportUpdate updates = 5; 126 + 127 + // Timestamp when the report was created (RFC 3339 format). 128 + string created_at = 6; 129 + 130 + // Timestamp when the report was last updated (RFC 3339 format). 131 + string updated_at = 7; 132 + } 133 + ``` 134 + 135 + ### File 2: `service.proto` 136 + 137 + ```protobuf 138 + syntax = "proto3"; 139 + 140 + package openstatus.status_report.v1; 141 + 142 + import "buf/validate/validate.proto"; 143 + import "openstatus/status_report/v1/status_report.proto"; 144 + 145 + option go_package = "github.com/openstatushq/openstatus/packages/proto/openstatus/status_report/v1;statusreportv1"; 146 + 147 + // StatusReportService provides CRUD operations for status reports. 148 + service StatusReportService { 149 + // CreateStatusReport creates a new status report. 150 + rpc CreateStatusReport(CreateStatusReportRequest) returns (CreateStatusReportResponse); 151 + 152 + // GetStatusReport retrieves a specific status report by ID (includes full update timeline). 153 + rpc GetStatusReport(GetStatusReportRequest) returns (GetStatusReportResponse); 154 + 155 + // ListStatusReports returns all status reports for the workspace (metadata only). 156 + rpc ListStatusReports(ListStatusReportsRequest) returns (ListStatusReportsResponse); 157 + 158 + // UpdateStatusReport updates the metadata of a status report (title, page, monitors). 159 + rpc UpdateStatusReport(UpdateStatusReportRequest) returns (UpdateStatusReportResponse); 160 + 161 + // DeleteStatusReport removes a status report and all its updates. 162 + rpc DeleteStatusReport(DeleteStatusReportRequest) returns (DeleteStatusReportResponse); 163 + 164 + // AddStatusReportUpdate adds a new update to an existing status report timeline. 165 + rpc AddStatusReportUpdate(AddStatusReportUpdateRequest) returns (AddStatusReportUpdateResponse); 166 + } 167 + 168 + // --- Create Status Report --- 169 + 170 + message CreateStatusReportRequest { 171 + // Title of the status report (required). 172 + string title = 1 [(buf.validate.field).string.min_len = 1]; 173 + 174 + // Initial status (required). 175 + StatusReportStatus status = 2 [(buf.validate.field).enum.defined_only = true]; 176 + 177 + // Initial message describing the incident (required). 178 + string message = 3 [(buf.validate.field).string.min_len = 1]; 179 + 180 + // Date when the event occurred (RFC 3339 format, required). 181 + string date = 4 [(buf.validate.field).string.min_len = 1]; 182 + 183 + // Page component IDs to associate with this report (required). 184 + repeated string page_component_ids = 5 [(buf.validate.field).repeated.min_items = 1]; 185 + } 186 + 187 + message CreateStatusReportResponse { 188 + // The created status report. 189 + StatusReport status_report = 1; 190 + } 191 + 192 + // --- Get Status Report --- 193 + 194 + message GetStatusReportRequest { 195 + // ID of the status report to retrieve (required). 196 + string id = 1 [(buf.validate.field).string.min_len = 1]; 197 + } 198 + 199 + message GetStatusReportResponse { 200 + // The requested status report. 201 + StatusReport status_report = 1; 202 + } 203 + 204 + // --- List Status Reports --- 205 + 206 + message ListStatusReportsRequest { 207 + // Maximum number of reports to return (1-100, defaults to 50). 208 + optional int32 limit = 1 [(buf.validate.field).int32 = { 209 + gte: 1 210 + lte: 100 211 + }]; 212 + 213 + // Number of reports to skip for pagination (defaults to 0). 214 + optional int32 offset = 2 [(buf.validate.field).int32.gte = 0]; 215 + 216 + // Filter by status (optional). If empty, returns all statuses. 217 + repeated StatusReportStatus statuses = 3; 218 + } 219 + 220 + message ListStatusReportsResponse { 221 + // List of status reports (metadata only, use GetStatusReport for full details). 222 + repeated StatusReportSummary status_reports = 1; 223 + 224 + // Total number of reports matching the filter. 225 + int32 total_size = 2; 226 + } 227 + 228 + // --- Update Status Report --- 229 + 230 + message UpdateStatusReportRequest { 231 + // ID of the status report to update (required). 232 + string id = 1 [(buf.validate.field).string.min_len = 1]; 233 + 234 + // New title for the report (optional). 235 + optional string title = 2; 236 + 237 + // New list of page component IDs (optional, replaces existing list). 238 + repeated string page_component_ids = 3; 239 + } 240 + 241 + message UpdateStatusReportResponse { 242 + // The updated status report. 243 + StatusReport status_report = 1; 244 + } 245 + 246 + // --- Delete Status Report --- 247 + 248 + message DeleteStatusReportRequest { 249 + // ID of the status report to delete (required). 250 + string id = 1 [(buf.validate.field).string.min_len = 1]; 251 + } 252 + 253 + message DeleteStatusReportResponse { 254 + // Whether the deletion was successful. 255 + bool success = 1; 256 + } 257 + 258 + // --- Add Status Report Update --- 259 + 260 + message AddStatusReportUpdateRequest { 261 + // ID of the status report to update (required). 262 + string status_report_id = 1 [(buf.validate.field).string.min_len = 1]; 263 + 264 + // New status for the report (required). 265 + StatusReportStatus status = 2 [(buf.validate.field).enum.defined_only = true]; 266 + 267 + // Message describing what changed (required). 268 + string message = 3 [(buf.validate.field).string.min_len = 1]; 269 + 270 + // Optional date for the update. Defaults to current time if not provided. 271 + optional string date = 4; 272 + } 273 + 274 + message AddStatusReportUpdateResponse { 275 + // The updated status report with the new update included. 276 + StatusReport status_report = 1; 277 + } 278 + ``` 279 + 280 + --- 281 + 282 + ## Questions Resolved 283 + 284 + Based on the database schema: 285 + 286 + 1. **Status Report Structure**: Uses `id`, `status`, `title`, `workspaceId`, `createdAt`, `updatedAt` 287 + 2. **Updates**: Separate table `status_report_update` with timeline entries (id, status, date, message, statusReportId) 288 + 3. **Status Values**: `investigating`, `identified`, `monitoring`, `resolved` 289 + 4. **Relationships**: Links to PageComponents (many-to-many via `statusReportsToPageComponents` junction table) 290 + 291 + --- 292 + 293 + ## Implementation Notes 294 + 295 + 1. **Workspace ID**: Not included in request/response as it should be derived from the authenticated context (API key belongs to workspace) 296 + 2. **Timestamps**: Use RFC 3339 format strings for interoperability 297 + 3. **Pagination**: Uses offset-based pagination with `limit` and `offset` parameters 298 + 4. **Validation**: Use `buf.validate` for field validation 299 + 300 + --- 301 + 302 + ## Decisions Made 303 + 304 + 1. **Delete Operation**: Yes - `DeleteStatusReport` RPC added (cascade deletes all updates) 305 + 2. **Update Status Report**: Yes - `UpdateStatusReport` RPC added to modify title/page/monitors without creating a timeline entry 306 + 3. **Filtering**: `ListStatusReports` supports filtering by status only (via `statuses` field) 307 + 4. **Include Updates**: `ListStatusReports` returns `StatusReportSummary` (metadata only), `GetStatusReport` returns full `StatusReport` with update timeline
+149
packages/proto/api/openstatus/status_report/v1/service.proto
··· 1 + syntax = "proto3"; 2 + 3 + package openstatus.status_report.v1; 4 + 5 + import "buf/validate/validate.proto"; 6 + import "openstatus/status_report/v1/status_report.proto"; 7 + 8 + option go_package = "github.com/openstatushq/openstatus/packages/proto/openstatus/status_report/v1;statusreportv1"; 9 + 10 + // StatusReportService provides CRUD operations for status reports. 11 + service StatusReportService { 12 + // CreateStatusReport creates a new status report. 13 + rpc CreateStatusReport(CreateStatusReportRequest) returns (CreateStatusReportResponse); 14 + 15 + // GetStatusReport retrieves a specific status report by ID (includes full update timeline). 16 + rpc GetStatusReport(GetStatusReportRequest) returns (GetStatusReportResponse); 17 + 18 + // ListStatusReports returns all status reports for the workspace (metadata only). 19 + rpc ListStatusReports(ListStatusReportsRequest) returns (ListStatusReportsResponse); 20 + 21 + // UpdateStatusReport updates the metadata of a status report (title, page components). 22 + rpc UpdateStatusReport(UpdateStatusReportRequest) returns (UpdateStatusReportResponse); 23 + 24 + // DeleteStatusReport removes a status report and all its updates. 25 + rpc DeleteStatusReport(DeleteStatusReportRequest) returns (DeleteStatusReportResponse); 26 + 27 + // AddStatusReportUpdate adds a new update to an existing status report timeline. 28 + rpc AddStatusReportUpdate(AddStatusReportUpdateRequest) returns (AddStatusReportUpdateResponse); 29 + } 30 + 31 + // --- Create Status Report --- 32 + 33 + message CreateStatusReportRequest { 34 + // Title of the status report (required). 35 + string title = 1 [(buf.validate.field).string.min_len = 1]; 36 + 37 + // Initial status (required). 38 + StatusReportStatus status = 2 [(buf.validate.field).enum.defined_only = true]; 39 + 40 + // Initial message describing the incident (required). 41 + string message = 3 [(buf.validate.field).string.min_len = 1]; 42 + 43 + // Date when the event occurred (RFC 3339 format, required). 44 + string date = 4 [(buf.validate.field).string.pattern = "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}"]; 45 + 46 + // Page ID to associate with this report (required). 47 + string page_id = 5 [(buf.validate.field).string.min_len = 1]; 48 + 49 + // Page component IDs to associate with this report (optional). 50 + repeated string page_component_ids = 6; 51 + 52 + // Whether to notify subscribers about this status report (optional, defaults to false). 53 + optional bool notify = 7; 54 + } 55 + 56 + message CreateStatusReportResponse { 57 + // The created status report. 58 + StatusReport status_report = 1; 59 + } 60 + 61 + // --- Get Status Report --- 62 + 63 + message GetStatusReportRequest { 64 + // ID of the status report to retrieve (required). 65 + string id = 1 [(buf.validate.field).string.min_len = 1]; 66 + } 67 + 68 + message GetStatusReportResponse { 69 + // The requested status report. 70 + StatusReport status_report = 1; 71 + } 72 + 73 + // --- List Status Reports --- 74 + 75 + message ListStatusReportsRequest { 76 + // Maximum number of reports to return (1-100, defaults to 50). 77 + optional int32 limit = 1 [(buf.validate.field).int32 = { 78 + gte: 1 79 + lte: 100 80 + }]; 81 + 82 + // Number of reports to skip for pagination (defaults to 0). 83 + optional int32 offset = 2 [(buf.validate.field).int32.gte = 0]; 84 + 85 + // Filter by status (optional). If empty, returns all statuses. 86 + repeated StatusReportStatus statuses = 3; 87 + } 88 + 89 + message ListStatusReportsResponse { 90 + // List of status reports (metadata only, use GetStatusReport for full details). 91 + repeated StatusReportSummary status_reports = 1; 92 + 93 + // Total number of reports matching the filter. 94 + int32 total_size = 2; 95 + } 96 + 97 + // --- Update Status Report --- 98 + 99 + message UpdateStatusReportRequest { 100 + // ID of the status report to update (required). 101 + string id = 1 [(buf.validate.field).string.min_len = 1]; 102 + 103 + // New title for the report (optional). 104 + optional string title = 2; 105 + 106 + // New list of page component IDs (optional, replaces existing list). 107 + repeated string page_component_ids = 3; 108 + } 109 + 110 + message UpdateStatusReportResponse { 111 + // The updated status report. 112 + StatusReport status_report = 1; 113 + } 114 + 115 + // --- Delete Status Report --- 116 + 117 + message DeleteStatusReportRequest { 118 + // ID of the status report to delete (required). 119 + string id = 1 [(buf.validate.field).string.min_len = 1]; 120 + } 121 + 122 + message DeleteStatusReportResponse { 123 + // Whether the deletion was successful. 124 + bool success = 1; 125 + } 126 + 127 + // --- Add Status Report Update --- 128 + 129 + message AddStatusReportUpdateRequest { 130 + // ID of the status report to update (required). 131 + string status_report_id = 1 [(buf.validate.field).string.min_len = 1]; 132 + 133 + // New status for the report (required). 134 + StatusReportStatus status = 2 [(buf.validate.field).enum.defined_only = true]; 135 + 136 + // Message describing what changed (required). 137 + string message = 3 [(buf.validate.field).string.min_len = 1]; 138 + 139 + // Optional date for the update (RFC 3339 format). Defaults to current time if not provided. 140 + optional string date = 4 [(buf.validate.field).string.pattern = "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}"]; 141 + 142 + // Whether to notify subscribers about this update (optional, defaults to false). 143 + optional bool notify = 5; 144 + } 145 + 146 + message AddStatusReportUpdateResponse { 147 + // The updated status report with the new update included. 148 + StatusReport status_report = 1; 149 + }
+77
packages/proto/api/openstatus/status_report/v1/status_report.proto
··· 1 + syntax = "proto3"; 2 + 3 + package openstatus.status_report.v1; 4 + 5 + option go_package = "github.com/openstatushq/openstatus/packages/proto/openstatus/status_report/v1;statusreportv1"; 6 + 7 + // StatusReportStatus represents the current state of a status report. 8 + enum StatusReportStatus { 9 + STATUS_REPORT_STATUS_UNSPECIFIED = 0; 10 + STATUS_REPORT_STATUS_INVESTIGATING = 1; 11 + STATUS_REPORT_STATUS_IDENTIFIED = 2; 12 + STATUS_REPORT_STATUS_MONITORING = 3; 13 + STATUS_REPORT_STATUS_RESOLVED = 4; 14 + } 15 + 16 + // StatusReportUpdate represents a single update entry in a status report timeline. 17 + message StatusReportUpdate { 18 + // Unique identifier for the update. 19 + string id = 1; 20 + 21 + // Status at the time of this update. 22 + StatusReportStatus status = 2; 23 + 24 + // Timestamp when this update occurred (RFC 3339 format). 25 + string date = 3; 26 + 27 + // Message describing the update. 28 + string message = 4; 29 + 30 + // Timestamp when the update was created (RFC 3339 format). 31 + string created_at = 5; 32 + } 33 + 34 + // StatusReportSummary represents metadata for a status report (used in list responses). 35 + message StatusReportSummary { 36 + // Unique identifier for the status report. 37 + string id = 1; 38 + 39 + // Current status of the report. 40 + StatusReportStatus status = 2; 41 + 42 + // Title of the status report. 43 + string title = 3; 44 + 45 + // IDs of affected page components. 46 + repeated string page_component_ids = 4; 47 + 48 + // Timestamp when the report was created (RFC 3339 format). 49 + string created_at = 5; 50 + 51 + // Timestamp when the report was last updated (RFC 3339 format). 52 + string updated_at = 6; 53 + } 54 + 55 + // StatusReport represents an incident or maintenance report with full details. 56 + message StatusReport { 57 + // Unique identifier for the status report. 58 + string id = 1; 59 + 60 + // Current status of the report. 61 + StatusReportStatus status = 2; 62 + 63 + // Title of the status report. 64 + string title = 3; 65 + 66 + // IDs of affected page components. 67 + repeated string page_component_ids = 4; 68 + 69 + // Timeline of updates for this report (only included in GetStatusReport). 70 + repeated StatusReportUpdate updates = 5; 71 + 72 + // Timestamp when the report was created (RFC 3339 format). 73 + string created_at = 6; 74 + 75 + // Timestamp when the report was last updated (RFC 3339 format). 76 + string updated_at = 7; 77 + }
+1
packages/proto/gen/ts/index.ts
··· 1 1 // @openstatus/proto main exports 2 2 export * from "./openstatus/monitor/v1/index.js"; 3 3 export * from "./openstatus/health/v1/index.js"; 4 + export * from "./openstatus/status_report/v1/index.js";
+3
packages/proto/gen/ts/openstatus/status_report/v1/index.ts
··· 1 + // Status report service exports 2 + export * from "./status_report_pb.js"; 3 + export * from "./service_pb.js";
+419
packages/proto/gen/ts/openstatus/status_report/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_report/v1/service.proto (package openstatus.status_report.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 { StatusReport, StatusReportStatus, StatusReportSummary } from "./status_report_pb.ts"; 9 + import { file_openstatus_status_report_v1_status_report } from "./status_report_pb.ts"; 10 + import type { Message } from "@bufbuild/protobuf"; 11 + 12 + /** 13 + * Describes the file openstatus/status_report/v1/service.proto. 14 + */ 15 + export const file_openstatus_status_report_v1_service: GenFile = /*@__PURE__*/ 16 + fileDesc("CilvcGVuc3RhdHVzL3N0YXR1c19yZXBvcnQvdjEvc2VydmljZS5wcm90bxIbb3BlbnN0YXR1cy5zdGF0dXNfcmVwb3J0LnYxIqkCChlDcmVhdGVTdGF0dXNSZXBvcnRSZXF1ZXN0EhYKBXRpdGxlGAEgASgJQge6SARyAhABEkkKBnN0YXR1cxgCIAEoDjIvLm9wZW5zdGF0dXMuc3RhdHVzX3JlcG9ydC52MS5TdGF0dXNSZXBvcnRTdGF0dXNCCLpIBYIBAhABEhgKB21lc3NhZ2UYAyABKAlCB7pIBHICEAESOQoEZGF0ZRgEIAEoCUIrukgociYyJF5cZHs0fS1cZHsyfS1cZHsyfVRcZHsyfTpcZHsyfTpcZHsyfRIYCgdwYWdlX2lkGAUgASgJQge6SARyAhABEhoKEnBhZ2VfY29tcG9uZW50X2lkcxgGIAMoCRITCgZub3RpZnkYByABKAhIAIgBAUIJCgdfbm90aWZ5Il4KGkNyZWF0ZVN0YXR1c1JlcG9ydFJlc3BvbnNlEkAKDXN0YXR1c19yZXBvcnQYASABKAsyKS5vcGVuc3RhdHVzLnN0YXR1c19yZXBvcnQudjEuU3RhdHVzUmVwb3J0Ii0KFkdldFN0YXR1c1JlcG9ydFJlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAEiWwoXR2V0U3RhdHVzUmVwb3J0UmVzcG9uc2USQAoNc3RhdHVzX3JlcG9ydBgBIAEoCzIpLm9wZW5zdGF0dXMuc3RhdHVzX3JlcG9ydC52MS5TdGF0dXNSZXBvcnQirwEKGExpc3RTdGF0dXNSZXBvcnRzUmVxdWVzdBIdCgVsaW1pdBgBIAEoBUIJukgGGgQYZCgBSACIAQESHAoGb2Zmc2V0GAIgASgFQge6SAQaAigASAGIAQESQQoIc3RhdHVzZXMYAyADKA4yLy5vcGVuc3RhdHVzLnN0YXR1c19yZXBvcnQudjEuU3RhdHVzUmVwb3J0U3RhdHVzQggKBl9saW1pdEIJCgdfb2Zmc2V0InkKGUxpc3RTdGF0dXNSZXBvcnRzUmVzcG9uc2USSAoOc3RhdHVzX3JlcG9ydHMYASADKAsyMC5vcGVuc3RhdHVzLnN0YXR1c19yZXBvcnQudjEuU3RhdHVzUmVwb3J0U3VtbWFyeRISCgp0b3RhbF9zaXplGAIgASgFImoKGVVwZGF0ZVN0YXR1c1JlcG9ydFJlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAESEgoFdGl0bGUYAiABKAlIAIgBARIaChJwYWdlX2NvbXBvbmVudF9pZHMYAyADKAlCCAoGX3RpdGxlIl4KGlVwZGF0ZVN0YXR1c1JlcG9ydFJlc3BvbnNlEkAKDXN0YXR1c19yZXBvcnQYASABKAsyKS5vcGVuc3RhdHVzLnN0YXR1c19yZXBvcnQudjEuU3RhdHVzUmVwb3J0IjAKGURlbGV0ZVN0YXR1c1JlcG9ydFJlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAEiLQoaRGVsZXRlU3RhdHVzUmVwb3J0UmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCCKPAgocQWRkU3RhdHVzUmVwb3J0VXBkYXRlUmVxdWVzdBIhChBzdGF0dXNfcmVwb3J0X2lkGAEgASgJQge6SARyAhABEkkKBnN0YXR1cxgCIAEoDjIvLm9wZW5zdGF0dXMuc3RhdHVzX3JlcG9ydC52MS5TdGF0dXNSZXBvcnRTdGF0dXNCCLpIBYIBAhABEhgKB21lc3NhZ2UYAyABKAlCB7pIBHICEAESPgoEZGF0ZRgEIAEoCUIrukgociYyJF5cZHs0fS1cZHsyfS1cZHsyfVRcZHsyfTpcZHsyfTpcZHsyfUgAiAEBEhMKBm5vdGlmeRgFIAEoCEgBiAEBQgcKBV9kYXRlQgkKB19ub3RpZnkiYQodQWRkU3RhdHVzUmVwb3J0VXBkYXRlUmVzcG9uc2USQAoNc3RhdHVzX3JlcG9ydBgBIAEoCzIpLm9wZW5zdGF0dXMuc3RhdHVzX3JlcG9ydC52MS5TdGF0dXNSZXBvcnQywQYKE1N0YXR1c1JlcG9ydFNlcnZpY2UShQEKEkNyZWF0ZVN0YXR1c1JlcG9ydBI2Lm9wZW5zdGF0dXMuc3RhdHVzX3JlcG9ydC52MS5DcmVhdGVTdGF0dXNSZXBvcnRSZXF1ZXN0Gjcub3BlbnN0YXR1cy5zdGF0dXNfcmVwb3J0LnYxLkNyZWF0ZVN0YXR1c1JlcG9ydFJlc3BvbnNlEnwKD0dldFN0YXR1c1JlcG9ydBIzLm9wZW5zdGF0dXMuc3RhdHVzX3JlcG9ydC52MS5HZXRTdGF0dXNSZXBvcnRSZXF1ZXN0GjQub3BlbnN0YXR1cy5zdGF0dXNfcmVwb3J0LnYxLkdldFN0YXR1c1JlcG9ydFJlc3BvbnNlEoIBChFMaXN0U3RhdHVzUmVwb3J0cxI1Lm9wZW5zdGF0dXMuc3RhdHVzX3JlcG9ydC52MS5MaXN0U3RhdHVzUmVwb3J0c1JlcXVlc3QaNi5vcGVuc3RhdHVzLnN0YXR1c19yZXBvcnQudjEuTGlzdFN0YXR1c1JlcG9ydHNSZXNwb25zZRKFAQoSVXBkYXRlU3RhdHVzUmVwb3J0EjYub3BlbnN0YXR1cy5zdGF0dXNfcmVwb3J0LnYxLlVwZGF0ZVN0YXR1c1JlcG9ydFJlcXVlc3QaNy5vcGVuc3RhdHVzLnN0YXR1c19yZXBvcnQudjEuVXBkYXRlU3RhdHVzUmVwb3J0UmVzcG9uc2UShQEKEkRlbGV0ZVN0YXR1c1JlcG9ydBI2Lm9wZW5zdGF0dXMuc3RhdHVzX3JlcG9ydC52MS5EZWxldGVTdGF0dXNSZXBvcnRSZXF1ZXN0Gjcub3BlbnN0YXR1cy5zdGF0dXNfcmVwb3J0LnYxLkRlbGV0ZVN0YXR1c1JlcG9ydFJlc3BvbnNlEo4BChVBZGRTdGF0dXNSZXBvcnRVcGRhdGUSOS5vcGVuc3RhdHVzLnN0YXR1c19yZXBvcnQudjEuQWRkU3RhdHVzUmVwb3J0VXBkYXRlUmVxdWVzdBo6Lm9wZW5zdGF0dXMuc3RhdHVzX3JlcG9ydC52MS5BZGRTdGF0dXNSZXBvcnRVcGRhdGVSZXNwb25zZUJeWlxnaXRodWIuY29tL29wZW5zdGF0dXNocS9vcGVuc3RhdHVzL3BhY2thZ2VzL3Byb3RvL29wZW5zdGF0dXMvc3RhdHVzX3JlcG9ydC92MTtzdGF0dXNyZXBvcnR2MWIGcHJvdG8z", [file_buf_validate_validate, file_openstatus_status_report_v1_status_report]); 17 + 18 + /** 19 + * @generated from message openstatus.status_report.v1.CreateStatusReportRequest 20 + */ 21 + export type CreateStatusReportRequest = Message<"openstatus.status_report.v1.CreateStatusReportRequest"> & { 22 + /** 23 + * Title of the status report (required). 24 + * 25 + * @generated from field: string title = 1; 26 + */ 27 + title: string; 28 + 29 + /** 30 + * Initial status (required). 31 + * 32 + * @generated from field: openstatus.status_report.v1.StatusReportStatus status = 2; 33 + */ 34 + status: StatusReportStatus; 35 + 36 + /** 37 + * Initial message describing the incident (required). 38 + * 39 + * @generated from field: string message = 3; 40 + */ 41 + message: string; 42 + 43 + /** 44 + * Date when the event occurred (RFC 3339 format, required). 45 + * 46 + * @generated from field: string date = 4; 47 + */ 48 + date: string; 49 + 50 + /** 51 + * Page ID to associate with this report (required). 52 + * 53 + * @generated from field: string page_id = 5; 54 + */ 55 + pageId: string; 56 + 57 + /** 58 + * Page component IDs to associate with this report (optional). 59 + * 60 + * @generated from field: repeated string page_component_ids = 6; 61 + */ 62 + pageComponentIds: string[]; 63 + 64 + /** 65 + * Whether to notify subscribers about this status report (optional, defaults to false). 66 + * 67 + * @generated from field: optional bool notify = 7; 68 + */ 69 + notify?: boolean; 70 + }; 71 + 72 + /** 73 + * Describes the message openstatus.status_report.v1.CreateStatusReportRequest. 74 + * Use `create(CreateStatusReportRequestSchema)` to create a new message. 75 + */ 76 + export const CreateStatusReportRequestSchema: GenMessage<CreateStatusReportRequest> = /*@__PURE__*/ 77 + messageDesc(file_openstatus_status_report_v1_service, 0); 78 + 79 + /** 80 + * @generated from message openstatus.status_report.v1.CreateStatusReportResponse 81 + */ 82 + export type CreateStatusReportResponse = Message<"openstatus.status_report.v1.CreateStatusReportResponse"> & { 83 + /** 84 + * The created status report. 85 + * 86 + * @generated from field: openstatus.status_report.v1.StatusReport status_report = 1; 87 + */ 88 + statusReport?: StatusReport; 89 + }; 90 + 91 + /** 92 + * Describes the message openstatus.status_report.v1.CreateStatusReportResponse. 93 + * Use `create(CreateStatusReportResponseSchema)` to create a new message. 94 + */ 95 + export const CreateStatusReportResponseSchema: GenMessage<CreateStatusReportResponse> = /*@__PURE__*/ 96 + messageDesc(file_openstatus_status_report_v1_service, 1); 97 + 98 + /** 99 + * @generated from message openstatus.status_report.v1.GetStatusReportRequest 100 + */ 101 + export type GetStatusReportRequest = Message<"openstatus.status_report.v1.GetStatusReportRequest"> & { 102 + /** 103 + * ID of the status report to retrieve (required). 104 + * 105 + * @generated from field: string id = 1; 106 + */ 107 + id: string; 108 + }; 109 + 110 + /** 111 + * Describes the message openstatus.status_report.v1.GetStatusReportRequest. 112 + * Use `create(GetStatusReportRequestSchema)` to create a new message. 113 + */ 114 + export const GetStatusReportRequestSchema: GenMessage<GetStatusReportRequest> = /*@__PURE__*/ 115 + messageDesc(file_openstatus_status_report_v1_service, 2); 116 + 117 + /** 118 + * @generated from message openstatus.status_report.v1.GetStatusReportResponse 119 + */ 120 + export type GetStatusReportResponse = Message<"openstatus.status_report.v1.GetStatusReportResponse"> & { 121 + /** 122 + * The requested status report. 123 + * 124 + * @generated from field: openstatus.status_report.v1.StatusReport status_report = 1; 125 + */ 126 + statusReport?: StatusReport; 127 + }; 128 + 129 + /** 130 + * Describes the message openstatus.status_report.v1.GetStatusReportResponse. 131 + * Use `create(GetStatusReportResponseSchema)` to create a new message. 132 + */ 133 + export const GetStatusReportResponseSchema: GenMessage<GetStatusReportResponse> = /*@__PURE__*/ 134 + messageDesc(file_openstatus_status_report_v1_service, 3); 135 + 136 + /** 137 + * @generated from message openstatus.status_report.v1.ListStatusReportsRequest 138 + */ 139 + export type ListStatusReportsRequest = Message<"openstatus.status_report.v1.ListStatusReportsRequest"> & { 140 + /** 141 + * Maximum number of reports to return (1-100, defaults to 50). 142 + * 143 + * @generated from field: optional int32 limit = 1; 144 + */ 145 + limit?: number; 146 + 147 + /** 148 + * Number of reports to skip for pagination (defaults to 0). 149 + * 150 + * @generated from field: optional int32 offset = 2; 151 + */ 152 + offset?: number; 153 + 154 + /** 155 + * Filter by status (optional). If empty, returns all statuses. 156 + * 157 + * @generated from field: repeated openstatus.status_report.v1.StatusReportStatus statuses = 3; 158 + */ 159 + statuses: StatusReportStatus[]; 160 + }; 161 + 162 + /** 163 + * Describes the message openstatus.status_report.v1.ListStatusReportsRequest. 164 + * Use `create(ListStatusReportsRequestSchema)` to create a new message. 165 + */ 166 + export const ListStatusReportsRequestSchema: GenMessage<ListStatusReportsRequest> = /*@__PURE__*/ 167 + messageDesc(file_openstatus_status_report_v1_service, 4); 168 + 169 + /** 170 + * @generated from message openstatus.status_report.v1.ListStatusReportsResponse 171 + */ 172 + export type ListStatusReportsResponse = Message<"openstatus.status_report.v1.ListStatusReportsResponse"> & { 173 + /** 174 + * List of status reports (metadata only, use GetStatusReport for full details). 175 + * 176 + * @generated from field: repeated openstatus.status_report.v1.StatusReportSummary status_reports = 1; 177 + */ 178 + statusReports: StatusReportSummary[]; 179 + 180 + /** 181 + * Total number of reports matching the filter. 182 + * 183 + * @generated from field: int32 total_size = 2; 184 + */ 185 + totalSize: number; 186 + }; 187 + 188 + /** 189 + * Describes the message openstatus.status_report.v1.ListStatusReportsResponse. 190 + * Use `create(ListStatusReportsResponseSchema)` to create a new message. 191 + */ 192 + export const ListStatusReportsResponseSchema: GenMessage<ListStatusReportsResponse> = /*@__PURE__*/ 193 + messageDesc(file_openstatus_status_report_v1_service, 5); 194 + 195 + /** 196 + * @generated from message openstatus.status_report.v1.UpdateStatusReportRequest 197 + */ 198 + export type UpdateStatusReportRequest = Message<"openstatus.status_report.v1.UpdateStatusReportRequest"> & { 199 + /** 200 + * ID of the status report to update (required). 201 + * 202 + * @generated from field: string id = 1; 203 + */ 204 + id: string; 205 + 206 + /** 207 + * New title for the report (optional). 208 + * 209 + * @generated from field: optional string title = 2; 210 + */ 211 + title?: string; 212 + 213 + /** 214 + * New list of page component IDs (optional, replaces existing list). 215 + * 216 + * @generated from field: repeated string page_component_ids = 3; 217 + */ 218 + pageComponentIds: string[]; 219 + }; 220 + 221 + /** 222 + * Describes the message openstatus.status_report.v1.UpdateStatusReportRequest. 223 + * Use `create(UpdateStatusReportRequestSchema)` to create a new message. 224 + */ 225 + export const UpdateStatusReportRequestSchema: GenMessage<UpdateStatusReportRequest> = /*@__PURE__*/ 226 + messageDesc(file_openstatus_status_report_v1_service, 6); 227 + 228 + /** 229 + * @generated from message openstatus.status_report.v1.UpdateStatusReportResponse 230 + */ 231 + export type UpdateStatusReportResponse = Message<"openstatus.status_report.v1.UpdateStatusReportResponse"> & { 232 + /** 233 + * The updated status report. 234 + * 235 + * @generated from field: openstatus.status_report.v1.StatusReport status_report = 1; 236 + */ 237 + statusReport?: StatusReport; 238 + }; 239 + 240 + /** 241 + * Describes the message openstatus.status_report.v1.UpdateStatusReportResponse. 242 + * Use `create(UpdateStatusReportResponseSchema)` to create a new message. 243 + */ 244 + export const UpdateStatusReportResponseSchema: GenMessage<UpdateStatusReportResponse> = /*@__PURE__*/ 245 + messageDesc(file_openstatus_status_report_v1_service, 7); 246 + 247 + /** 248 + * @generated from message openstatus.status_report.v1.DeleteStatusReportRequest 249 + */ 250 + export type DeleteStatusReportRequest = Message<"openstatus.status_report.v1.DeleteStatusReportRequest"> & { 251 + /** 252 + * ID of the status report to delete (required). 253 + * 254 + * @generated from field: string id = 1; 255 + */ 256 + id: string; 257 + }; 258 + 259 + /** 260 + * Describes the message openstatus.status_report.v1.DeleteStatusReportRequest. 261 + * Use `create(DeleteStatusReportRequestSchema)` to create a new message. 262 + */ 263 + export const DeleteStatusReportRequestSchema: GenMessage<DeleteStatusReportRequest> = /*@__PURE__*/ 264 + messageDesc(file_openstatus_status_report_v1_service, 8); 265 + 266 + /** 267 + * @generated from message openstatus.status_report.v1.DeleteStatusReportResponse 268 + */ 269 + export type DeleteStatusReportResponse = Message<"openstatus.status_report.v1.DeleteStatusReportResponse"> & { 270 + /** 271 + * Whether the deletion was successful. 272 + * 273 + * @generated from field: bool success = 1; 274 + */ 275 + success: boolean; 276 + }; 277 + 278 + /** 279 + * Describes the message openstatus.status_report.v1.DeleteStatusReportResponse. 280 + * Use `create(DeleteStatusReportResponseSchema)` to create a new message. 281 + */ 282 + export const DeleteStatusReportResponseSchema: GenMessage<DeleteStatusReportResponse> = /*@__PURE__*/ 283 + messageDesc(file_openstatus_status_report_v1_service, 9); 284 + 285 + /** 286 + * @generated from message openstatus.status_report.v1.AddStatusReportUpdateRequest 287 + */ 288 + export type AddStatusReportUpdateRequest = Message<"openstatus.status_report.v1.AddStatusReportUpdateRequest"> & { 289 + /** 290 + * ID of the status report to update (required). 291 + * 292 + * @generated from field: string status_report_id = 1; 293 + */ 294 + statusReportId: string; 295 + 296 + /** 297 + * New status for the report (required). 298 + * 299 + * @generated from field: openstatus.status_report.v1.StatusReportStatus status = 2; 300 + */ 301 + status: StatusReportStatus; 302 + 303 + /** 304 + * Message describing what changed (required). 305 + * 306 + * @generated from field: string message = 3; 307 + */ 308 + message: string; 309 + 310 + /** 311 + * Optional date for the update (RFC 3339 format). Defaults to current time if not provided. 312 + * 313 + * @generated from field: optional string date = 4; 314 + */ 315 + date?: string; 316 + 317 + /** 318 + * Whether to notify subscribers about this update (optional, defaults to false). 319 + * 320 + * @generated from field: optional bool notify = 5; 321 + */ 322 + notify?: boolean; 323 + }; 324 + 325 + /** 326 + * Describes the message openstatus.status_report.v1.AddStatusReportUpdateRequest. 327 + * Use `create(AddStatusReportUpdateRequestSchema)` to create a new message. 328 + */ 329 + export const AddStatusReportUpdateRequestSchema: GenMessage<AddStatusReportUpdateRequest> = /*@__PURE__*/ 330 + messageDesc(file_openstatus_status_report_v1_service, 10); 331 + 332 + /** 333 + * @generated from message openstatus.status_report.v1.AddStatusReportUpdateResponse 334 + */ 335 + export type AddStatusReportUpdateResponse = Message<"openstatus.status_report.v1.AddStatusReportUpdateResponse"> & { 336 + /** 337 + * The updated status report with the new update included. 338 + * 339 + * @generated from field: openstatus.status_report.v1.StatusReport status_report = 1; 340 + */ 341 + statusReport?: StatusReport; 342 + }; 343 + 344 + /** 345 + * Describes the message openstatus.status_report.v1.AddStatusReportUpdateResponse. 346 + * Use `create(AddStatusReportUpdateResponseSchema)` to create a new message. 347 + */ 348 + export const AddStatusReportUpdateResponseSchema: GenMessage<AddStatusReportUpdateResponse> = /*@__PURE__*/ 349 + messageDesc(file_openstatus_status_report_v1_service, 11); 350 + 351 + /** 352 + * StatusReportService provides CRUD operations for status reports. 353 + * 354 + * @generated from service openstatus.status_report.v1.StatusReportService 355 + */ 356 + export const StatusReportService: GenService<{ 357 + /** 358 + * CreateStatusReport creates a new status report. 359 + * 360 + * @generated from rpc openstatus.status_report.v1.StatusReportService.CreateStatusReport 361 + */ 362 + createStatusReport: { 363 + methodKind: "unary"; 364 + input: typeof CreateStatusReportRequestSchema; 365 + output: typeof CreateStatusReportResponseSchema; 366 + }, 367 + /** 368 + * GetStatusReport retrieves a specific status report by ID (includes full update timeline). 369 + * 370 + * @generated from rpc openstatus.status_report.v1.StatusReportService.GetStatusReport 371 + */ 372 + getStatusReport: { 373 + methodKind: "unary"; 374 + input: typeof GetStatusReportRequestSchema; 375 + output: typeof GetStatusReportResponseSchema; 376 + }, 377 + /** 378 + * ListStatusReports returns all status reports for the workspace (metadata only). 379 + * 380 + * @generated from rpc openstatus.status_report.v1.StatusReportService.ListStatusReports 381 + */ 382 + listStatusReports: { 383 + methodKind: "unary"; 384 + input: typeof ListStatusReportsRequestSchema; 385 + output: typeof ListStatusReportsResponseSchema; 386 + }, 387 + /** 388 + * UpdateStatusReport updates the metadata of a status report (title, page components). 389 + * 390 + * @generated from rpc openstatus.status_report.v1.StatusReportService.UpdateStatusReport 391 + */ 392 + updateStatusReport: { 393 + methodKind: "unary"; 394 + input: typeof UpdateStatusReportRequestSchema; 395 + output: typeof UpdateStatusReportResponseSchema; 396 + }, 397 + /** 398 + * DeleteStatusReport removes a status report and all its updates. 399 + * 400 + * @generated from rpc openstatus.status_report.v1.StatusReportService.DeleteStatusReport 401 + */ 402 + deleteStatusReport: { 403 + methodKind: "unary"; 404 + input: typeof DeleteStatusReportRequestSchema; 405 + output: typeof DeleteStatusReportResponseSchema; 406 + }, 407 + /** 408 + * AddStatusReportUpdate adds a new update to an existing status report timeline. 409 + * 410 + * @generated from rpc openstatus.status_report.v1.StatusReportService.AddStatusReportUpdate 411 + */ 412 + addStatusReportUpdate: { 413 + methodKind: "unary"; 414 + input: typeof AddStatusReportUpdateRequestSchema; 415 + output: typeof AddStatusReportUpdateResponseSchema; 416 + }, 417 + }> = /*@__PURE__*/ 418 + serviceDesc(file_openstatus_status_report_v1_service, 0); 419 +
+220
packages/proto/gen/ts/openstatus/status_report/v1/status_report_pb.ts
··· 1 + // @generated by protoc-gen-es v2.10.2 with parameter "target=ts,import_extension=.ts" 2 + // @generated from file openstatus/status_report/v1/status_report.proto (package openstatus.status_report.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_report/v1/status_report.proto. 11 + */ 12 + export const file_openstatus_status_report_v1_status_report: GenFile = /*@__PURE__*/ 13 + fileDesc("Ci9vcGVuc3RhdHVzL3N0YXR1c19yZXBvcnQvdjEvc3RhdHVzX3JlcG9ydC5wcm90bxIbb3BlbnN0YXR1cy5zdGF0dXNfcmVwb3J0LnYxIpQBChJTdGF0dXNSZXBvcnRVcGRhdGUSCgoCaWQYASABKAkSPwoGc3RhdHVzGAIgASgOMi8ub3BlbnN0YXR1cy5zdGF0dXNfcmVwb3J0LnYxLlN0YXR1c1JlcG9ydFN0YXR1cxIMCgRkYXRlGAMgASgJEg8KB21lc3NhZ2UYBCABKAkSEgoKY3JlYXRlZF9hdBgFIAEoCSK1AQoTU3RhdHVzUmVwb3J0U3VtbWFyeRIKCgJpZBgBIAEoCRI/CgZzdGF0dXMYAiABKA4yLy5vcGVuc3RhdHVzLnN0YXR1c19yZXBvcnQudjEuU3RhdHVzUmVwb3J0U3RhdHVzEg0KBXRpdGxlGAMgASgJEhoKEnBhZ2VfY29tcG9uZW50X2lkcxgEIAMoCRISCgpjcmVhdGVkX2F0GAUgASgJEhIKCnVwZGF0ZWRfYXQYBiABKAki8AEKDFN0YXR1c1JlcG9ydBIKCgJpZBgBIAEoCRI/CgZzdGF0dXMYAiABKA4yLy5vcGVuc3RhdHVzLnN0YXR1c19yZXBvcnQudjEuU3RhdHVzUmVwb3J0U3RhdHVzEg0KBXRpdGxlGAMgASgJEhoKEnBhZ2VfY29tcG9uZW50X2lkcxgEIAMoCRJACgd1cGRhdGVzGAUgAygLMi8ub3BlbnN0YXR1cy5zdGF0dXNfcmVwb3J0LnYxLlN0YXR1c1JlcG9ydFVwZGF0ZRISCgpjcmVhdGVkX2F0GAYgASgJEhIKCnVwZGF0ZWRfYXQYByABKAkqzwEKElN0YXR1c1JlcG9ydFN0YXR1cxIkCiBTVEFUVVNfUkVQT1JUX1NUQVRVU19VTlNQRUNJRklFRBAAEiYKIlNUQVRVU19SRVBPUlRfU1RBVFVTX0lOVkVTVElHQVRJTkcQARIjCh9TVEFUVVNfUkVQT1JUX1NUQVRVU19JREVOVElGSUVEEAISIwofU1RBVFVTX1JFUE9SVF9TVEFUVVNfTU9OSVRPUklORxADEiEKHVNUQVRVU19SRVBPUlRfU1RBVFVTX1JFU09MVkVEEARCXlpcZ2l0aHViLmNvbS9vcGVuc3RhdHVzaHEvb3BlbnN0YXR1cy9wYWNrYWdlcy9wcm90by9vcGVuc3RhdHVzL3N0YXR1c19yZXBvcnQvdjE7c3RhdHVzcmVwb3J0djFiBnByb3RvMw"); 14 + 15 + /** 16 + * StatusReportUpdate represents a single update entry in a status report timeline. 17 + * 18 + * @generated from message openstatus.status_report.v1.StatusReportUpdate 19 + */ 20 + export type StatusReportUpdate = Message<"openstatus.status_report.v1.StatusReportUpdate"> & { 21 + /** 22 + * Unique identifier for the update. 23 + * 24 + * @generated from field: string id = 1; 25 + */ 26 + id: string; 27 + 28 + /** 29 + * Status at the time of this update. 30 + * 31 + * @generated from field: openstatus.status_report.v1.StatusReportStatus status = 2; 32 + */ 33 + status: StatusReportStatus; 34 + 35 + /** 36 + * Timestamp when this update occurred (RFC 3339 format). 37 + * 38 + * @generated from field: string date = 3; 39 + */ 40 + date: string; 41 + 42 + /** 43 + * Message describing the update. 44 + * 45 + * @generated from field: string message = 4; 46 + */ 47 + message: string; 48 + 49 + /** 50 + * Timestamp when the update was created (RFC 3339 format). 51 + * 52 + * @generated from field: string created_at = 5; 53 + */ 54 + createdAt: string; 55 + }; 56 + 57 + /** 58 + * Describes the message openstatus.status_report.v1.StatusReportUpdate. 59 + * Use `create(StatusReportUpdateSchema)` to create a new message. 60 + */ 61 + export const StatusReportUpdateSchema: GenMessage<StatusReportUpdate> = /*@__PURE__*/ 62 + messageDesc(file_openstatus_status_report_v1_status_report, 0); 63 + 64 + /** 65 + * StatusReportSummary represents metadata for a status report (used in list responses). 66 + * 67 + * @generated from message openstatus.status_report.v1.StatusReportSummary 68 + */ 69 + export type StatusReportSummary = Message<"openstatus.status_report.v1.StatusReportSummary"> & { 70 + /** 71 + * Unique identifier for the status report. 72 + * 73 + * @generated from field: string id = 1; 74 + */ 75 + id: string; 76 + 77 + /** 78 + * Current status of the report. 79 + * 80 + * @generated from field: openstatus.status_report.v1.StatusReportStatus status = 2; 81 + */ 82 + status: StatusReportStatus; 83 + 84 + /** 85 + * Title of the status report. 86 + * 87 + * @generated from field: string title = 3; 88 + */ 89 + title: string; 90 + 91 + /** 92 + * IDs of affected page components. 93 + * 94 + * @generated from field: repeated string page_component_ids = 4; 95 + */ 96 + pageComponentIds: string[]; 97 + 98 + /** 99 + * Timestamp when the report was created (RFC 3339 format). 100 + * 101 + * @generated from field: string created_at = 5; 102 + */ 103 + createdAt: string; 104 + 105 + /** 106 + * Timestamp when the report was last updated (RFC 3339 format). 107 + * 108 + * @generated from field: string updated_at = 6; 109 + */ 110 + updatedAt: string; 111 + }; 112 + 113 + /** 114 + * Describes the message openstatus.status_report.v1.StatusReportSummary. 115 + * Use `create(StatusReportSummarySchema)` to create a new message. 116 + */ 117 + export const StatusReportSummarySchema: GenMessage<StatusReportSummary> = /*@__PURE__*/ 118 + messageDesc(file_openstatus_status_report_v1_status_report, 1); 119 + 120 + /** 121 + * StatusReport represents an incident or maintenance report with full details. 122 + * 123 + * @generated from message openstatus.status_report.v1.StatusReport 124 + */ 125 + export type StatusReport = Message<"openstatus.status_report.v1.StatusReport"> & { 126 + /** 127 + * Unique identifier for the status report. 128 + * 129 + * @generated from field: string id = 1; 130 + */ 131 + id: string; 132 + 133 + /** 134 + * Current status of the report. 135 + * 136 + * @generated from field: openstatus.status_report.v1.StatusReportStatus status = 2; 137 + */ 138 + status: StatusReportStatus; 139 + 140 + /** 141 + * Title of the status report. 142 + * 143 + * @generated from field: string title = 3; 144 + */ 145 + title: string; 146 + 147 + /** 148 + * IDs of affected page components. 149 + * 150 + * @generated from field: repeated string page_component_ids = 4; 151 + */ 152 + pageComponentIds: string[]; 153 + 154 + /** 155 + * Timeline of updates for this report (only included in GetStatusReport). 156 + * 157 + * @generated from field: repeated openstatus.status_report.v1.StatusReportUpdate updates = 5; 158 + */ 159 + updates: StatusReportUpdate[]; 160 + 161 + /** 162 + * Timestamp when the report was created (RFC 3339 format). 163 + * 164 + * @generated from field: string created_at = 6; 165 + */ 166 + createdAt: string; 167 + 168 + /** 169 + * Timestamp when the report was last updated (RFC 3339 format). 170 + * 171 + * @generated from field: string updated_at = 7; 172 + */ 173 + updatedAt: string; 174 + }; 175 + 176 + /** 177 + * Describes the message openstatus.status_report.v1.StatusReport. 178 + * Use `create(StatusReportSchema)` to create a new message. 179 + */ 180 + export const StatusReportSchema: GenMessage<StatusReport> = /*@__PURE__*/ 181 + messageDesc(file_openstatus_status_report_v1_status_report, 2); 182 + 183 + /** 184 + * StatusReportStatus represents the current state of a status report. 185 + * 186 + * @generated from enum openstatus.status_report.v1.StatusReportStatus 187 + */ 188 + export enum StatusReportStatus { 189 + /** 190 + * @generated from enum value: STATUS_REPORT_STATUS_UNSPECIFIED = 0; 191 + */ 192 + UNSPECIFIED = 0, 193 + 194 + /** 195 + * @generated from enum value: STATUS_REPORT_STATUS_INVESTIGATING = 1; 196 + */ 197 + INVESTIGATING = 1, 198 + 199 + /** 200 + * @generated from enum value: STATUS_REPORT_STATUS_IDENTIFIED = 2; 201 + */ 202 + IDENTIFIED = 2, 203 + 204 + /** 205 + * @generated from enum value: STATUS_REPORT_STATUS_MONITORING = 3; 206 + */ 207 + MONITORING = 3, 208 + 209 + /** 210 + * @generated from enum value: STATUS_REPORT_STATUS_RESOLVED = 4; 211 + */ 212 + RESOLVED = 4, 213 + } 214 + 215 + /** 216 + * Describes the enum openstatus.status_report.v1.StatusReportStatus. 217 + */ 218 + export const StatusReportStatusSchema: GenEnum<StatusReportStatus> = /*@__PURE__*/ 219 + enumDesc(file_openstatus_status_report_v1_status_report, 0); 220 +
+4
packages/proto/package.json
··· 16 16 "./health/v1": { 17 17 "import": "./gen/ts/openstatus/health/v1/index.ts", 18 18 "types": "./gen/ts/openstatus/health/v1/index.ts" 19 + }, 20 + "./status_report/v1": { 21 + "import": "./gen/ts/openstatus/status_report/v1/index.ts", 22 + "types": "./gen/ts/openstatus/status_report/v1/index.ts" 19 23 } 20 24 }, 21 25 "scripts": {