Skip to content

Conversation

@mxkaske
Copy link
Member

@mxkaske mxkaske commented Feb 12, 2026

Type of change

  • 🐛 Bug fix
  • 🌟 New feature
  • 🔨 Breaking change
  • 📖 Refactoring / dependency upgrade / documentation

Description

A picture tells a thousand words (if any)

Before this PR

{Please add a screenshot here}

After this PR

{Please add a screenshot here}

Related Issue (optional)

@vercel
Copy link

vercel bot commented Feb 12, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
openstatus-dashboard Ready Ready Preview, Comment Feb 12, 2026 8:41pm
openstatus-status-page Ready Ready Preview, Comment Feb 12, 2026 8:41pm
openstatus-web Error Error Feb 12, 2026 8:41pm

Request Review

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements a comprehensive multi-channel subscription system with component-level granularity, migrating from the simple page_subscriber table to a new page_subscription architecture. It introduces a new @openstatus/subscriptions package that centralizes subscription logic, adds support for webhook channels alongside email, and enables users to subscribe to specific page components rather than entire pages.

Changes:

  • Created new @openstatus/subscriptions package with service layer, dispatcher, and channel abstractions for email and webhook notifications
  • Added database schema with page_subscription and junction table for component associations, including migration SQL that preserves existing subscriber tokens
  • Refactored API endpoints from statusPage router to new pageSubscription router with improved public and protected procedures

Reviewed changes

Copilot reviewed 48 out of 51 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
packages/subscriptions/* New package implementing service layer, dispatcher, channels (email/webhook), and comprehensive tests
packages/db/src/schema/page_subscriptions/* New database schema with multi-channel support, component junction table, CHECK constraints, and validation schemas
packages/db/drizzle/0056_clammy_tombstone.sql Migration SQL creating new tables and migrating existing page_subscriber data with token preservation
packages/api/src/router/pageSubscription.ts New tRPC router with public endpoints for upsert/verify/unsubscribe and protected endpoints for list/delete
packages/api/src/router/statusPage.ts Removed deprecated subscription endpoints that were migrated to new router
packages/api/src/router/email/index.ts Updated to use new dispatcher and added sendPageSubscriptionVerification endpoint
packages/proto/* Added PageSubscription proto message while keeping PageSubscriber for backward compatibility
packages/emails/* Renamed page subscription email template and client method for clarity
apps/server/src/routes/rpc/services/* Updated gRPC services to use new dispatcher functions instead of direct email sending
apps/dashboard/src/components/data-table/subscribers/* Updated UI to show subscription scope (entire page vs components) and use new API
apps/status-page/src/components/nav/header.tsx Updated subscription flow to use new upsert and verification endpoints
apps/status-page/src/app//(public)/ Updated verify and unsubscribe pages to use new pageSubscription router
pnpm-lock.yaml Updated with new dependencies for subscriptions package
apps/server/Dockerfile, dofigen.yml Added subscriptions package to Docker build process
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

pageSlug: pageData.slug,
customDomain: pageData.customDomain,
workspaceId: existing.workspaceId,
channelType: "email",
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The channelType is hardcoded as "email" in the return object. This should use a const from the function signature or the existing.channelType to maintain consistency and type safety. Using the actual channel type from the database ensures that if the schema evolves or if there are any edge cases, the returned data accurately reflects what's stored.

Copilot uses AI. Check for mistakes.
Comment on lines +10 to +15
// Validate RESEND_API_KEY exists
if (!process.env.RESEND_API_KEY) {
throw new Error(
"RESEND_API_KEY environment variable is required for email notifications",
);
}
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The environment variable validation at the module level will cause the module to fail to load if RESEND_API_KEY is not set. This could break the application during development or testing where email functionality might not be needed. Consider moving this validation into the functions that actually use it, or making it conditional based on NODE_ENV.

Copilot uses AI. Check for mistakes.
token: pageSubscription.token,
verifiedAt: pageSubscription.verifiedAt,
// Aggregate component IDs as comma-separated string (NULL if empty)
componentIds: sql<string>`GROUP_CONCAT(${pageSubscriptionToPageComponent.pageComponentId})`,
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using GROUP_CONCAT with comma separator could potentially break if component IDs themselves contain commas (though unlikely with integer IDs). More critically, GROUP_CONCAT in SQLite has a default limit of 1MB for the result string. While this is unlikely to be hit with typical component counts, consider documenting this limitation or using a more robust aggregation approach for production systems with many components.

Copilot uses AI. Check for mistakes.
email: z.email(),
pageId: z.number().int().positive(),
componentIds: z.array(z.number().int().positive()).optional(),
groupId: z.number().int().positive().optional(),
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The upsert endpoint accepts a groupId parameter in the input schema but never uses it. This could be confusing for API consumers and suggests incomplete implementation. Either remove the groupId parameter from the input schema or implement group-based subscription functionality.

Suggested change
groupId: z.number().int().positive().optional(),

Copilot uses AI. Check for mistakes.
Comment on lines +24 to +62
upsert: publicProcedure
.input(
z.object({
email: z.email(),
pageId: z.number().int().positive(),
componentIds: z.array(z.number().int().positive()).optional(),
groupId: z.number().int().positive().optional(),
}),
)
.mutation(async (opts) => {
try {
const subscription = await upsertEmailSubscription({
email: opts.input.email,
pageId: opts.input.pageId,
componentIds: opts.input.componentIds,
});

return {
success: true,
subscription: {
id: subscription.id,
token: subscription.token,
verifiedAt: subscription.verifiedAt,
componentIds: subscription.componentIds,
},
};
} catch (error) {
if (error instanceof Error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: error.message,
});
}
throw new TRPCError({
code: "BAD_REQUEST",
message: "Failed to create subscription",
});
}
}),
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The upsert endpoint is PUBLIC and lacks rate limiting or workspace limits validation. A malicious actor could spam subscription creation requests, potentially exhausting database resources or flooding email systems. Consider adding rate limiting, CAPTCHA, or at minimum checking workspace limits before allowing subscription creation.

Copilot uses AI. Check for mistakes.
<span className="font-semibold">
{subscriberQuery.data.maskedEmail}
</span>{" "}
<span className="font-semibold">{subscriberQuery.data.email}</span>{" "}
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unsubscribe page now displays the full email address instead of a masked version (e.g., "j***@example.com"). While the old implementation had email masking for privacy, the new getByToken endpoint returns the full email. This is a privacy regression that could expose email addresses to shoulder-surfing or screenshot leaks. Consider either returning a masked email from the API or masking it client-side for display purposes.

Copilot uses AI. Check for mistakes.
Comment on lines +178 to +248
const { report: newReport, pageId } = await db.transaction(async (tx) => {
// Validate page component IDs inside transaction to prevent TOCTOU race condition
const validatedComponents = await validatePageComponentIds(
req.pageComponentIds,
workspaceId,
tx,
);

// Validate that provided pageId matches the components' page
const derivedPageId = validatedComponents.pageId;
const providedPageId = req.pageId?.trim();
if (
derivedPageId !== null &&
providedPageId &&
providedPageId !== "" &&
Number(providedPageId) !== derivedPageId
) {
throw pageIdComponentMismatchError(
providedPageId,
String(derivedPageId),
);
}

// Use the derived pageId from components (null if no components)
const pageId = Number(providedPageId);

// Create the status report
const report = await tx
.insert(statusReport)
.values({
workspaceId,
pageId,
title: req.title,
status: protoStatusToDb(req.status),
})
.returning()
.get();

if (!report) {
throw statusReportCreateFailedError();
}

// Create page component associations
await updatePageComponentAssociations(
report.id,
validatedComponents.componentIds,
tx,
);

// Create the initial update
const newUpdate = await tx
.insert(statusReportUpdate)
.values({
statusReportId: report.id,
status: protoStatusToDb(req.status),
date,
message: req.message,
})
.returning()
.get();

if (!newUpdate) {
throw statusReportCreateFailedError();
}

return { report, pageId };
return { report, pageId, updateId: newUpdate.id };
});

// Send notifications if requested (outside transaction)
if (req.notify) {
await sendStatusReportNotification({
statusReportId: newReport.id,
pageId,
reportTitle: newReport.title,
status: protoStatusToDb(req.status),
message: req.message,
date,
limits: rpcCtx.workspace.limits,
});
if (req.notify && rpcCtx.workspace.limits["status-subscribers"]) {
await dispatchStatusReportUpdate(newReport.id);
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The transaction returns an updateId on line 243, but the destructuring on line 178 only captures report and pageId, ignoring the updateId. Then line 248 calls dispatchStatusReportUpdate with newReport.id (the status report ID) instead of the updateId. The dispatcher expects a statusReportUpdateId, not a statusReportId. This should destructure as { report: newReport, pageId, updateId } and call dispatchStatusReportUpdate(updateId).

Copilot uses AI. Check for mistakes.
Comment on lines +31 to +99
sendPageSubscriptionVerification: publicProcedure
.input(
z.object({
id: z.number().int().positive(),
}),
)
.mutation(async (opts) => {
const subscription = await opts.ctx.db.query.pageSubscription.findFirst({
where: eq(pageSubscription.id, opts.input.id),
with: {
page: true,
},
});

if (!subscription) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Subscription not found",
});
}

if (!subscription.email) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "No email associated with this subscription",
});
}

if (subscription.verifiedAt) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Subscription already verified",
});
}

// Build verification URL
const verifyUrl = subscription.page.customDomain
? `https://${subscription.page.customDomain}/verify/${subscription.token}`
: `https://${subscription.page.slug}.openstatus.dev/verify/${subscription.token}`;

// Get email channel and send verification
const channel = getChannel("email");

if (!channel) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Email channel not found",
});
}

await channel.sendVerification?.(
{
id: subscription.id,
pageId: subscription.pageId,
pageName: subscription.page.title,
pageSlug: subscription.page.slug,
customDomain: subscription.page.customDomain,
workspaceId: subscription.workspaceId,
componentIds: [], // Not needed for verification email
channelType: "email",
email: subscription.email,
token: subscription.token,
verifiedAt: subscription.verifiedAt ?? undefined,
},
verifyUrl,
);

return { success: true };
}),
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sendPageSubscriptionVerification endpoint is marked as PUBLIC but lacks workspace limits validation. A malicious actor could spam verification emails by repeatedly calling this endpoint with valid subscription IDs. Consider adding rate limiting or making this a protected endpoint, or at minimum checking workspace limits before sending emails.

Copilot uses AI. Check for mistakes.
if (statusReport.notifySubscribers) {
await sendStatusReportUpdateMutation.mutateAsync({
id: statusReport.id,
id: statusReport.statusReportId,
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code sends a notification using statusReport.statusReportId, but this field name seems suspicious. Based on the API endpoint expecting just 'id', this should likely be statusReport.id or the first statusReportUpdateId. Verify that the create mutation returns the correct field name, as this could cause notifications to fail silently or send for the wrong update.

Suggested change
id: statusReport.statusReportId,
id: statusReport.id,

Copilot uses AI. Check for mistakes.
"dependencies": {
"@openstatus/db": "workspace:*",
"@openstatus/emails": "workspace:*",
"zod": "^3.24.1"
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The zod dependency version is ^3.24.1, but the pnpm-lock.yaml shows it resolves to version 3.25.76. Meanwhile, other packages in the monorepo use zod 4.1.13. This version inconsistency could lead to type compatibility issues or unexpected behavior when different parts of the codebase interact. Consider standardizing on a single zod version across the monorepo.

Copilot uses AI. Check for mistakes.
@thibaultleouay
Copy link
Member

@claude review this and nit pick

@claude
Copy link

claude bot commented Feb 12, 2026

Claude finished @thibaultleouay's task in 2m 26s —— View job


Code Review - Nitpick Mode Activated

Conducted a thorough, nitpicky code review of the page subscriptions refactor. Overall, this is a well-architected feature with good separation of concerns. Here are my findings organized by severity:


🔴 Critical Issues

1. Incorrect parameter passed to dispatcher (apps/server/src/routes/rpc/services/status-report/index.ts:248)

return { report, pageId, updateId: newUpdate.id }; // Line 243

But line 178 only destructures { report, pageId }, ignoring updateId. Then line 248 calls:

await dispatchStatusReportUpdate(newReport.id); // WRONG - using statusReportId instead of updateId

Should be:

const { report: newReport, pageId, updateId } = await db.transaction(...);
await dispatchStatusReportUpdate(updateId); // Use the updateId

The dispatcher expects statusReportUpdateId, not statusReportId.

Impact: Notifications may fail or send for wrong update.

Fix this →


🟡 High Priority Issues

2. Hardcoded channel type in return value (packages/subscriptions/src/service.ts:138)

channelType: "email", // Line 138

This should use a constant or the actual channelType from the function context. While this function is currently email-only, hardcoding reduces maintainability.

Recommendation: Use channelType: "email" as const or extract to a constant.

3. Environment variable validation at module load (packages/subscriptions/src/channels/email.ts:11-14)

if (!process.env.RESEND_API_KEY) {
  throw new Error("RESEND_API_KEY environment variable is required...");
}

This will crash the entire application at module load time, even in development/testing where email functionality isn't needed.

Recommendation: Move validation into the functions that use it, or make it conditional:

if (process.env.NODE_ENV === 'production' && !process.env.RESEND_API_KEY) {
  throw new Error(...);
}

4. GROUP_CONCAT limitations (packages/subscriptions/src/dispatcher.ts:151)

componentIds: sql<string>`GROUP_CONCAT(${pageSubscriptionToPageComponent.pageComponentId})`

SQLite's GROUP_CONCAT has a 1MB default limit and commas in data could theoretically break parsing. While unlikely with integer IDs, this is a subtle production risk.

Recommendation: Document this limitation or consider array aggregation if available in your SQLite version.

5. Missing rate limiting on public endpoints (packages/api/src/router/pageSubscription.ts:24,68,197)

All three public endpoints (upsert, verify, unsubscribe) lack rate limiting. A malicious actor could:

  • Spam subscription creation
  • Flood verification endpoints
  • DoS the unsubscribe endpoint

Recommendation: Add rate limiting middleware or at minimum check workspace limits before processing.

6. Unused groupId parameter (packages/api/src/router/pageSubscription.ts:30)

groupId: z.number().int().positive().optional(), // Line 30

This is accepted but never used. Either remove it or implement group-based subscription functionality.


🟠 Medium Priority Issues

7. Inconsistent error handling in webhook channel (packages/subscriptions/src/channels/webhook.ts:104-108)

await fetch(subscription.webhookUrl, {
  method: "POST",
  headers,
  body: JSON.stringify(payload),
});

No response checking, no timeout, no error handling. If the webhook URL is unreachable or returns an error, this will silently fail.

Recommendation: Add response validation and error handling:

const response = await fetch(subscription.webhookUrl, {
  method: "POST",
  headers,
  body: JSON.stringify(payload),
  signal: AbortSignal.timeout(10000), // 10s timeout
});

if (!response.ok) {
  console.error(`Webhook failed: ${response.status}`);
  throw new Error(`Webhook returned ${response.status}`);
}

8. Potential SQL injection in migration (packages/db/drizzle/0056_clammy_tombstone.sql:93-98)

The WHERE NOT EXISTS subquery uses LOWER(ps.email) = LOWER(psub.email) without parameterization. While this is in a migration file and probably safe, it's a pattern to avoid.

Note: This is likely fine for a one-time migration, but document that it relies on data integrity.

9. Missing webhook verification response handling (packages/subscriptions/src/channels/webhook.ts:44-52)

await fetch(subscription.webhookUrl, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    type: "verification",
    token: subscription.token,
    verifyUrl,
  }),
});

No response checking. If the webhook endpoint is down or returns an error, verification silently fails.

10. Inconsistent zod version (packages/subscriptions/package.json:19)

"zod": "^3.24.1"

But pnpm-lock.yaml shows it resolves to 3.25.76, while other packages use 4.1.13. This version inconsistency could cause type compatibility issues.

Recommendation: Standardize on a single zod version across the monorepo.


🔵 Low Priority / Nitpicks

11. Console logs in dispatcher (packages/subscriptions/src/dispatcher.ts:40,45,201,229,233)

Multiple console.error and console.log statements. Consider using a proper logging library for production.

12. Missing JSDoc for complex functions

Functions like upsertEmailSubscription and dispatchPageUpdate have good inline comments but lack structured JSDoc. This would help with IDE autocomplete.

13. Magic numbers for timeouts (packages/subscriptions/src/service.ts:120,148,209)

const newExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days

Extract to a named constant:

const VERIFICATION_EXPIRY_DAYS = 7;
const VERIFICATION_EXPIRY_MS = VERIFICATION_EXPIRY_DAYS * 24 * 60 * 60 * 1000;

14. Type casting instead of proper types (packages/subscriptions/src/service.ts:300,333,376,473)

channelType: subscription.channelType as "email" | "webhook",

Consider using a discriminated union or zod schema to ensure type safety at runtime.

15. Duplicate domain validation logic (packages/subscriptions/src/service.ts:280-289,359-367,407-415,497-505)

The domain validation logic is duplicated in 4 functions. Extract to a helper:

function validateDomain(
  domain: string | undefined,
  pageSlug: string,
  customDomain: string | null | undefined
): boolean {
  if (!domain) return true;
  const domainLower = domain.toLowerCase();
  return domainLower === pageSlug.toLowerCase() || 
         domainLower === customDomain?.toLowerCase();
}

16. Missing HMAC signature in webhook (packages/subscriptions/src/channels/webhook.ts:100-102)

// TODO: Add HMAC signature if secret is provided

This TODO should be tracked as a follow-up issue for security.

17. Test cleanup could use afterEach (packages/subscriptions/src/__tests__/service.test.ts)

Each test manually cleans up subscriptions. Consider using afterEach for more reliable cleanup:

afterEach(async () => {
  await db.delete(pageSubscription)
    .where(eq(pageSubscription.pageId, testPageId));
});

18. Incomplete frontend email masking (apps/status-page/src/app/(status-page)/[domain]/(public)/unsubscribe/[token]/page.tsx:102)

Copilot reviewer noted that the unsubscribe page now displays full email addresses instead of masked versions (e.g., j***@example.com). This is a privacy regression.

Recommendation: Add client-side masking or return masked emails from the API.

19. Missing verification status feedback (apps/status-page/src/components/nav/header.tsx:88-101)

sendVerificationEmailMutation.mutate(
  { id: result.subscription.id },
  {
    onError: (error) => {
      // Error handling
    },
  },
);

No onSuccess callback. Users don't know if the verification email was sent successfully.

Recommendation: Add toast notification on success:

onSuccess: () => {
  toast.success("Verification email sent! Check your inbox.");
}

✅ Things Done Well

  1. Excellent test coverage - The service layer has comprehensive tests covering happy paths and edge cases
  2. Clean separation of concerns - Service layer, dispatcher, and channels are well separated
  3. Type safety - Good use of discriminated unions in the database schema
  4. Migration preserves data - The migration correctly preserves existing tokens and data
  5. Transaction usage - Proper use of transactions for data consistency
  6. Database constraints - CHECK constraints enforce data integrity at the DB level
  7. Domain validation - Extra security layer with domain validation
  8. Component-level subscriptions - Well-designed junction table pattern

📊 Summary

Total Issues Found: 19

  • 🔴 Critical: 1
  • 🟡 High Priority: 6
  • 🟠 Medium Priority: 4
  • 🔵 Low Priority: 8

The most critical issue is #1 (incorrect dispatcher parameter), which could cause notification failures. Issues #2-6 are high priority and should be addressed before merge.

This is a solid implementation overall. The architecture is clean and the code is well-structured. Most issues are polish items that can be addressed in follow-up PRs if needed.


Tasks

  • Review core subscriptions package implementation
  • Analyze database schema and migration
  • Review API router changes
  • Check frontend integration
  • Review test coverage
  • Examine error handling patterns
  • Check type safety
  • Provide consolidated feedback

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants