feat: per-category email prefs, admin ops webhooks, per-user feature toggles (v0.61.0)
- Notification email preferences: every push category (follow, comment, reply, reaction, rating, mention, leftoverExpiring, shoppingList) now has an independent email toggle, plus a Weekly Digest toggle. Previously email sent unconditionally whenever the recipient had one; now gated the same way push already was. The weekly-digest cron route excludes opted-out users. - Admin-only site-wide webhooks (Admin → Webhooks): new signups, support tickets, and reports filed can now fire an HMAC-signed HTTP webhook (Slack/Discord/ops alerting), independent of the existing per-user webhooks (which stay scoped to a user's own recipe/meal-plan/shopping-list events). Signing/delivery logic factored into lib/webhook-delivery.ts and shared by both dispatchers instead of duplicated. - Settings → Features: users can hide Nutrition, Pantry, Meal Plan, Shopping Lists, Collections, or Messages from their own nav. Purely cosmetic — hidden pages stay reachable by direct link, nothing is access-restricted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+45
-1
@@ -465,11 +465,26 @@ export function generateOpenApiSpec(): object {
|
||||
const NotificationPrefsRef = registry.register("NotificationPrefs", z.object({
|
||||
follow: z.boolean(), comment: z.boolean(), reply: z.boolean(), reaction: z.boolean(),
|
||||
rating: z.boolean(), mention: z.boolean(), leftoverExpiring: z.boolean(), shoppingList: z.boolean(),
|
||||
}));
|
||||
followEmail: z.boolean(), commentEmail: z.boolean(), replyEmail: z.boolean(), reactionEmail: z.boolean(),
|
||||
ratingEmail: z.boolean(), mentionEmail: z.boolean(), leftoverExpiringEmail: z.boolean(), shoppingListEmail: z.boolean(),
|
||||
weeklyDigestEmail: z.boolean(),
|
||||
}).describe("The non-Email fields gate push notifications; the *Email fields gate the matching email. weeklyDigestEmail gates the weekly digest email (no push equivalent)."));
|
||||
const UpdateNotificationPrefsRef = registry.register("UpdateNotificationPrefs", z.object({
|
||||
follow: z.boolean().optional(), comment: z.boolean().optional(), reply: z.boolean().optional(), reaction: z.boolean().optional(),
|
||||
rating: z.boolean().optional(), mention: z.boolean().optional(), leftoverExpiring: z.boolean().optional(), shoppingList: z.boolean().optional(),
|
||||
followEmail: z.boolean().optional(), commentEmail: z.boolean().optional(), replyEmail: z.boolean().optional(), reactionEmail: z.boolean().optional(),
|
||||
ratingEmail: z.boolean().optional(), mentionEmail: z.boolean().optional(), leftoverExpiringEmail: z.boolean().optional(), shoppingListEmail: z.boolean().optional(),
|
||||
weeklyDigestEmail: z.boolean().optional(),
|
||||
}));
|
||||
const FeaturePrefsRef = registry.register("FeaturePrefs", z.object({
|
||||
nutrition: z.boolean(), pantry: z.boolean(), mealPlan: z.boolean(),
|
||||
shoppingLists: z.boolean(), collections: z.boolean(), messages: z.boolean(),
|
||||
}).describe("Purely cosmetic — hides the feature from the user's own nav. Never restricts access to the underlying pages/routes."));
|
||||
const UpdateFeaturePrefsRef = registry.register("UpdateFeaturePrefs", z.object({
|
||||
nutrition: z.boolean().optional(), pantry: z.boolean().optional(), mealPlan: z.boolean().optional(),
|
||||
shoppingLists: z.boolean().optional(), collections: z.boolean().optional(), messages: z.boolean().optional(),
|
||||
}));
|
||||
|
||||
const NutritionGoalsRef2 = registry.register("NutritionGoalsMe", z.object({
|
||||
id: z.string(), userId: z.string(),
|
||||
caloriesKcal: z.number().int().nullable(), proteinG: z.number().int().nullable(),
|
||||
@@ -509,6 +524,8 @@ export function generateOpenApiSpec(): object {
|
||||
registry.registerPath({ method: "put", path: "/api/v1/users/me/model-prefs", summary: "Set your AI model provider/model preferences", security, request: { body: { content: { "application/json": { schema: UpdateModelPrefsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "get", path: "/api/v1/users/me/notification-prefs", summary: "Get your notification category preferences", description: "Categories with no saved row default to enabled.", security, responses: { 200: { description: "Preferences", content: { "application/json": { schema: z.object({ data: NotificationPrefsRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "put", path: "/api/v1/users/me/notification-prefs", summary: "Set your notification category preferences", security, request: { body: { content: { "application/json": { schema: UpdateNotificationPrefsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "get", path: "/api/v1/users/me/feature-prefs", summary: "Get your feature visibility preferences", description: "Features with no saved row default to visible.", security, responses: { 200: { description: "Preferences", content: { "application/json": { schema: z.object({ data: FeaturePrefsRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "put", path: "/api/v1/users/me/feature-prefs", summary: "Set your feature visibility preferences", description: "Purely cosmetic (hides nav items) — does not restrict access to the underlying pages.", security, request: { body: { content: { "application/json": { schema: UpdateFeaturePrefsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "get", path: "/api/v1/users/me/nutrition-diary", summary: "Get a day's cooked-recipe nutrition totals vs. your goals", security, request: { query: z.object({ date: z.string().optional().describe("ISO date YYYY-MM-DD, defaults to today (UTC)") }) }, responses: { 200: { description: "Diary", content: { "application/json": { schema: NutritionDiaryRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "get", path: "/api/v1/users/me/nutrition-goals", summary: "Get your daily nutrition goals", security, responses: { 200: { description: "Goals", content: { "application/json": { schema: z.object({ data: NutritionGoalsRef2 }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "put", path: "/api/v1/users/me/nutrition-goals", summary: "Set your daily nutrition goals", security, request: { body: { content: { "application/json": { schema: UpdateNutritionGoalsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
@@ -600,6 +617,26 @@ export function generateOpenApiSpec(): object {
|
||||
registry.registerPath({ method: "get", path: "/api/v1/webhooks/{id}/deliveries", summary: "List recent delivery attempts (most recent 20)", security, request: { params: idParam }, responses: { 200: { description: "Deliveries, newest first", content: { "application/json": { schema: z.array(WebhookDeliveryRef) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "post", path: "/api/v1/webhooks/{id}/redeliver", summary: "Re-send a past delivery's payload", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ deliveryId: z.string().uuid() }) } }, required: true } }, responses: { 200: { description: "Redelivery dispatched", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Webhook or delivery not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
|
||||
const AdminWebhookEventEnum = z.enum(["user.signed_up", "support_ticket.created", "report.filed"]);
|
||||
const AdminWebhookRef = registry.register("AdminWebhook", z.object({
|
||||
id: z.string(), url: z.string(), events: z.array(AdminWebhookEventEnum), active: z.boolean(), createdAt: z.string().datetime(),
|
||||
}));
|
||||
const CreateAdminWebhookRef = registry.register("CreateAdminWebhook", z.object({
|
||||
url: z.string().min(1).max(2048), events: z.array(AdminWebhookEventEnum).default([]),
|
||||
}));
|
||||
const CreatedAdminWebhookRef = registry.register("CreatedAdminWebhook", z.object({
|
||||
id: z.string(), url: z.string(), events: z.array(AdminWebhookEventEnum),
|
||||
secret: z.string().describe("Signing secret — shown only in this response, never returned again."),
|
||||
active: z.boolean(), createdAt: z.string().datetime(),
|
||||
}));
|
||||
const UpdateAdminWebhookRef = registry.register("UpdateAdminWebhook", z.object({
|
||||
url: z.string().min(1).max(2048).optional(), events: z.array(AdminWebhookEventEnum).optional(), active: z.boolean().optional(),
|
||||
}));
|
||||
const AdminWebhookDeliveryRef = registry.register("AdminWebhookDelivery", z.object({
|
||||
id: z.string(), webhookId: z.string(), event: z.string(), payload: z.record(z.string(), z.unknown()).nullable(),
|
||||
statusCode: z.number().int().nullable(), success: z.boolean(), attempts: z.number().int(), createdAt: z.string().datetime(),
|
||||
}));
|
||||
|
||||
// --- Support: bug reports, suggestions, and questions submitted in-app ---
|
||||
const SupportTicketTypeEnum = z.enum(["bug", "suggestion", "question"]);
|
||||
const SupportTicketStatusEnum = z.enum(["open", "triaged", "closed"]);
|
||||
@@ -842,6 +879,13 @@ export function generateOpenApiSpec(): object {
|
||||
|
||||
registry.registerPath({ method: "get", path: "/api/v1/admin/support", summary: "List all support tickets, newest first", description: "Admin only.", security: adminSecurity, responses: { 200: { description: "Tickets", content: { "application/json": { schema: z.array(AdminSupportTicketRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "patch", path: "/api/v1/admin/support/{id}", summary: "Update a support ticket's status, or retry opening its Gitea issue", description: "Admin only.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: UpdateAdminSupportTicketRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
|
||||
registry.registerPath({ method: "get", path: "/api/v1/admin/webhooks", summary: "List site-wide ops webhooks", description: "Admin only. Site-wide events (new signups, support tickets, reports) — distinct from the per-user webhooks under /api/v1/webhooks. The signing secret is never included — it is only ever returned once, at creation time.", security: adminSecurity, responses: { 200: { description: "Webhooks", content: { "application/json": { schema: z.array(AdminWebhookRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "post", path: "/api/v1/admin/webhooks", summary: "Create a site-wide ops webhook", description: "Admin only. Validates the URL against SSRF targets. Generates a signing secret returned once in this response only.", security: adminSecurity, request: { body: { content: { "application/json": { schema: CreateAdminWebhookRef } }, required: true } }, responses: { 201: { description: "Created — includes the signing secret (write-once)", content: { "application/json": { schema: CreatedAdminWebhookRef } } }, 400: { description: "Validation error or blocked URL", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "patch", path: "/api/v1/admin/webhooks/{id}", summary: "Update a site-wide ops webhook's URL, events, or active state", description: "Admin only. Validates the URL against SSRF targets if changed.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: UpdateAdminWebhookRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: AdminWebhookRef } } }, 400: { description: "Validation error, blocked URL, or no fields to update", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "delete", path: "/api/v1/admin/webhooks/{id}", summary: "Delete a site-wide ops webhook", description: "Admin only.", security: adminSecurity, request: { params: idParam }, responses: { 204: { description: "Deleted" }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "get", path: "/api/v1/admin/webhooks/{id}/deliveries", summary: "List recent delivery attempts for a site-wide ops webhook (most recent 20)", description: "Admin only.", security: adminSecurity, request: { params: idParam }, responses: { 200: { description: "Deliveries, newest first", content: { "application/json": { schema: z.array(AdminWebhookDeliveryRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "post", path: "/api/v1/admin/webhooks/{id}/redeliver", summary: "Re-send a past delivery's payload for a site-wide ops webhook", description: "Admin only.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ deliveryId: z.string().uuid() }) } }, required: true } }, responses: { 200: { description: "Redelivery dispatched", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Webhook or delivery not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "patch", path: "/api/v1/admin/reports/{id}", summary: "Resolve a report (mark reviewed or dismissed)", description: "Admin only.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: ResolveReportBodyRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ report: ResolvedReportRef }) } } }, 400: { description: "Invalid status", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
|
||||
registry.registerPath({ method: "put", path: "/api/v1/admin/settings", summary: "Update site settings (AI provider keys, VAPID keys, signups toggle)", description: "Admin only. Secret-flagged keys are encrypted at rest and never echoed back by any endpoint; this route only accepts new values, it does not return current ones.", security: adminSecurity, request: { body: { content: { "application/json": { schema: UpdateSiteSettingsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
|
||||
Reference in New Issue
Block a user