-
Notifications
You must be signed in to change notification settings - Fork 498
Email Drafts #849
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Email Drafts #849
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Note Other AI code review bot(s) detectedCodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review. WalkthroughAdds EmailDraft DB model and migration; tenancy-scoped CRUD API and dashboard UI for drafts; draft-aware render/send handlers and utilities; AI adapter/tool for TSX draft generation; type/interface updates (XOR, sendEmail options); and e2e tests with updated validation snapshots. Changes
Sequence Diagram(s)sequenceDiagram
rect rgba(220,235,255,0.6)
participant UI as Dashboard (create)
participant AdminApp as StackAdminApp
participant API as /internal/email-drafts
participant DB as Prisma
UI->>AdminApp: createEmailDraft({ displayName, themeId?, tsxSource? })
AdminApp->>API: POST /internal/email-drafts
API->>DB: create EmailDraft (tenancyId,...)
DB-->>API: created
API-->>AdminApp: { id }
AdminApp-->>UI: navigate to /email-drafts/{id}
end
sequenceDiagram
rect rgba(235,245,220,0.6)
participant UI as Dashboard (send)
participant AdminApp as StackServerInterface
participant PublicAPI as POST /api/v1/emails/send-email
participant Core as Send Handler
participant DB as Prisma
participant Mail as Email Service
UI->>AdminApp: sendEmail({ draftId, allUsers|userIds, themeId? })
AdminApp->>PublicAPI: { draft_id, all_users|user_ids, theme_id? }
PublicAPI->>Core: validate union body
Core->>DB: getEmailDraft(tenancyId,draftId)
Core->>Core: resolve theme via draft.themeMode/themeId
Core->>Mail: render per-user and send (per-user handling)
Mail-->>Core: per-user results
Core-->>PublicAPI: 200 { results[...] }
PublicAPI-->>AdminApp: results
end
sequenceDiagram
rect rgba(255,245,220,0.6)
participant ChatUI as Assistant Chat
participant Adapter as email-draft adapter
participant Editor as Code Editor
ChatUI->>Adapter: contextType="email-draft"
Adapter-->>ChatUI: exposes tool createEmailTemplate
ChatUI->>Adapter: tool-call -> { content: "<TSX...>" }
Adapter-->>Editor: tool result { content }
Editor->>Editor: setCurrentCode(content)
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
Pre-merge checks (2 passed, 1 warning)❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
Poem
✨ Finishing Touches
🧪 Generate unit tests
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Claude finished @BilalG1's task —— View job Code Review Complete ✅Reviewing PR #849: Email Drafts functionality Review Tasks:
🔍 Overall AssessmentThis is a well-implemented feature that adds email draft functionality with AI assistance. The code generally follows good practices, but there are several areas for improvement. ✅ What's Working WellDatabase Design:
Test Coverage:
Architecture:
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Greptile Summary
This PR implements a comprehensive email drafts feature for the Stack Auth dashboard, allowing users to create, edit, and manage email templates before sending them. The implementation includes:
Backend Infrastructure:
- New
EmailDraftdatabase table with theme configuration, TSX source code storage, and sent status tracking - API endpoints for CRUD operations on email drafts (
/internal/email-draftsand/internal/email-drafts/[id]) - Extended the existing
/emails/send-emailendpoint to support sending from drafts viadraft_idparameter - Added
all_usersfunctionality for bulk email sending to entire user bases - New AI chat adapter for draft creation with system prompts and tools for AI-assisted template generation
Frontend Features:
- New dashboard pages at
/email-drafts(list view) and/email-drafts/[draftId](editor view) - Two-stage workflow: editing stage with code editor and AI chat assistance, then sending stage with recipient selection
- Extracted and created reusable
EmailThemeSelectorcomponent for consistent theme selection across templates and drafts - Added sidebar navigation with breadcrumb support for draft management
- Integration with existing
TeamMemberSearchTablefor user selection during send process
Type System & API Extensions:
- Extended admin interface with
listInternalEmailDrafts,createEmailDraft, andupdateEmailDraftmethods - Modified
sendEmailto support optionaluserIds, newallUsersflag, anddraftIdparameter - Added XOR type utility for mutually exclusive option combinations
- Updated email rendering system to handle draft content alongside existing template rendering
AI Integration:
- Extended chat context types to include 'email-draft' across multiple adapter systems
- Created draft-specific UI components for AI-generated content with undo functionality
- Email drafts can be created and edited through AI assistance similar to existing theme and template workflows
The feature follows established patterns throughout the codebase, maintaining consistency with existing email template functionality while providing a more flexible drafting workflow for one-time email compositions.
Confidence score: 3/5
- This PR introduces significant new functionality with potential runtime risks due to incomplete validation and error handling in several areas
- Score reflects concerns about missing foreign key constraints in database schema, 'any' type usage, and inconsistent theme handling logic across components
- Pay close attention to
apps/backend/src/app/api/latest/emails/send-email/route.tsx,apps/backend/prisma/migrations/20250815012830_email_drafts/migration.sql, andapps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx
30 files reviewed, 14 comments
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx
Show resolved
Hide resolved
...board/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx
Show resolved
Hide resolved
apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx
Outdated
Show resolved
Hide resolved
apps/backend/prisma/migrations/20250815012830_email_drafts/migration.sql
Show resolved
Hide resolved
apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx
Outdated
Show resolved
Hide resolved
apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx
Outdated
Show resolved
Hide resolved
apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx
Outdated
Show resolved
Hide resolved
Review by RecurseML🔍 Review performed on 2c237ef..060335c ✨ No bugs found, your code is sparkling clean ✅ Files analyzed, no issues (5)• ⏭️ Files skipped (low suspicion) (25)• |
stack-app.mdxThe documentation for
Please ensure these changes are reflected in the documentation to accurately represent the current functionality of |
|
Claude finished @BilalG1's task —— View job Code Review Complete ✅Reviewing PR #849: Email Drafts functionality Review Tasks:
🔍 Overall AssessmentThis is a well-implemented feature that adds email draft functionality with AI assistance. The code generally follows good practices, but there are several areas for improvement. ✅ What's Working WellDatabase Design:
Test Coverage:
Architecture:
|
|
Claude encountered an error —— View job Code Review in Progress
|
|
Claude is reviewing PR #849: Email Drafts Review Tasks:
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 10
🔭 Outside diff range comments (2)
apps/dashboard/src/components/vibe-coding/code-editor.tsx (1)
84-92: Defaulting Props to never makes variables impossible to passWith T = never, the variables prop becomes never, which no value can satisfy. This will surface false-positive type errors in the editor. Use a permissive default.
- type Props<T = never> = { + type Props<T = Record<string, unknown>> = { variables: T; project: { displayName: string; }; user: { displayName: string | null; }; };apps/backend/src/app/api/latest/emails/send-email/route.tsx (1)
54-61: Expose per-user errors in API response (schema).Augment the response schema to include an optional
errorstring per result.body: yupObject({ results: yupArray(yupObject({ user_id: yupString().defined(), user_email: yupString().optional(), + error: yupString().optional(), })).defined(), }).defined(),
♻️ Duplicate comments (8)
apps/dashboard/src/components/email-theme-selector.tsx (1)
10-15: Keep the null/undefined coercion; it’s intentional and correct for your tri-state mappingThe JSON encode/decode approach correctly:
- maps undefined -> "null" (via stringify) -> undefined on parse (via ?? undefined),
- maps false -> "false" -> false,
- maps string IDs -> quoted JSON string -> the original string.
This contradicts the earlier suggestion to drop the coalescing; that would change semantics and break PROJECT_DEFAULT handling.
apps/backend/prisma/migrations/20250815012830_email_drafts/migration.sql (1)
5-17: Add pragmatic indexes and an integrity CHECK; optionally add a tenancy FK if consistent with your schema policyFor typical list/filter patterns, indexes materially help. A CHECK constraint keeps themeMode/themeId coherent. Re: Tenancy FK—many of your models omit a direct FK to Tenancy for flexibility; if you want cascade guarantees, add it here for consistency across the codebase.
Append these statements after the CREATE TABLE block:
CREATE TABLE "EmailDraft" ( @@ CONSTRAINT "EmailDraft_pkey" PRIMARY KEY ("tenancyId","id") ); + +-- Useful indexes for common queries +CREATE INDEX "EmailDraft_tenancyId_createdAt_idx" ON "EmailDraft" ("tenancyId", "createdAt" DESC); +CREATE INDEX "EmailDraft_tenancyId_sentAt_idx" ON "EmailDraft" ("tenancyId", "sentAt"); +CREATE INDEX "EmailDraft_tenancyId_displayName_idx" ON "EmailDraft" ("tenancyId", "displayName"); + +-- Optional: enforce themeMode/themeId consistency +ALTER TABLE "EmailDraft" +ADD CONSTRAINT "EmailDraft_theme_consistency" +CHECK ( + ("themeMode" = 'CUSTOM' AND "themeId" IS NOT NULL) + OR ("themeMode" <> 'CUSTOM' AND "themeId" IS NULL) +); + +-- Optional: add FK if you want referential integrity to Tenancy with cascade +-- ALTER TABLE "EmailDraft" +-- ADD CONSTRAINT "EmailDraft_tenancy_fkey" +-- FOREIGN KEY ("tenancyId") REFERENCES "Tenancy"("id") ON DELETE CASCADE;If you prefer, I can generate a follow-up migration with these statements.
apps/backend/src/app/api/latest/emails/send-email/route.tsx (1)
96-106: Fix all_users handling: query currently filters by undefined list.When
all_usersis true, the current query still addsprojectUserId: { in: body.user_ids }, which will result in no matches (or an error) becausebody.user_idsis undefined.Apply this diff to conditionally include the
projectUserIdfilter only whenuser_idsis provided:- const users = await prisma.projectUser.findMany({ - where: { - tenancyId: auth.tenancy.id, - projectUserId: { - in: body.user_ids - }, - }, - include: { - contactChannels: true, - }, - }); + const users = await prisma.projectUser.findMany({ + where: { + tenancyId: auth.tenancy.id, + ...(body.user_ids && { + projectUserId: { in: body.user_ids }, + }), + }, + include: { + contactChannels: true, + }, + });apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx (1)
35-35: Avoid any — define a proper EmailDraft shape.Using any here loses type safety. Define a minimal interface that matches useEmailDrafts() and use it.
For example (adjust fields as needed):
- {drafts.map((draft: any) => ( + type EmailDraftListItem = { id: string, displayName: string }; + {drafts.map((draft: EmailDraftListItem) => (packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts (1)
37-37: UnifythemeIdtype: preferstring | null | falsefor consistencyCurrent:
string | undefined | false. Elsewhere (render preview, server interface),nullrepresents “project theme”. Recommend aligning this store property tostring | null | false.Apply this diff:
- & AsyncStoreProperty<"emailDrafts", [], { id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null }[], true> + & AsyncStoreProperty<"emailDrafts", [], { id: string, displayName: string, themeId: string | null | false, tsxSource: string, sentAt: Date | null }[], true>apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (1)
24-27: Initialize editor state when the draft loads (async hook timing)If the draft arrives after mount,
currentCode/selectedThemeIdremain empty. Seed state once when the draft becomes available to avoid editing a blank buffer.Apply this diff:
const [currentCode, setCurrentCode] = useState<string>(draft?.tsxSource ?? ""); const [stage, setStage] = useState<"edit" | "send">("edit"); const [selectedThemeId, setSelectedThemeId] = useState<string | undefined | false>(draft?.themeId); + const [didInitFromDraft, setDidInitFromDraft] = useState(false); + + useEffect(() => { + if (!didInitFromDraft && draft) { + setCurrentCode(draft.tsxSource ?? ""); + setSelectedThemeId(draft.themeId); + setDidInitFromDraft(true); + } + }, [draft, didInitFromDraft]);packages/stack-shared/src/interface/admin-interface.ts (2)
155-167: StandardizeupdateEmailDrafttheme_id typeKeep
theme_idconsistent with list/create.Apply this diff:
- async updateEmailDraft(id: string, data: { display_name?: string, theme_id?: string | null | false, tsx_source?: string, sent_at_millis?: number | null }): Promise<void> { + async updateEmailDraft(id: string, data: { display_name?: string, theme_id?: string | null | false, tsx_source?: string, sent_at_millis?: number | null }): Promise<void> {
140-153: Allownullon create to explicitly select “project theme”Creating a draft currently can’t explicitly set project theme. Align with update/list and other email APIs by allowing
null.Apply this diff:
- async createEmailDraft(options: { display_name?: string, theme_id?: string | false, tsx_source?: string }): Promise<{ id: string }> { + async createEmailDraft(options: { display_name?: string, theme_id?: string | null | false, tsx_source?: string }): Promise<{ id: string }> {
🧹 Nitpick comments (33)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (2)
202-221: Make draft breadcrumbs resilient to nested routes (e.g., /edit, /send).Current regex only matches the base detail path. If detail pages have subroutes, breadcrumbs won’t render. Broaden both the match and regex to include optional trailing segments.
Apply this diff:
- const match = pathname.match(/^\/projects\/[^\/]+\/email-drafts\/([^\/]+)$/); + const match = pathname.match(/^\/projects\/[^\/]+\/email-drafts\/([^\/]+)(?:\/.*)?$/); @@ - regex: /^\/projects\/[^\/]+\/email-drafts\/[^\/]+$/, + regex: /^\/projects\/[^\/]+\/email-drafts\/[^\/]+(?:\/.*)?$/,
352-361: Provide a stable fallback for draft breadcrumb text while data loads.When the draft isn’t found yet, the crumb renders empty. Fallback to the draftId to avoid a blank label.
Confirm that useEmailDrafts() always returns an array (not undefined). If it can be undefined, add a default (e.g., const drafts = stackAdminApp.useEmailDrafts() ?? []).
Apply this diff:
function DraftBreadcrumbItem(props: { draftId: string }) { const stackAdminApp = useAdminApp(); const drafts = stackAdminApp.useEmailDrafts(); const draft = drafts.find((d) => d.id === props.draftId); - if (!draft) { - return null; - } - return draft.displayName; + return draft?.displayName ?? props.draftId; }apps/dashboard/src/components/assistant-ui/thread.tsx (2)
81-81: Welcome suggestions hidden — confirm UX intent or remove dead codeYou’ve disabled the welcome suggestions. If this is a permanent change, consider removing the unused component to avoid dead code and keep bundle size lean.
87-112: ThreadWelcomeSuggestions is now unused — delete to reduce bundle sizeThis component isn’t referenced anymore. Safe to remove.
-const ThreadWelcomeSuggestions: FC = () => { - return ( - <div className="mt-3 flex w-full items-stretch justify-center gap-4"> - <ThreadPrimitive.Suggestion - className="hover:bg-muted/80 flex max-w-sm grow basis-0 flex-col items-center justify-center rounded-lg border p-3 transition-colors ease-in" - prompt="Make a modern email theme with a blue color scheme" - method="replace" - autoSend - > - <span className="line-clamp-2 text-ellipsis text-sm font-semibold"> - Make a blue theme - </span> - </ThreadPrimitive.Suggestion> - <ThreadPrimitive.Suggestion - className="hover:bg-muted/80 flex max-w-sm grow basis-0 flex-col items-center justify-center rounded-lg border p-3 transition-colors ease-in" - prompt="Make a modern email theme in dark mode" - method="replace" - autoSend - > - <span className="line-clamp-2 text-ellipsis text-sm font-semibold"> - Make a dark theme - </span> - </ThreadPrimitive.Suggestion> - </div> - ); -};apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx (1)
31-36: Schema consistency: mark auth.tenancy as defined() in POST like PATCH/GETPOST’s auth.tenancy currently omits .defined(), unlike PATCH/GET. For consistency and clearer validations, define it here too.
- auth: yupObject({ - type: yupString().oneOf(["admin"]).defined(), - tenancy: adaptSchema, - }), + auth: yupObject({ + type: yupString().oneOf(["admin"]).defined(), + tenancy: adaptSchema.defined(), + }),apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx (1)
41-45: Simplify undefined→null conversionMinor nit: coalesce to null more succinctly without changing behavior.
- await stackAdminApp.updateEmailTemplate(props.templateId, currentCode, selectedThemeId === undefined ? null : selectedThemeId); + await stackAdminApp.updateEmailTemplate(props.templateId, currentCode, selectedThemeId ?? null);apps/backend/prisma/schema.prisma (2)
672-687: Solid model addition; consider adding tenant-scoped indexes for list/sort and query patternsEmailDraft looks consistent with your multi-tenant composite PK pattern. To keep list pages and queries snappy, add a couple of pragmatic indexes commonly used in dashboards (createdAt desc, sentAt filter) and optionally by displayName.
Apply this diff to add useful indexes:
model EmailDraft { tenancyId String @db.Uuid id String @default(uuid()) @db.Uuid displayName String themeMode DraftThemeMode @default(PROJECT_DEFAULT) themeId String? tsxSource String sentAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + @@index([tenancyId, createdAt(sort: Desc)], name: "EmailDraft_createdAt_desc") + @@index([tenancyId, sentAt], name: "EmailDraft_sentAt") + @@index([tenancyId, displayName], name: "EmailDraft_displayName") @@id([tenancyId, id]) }
672-687: Optional: Enforce themeMode/themeId consistency at the DB level (CHECK constraint via SQL migration)App logic maps PROJECT_DEFAULT -> themeId NULL/undefined, NONE -> themeId NULL/false, CUSTOM -> themeId non-null. To prevent bad states, add a CHECK constraint in the migration (Prisma doesn’t support CHECK directly in schema). See the migration file comment for the exact SQL.
I can generate a follow-up migration with the CHECK constraint if you’d like.
apps/dashboard/src/components/email-theme-selector.tsx (1)
21-37: Minor optional: avoid JSON for value encoding to reduce cognitive overheadCurrent JSON sentinel encoding is safe (quoted IDs won’t collide with "null"/"false"). If you want a simpler mental model, consider explicit tags:
- "none" => No theme
- "project" => Project theme
- "id:" => Custom theme
Apply this refactor if desired:
-function themeIdToSelectString(themeId: string | undefined | false): string { - return JSON.stringify(themeId ?? null); -} -function selectStringToThemeId(value: string): string | undefined | false { - return JSON.parse(value) ?? undefined; -} +function themeIdToSelectString(themeId: string | undefined | false): string { + if (themeId === undefined) return "project"; + if (themeId === false) return "none"; + return `id:${themeId}`; +} +function selectStringToThemeId(value: string): string | undefined | false { + if (value === "project") return undefined; + if (value === "none") return false; + if (value.startsWith("id:")) return value.slice(3); + return undefined; +} @@ - <SelectItem value={"false"}>No theme</SelectItem> - <SelectItem value={"null"}>Project theme</SelectItem> + <SelectItem value="none">No theme</SelectItem> + <SelectItem value="project">Project theme</SelectItem> @@ - <SelectItem key={theme.id} value={JSON.stringify(theme.id)}> + <SelectItem key={theme.id} value={`id:${theme.id}`}> {theme.displayName} </SelectItem>apps/backend/src/lib/ai-chat/email-draft-adapter.ts (1)
10-20: Nit: unused context parametercontext is not used. Either prefix with underscore to signal intent or leverage it (e.g., tenancy/thread scoping in a future execute handler).
Apply this minimal tidy:
-export const emailDraftAdapter = (context: ChatAdapterContext) => ({ +export const emailDraftAdapter = (_context: ChatAdapterContext) => ({apps/backend/src/lib/email-drafts.tsx (1)
25-33: Optional guard for inconsistent CUSTOM-without-themeId casesIf the DB ever contains CUSTOM with themeId null, you currently coerce to undefined. Consider asserting/logging to detect data issues early.
Apply this defensive (non-breaking) tweak:
export const themeModeToTemplateThemeId = (themeMode: DraftThemeMode, themeId: string | null): string | false | undefined => { if (themeMode === DraftThemeMode.PROJECT_DEFAULT) { return undefined; } if (themeMode === DraftThemeMode.NONE) { return false; } - return themeId === null ? undefined : themeId; + if (themeId === null) { + // CUSTOM without themeId should not happen; surface as default and log in dev + if (process.env.NODE_ENV !== "production") { + // eslint-disable-next-line no-console + console.warn("EmailDraft CUSTOM themeMode without themeId; defaulting to undefined"); + } + return undefined; + } + return themeId; };packages/stack-shared/src/interface/server-interface.ts (1)
799-809: Enforce recipient XOR at the client boundary (and consider typing it).Allowing both
userIdsandallUsers(or neither) to flow to the server shifts an avoidable error to runtime. Add a quick XOR guard (and optionally encode via overloads) to fail fast.Example minimal runtime guard:
async sendEmail(options: { userIds?: string[], allUsers?: true, themeId?: string | null | false, html?: string, subject?: string, notificationCategoryName?: string, templateId?: string, variables?: Record<string, any>, draftId?: string, }): Promise<Result<void, KnownErrors["RequiresCustomEmailServer"] | KnownErrors["SchemaError"] | KnownErrors["UserIdDoesNotExist"]>> { + if (!!options.userIds === !!options.allUsers) { + return Result.error(new KnownErrors.SchemaError("Exactly one of userIds or allUsers must be provided")); + }If you want compile-time guarantees, we can switch to overloads/XOR types to represent:
- one of { html } | { templateId (+variables?) } | { draftId }
- and exactly one of { userIds } | { allUsers: true }
apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx (2)
10-25: Avoid re-creating ToolUI on every render (wrap in useMemo).makeAssistantToolUI returns a component; creating it inside the render path can cause unnecessary re-instantiation and lost internal state. Memoize it based on setCurrentCode.
Apply this diff within the selected lines:
- const ToolUI = makeAssistantToolUI< - { content: string }, - "success" - >({ - toolName: "createEmailTemplate", - render: ({ args }) => { - return ( - <Card className="flex items-center gap-2 p-4 justify-between"> - <span className="text-sm">Created draft</span> - <Button variant="ghost" size="icon" onClick={() => setCurrentCode(args.content)}> - <Undo2 className="size-4" /> - </Button> - </Card> - ); - }, - }); + const ToolUI = useMemo(() => makeAssistantToolUI< + { content: string }, + "success" + >({ + toolName: "createEmailTemplate", + render: ({ args }) => ( + <Card className="flex items-center gap-2 p-4 justify-between"> + <span className="text-sm">Created draft</span> + <Button variant="ghost" size="icon" aria-label="Apply draft to editor" title="Apply draft to editor" onClick={() => setCurrentCode(args.content)}> + <Undo2 className="size-4" /> + </Button> + </Card> + ), + }), [setCurrentCode]);Additionally add the import (outside the selected range):
import { useMemo } from "react";
19-21: Add accessible label to the icon-only button.Icon-only buttons should have an aria-label/title for screen readers. Also, “Undo” might be semantically confusing for “apply draft”; label clarifies intent.
Apply this diff:
- <Button variant="ghost" size="icon" onClick={() => setCurrentCode(args.content)}> + <Button + variant="ghost" + size="icon" + aria-label="Apply draft to editor" + title="Apply draft to editor" + onClick={() => setCurrentCode(args.content)} + > <Undo2 className="size-4" /> </Button>apps/e2e/tests/js/email.test.ts (1)
180-191: Reduce brittleness of schema error assertion (avoid giant inline snapshot).The union-validator’s error formatting can change; asserting the entire deindented block is brittle. Prefer targeted contains on stable substrings.
Apply this diff to replace the inline snapshot with focused assertions:
- expect(result.error.message).toMatchInlineSnapshot(` - deindent\` - Request validation failed on POST /api/v1/emails/send-email: - - body is not matched by any of the provided schemas: - Schema 0: - body.html must be defined - Schema 1: - body.template_id must be defined - Schema 2: - body.draft_id must be defined - \` - `); + expect(result.error.message).toContain("Request validation failed on POST /api/v1/emails/send-email:"); + expect(result.error.message).toContain("body is not matched by any of the provided schemas:"); + expect(result.error.message).toContain("body.html must be defined"); + expect(result.error.message).toContain("body.template_id must be defined"); + expect(result.error.message).toContain("body.draft_id must be defined");If you’d like, I can add a new test for the draftId happy-path as part of this suite.
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page.tsx (1)
7-10: Fix Page props typing; params is not a Promise and async/await is unnecessary.Next.js App Router provides params synchronously. Cleaning this up avoids confusion and matches framework conventions.
Apply this diff:
-export default async function Page(props: { params: Promise<{ draftId: string }> }) { - const params = await props.params; - return <PageClient draftId={params.draftId} />; -} +export default function Page({ params }: { params: { draftId: string } }) { + return <PageClient draftId={params.draftId} />; +}apps/e2e/tests/backend/endpoints/api/v1/render-email.test.ts (1)
69-105: Prefer targeted assertions over full inline snapshot for schema errors.Inline snapshots of long, structured schema errors are fragile. Assert key fields and a couple of substrings to keep tests resilient to formatting/order changes.
Apply this diff within the selected lines:
- "body": { - "code": "SCHEMA_ERROR", - "details": { - "message": deindent\` - Request validation failed on POST /api/v1/emails/render-email: - - body is not matched by any of the provided schemas: - Schema 0: - body.template_id must be defined - body contains unknown properties: theme_tsx_source, template_tsx_source - Schema 1: - body.template_id must be defined - body contains unknown properties: theme_id, template_tsx_source - Schema 2: - body contains unknown properties: theme_tsx_source - Schema 3: - body contains unknown properties: theme_id - \`, - }, - "error": deindent\` - Request validation failed on POST /api/v1/emails/render-email: - - body is not matched by any of the provided schemas: - Schema 0: - body.template_id must be defined - body contains unknown properties: theme_tsx_source, template_tsx_source - Schema 1: - body.template_id must be defined - body contains unknown properties: theme_id, template_tsx_source - Schema 2: - body contains unknown properties: theme_tsx_source - Schema 3: - body contains unknown properties: theme_id - \`, - }, - "headers": Headers { - "x-stack-known-error": "SCHEMA_ERROR", - <some fields may have been hidden>, - }, + "body": expect.objectContaining({ + code: "SCHEMA_ERROR", + details: expect.objectContaining({ + message: expect.stringContaining("body is not matched by any of the provided schemas:") + }), + }), + "headers": expect.anything(),If you prefer not to change snapshot style, consider at least snapshotting only response.body and headers key presence separately.
packages/stack-shared/src/utils/types.tsx (1)
69-74: Consider documenting and adding compile-time assertions for XOR.A brief JSDoc and a couple of typeAssertIs examples will prevent regressions and clarify intended usage (especially around disjoint keys).
Example assertions you could add nearby:
// XOR over two shapes type A = { html: string }; type B = { templateId: string; variables?: Record<string, any> }; typeAssertIs<XOR<[A, B]>, (A & { [K in keyof B]?: never }) | (B & { [K in keyof A]?: never })>()(); // XOR over three shapes type C = { draftId: string }; typeAssertIs<XOR<[A, B, C]>, A | B | C>()();And a short doc comment:
/** * Exclusive-or over a tuple of object types. * Ensures exactly one shape is present by disallowing the keys of the others (via `?: never`). * Note: Works best when shapes have disjoint keys. */apps/dashboard/src/components/email-preview.tsx (1)
47-51: Nit: optional already implies undefined — simplify type.themeId is an optional prop, so explicitly including undefined in the union is redundant. Recommend simplifying for readability.
Apply this diff:
- themeId?: string | undefined | false, + themeId?: string | false,apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts (2)
437-531: Solid coverage for all_users path; consider reducing snapshot brittleness.The new tests cover mutual exclusivity and broadcast behavior well. However, long deindent() inline snapshots can be fragile if error aggregation text changes. Prefer partial structural assertions for stability.
For example, in the “both user_ids and all_users” test:
- expect(response).toMatchInlineSnapshot(` - NiceResponse { - "status": 400, - "body": { - "code": "SCHEMA_ERROR", - "details": { "message": "Exactly one of user_ids or all_users must be provided" }, - "error": "Exactly one of user_ids or all_users must be provided", - }, - "headers": Headers { - "x-stack-known-error": "SCHEMA_ERROR", - <some fields may have been hidden>, - }, - } - `); + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ + code: "SCHEMA_ERROR", + details: { message: "Exactly one of user_ids or all_users must be provided" }, + });
533-633: Template/draft mode validation snapshots are precise; consider adding a draft-based happy-path test.You already validate invalid cases and html/template modes thoroughly. Adding one e2e for draft_id content mode would close the loop on the new “third” input schema.
If helpful, I can draft a test that:
- creates a draft via internal API (admin),
- updates its tsx_source,
- sends via /api/v1/emails/send-email with { all_users: true, draft_id, subject },
- asserts results and mailbox contents.
packages/template/src/lib/stack-app/email/index.ts (2)
26-29: Tightenvariablestyping to avoidanyleaksUsing
unknowninstead ofanyimproves type safety without reducing flexibility for callers.Apply this diff:
{ templateId: string, - variables?: Record<string, any>, + variables?: Record<string, unknown>, },
12-16: StandardizethemeIdunion type across layersThere are multiple definitions of
themeIdin the codebase—some allowundefined, somenull, and some omit either. This inconsistency can lead to subtle bugs when passing values between UI, app implementations, and public/server interfaces. Please align all occurrences to a single union (e.g.string | null | false) and mapundefined→nullat the UI boundary.Key locations with divergent
themeIdtypes:
- packages/stack-shared/src/interface/server-interface.ts:
•themeId?: string | null | false- packages/stack-shared/src/interface/admin-interface.ts:
•renderEmailPreview(… options: { themeId?: string | null | false, … })- packages/template/src/lib/stack-app/email/index.ts:
•type SendEmailOptionsBase { themeId?: string | null | false; … }- packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts:
•createEmailDraft(options: { … themeId?: string | undefined | false, … })
•updateEmailDraft(…, data: { … themeId?: string | undefined | false, … })
•AsyncStoreProperty<"emailDrafts", … { themeId: string | undefined | false, … }[]>- packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts:
•async createEmailDraft(options: { … themeId?: string | false, … })
•async updateEmailDraft(…, data: { … themeId?: string | undefined | false, … })- apps/dashboard/src/components/email-preview.tsx:
•themeId?: string | undefined | false- packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts:
•AsyncStoreProperty<"emailPreview", … { themeId?: string | null | false, … }>- packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts:
•getEmailPreview(… options: { themeId?: string | null | false, … })
•useEmailPreview({ themeId?: string | null | false, … })Suggested approach:
- Choose
string | null | falseas the canonical type.- Update all UI and admin-app interfaces/implementations to use
themeId?: string | null | false.- Where callers may pass
undefined, convert tonullbefore invoking the shared/public APIs.packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts (1)
78-79: Align draft API surface to a singlethemeIdrepresentationBoth methods use
string | undefined | false. Recommend switching tostring | null | falseto align with backend/admin interfaces and EmailPreview, and avoidundefinedinside unions for optional props.Apply this diff:
- createEmailDraft(options: { displayName?: string, themeId?: string | undefined | false, tsxSource?: string }): Promise<{ id: string }>, - updateEmailDraft(id: string, data: { displayName?: string, themeId?: string | undefined | false, tsxSource?: string }): Promise<void>, + createEmailDraft(options: { displayName?: string, themeId?: string | null | false, tsxSource?: string }): Promise<{ id: string }>, + updateEmailDraft(id: string, data: { displayName?: string, themeId?: string | null | false, tsxSource?: string }): Promise<void>,apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (3)
18-21: Drop local EmailDraft type and cast; rely on inferred hook return typeAvoid shape duplication that can drift from the source. The hook already carries the precise type, so you can remove the alias and cast.
Apply this diff:
- const { toast } = useToast(); - - type EmailDraft = { id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null }; - const drafts = stackAdminApp.useEmailDrafts() as EmailDraft[]; + // Prefer the hook's inferred type to avoid duplication + const drafts = stackAdminApp.useEmailDrafts();
18-19: Use a single toast API consistentlyBoth
toastimport anduseToast()are present; you only need one. Sincetoastis imported and used elsewhere, drop the hook to reduce duplication.Apply this diff:
- const { toast } = useToast();
70-71: Guard “Next” button when nothing changedPrevents a no-op PATCH and stage transition when code/theme are unchanged.
Apply this diff:
- <Button onClick={handleNext}>Next</Button> + <Button + disabled={!!draft && currentCode === (draft.tsxSource ?? "") && selectedThemeId === draft.themeId} + onClick={handleNext} + > + Next + </Button>packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (2)
304-315: Return type alignment for draft themeIdThis exposes
themeId: string | undefined | false. Consider returningstring | null | falseto align with server/public email types and reduce conversions at call sites.Apply this diff:
- useEmailDrafts(): { id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null }[] { + useEmailDrafts(): { id: string, displayName: string, themeId: string | null | false, tsxSource: string, sentAt: Date | null }[] {Note: if you adopt this, also adjust
draft.theme_idmapping to coerceundefinedtonullwhere appropriate.
335-344: List API: mirroruseEmailDraftsthemeId unionSame rationale as above; align to a single representation and let UI map to its local preference.
Apply this diff:
- async listEmailDrafts(): Promise<{ id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null }[]> { + async listEmailDrafts(): Promise<{ id: string, displayName: string, themeId: string | null | false, tsxSource: string, sentAt: Date | null }[]> {apps/backend/src/app/api/latest/emails/render-email/route.tsx (4)
47-49: Avoid building Maps for config lookups; use direct object accessConstructing
Maps here is unnecessary and adds overhead. The config objects are already key-addressable. This also aligns the membership check with howgetEmailThemeForTemplateresolves themes internally.Apply this diff:
- const templateList = new Map(Object.entries(tenancy.config.emails.templates)); - const themeList = new Map(Object.entries(tenancy.config.emails.themes)); + const templateList = tenancy.config.emails.templates; + const themeList = tenancy.config.emails.themes; - if (typeof body.theme_id === "string" && !themeList.has(body.theme_id)) { + if (typeof body.theme_id === "string" && !Object.prototype.hasOwnProperty.call(themeList, body.theme_id)) { throw new StatusError(400, "No theme found with given id"); } themeSource = getEmailThemeForTemplate(tenancy, body.theme_id); ... - const template = templateList.get(body.template_id); + const template = templateList[body.template_id];Also applies to: 53-56, 63-63
53-56: Use a consistent error type for invalid IDs (SchemaError vs raw 400)For consistency with the other schema-related failure (Line 69) and downstream error handling, prefer throwing
KnownErrors.SchemaErrorinstead of a genericStatusError(400, ...)when the provided IDs don’t resolve.Apply this diff:
- if (typeof body.theme_id === "string" && !Object.prototype.hasOwnProperty.call(themeList, body.theme_id)) { - throw new StatusError(400, "No theme found with given id"); + if (typeof body.theme_id === "string" && !Object.prototype.hasOwnProperty.call(themeList, body.theme_id)) { + throw new KnownErrors.SchemaError("No theme found with given id"); } ... - if (!template) { - throw new StatusError(400, "No template found with given id"); + if (!template) { + throw new KnownErrors.SchemaError("No template found with given id"); }Also applies to: 64-66
38-45: Optionally include plain-text in the response
renderEmailWithTemplatereturnstext, but the API omits it. Including it helps clients with plain-text fallbacks and parity with renderer output.Apply this diff:
response: yupObject({ statusCode: yupNumber().oneOf([200]).defined(), bodyType: yupString().oneOf(["json"]).defined(), body: yupObject({ html: yupString().defined(), + text: yupString(), subject: yupString(), notification_category: yupString(), }).defined(), }), ... return { statusCode: 200, bodyType: "json", body: { html: result.data.html, + text: result.data.text, subject: result.data.subject, notification_category: result.data.notificationCategory, }, };Also applies to: 86-91
9-12: Tighten metadata wordingThe route renders an email (template + theme), not just a theme. Minor wording tweak improves clarity in generated docs.
Apply this diff:
- summary: "Render email theme", - description: "Renders HTML content using the specified email theme", + summary: "Render email (template + theme)", + description: "Renders HTML using a template and theme (or direct TSX sources) for preview",
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (32)
apps/backend/prisma/migrations/20250815012830_email_drafts/migration.sql(1 hunks)apps/backend/prisma/schema.prisma(2 hunks)apps/backend/src/app/api/latest/emails/render-email/route.tsx(3 hunks)apps/backend/src/app/api/latest/emails/send-email/route.tsx(4 hunks)apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx(1 hunks)apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx(1 hunks)apps/backend/src/app/api/latest/internal/email-drafts/route.tsx(1 hunks)apps/backend/src/lib/ai-chat/adapter-registry.ts(2 hunks)apps/backend/src/lib/ai-chat/email-draft-adapter.ts(1 hunks)apps/backend/src/lib/email-drafts.tsx(1 hunks)apps/backend/src/lib/email-rendering.tsx(5 hunks)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx(1 hunks)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page.tsx(1 hunks)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx(1 hunks)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page.tsx(1 hunks)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx(3 hunks)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx(4 hunks)apps/dashboard/src/components/assistant-ui/thread.tsx(1 hunks)apps/dashboard/src/components/email-preview.tsx(2 hunks)apps/dashboard/src/components/email-theme-selector.tsx(1 hunks)apps/dashboard/src/components/vibe-coding/chat-adapters.ts(1 hunks)apps/dashboard/src/components/vibe-coding/code-editor.tsx(1 hunks)apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx(1 hunks)apps/e2e/tests/backend/endpoints/api/v1/render-email.test.ts(1 hunks)apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts(6 hunks)apps/e2e/tests/js/email.test.ts(1 hunks)packages/stack-shared/src/interface/admin-interface.ts(2 hunks)packages/stack-shared/src/interface/server-interface.ts(2 hunks)packages/stack-shared/src/utils/types.tsx(1 hunks)packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts(4 hunks)packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts(3 hunks)packages/template/src/lib/stack-app/email/index.ts(2 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
apps/backend/src/app/api/latest/**/*
📄 CodeRabbit Inference Engine (CLAUDE.md)
apps/backend/src/app/api/latest/**/*: Main API routes are located in /apps/backend/src/app/api/latest
The project uses a custom route handler system in the backend for consistent API responses
Files:
apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsxapps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsxapps/backend/src/app/api/latest/internal/email-drafts/route.tsxapps/backend/src/app/api/latest/emails/render-email/route.tsxapps/backend/src/app/api/latest/emails/send-email/route.tsx
apps/backend/prisma/schema.prisma
📄 CodeRabbit Inference Engine (CLAUDE.md)
Database models use Prisma
Files:
apps/backend/prisma/schema.prisma
🧠 Learnings (1)
📚 Learning: 2025-08-04T22:25:51.260Z
Learnt from: CR
PR: stack-auth/stack-auth#0
File: CLAUDE.md:0-0
Timestamp: 2025-08-04T22:25:51.260Z
Learning: Applies to apps/backend/prisma/schema.prisma : Database models use Prisma
Applied to files:
apps/backend/prisma/schema.prismaapps/backend/src/lib/email-rendering.tsx
🧬 Code Graph Analysis (19)
apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx (1)
packages/stack-ui/src/components/ui/button.tsx (1)
Button(92-92)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page.tsx (3)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page.tsx (2)
metadata(3-5)Page(7-10)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx (1)
PageClient(13-80)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (1)
PageClient(15-88)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx (6)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx (1)
useAdminApp(27-34)apps/dashboard/src/components/router.tsx (1)
useRouter(15-33)packages/stack-ui/src/components/ui/alert.tsx (3)
Alert(60-60)AlertTitle(60-60)AlertDescription(60-60)packages/stack-ui/src/components/action-dialog.tsx (1)
ActionDialog(31-135)apps/dashboard/src/components/form-dialog.tsx (1)
FormDialog(53-133)apps/dashboard/src/components/form-fields.tsx (1)
InputField(59-97)
apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx (1)
packages/stack-shared/src/schema-fields.ts (1)
yupString(185-188)
apps/backend/src/lib/ai-chat/email-draft-adapter.ts (1)
apps/backend/src/lib/ai-chat/adapter-registry.ts (1)
ChatAdapterContext(7-10)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page.tsx (3)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page.tsx (2)
metadata(3-5)Page(7-11)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx (1)
PageClient(21-98)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (1)
PageClient(15-88)
apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx (4)
apps/backend/src/app/api/latest/internal/email-drafts/route.tsx (1)
GET(8-50)packages/stack-shared/src/schema-fields.ts (4)
yupObject(245-249)yupString(185-188)yupNumber(189-192)templateThemeIdSchema(477-477)apps/backend/src/prisma-client.tsx (1)
getPrismaClientForTenancy(51-53)apps/backend/src/lib/email-drafts.tsx (2)
themeModeToTemplateThemeId(25-33)templateThemeIdToThemeMode(15-23)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (11)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx (1)
PageClient(21-98)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx (1)
useAdminApp(27-34)apps/dashboard/src/components/router.tsx (1)
useRouterConfirm(35-37)apps/dashboard/src/components/vibe-coding/chat-adapters.ts (3)
ToolCallContent(9-9)createHistoryAdapter(63-76)createChatAdapter(15-61)apps/dashboard/src/components/vibe-coding/vibe-code-layout.tsx (1)
VibeCodeLayout(13-37)apps/dashboard/src/components/email-preview.tsx (1)
EmailPreview(91-122)apps/dashboard/src/components/vibe-coding/code-editor.tsx (1)
CodeEditor(15-144)apps/dashboard/src/components/email-theme-selector.tsx (1)
EmailThemeSelector(17-39)apps/dashboard/src/components/vibe-coding/assistant-chat.tsx (1)
AssistantChat(16-34)apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx (1)
EmailDraftUI(9-28)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes/[themeId]/page-client.tsx (1)
PageClient(18-78)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx (2)
apps/dashboard/src/components/email-preview.tsx (1)
EmailPreview(91-122)apps/dashboard/src/components/email-theme-selector.tsx (1)
EmailThemeSelector(17-39)
apps/backend/src/lib/email-rendering.tsx (1)
packages/stack-shared/src/utils/results.tsx (1)
error(36-41)
apps/backend/src/app/api/latest/internal/email-drafts/route.tsx (6)
apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx (1)
GET(6-41)apps/backend/src/route-handlers/smart-route-handler.tsx (1)
createSmartRouteHandler(209-294)packages/stack-shared/src/schema-fields.ts (4)
yupObject(245-249)yupString(185-188)yupNumber(189-192)templateThemeIdSchema(477-477)apps/backend/src/prisma-client.tsx (1)
getPrismaClientForTenancy(51-53)apps/backend/src/lib/email-drafts.tsx (2)
themeModeToTemplateThemeId(25-33)templateThemeIdToThemeMode(15-23)packages/stack-shared/src/utils/strings.tsx (1)
deindent(235-238)
apps/backend/src/lib/ai-chat/adapter-registry.ts (3)
apps/backend/src/lib/ai-chat/email-theme-adapter.ts (1)
emailThemeAdapter(7-18)apps/backend/src/lib/ai-chat/email-template-adapter.ts (1)
emailTemplateAdapter(10-20)apps/backend/src/lib/ai-chat/email-draft-adapter.ts (1)
emailDraftAdapter(10-20)
apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts (1)
apps/e2e/tests/backend/backend-helpers.ts (1)
niceBackendFetch(107-165)
packages/template/src/lib/stack-app/email/index.ts (1)
packages/stack-shared/src/utils/types.tsx (1)
XOR(69-73)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (1)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx (1)
useAdminApp(27-34)
packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts (1)
packages/template/src/lib/stack-app/common.ts (1)
AsyncStoreProperty(8-10)
apps/backend/src/app/api/latest/emails/render-email/route.tsx (3)
packages/stack-shared/src/schema-fields.ts (4)
yupUnion(255-279)yupObject(245-249)yupString(185-188)templateThemeIdSchema(477-477)apps/backend/src/lib/email-rendering.tsx (2)
getEmailThemeForTemplate(23-32)renderEmailWithTemplate(45-129)packages/stack-shared/src/known-errors.tsx (2)
KnownErrors(1490-1492)KnownErrors(1494-1609)
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (1)
packages/template/src/lib/stack-app/apps/implementations/common.ts (2)
createCache(22-27)useAsyncCache(145-190)
apps/backend/src/app/api/latest/emails/send-email/route.tsx (5)
packages/stack-shared/src/schema-fields.ts (9)
yupObject(245-249)yupArray(211-214)yupString(185-188)yupBoolean(193-196)serverOrHigherAuthTypeSchema(429-429)adaptSchema(328-328)yupUnion(255-279)yupRecord(281-320)yupMixed(208-210)apps/backend/src/prisma-client.tsx (1)
getPrismaClientForTenancy(51-53)apps/backend/src/lib/notification-categories.ts (2)
getNotificationCategoryByName(25-27)hasNotificationEnabled(29-48)apps/backend/src/lib/email-rendering.tsx (3)
getEmailThemeForTemplate(23-32)createTemplateComponentFromHtml(34-43)renderEmailWithTemplate(45-129)apps/backend/src/lib/email-drafts.tsx (2)
getEmailDraft(3-13)themeModeToTemplateThemeId(25-33)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (9)
- GitHub Check: all-good
- GitHub Check: docker
- GitHub Check: lint_and_build (latest)
- GitHub Check: docker
- GitHub Check: build (22.x)
- GitHub Check: build (22.x)
- GitHub Check: restart-dev-and-test
- GitHub Check: setup-tests
- GitHub Check: Security Check
🔇 Additional comments (35)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (3)
27-31: Icon imports align with usage; LGTM.Switching to FilePen for Drafts and LayoutTemplate for Templates is appropriate and consistent with the new nav items.
If our lucide-react version is pinned, please confirm it includes FilePen and LayoutTemplate to avoid runtime icon import errors.
181-187: New “Drafts” nav item is correctly wired.Route, regex, and icon are consistent with the rest of the sidebar. Selection logic will work with the existing trailing-slash handling.
192-192: Template icon update is correct.LayoutTemplate better reflects Templates; no other changes needed here.
apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx (1)
40-40: Adding "email-draft" context_type — LGTMSchema now accepts “email-draft”, aligning with the new adapter. Looks good.
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx (2)
65-66: Pass-through of selectedThemeId to EmailPreview — LGTMProp contract matches the new EmailPreview typing. Looks good.
73-79: Switch to shared EmailThemeSelector — LGTMGood reuse of shared component; keeps the page thinner and consistent across the app.
apps/backend/src/lib/email-rendering.tsx (3)
3-3: Unused import removal — LGTMRemoving an unused env import keeps the module clean.
46-47: Parameter rename to templateOrDraftComponent — LGTMName clarifies usage with drafts; no functional change.
123-128: Catch-and-sanitize Freestyle execution errors — LGTMGood move to avoid leaking engine internals while still surfacing a clear failure.
apps/backend/prisma/schema.prisma (2)
689-694: Enum is clear and aligned with the UI semanticsThe DraftThemeMode values match the frontend union (undefined/null => PROJECT_DEFAULT, false => NONE, string => CUSTOM). No issues.
751-769: Subscription block reflow looks non-functional; LGTMField order/formatting change only; types/constraints remain intact.
apps/backend/src/lib/ai-chat/email-draft-adapter.ts (2)
33-49: Escaping inside the example is correct; niceThe escaped backticks and ${…} prevent unintended interpolation inside the template string. This will render properly in the system prompt.
13-20: Verify createEmailTemplate Execute Handler Wiring
I didn’t find anyexecutehandlers for thecreateEmailTemplatetool or any adapter wiring in the chat runtime. Without anexecuteimplementation, calls to this tool will go unhandled.Please confirm that:
- The
createEmailTemplatetool is registered with anexecutefunction (either inline in its definition or via a handler lookup).- The
email-draft-adapter(or equivalent) is imported and wired into your chat runtime’s tool registry.- The adapter’s key (e.g.
"email-draft") matches what your runtime expects for dispatching tool calls.apps/backend/src/lib/email-drafts.tsx (2)
3-13: Fetcher looks right for the composite ID; LGTMfindUnique on tenancyId_id matches the @@id([tenancyId, id]) in the schema. Returning null is fine; let callers decide 404 vs. silent.
15-23: ThemeId -> ThemeMode mapping is clear and matches UIundefined => PROJECT_DEFAULT, false => NONE, string => CUSTOM. No changes needed.
packages/stack-shared/src/interface/server-interface.ts (1)
817-827: LGTM: payload aligns with backend contract.
user_ids,all_users, anddraft_idare correctly forwarded. JSON.stringify will omitundefined, so optional fields won’t accidentally send null-like values.apps/backend/src/app/api/latest/internal/email-drafts/route.tsx (3)
29-49: GET drafts: solid tenancy scoping and projection.Good use of tenancy-scoped Prisma, fixed page size, and proper mapping to API shape (including theme conversion via
themeModeToTemplateThemeId).
53-67: Default draft source is reasonable and safe.Sensible starter template using React Email components. Keeping it internal/hidden mitigates exposure risk.
69-106: POST draft: correct theme mode/id mapping and defaults.
templateThemeIdToThemeMode+ selectivethemeIdpersistence is consistent with the conversion helpers; falling back todefaultDraftSourceis a good DX touch.apps/backend/src/app/api/latest/emails/send-email/route.tsx (3)
39-51: Body union shape is clear and future-proof.The union of html/template/draft modes atop a shared base reads cleanly and matches the described API. Nice use of
yupUnion.
70-72: LGTM: recipient XOR validation.Server-side check correctly enforces “exactly one of user_ids or all_users”.
74-94: Theme selection precedence is correct.Initializing from request
theme_idand only overriding with draft’s theme whentheme_idis undefined matches the intended semantics.apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page.tsx (1)
3-11: LGTM: page wrapper matches App Router conventions.Static metadata and a thin server component delegating to PageClient is consistent with the rest of the app.
apps/dashboard/src/components/vibe-coding/chat-adapters.ts (1)
18-19: LGTM: context type widened to include "email-draft".Type-only extension; runtime remains stable. Aligns frontend with new backend adapter.
apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx (1)
14-15: ToolName alignment verified for “createEmailTemplate”All occurrences of
createEmailTemplateare consistent between the UI and backend adapters:
- apps/backend/src/lib/ai-chat/email-draft-adapter.ts → tools:
{ createEmailTemplate: tool(…) }- apps/backend/src/lib/ai-chat/email-template-adapter.ts → tools:
{ createEmailTemplate: tool(…) }- apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx →
toolName: "createEmailTemplate"- apps/dashboard/src/components/vibe-coding/template-tool-components.tsx →
toolName: "createEmailTemplate"No action required.
packages/stack-shared/src/utils/types.tsx (1)
69-74: XOR type looks correct and is broadly useful.The tuple-folding XOR works for 2+ mutually exclusive shapes and matches the email options use cases with disjoint keys. Nice addition.
apps/backend/src/lib/ai-chat/adapter-registry.ts (1)
5-5: Adapter registration looks correct.
- New "email-draft" context added to the union and wired into CHAT_ADAPTERS.
- getChatAdapter continues to be type-safe and straightforward.
No issues spotted here.
Also applies to: 17-17, 22-22
apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx (1)
26-40: LGTM on GET route contract and helper usage.
- Validates admin tenancy and id, uses the per-tenancy prisma client, and translates theme fields via themeModeToTemplateThemeId.
- Response schema aligns with other internal routes.
No action needed.
apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts (1)
89-98: Good: explicit Project.createAndSwitch() before shared-config test.This ensures a valid project context and aligns with how niceBackendFetch expects headers. Looks good.
packages/template/src/lib/stack-app/email/index.ts (1)
19-31: Solid XOR typing for target and content shapesNice upgrade. The XOR composition cleanly enforces mutual exclusivity for recipient targeting and content sources, and the draftId variant integrates well with the existing shapes.
packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts (1)
66-66: Chat context extension looks goodAdding "email-draft" keeps the adapters in sync with new drafting tooling.
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (1)
95-103: sendEmail returns aResultand won’t throw on known errors—no change needed
ThesendEmailimplementation usessendServerRequestAndCatchKnownErrorto catch known errors and returns aResult<void, KnownErrors>viaResult.ok/Result.error. Checkingresult.status === "ok"is safe and won’t be skipped due to thrown errors.Likely an incorrect or invalid review comment.
packages/stack-shared/src/interface/admin-interface.ts (1)
352-352: Chat context union update LGTM“email-draft” addition is consistent with adapters and UI.
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (2)
46-48: Good: dedicated drafts cacheLocal cache mirrors existing patterns and is refreshed on mutations below.
496-503: Chat context passthrough LGTMConsistent with updated interface and dashboard adapters.
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx
Show resolved
Hide resolved
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts
Outdated
Show resolved
Hide resolved
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 7
♻️ Duplicate comments (5)
apps/backend/src/lib/ai-chat/email-draft-adapter.ts (1)
33-49: Critical: Unescaped backticks and ${} in template literal will break at runtimeThe example block is inside a JS template string. The raw ``` fences and ${user.displayName} will prematurely terminate/interpolate the outer template, causing syntax errors or ReferenceError at module load. Escape the backticks or remove the fences, and escape ${.
This also addresses the prior bot comment about escaping in the Subject line.
Apply one of the following fixes.
Option A — Remove code fences and escape the
${}in the example:-Here is an example of a valid email draft: -```tsx +Here is an example of a valid email draft: import { Container } from "@react-email/components"; import { Subject, NotificationCategory, Props } from "@stackframe/emails"; export function EmailTemplate({ user, project }: Props) { return ( <Container> - <Subject value={`Hello ${user.displayName}!`} /> + <Subject value={\`Hello \\\${user.displayName}!\`} /> <NotificationCategory value="Transactional" /> <div className="font-bold">Hi {user.displayName}!</div> <br /> </Container> ); } -```Option B — Keep fences by escaping backticks and
${}:-```tsx +\`\`\`tsx @@ - <Subject value={`Hello ${user.displayName}!`} /> + <Subject value={\`Hello \\\${user.displayName}!\`} /> @@ -``` +\`\`\`apps/backend/prisma/migrations/20250815012830_email_drafts/migration.sql (1)
5-17: Add missing foreign key constraint to Tenancy (and enforce themeMode/themeId consistency).There’s no FK from EmailDraft.tenancyId to Tenancy(id). This risks orphaned rows and inconsistent cleanup on tenancy deletion. Prior feedback already flagged this.
Prefer adding explicit constraints after the CREATE TABLE to keep the migration minimally invasive:
CREATE TABLE "EmailDraft" ( "tenancyId" UUID NOT NULL, "id" UUID NOT NULL, "displayName" TEXT NOT NULL, "themeMode" "DraftThemeMode" NOT NULL DEFAULT 'PROJECT_DEFAULT', "themeId" TEXT, "tsxSource" TEXT NOT NULL, "sentAt" TIMESTAMP(3), "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "EmailDraft_pkey" PRIMARY KEY ("tenancyId","id") ); + +-- Enforce tenancy referential integrity +ALTER TABLE "EmailDraft" + ADD CONSTRAINT "EmailDraft_tenancyId_fkey" + FOREIGN KEY ("tenancyId") REFERENCES "Tenancy"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +-- Enforce that themeId presence matches themeMode semantics +ALTER TABLE "EmailDraft" + ADD CONSTRAINT "EmailDraft_theme_consistency_ck" CHECK ( + ("themeMode" = 'CUSTOM' AND "themeId" IS NOT NULL) + OR ("themeMode" IN ('PROJECT_DEFAULT', 'NONE') AND "themeId" IS NULL) + );Optional follow-up (if your schema supports it): add an FK to EmailTheme with the appropriate composite key, e.g., (
tenancyId,themeId) →EmailTheme(tenancyId,id). If themes are cross-tenant or denormalized, skip this.apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx (1)
35-35: Avoidanyfor draft items; add a minimal typed shape.This has been flagged before. Replace the
anyand type the drafts list.- {drafts.map((draft: any) => ( + {drafts.map((draft) => (Add near the drafts declaration to preserve type safety:
type EmailDraftListItem = { id: string; displayName: string }; const drafts = stackAdminApp.useEmailDrafts() as EmailDraftListItem[];apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (1)
24-27: Initialize local state when draft finishes loading to prevent accidental overwriteIf draft loads after mount, currentCode stays "", selectedThemeId stays undefined. Pressing “Next” would overwrite the server draft with empty content. Hydrate state once when the draft becomes available.
Apply this diff to hydrate safely:
const [currentCode, setCurrentCode] = useState<string>(draft?.tsxSource ?? ""); const [stage, setStage] = useState<"edit" | "send">("edit"); const [selectedThemeId, setSelectedThemeId] = useState<string | undefined | false>(draft?.themeId); + // Hydrate state when draft loads (avoid clobbering user edits) + useEffect(() => { + if (!draft || stage !== "edit") return; + // Only hydrate when the editor is blank and theme unchosen, + // which indicates initial load rather than user edits. + if (currentCode === "" && selectedThemeId === undefined) { + setCurrentCode(draft.tsxSource); + setSelectedThemeId(draft.themeId); + } + }, [draft, stage]); // intentionally exclude currentCode/selectedThemeIdapps/backend/src/app/api/latest/emails/send-email/route.tsx (1)
96-106: Fix: all_users=true breaks Prisma query (in: undefined). Add conditional filter.When all_users is true, body.user_ids is undefined and Prisma will choke on { in: undefined }. Guard the filter.
Apply this diff:
- const users = await prisma.projectUser.findMany({ - where: { - tenancyId: auth.tenancy.id, - projectUserId: { - in: body.user_ids - }, - }, - include: { - contactChannels: true, - }, - }); + const users = await prisma.projectUser.findMany({ + where: { + tenancyId: auth.tenancy.id, + ...(body.user_ids && { + projectUserId: { + in: body.user_ids, + }, + }), + }, + include: { + contactChannels: true, + }, + });
🧹 Nitpick comments (42)
apps/dashboard/src/components/assistant-ui/thread.tsx (1)
81-81: Avoid commented-out JSX; conditionally render to keep symbol referenced and satisfy lintersCommenting out the JSX leaves
ThreadWelcomeSuggestionsunused and likely triggers no-unused-vars. Prefer a conditional render so the symbol remains referenced and can be toggled easily.Apply this diff:
- {/* <ThreadWelcomeSuggestions /> */} + {false && <ThreadWelcomeSuggestions />}apps/dashboard/src/components/email-preview.tsx (3)
47-51: Simplify optional prop type: remove redundantundefinedfrom unionSince
themeIdis already optional, includingundefinedin the union is redundant. This simplifies the type without changing runtime behavior.- themeId?: string | undefined | false, + themeId?: string | false,
70-79: Align prop variant with optional semantics to avoid “present but undefined” ambiguityIn the “themeId” variant, you’ve made
themeIdrequired but allowedundefinedin the union. That allows cases where the key is present with valueundefined, which can accidentally exclude the alternative variant that expectsthemeIdto be absent. If that distinction isn’t intentional, prefer making it optional here too.- | ({ - themeId: string | undefined | false, - themeTsxSource?: undefined, - } | { + | ({ + themeId?: string | false, + themeTsxSource?: undefined, + } | { themeId?: undefined, themeTsxSource: string, })If you do need to distinguish presence vs. absence, consider documenting that in a comment to avoid confusion for future contributors.
70-89: Consider XOR to enforce exactly-one-of for theme/template sourcesYou already added a generic XOR utility in shared types. Using it here would clearly enforce the “exactly one” constraint for theme and template sources, reducing edge cases around presence vs. undefined.
For example:
import type { XOR } from "@stackframe/stack-shared/dist/utils/types"; type ThemeSource = XOR<[ { themeId: string | false }, { themeTsxSource: string } ]>; type TemplateSource = XOR<[ { templateId: string }, { templateTsxSource: string } ]>; type EmailPreviewProps = ThemeSource & TemplateSource & { disableResizing?: boolean; };apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx (2)
19-21: Add accessible label/tooltip to icon-only buttonIcon-only buttons should have an accessible name. Add
aria-label(and optionallytitle) to improve a11y.- <Button variant="ghost" size="icon" onClick={() => setCurrentCode(args.content)}> + <Button + variant="ghost" + size="icon" + aria-label="Insert generated draft into editor" + title="Insert generated draft into editor" + onClick={() => setCurrentCode(args.content)} + > <Undo2 className="size-4" /> </Button>
17-22: Icon choice may be confusing for “apply/insert” action“Undo” glyph suggests reverting a prior change; here it applies the generated draft. Consider a more semantically aligned icon (e.g., Check, Replace, ClipboardCheck) to reduce ambiguity. Not blocking.
packages/stack-shared/src/utils/types.tsx (1)
69-74: XOR utility looks good; consider documenting constraints and adding compile-time examplesThe recursive XOR is solid. Two small improvements:
- Document that it’s intended for object-like members (key-based exclusion) to avoid surprising results with primitives.
- Add type-level assertions (like others in this file) to lock behavior.
-export type XOR<T extends readonly any[]> = T extends readonly [infer A, infer B, ...infer Rest] +/** + * Exclusive-or across a tuple of object-like types. + * For two elements A and B, yields: + * (A with keys of B forbidden) | (B with keys of A forbidden) + * Recursively folds for >2 elements. + * Note: Intended for object-like members; behavior with primitives may be unintuitive. + */ +export type XOR<T extends readonly any[]> = T extends readonly [infer A, infer B, ...infer Rest] ? Rest extends [] ? (A & { [K in keyof B]?: never }) | (B & { [K in keyof A]?: never }) : XOR<[(A & { [K in keyof B]?: never }) | (B & { [K in keyof A]?: never }), ...Rest]> : T[0];Additionally (outside this hunk), consider adding examples alongside existing assertions:
// Place near other typeAssert* calls: typeAssertIs<XOR<[ { a: number }, { b: string } ]>, ({ a: number; b?: never } | { b: string; a?: never })>()(); typeAssertIs<XOR<[ { a: number }, { b: string }, { c: boolean } ]>, ( | { a: number; b?: never; c?: never } | { b: string; a?: never; c?: never } | { c: boolean; a?: never; b?: never } )>()();apps/backend/prisma/schema.prisma (2)
678-681: Enforce invariants between themeMode and themeIdTo prevent invalid states:
- When themeMode = CUSTOM, themeId should be non-null.
- When themeMode ∈ {PROJECT_DEFAULT, NONE}, themeId should be null.
Prisma can’t express conditional constraints directly; consider:
- Adding an app-level validation when creating/updating drafts, and/or
- A DB CHECK constraint in the SQL migration to enforce this invariant.
683-687: Add indexes to support common query patterns (list/sort/filter)Drafts are often listed by recency or filtered by send status. Add tenancy-scoped indexes to keep these queries fast as data grows.
Proposed Prisma additions:
model EmailDraft { tenancyId String @db.Uuid id String @default(uuid()) @db.Uuid displayName String themeMode DraftThemeMode @default(PROJECT_DEFAULT) themeId String? tsxSource String sentAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@id([tenancyId, id]) + @@index([tenancyId, createdAt(sort: Desc)]) + @@index([tenancyId, updatedAt(sort: Desc)]) + @@index([tenancyId, sentAt(sort: Desc)]) }apps/backend/src/lib/ai-chat/email-draft-adapter.ts (2)
13-19: Tool naming: consider createEmailDraft (alias createEmailTemplate for BC)For clarity and discoverability in the “email-draft” context, prefer a tool named createEmailDraft (keep createEmailTemplate as an alias to avoid breaking clients).
Proposed change:
tools: { - createEmailTemplate: tool({ + createEmailDraft: tool({ description: CREATE_EMAIL_DRAFT_TOOL_DESCRIPTION(), parameters: z.object({ content: z.string().describe("A react component that renders the email template"), }), }), + // Optional alias for backwards-compatibility: + createEmailTemplate: tool({ + description: CREATE_EMAIL_DRAFT_TOOL_DESCRIPTION(), + parameters: z.object({ + content: z.string().describe("A react component that renders the email template"), + }), + }), },
16-17: Nit: wording “template” → “draft” in parameter descriptionThis tool is for drafts; align wording to reduce confusion.
- content: z.string().describe("A react component that renders the email template"), + content: z.string().describe("A React component that renders the email draft"),apps/backend/src/lib/email-rendering.tsx (1)
123-128: Preserve underlying error details from Freestyle for easier debuggingCatching and mapping to a generic message hides useful context. Return the original message when available; also align the cast with optional subject/notificationCategory.
- try { - const output = await freestyle.executeScript(result.data, { nodeModules }); - return Result.ok(output.result as { html: string, text: string, subject: string, notificationCategory: string }); - } catch (error) { - return Result.error("Unable to render email"); - } + try { + const output = await freestyle.executeScript(result.data, { nodeModules }); + return Result.ok(output.result as { html: string; text: string; subject?: string; notificationCategory?: string }); + } catch (error) { + const message = (error && (error as any).message) || (typeof error === "string" ? error : null); + return Result.error(message ?? "Unable to render email"); + }apps/e2e/tests/backend/endpoints/api/v1/render-email.test.ts (1)
69-105: Schema-error expectation looks correct; consider asserting minimally to reduce snapshot brittlenessThe new SCHEMA_ERROR shape and x-stack-known-error header are good and align with the union validation. Inline snapshots of long, deindented messages tend to be brittle across minor wording/ordering changes. Prefer asserting the status, header, and code, plus a couple of stable substrings.
Proposed change:
- expect(response).toMatchInlineSnapshot(` - NiceResponse { - "status": 400, - "body": { - "code": "SCHEMA_ERROR", - "details": { - "message": deindent\` - Request validation failed on POST /api/v1/emails/render-email: - - body is not matched by any of the provided schemas: - Schema 0: - body.template_id must be defined - body contains unknown properties: theme_tsx_source, template_tsx_source - Schema 1: - body.template_id must be defined - body contains unknown properties: theme_id, template_tsx_source - Schema 2: - body contains unknown properties: theme_tsx_source - Schema 3: - body contains unknown properties: theme_id - \`, - }, - "error": deindent\` - Request validation failed on POST /api/v1/emails/render-email: - - body is not matched by any of the provided schemas: - Schema 0: - body.template_id must be defined - body contains unknown properties: theme_tsx_source, template_tsx_source - Schema 1: - body.template_id must be defined - body contains unknown properties: theme_id, template_tsx_source - Schema 2: - body contains unknown properties: theme_tsx_source - Schema 3: - body contains unknown properties: theme_id - \`, - }, - "headers": Headers { - "x-stack-known-error": "SCHEMA_ERROR", - <some fields may have been hidden>, - }, - } - `); + expect(response.status).toBe(400); + expect(response.headers.get("x-stack-known-error")).toBe("SCHEMA_ERROR"); + expect(response.body).toMatchObject({ code: "SCHEMA_ERROR" }); + // Keep just a stable substring to avoid brittleness: + expect( + typeof response.body?.details?.message === "string" ? response.body.details.message : "" + ).toContain("body is not matched by any of the provided schemas");apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx (2)
32-36: Make tenancy required for POST to match PATCH/GET and avoid accidental undefined tenancyPATCH and GET use
adaptSchema.defined()forauth.tenancy, while POST currently allows it to be undefined. For consistency and to prevent accidental missing tenancy in the OpenAI flow, mark it as defined here too.- auth: yupObject({ - type: yupString().oneOf(["admin"]).defined(), - tenancy: adaptSchema, - }), + auth: yupObject({ + type: yupString().oneOf(["admin"]).defined(), + tenancy: adaptSchema.defined(), + }),
63-81: Optionally include actual tool call results or omit the field to avoid misleading “success”You currently set
result: "success"on every returned tool-call block. If consumers rely on this field, a literal "success" can be misleading. Consider either:
- Propagating the actual tool result (if available in
result.steps), or- Omitting
resultif there is no useful payload to return.If tool results are available in your
generateTextstep data, map them through; otherwise, consider:- contentBlocks.push({ - type: "tool-call", - toolName: toolCall.toolName, - toolCallId: toolCall.toolCallId, - args: toolCall.args, - argsText: JSON.stringify(toolCall.args), - result: "success", - }); + contentBlocks.push({ + type: "tool-call", + toolName: toolCall.toolName, + toolCallId: toolCall.toolCallId, + args: toolCall.args, + argsText: JSON.stringify(toolCall.args), + // Optional: only include result if meaningful + // result: toolCall.result ?? "success", + });apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page.tsx (1)
1-5: Type metadata for editor support and consistency with Next.js conventionsAnnotating
metadatawithMetadataimproves type safety and IDE hints.+import type { Metadata } from "next"; -export const metadata = { +export const metadata: Metadata = { title: "Email Drafts", };apps/e2e/tests/js/email.test.ts (1)
180-191: Assertion is correct; prefer resilient check over full inline snapshotThe unionized validation text can change ordering/wording. A substring check keeps intent while reducing churn.
- expect(result.error.message).toMatchInlineSnapshot(` - deindent\` - Request validation failed on POST /api/v1/emails/send-email: - - body is not matched by any of the provided schemas: - Schema 0: - body.html must be defined - Schema 1: - body.template_id must be defined - Schema 2: - body.draft_id must be defined - \` - `); + expect(result.error.message).toContain("body is not matched by any of the provided schemas");apps/dashboard/src/components/vibe-coding/chat-adapters.ts (1)
43-49: Trigger all tool-call callbacks, not just the firstIf the model/tooling returns multiple tool calls in one response, only the first currently triggers
onToolCall. Iterate and notify for all.- if (response.content.some(isToolCall)) { - const toolCall = response.content.find(isToolCall); - if (toolCall) { - onToolCall(toolCall); - } - } + for (const toolCall of response.content.filter(isToolCall)) { + onToolCall(toolCall); + }apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page.tsx (2)
3-5: Optional: Strongly typemetadatato catch future shape regressions.This helps TS flag invalid metadata fields early.
Apply this diff:
+import type { Metadata } from "next"; export const metadata = { title: "Email Draft", -}; +} satisfies Metadata;
7-10: DropPromisetyping andasync/awaitfor route params in email-drafts pageThe App Router always passes
paramssynchronously. You can simplify the signature and remove the unnecessaryPromiseandawait:apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page.tsx -export default async function Page(props: { params: Promise<{ draftId: string }> }) { - const params = await props.params; - return <PageClient draftId={params.draftId} />; -} +export default function Page({ params }: { params: { draftId: string } }) { + return <PageClient draftId={params.draftId} />; +}To identify and clean up other occurrences across the repo, you can run:
rg -nP -g '*.ts' -g '*.tsx' '\bparams\s*:\s*Promise<\s*{' -C2And apply similar diffs to pages, layouts, and route handlers under
apps/…andpackages/…that typeparams(orsearchParams) as aPromise.apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (1)
352-361: Provide a non-null fallback for missing draft names.Returning null can render a blank crumb. A neutral fallback prevents odd UI in edge cases (e.g., drafts not yet loaded).
Apply this diff:
function DraftBreadcrumbItem(props: { draftId: string }) { const stackAdminApp = useAdminApp(); const drafts = stackAdminApp.useEmailDrafts(); const draft = drafts.find((d) => d.id === props.draftId); - if (!draft) { - return null; - } - return draft.displayName; + if (!draft) { + return "Draft"; + } + return draft.displayName; }apps/dashboard/src/components/email-theme-selector.tsx (1)
10-15: Clarify the null/undefined/false mapping with a brief comment.The JSON encode/decode roundtrip is deliberate to normalize:
- undefined → project default (stored as "null" in the Select value)
- false → no theme
- string → custom theme id
A short comment will prevent regressions and unnecessary future refactors.
Apply this diff:
-function themeIdToSelectString(themeId: string | undefined | false): string { - return JSON.stringify(themeId ?? null); -} -function selectStringToThemeId(value: string): string | undefined | false { - return JSON.parse(value) ?? undefined; -} +// Mapping conventions: +// - undefined: "Project theme" (default) → encoded as "null" for the Select value +// - false: "No theme" → encoded as "false" +// - string: Custom theme id → encoded as the JSON string of the id +function themeIdToSelectString(themeId: string | undefined | false): string { + return JSON.stringify(themeId ?? null); +} +function selectStringToThemeId(value: string): string | undefined | false { + // Decode from Select value; convert null back to undefined to match UI state + return JSON.parse(value) ?? undefined; +}packages/stack-shared/src/interface/server-interface.ts (1)
799-809: Consider strengthening the sendEmail options type with XOR to prevent invalid combinations at compile time.Right now both recipients and content fields are optional. Using the shared XOR utility would align the client interface with the template package type and reduce drift.
For example:
import { XOR } from "../utils/types"; type SendEmailRecipient = XOR<[ { userIds: string[] }, { allUsers: true } ]>; type SendEmailContent = XOR<[ { html: string }, { templateId: string, variables?: Record<string, any> }, { draftId: string } ]>; type SendEmailOptions = SendEmailRecipient & SendEmailContent & { themeId?: string | null | false, subject?: string, notificationCategoryName?: string, }; async sendEmail(options: SendEmailOptions) { /* ... */ }apps/backend/src/lib/email-drafts.tsx (2)
25-33: Return-to-template mapping handles null safety; minor edge-case clarification.For CUSTOM with a null themeId, returning undefined gracefully avoids leaking inconsistent DB state. Optional: assert consistency upstream to prevent CUSTOM + null pairs entering storage.
3-13: Nit: add an explicit return type for getEmailDraft for clarity.Helps with call-site expectations (nullable result) and tooling.
-export async function getEmailDraft(prisma: PrismaClient, tenancyId: string, draftId: string) { +export async function getEmailDraft( + prisma: PrismaClient, + tenancyId: string, + draftId: string +): Promise<Awaited<ReturnType<PrismaClient['emailDraft']['findUnique']>>> {apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx (1)
21-79: Optional: defensive close before navigation in dialog.Not required, but closing the dialog before push avoids stale state if navigation is intercepted.
apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx (1)
28-28: Minor: prefer findUniqueOrThrow with the composite key for precision.Current findFirstOrThrow works but is semantically broader than necessary.
- const d = await prisma.emailDraft.findFirstOrThrow({ where: { tenancyId: tenancy.id, id: params.id } }); + const d = await prisma.emailDraft.findUniqueOrThrow({ + where: { tenancyId_id: { tenancyId: tenancy.id, id: params.id } }, + });apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts (2)
586-631: Reduce duplication in schema error messaging (cosmetic)The validation details list “body contains unknown properties: html” twice for Schema 1 and Schema 2. It’s harmless but noisy. Consider deduplicating per-schema unknown properties to keep error output concise. No change required in tests; this is a route-side improvement.
52-87: Rename test to reflect behavior (misleading title)The test name suggests a “user not found” error, but the scenario creates a valid user and expects a 200 with results. Please rename to avoid confusion (e.g., “should return 200 and include result for valid user”).
apps/backend/src/app/api/latest/internal/email-drafts/route.tsx (1)
31-35: Result capping and sort look goodLimiting to 50 most recently updated drafts per tenancy is reasonable for admin UI listing. Consider making 50 a shared constant if used elsewhere, but not required.
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (1)
39-50: Reset navigation guard after successful saveAfter updating the draft, clear the confirm flag so moving to the Send stage doesn’t keep prompting.
Apply this diff:
const handleNext = async () => { try { await stackAdminApp.updateEmailDraft(draftId, { tsxSource: currentCode, themeId: selectedThemeId }); + setNeedConfirm(false); setStage("send"); } catch (error) {packages/stack-shared/src/interface/admin-interface.ts (2)
134-139: Remove redundant| undefinedin optional fieldsIn TypeScript,
?already impliesundefined. Simplify the types and keep API shape consistent.Apply this diff:
- async listInternalEmailDrafts(): Promise<{ id: string, display_name: string, theme_id?: string | undefined | false, tsx_source: string, sent_at_millis?: number | null }[]> { + async listInternalEmailDrafts(): Promise<{ id: string, display_name: string, theme_id?: string | false, tsx_source: string, sent_at_millis?: number | null }[]> { const response = await this.sendAdminRequest(`/internal/email-drafts`, {}, null); - const result = await response.json() as { drafts: { id: string, display_name: string, theme_id?: string | undefined | false, tsx_source: string, sent_at_millis?: number | null }[] }; + const result = await response.json() as { drafts: { id: string, display_name: string, theme_id?: string | false, tsx_source: string, sent_at_millis?: number | null }[] }; return result.drafts; }
155-167: Unify theme_id nullability across create/updateUpdate allows
null(often meaning “project default”), while create did not. With the change above, create staysstring | false, which is fine; for update, keepnullto support resetting to project default. Ensure backend normalizesnullcorrectly (see route suggestion).If you decide to avoid
nullentirely, change this signature tostring | falseand have clients omit the field for project-default. Otherwise, keep as-is and ensure the route coercesnulltoundefinedbefore conversion (suggested in backend comment).packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts (2)
37-38: Usenullinstead ofundefinedfor themeId across app types (consistency)Other parts of the codebase model themeId as
string | null | false. Aligning here avoids casting and mismatches in consumers.Apply this diff:
- & AsyncStoreProperty<"emailDrafts", [], { id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null }[], true> + & AsyncStoreProperty<"emailDrafts", [], { id: string, displayName: string, themeId: string | null | false, tsxSource: string, sentAt: Date | null }[], true>
78-80: Align draft APIs’ themeId type with store (string | null | false)This keeps API, store, and backend expectations consistent and avoids downstream casting.
Apply this diff:
- createEmailDraft(options: { displayName?: string, themeId?: string | undefined | false, tsxSource?: string }): Promise<{ id: string }>, - updateEmailDraft(id: string, data: { displayName?: string, themeId?: string | undefined | false, tsxSource?: string }): Promise<void>, + createEmailDraft(options: { displayName?: string, themeId?: string | null | false, tsxSource?: string }): Promise<{ id: string }>, + updateEmailDraft(id: string, data: { displayName?: string, themeId?: string | null | false, tsxSource?: string }): Promise<void>,packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (4)
304-315: useEmailDrafts: solid mapping; consider extracting a mapper to avoid duplicationThe field transforms mirror templates and are consistent with interfaces. To reduce duplication with listEmailDrafts, consider extracting a private mapper method.
Apply this diff to use a mapper, and add the mapper near the other “fromCrud” helpers:
- useEmailDrafts(): { id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null }[] { + useEmailDrafts(): { id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null }[] { const crud = useAsyncCache(this._adminEmailDraftsCache, [], "useEmailDrafts()"); return useMemo(() => { - return crud.map((draft) => ({ - id: draft.id, - displayName: draft.display_name, - themeId: draft.theme_id, - tsxSource: draft.tsx_source, - sentAt: draft.sent_at_millis ? new Date(draft.sent_at_millis) : null, - })); + return crud.map((draft) => this._adminEmailDraftFromCrud(draft)); }, [crud]); }Additional code (outside the selected lines) to add once in this class:
private _adminEmailDraftFromCrud(draft: { id: string, display_name: string, theme_id: string | undefined | false, tsx_source: string, sent_at_millis: number | null }) { return { id: draft.id, displayName: draft.display_name, themeId: draft.theme_id, tsxSource: draft.tsx_source, sentAt: draft.sent_at_millis ? new Date(draft.sent_at_millis) : null, }; }
335-344: listEmailDrafts: mirrors hook mapping; consider reusing the same mapperSame suggestion as the hook: reuse a single mapper to keep mappings consistent and reduce maintenance overhead.
Apply this diff:
- async listEmailDrafts(): Promise<{ id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null }[]> { + async listEmailDrafts(): Promise<{ id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null }[]> { const crud = Result.orThrow(await this._adminEmailDraftsCache.getOrWait([], "write-only")); - return crud.map((draft) => ({ - id: draft.id, - displayName: draft.display_name, - themeId: draft.theme_id, - tsxSource: draft.tsx_source, - sentAt: draft.sent_at_millis ? new Date(draft.sent_at_millis) : null, - })); + return crud.map((draft) => this._adminEmailDraftFromCrud(draft)); }
477-485: Create draft: align themeId type with backend (allow null)Server-side templateThemeIdSchema allows null in addition to undefined/false/uuid. Consider permitting null here to avoid unnecessary client-side coercion.
Apply this diff:
- async createEmailDraft(options: { displayName?: string, themeId?: string | false, tsxSource?: string }): Promise<{ id: string }> { + async createEmailDraft(options: { displayName?: string, themeId?: string | false | null, tsxSource?: string }): Promise<{ id: string }> { const result = await this._interface.createEmailDraft({ display_name: options.displayName, theme_id: options.themeId, tsx_source: options.tsxSource, });
487-495: Update draft: mirror themeId union and support nullSame rationale as create. Keeps client/server types in sync.
Apply this diff:
- async updateEmailDraft(id: string, data: { displayName?: string, themeId?: string | undefined | false, tsxSource?: string }): Promise<void> { + async updateEmailDraft(id: string, data: { displayName?: string, themeId?: string | false | null | undefined, tsxSource?: string }): Promise<void> { await this._interface.updateEmailDraft(id, { display_name: data.displayName, theme_id: data.themeId, tsx_source: data.tsxSource, });apps/backend/src/app/api/latest/emails/send-email/route.tsx (3)
39-50: Validate draft_id as UUID for parity with template_idIf draft IDs are UUIDs, tighten validation for early, consistent failures.
Apply this diff:
- bodyBase.concat(yupObject({ - draft_id: yupString().defined(), - })), + bodyBase.concat(yupObject({ + draft_id: yupString().uuid().defined(), + })),
124-145: Avoid double-render when notification_category_name is explicitly providedYou only need the first pass to derive the category from the template if the caller didn’t specify one.
Apply this diff:
- if (!("html" in body)) { + if (!("html" in body) && !body.notification_category_name) { // We have to render email twice in this case, first pass is to get the notification category
198-201: Consider returning only successful recipients or surfacing per-user errorsCurrently, results includes all targeted users regardless of render/send failures; userSendErrors is unused. If callers rely on results to reflect success, consider filtering or adding an errors array. This changes API semantics, so confirm expectation first.
If filtering successes is desired without changing the schema, apply:
- const results: UserResult[] = Array.from(userMap.values()).map((user) => ({ - user_id: user.projectUserId, - user_email: userPrimaryEmails.get(user.projectUserId) ?? user.contactChannels.find((c) => c.isPrimary === "TRUE")?.value, - })); + const results: UserResult[] = Array.from(userMap.values()) + .filter((user) => !userSendErrors.has(user.projectUserId)) + .map((user) => ({ + user_id: user.projectUserId, + user_email: userPrimaryEmails.get(user.projectUserId) ?? user.contactChannels.find((c) => c.isPrimary === "TRUE")?.value, + }));Alternatively, if you want to keep all recipients and include failure info, we can extend the response type and update clients—say the word and I’ll draft it.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (32)
-
apps/backend/prisma/migrations/20250815012830_email_drafts/migration.sql(1 hunks) -
apps/backend/prisma/schema.prisma(2 hunks) -
apps/backend/src/app/api/latest/emails/render-email/route.tsx(3 hunks) -
apps/backend/src/app/api/latest/emails/send-email/route.tsx(4 hunks) -
apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx(1 hunks) -
apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx(1 hunks) -
apps/backend/src/app/api/latest/internal/email-drafts/route.tsx(1 hunks) -
apps/backend/src/lib/ai-chat/adapter-registry.ts(2 hunks) -
apps/backend/src/lib/ai-chat/email-draft-adapter.ts(1 hunks) -
apps/backend/src/lib/email-drafts.tsx(1 hunks) -
apps/backend/src/lib/email-rendering.tsx(5 hunks) -
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx(1 hunks) -
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page.tsx(1 hunks) -
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx(1 hunks) -
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page.tsx(1 hunks) -
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx(3 hunks) -
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx(4 hunks) -
apps/dashboard/src/components/assistant-ui/thread.tsx(1 hunks) -
apps/dashboard/src/components/email-preview.tsx(2 hunks) -
apps/dashboard/src/components/email-theme-selector.tsx(1 hunks) -
apps/dashboard/src/components/vibe-coding/chat-adapters.ts(1 hunks) -
apps/dashboard/src/components/vibe-coding/code-editor.tsx(1 hunks) -
apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx(1 hunks) -
apps/e2e/tests/backend/endpoints/api/v1/render-email.test.ts(1 hunks) -
apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts(6 hunks) -
apps/e2e/tests/js/email.test.ts(1 hunks) -
packages/stack-shared/src/interface/admin-interface.ts(2 hunks) -
packages/stack-shared/src/interface/server-interface.ts(2 hunks) -
packages/stack-shared/src/utils/types.tsx(1 hunks) -
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts(4 hunks) -
packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts(3 hunks) -
packages/template/src/lib/stack-app/email/index.ts(2 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
apps/backend/src/app/api/latest/**/*
📄 CodeRabbit Inference Engine (CLAUDE.md)
apps/backend/src/app/api/latest/**/*: Main API routes are located in /apps/backend/src/app/api/latest
The project uses a custom route handler system in the backend for consistent API responses
Files:
apps/backend/src/app/api/latest/internal/email-drafts/route.tsxapps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsxapps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsxapps/backend/src/app/api/latest/emails/render-email/route.tsxapps/backend/src/app/api/latest/emails/send-email/route.tsx
apps/backend/prisma/schema.prisma
📄 CodeRabbit Inference Engine (CLAUDE.md)
Database models use Prisma
Files:
apps/backend/prisma/schema.prisma
🧠 Learnings (1)
📚 Learning: 2025-08-04T22:25:51.260Z
Learnt from: CR
PR: stack-auth/stack-auth#0
File: CLAUDE.md:0-0
Timestamp: 2025-08-04T22:25:51.260Z
Learning: Applies to apps/backend/prisma/schema.prisma : Database models use Prisma
Applied to files:
apps/backend/prisma/schema.prismaapps/backend/src/lib/email-rendering.tsx
🧬 Code Graph Analysis (18)
apps/backend/src/app/api/latest/internal/email-drafts/route.tsx (5)
apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx (1)
GET(6-41)packages/stack-shared/src/schema-fields.ts (4)
yupObject(245-249)yupString(185-188)yupNumber(189-192)templateThemeIdSchema(477-477)apps/backend/src/prisma-client.tsx (1)
getPrismaClientForTenancy(51-53)apps/backend/src/lib/email-drafts.tsx (2)
themeModeToTemplateThemeId(25-33)templateThemeIdToThemeMode(15-23)apps/backend/src/app/api/latest/emails/send-email/route.tsx (1)
POST(29-209)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page.tsx (3)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page.tsx (2)
metadata(3-5)Page(7-11)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx (1)
PageClient(21-98)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (1)
PageClient(15-88)
apps/backend/src/lib/ai-chat/email-draft-adapter.ts (1)
apps/backend/src/lib/ai-chat/adapter-registry.ts (1)
ChatAdapterContext(7-10)
apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx (1)
packages/stack-shared/src/schema-fields.ts (1)
yupString(185-188)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx (4)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx (1)
useAdminApp(27-34)apps/dashboard/src/components/router.tsx (1)
useRouter(15-33)apps/dashboard/src/components/form-fields.tsx (1)
InputField(59-97)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx (1)
PageClient(21-77)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (1)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx (1)
useAdminApp(27-34)
apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx (4)
apps/backend/src/route-handlers/smart-route-handler.tsx (1)
createSmartRouteHandler(209-294)packages/stack-shared/src/schema-fields.ts (4)
yupObject(245-249)yupString(185-188)yupNumber(189-192)templateThemeIdSchema(477-477)apps/backend/src/prisma-client.tsx (1)
getPrismaClientForTenancy(51-53)apps/backend/src/lib/email-drafts.tsx (2)
themeModeToTemplateThemeId(25-33)templateThemeIdToThemeMode(15-23)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page.tsx (2)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page.tsx (2)
metadata(3-5)Page(7-10)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx (1)
PageClient(13-80)
apps/dashboard/src/components/email-theme-selector.tsx (2)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx (1)
useAdminApp(27-34)packages/stack-ui/src/components/ui/select.tsx (5)
Select(160-160)SelectTrigger(160-160)SelectValue(160-160)SelectContent(160-160)SelectItem(160-160)
apps/backend/src/lib/ai-chat/adapter-registry.ts (3)
apps/backend/src/lib/ai-chat/email-theme-adapter.ts (1)
emailThemeAdapter(7-18)apps/backend/src/lib/ai-chat/email-template-adapter.ts (1)
emailTemplateAdapter(10-20)apps/backend/src/lib/ai-chat/email-draft-adapter.ts (1)
emailDraftAdapter(10-20)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx (2)
apps/dashboard/src/components/email-preview.tsx (1)
EmailPreview(91-122)apps/dashboard/src/components/email-theme-selector.tsx (1)
EmailThemeSelector(17-39)
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (1)
packages/template/src/lib/stack-app/apps/implementations/common.ts (2)
createCache(22-27)useAsyncCache(145-190)
apps/backend/src/lib/email-rendering.tsx (2)
docs/scripts/generate-docs.js (1)
result(100-100)packages/stack-shared/src/utils/results.tsx (1)
error(36-41)
apps/backend/src/app/api/latest/emails/render-email/route.tsx (2)
packages/stack-shared/src/schema-fields.ts (3)
yupObject(245-249)yupString(185-188)templateThemeIdSchema(477-477)packages/stack-shared/src/known-errors.tsx (2)
KnownErrors(1490-1492)KnownErrors(1494-1609)
packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts (1)
packages/template/src/lib/stack-app/common.ts (1)
AsyncStoreProperty(8-10)
packages/template/src/lib/stack-app/email/index.ts (1)
packages/stack-shared/src/utils/types.tsx (1)
XOR(69-73)
apps/backend/src/app/api/latest/emails/send-email/route.tsx (3)
packages/stack-shared/src/schema-fields.ts (1)
templateThemeIdSchema(477-477)apps/backend/src/prisma-client.tsx (1)
getPrismaClientForTenancy(51-53)apps/backend/src/lib/email-drafts.tsx (2)
getEmailDraft(3-13)themeModeToTemplateThemeId(25-33)
apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts (1)
apps/e2e/tests/backend/backend-helpers.ts (1)
niceBackendFetch(107-165)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (9)
- GitHub Check: all-good
- GitHub Check: docker
- GitHub Check: lint_and_build (latest)
- GitHub Check: docker
- GitHub Check: build (22.x)
- GitHub Check: build (22.x)
- GitHub Check: restart-dev-and-test
- GitHub Check: setup-tests
- GitHub Check: Security Check
🔇 Additional comments (31)
apps/dashboard/src/components/vibe-coding/code-editor.tsx (1)
84-85: LGTM: defaulting Props generic to never is a sensible DX improvementThis allows omitting the generic when no variables are expected while keeping
variablesunusable by default, which avoids accidental usage. Nice touch.apps/backend/prisma/schema.prisma (2)
672-687: Model EmailDraft looks correct and aligns with the migrationComposite PK, timestamps, and fields look good. The enum-based theme selection is clear and matches the rest of the PR.
689-694: Enum DraftThemeMode is well-scopedValues cover the expected theme resolution modes and match the helpers used elsewhere.
apps/backend/src/lib/email-rendering.tsx (1)
83-87: Good guard for optional variablesSchemaThe conditional call avoids runtime errors when templates omit variablesSchema and aligns with the new draft-or-template input.
apps/backend/src/app/api/latest/emails/render-email/route.tsx (3)
18-35: Schema union for the four valid combinations is a solid simplificationThe yupUnion approach reduces branching and aligns with the new template/draft dual path. Good upgrade.
53-57: Theme existence check is correct and allows NONE/PROJECT_DEFAULT via templateThemeIdSchemaThe guard only validates string IDs and defers false/null cases to getEmailThemeForTemplate, which matches the schema contract.
72-79: No changes needed for project display name mappingI’ve verified that in the backend API and Prisma model, the project object exposes
display_name(snake_case), and that you’re correctly mapping it to thedisplayNameproperty when callingrenderEmailWithTemplate. There’s noproject.displayNameon the raw Tenancy payload, so usingtenancy.project.display_namehere is correct. Keep the code as-is.apps/backend/src/lib/ai-chat/adapter-registry.ts (1)
17-23: Adapter wiring for “email-draft” looks correctContextType updated, registry entry added, and getChatAdapter remains exhaustive via the ContextType keying.
apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx (1)
40-40: Allowing "email-draft" context_type is correct and consistent with the new adapterThis aligns with the registry and dashboard changes. No concerns with the new union member.
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page.tsx (1)
3-5: LGTM: Metadata title matches the route purposeThe page metadata is concise and correct.
apps/dashboard/src/components/vibe-coding/chat-adapters.ts (1)
18-19: LGTM: Added "email-draft" contextType is aligned with backend and interfacesType union correctly reflects the new chat context.
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx (2)
65-66: EmailPreview prop wiring looks correct.Passing
selectedThemeIddirectly aligns with the updatedEmailPreviewprops (string | undefined | false) and avoids awkward null conversions.
74-79: Nice refactor to EmailThemeSelector.Centralizing theme selection logic reduces duplication and keeps the template editor consistent with drafts.
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (3)
27-31: Icon updates look good.Switching Templates to
LayoutTemplateand importingFilePenaligns iconography with semantics.
181-187: Good addition: Drafts nav item.Route, icon, and regex align with the new drafts feature.
202-221: Breadcrumb wiring for drafts is consistent.Hidden item generates a two-level crumb and mirrors the patterns used for users/templates/themes.
apps/dashboard/src/components/email-theme-selector.tsx (1)
17-38: Selector implementation looks solid and consistent.
- Uses centralized themes source via
useEmailThemes.- Values are consistently JSON-encoded; decoding normalizes
null→undefined.- Options cover No theme, Project theme, and per-theme entries.
packages/template/src/lib/stack-app/email/index.ts (2)
19-31: Good use of XOR to enforce mutual exclusivity for recipients and content variants (including draftId).This shape is clear, prevents invalid combinations at compile time, and aligns with backend expectations for the new draft path.
1-1: Import path consistency verified
All existing imports from@stackframe/stack-sharedin this repo reference thedist/...subpath (e.g., inpackages/template/src/lib/stack-app/api-keys/index.tsandapps/backend/src/lib/events.tsx). Your new import aligns with the established pattern—no change needed here.packages/stack-shared/src/interface/server-interface.ts (1)
817-827: Payload mapping for all_users and draft_id looks correct and consistent with the backend route.Relying on JSON.stringify to drop undefined values is fine here, and matches how other endpoints omit absent fields.
apps/backend/src/lib/email-drafts.tsx (1)
15-23: Theme mode ←→ theme id mapping is correct and matches the public API semantics.
- undefined → PROJECT_DEFAULT
- false → NONE
- string → CUSTOM
Round-tripping behavior is as expected.
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx (1)
21-79: Confirm all “email-drafts/” routes and hrefs
I’ve located several additional occurrences—please verify each resolves to the correct/projects/[projectId]/email-drafts/[id]route (or adjust to use the proper relative/absolute path):• apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx
– line 209:href = \/email-drafts/${match[1]}``• apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx
– line 48:router.push(\email-drafts/${draft.id}`)– line 65:router.push(`email-drafts/${sharedSmtpWarningDialogOpen}`)– line 88:router.push(`email-drafts/${draft.id}`)`• packages/stack-shared/src/interface/admin-interface.ts
– line 157:`/internal/email-drafts/${id}`(backend API endpoint—ensure this is intentional)Please review and update any paths that should include the projectId segment or use leading slashes/relative segments appropriately.
apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx (1)
26-41: GET mapping and use of theme conversion helpers look good.Using themeModeToTemplateThemeId ensures response consistency with the public API and the shared schema.
apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts (2)
90-90: Correct environment setup for shared SMTP scenarioAdding Project.createAndSwitch() ensures the test runs under a tenancy using the shared email server, aligning with the expected REQUIRES_CUSTOM_EMAIL_SERVER error.
437-531: Great coverage for all_users flow and XOR constraintBoth tests look solid: you validate mutual exclusivity with user_ids and all_users, and you verify one email per user for the all_users path. This mirrors the route’s XOR semantics.
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (1)
95-110: Send-stage UX logic looks good
- Correct XOR between userIds and allUsers passed to sendEmail.
- Properly disables Send when no users selected under “users” scope.
- Clear, actionable toasts for known versus unknown errors.
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (2)
46-48: Drafts cache wiring looks correctCache is consistent with existing pattern (no deps + explicit refresh after mutations). Good addition.
498-503: Chat context extended with "email-draft": goodThe public surface matches the backend addition and maintains the same call signature. Looks good.
apps/backend/src/app/api/latest/emails/send-email/route.tsx (3)
19-27: Body base shape is sound and openapi meta on theme_id is helpfulOptional subject and notification_category_name are sensible; XOR with all_users is enforced later. Good structure.
70-72: XOR check between user_ids and all_users is correctPrevents ambiguous targeting. Keep as-is.
77-94: I’d like to confirm the full implementation ofgetEmailThemeForTemplateto verify how it handlesnullorfalse. Could you run:sed -n '1,200p' apps/backend/src/lib/email-rendering.tsxand share the output around the
getEmailThemeForTemplatedefinition? This will help ensure whether explicitly coalescingnulltoundefinedor checking fornullin thesend-emailhandler is actually necessary.
...board/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx
Show resolved
Hide resolved
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx
Show resolved
Hide resolved
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx
Show resolved
Hide resolved
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 6
🔭 Outside diff range comments (1)
apps/dashboard/src/components/email-preview.tsx (1)
72-89: RemovenullfromthemeIdunions across downstream codeThe recent change in apps/dashboard/src/components/email-preview.tsx updated
themeIdtostring | undefined | false, but there are still type definitions and call‐sites elsewhere that includestring | null | false. Please update these to useundefinedinstead ofnull(and adjust any code passingnullaccordingly):• packages/template/src/lib/stack-app/email/index.ts:13
• packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts:519, 523, 540
• packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts:35, 72
• packages/stack-shared/src/interface/server-interface.ts:802
• packages/stack-shared/src/interface/admin-interface.ts:394, 452
♻️ Duplicate comments (9)
packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts (2)
37-37: Align themeId nullability with existing conventions (duplicate of earlier feedback).Elsewhere in the codebase (e.g., templateThemeIdSchema), themeId allows string | null | false. Using undefined here is inconsistent. Recommend switching to null to match the rest.
Apply this diff:
- & AsyncStoreProperty<"emailDrafts", [], { id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null }[], true> + & AsyncStoreProperty<"emailDrafts", [], { id: string, displayName: string, themeId: string | null | false, tsxSource: string, sentAt: Date | null }[], true>
78-79: Unify themeId optional type with null instead of undefined (duplicate of earlier feedback).Maintain consistency with other APIs that use string | null | false.
Apply this diff:
- createEmailDraft(options: { displayName?: string, themeId?: string | undefined | false, tsxSource?: string }): Promise<{ id: string }>, - updateEmailDraft(id: string, data: { displayName?: string, themeId?: string | undefined | false, tsxSource?: string }): Promise<void>, + createEmailDraft(options: { displayName?: string, themeId?: string | null | false, tsxSource?: string }): Promise<{ id: string }>, + updateEmailDraft(id: string, data: { displayName?: string, themeId?: string | null | false, tsxSource?: string }): Promise<void>,packages/stack-shared/src/interface/admin-interface.ts (2)
134-138: Drop redundant “| undefined” and allow null for theme_id (duplicate of earlier feedback).
- Optional properties don’t need “| undefined”.
- Use string | null | false consistently to match templateThemeIdSchema and related handlers.
Apply this diff:
- async listInternalEmailDrafts(): Promise<{ id: string, display_name: string, theme_id?: string | undefined | false, tsx_source: string, sent_at_millis?: number | null }[]> { + async listInternalEmailDrafts(): Promise<{ id: string, display_name: string, theme_id?: string | null | false, tsx_source: string, sent_at_millis?: number | null }[]> { const response = await this.sendAdminRequest(`/internal/email-drafts`, {}, null); - const result = await response.json() as { drafts: { id: string, display_name: string, theme_id?: string | undefined | false, tsx_source: string, sent_at_millis?: number | null }[] }; + const result = await response.json() as { drafts: { id: string, display_name: string, theme_id?: string | null | false, tsx_source: string, sent_at_millis?: number | null }[] }; return result.drafts; }
140-153: Create shape should accept null for theme_id to match update/list (duplicate of earlier feedback).Currently: createEmailDraft(theme_id?: string | false) vs update/theme_id?: string | null | false.
Apply this diff:
- async createEmailDraft(options: { display_name?: string, theme_id?: string | false, tsx_source?: string }): Promise<{ id: string }> { + async createEmailDraft(options: { display_name?: string, theme_id?: string | null | false, tsx_source?: string }): Promise<{ id: string }> {apps/backend/src/app/api/latest/emails/send-email/route.tsx (1)
96-106: Bug: all_users path breaks Prisma query (filters by undefined user_ids) — handle both modes in queryWhen all_users is true, body.user_ids is undefined and Prisma will receive in: undefined, which is invalid. Omit the projectUserId filter when all_users is true to fetch all users in the tenancy.
Apply this diff to make the query robust:
- const users = await prisma.projectUser.findMany({ - where: { - tenancyId: auth.tenancy.id, - projectUserId: { - in: body.user_ids - }, - }, - include: { - contactChannels: true, - }, - }); + const users = await prisma.projectUser.findMany({ + where: { + tenancyId: auth.tenancy.id, + ...(body.user_ids ? { + projectUserId: { + in: body.user_ids, + }, + } : {}), + }, + include: { + contactChannels: true, + }, + });apps/backend/prisma/migrations/20250815012830_email_drafts/migration.sql (1)
5-17: Add missing foreign key to Tenancy (consistent with rest of the schema)EmailDraft.tenancyId should reference Tenancy(id) with ON DELETE CASCADE / ON UPDATE CASCADE. This preserves referential integrity and mirrors other multi-tenant tables.
You can add a follow-up migration:
ALTER TABLE "EmailDraft" ADD CONSTRAINT "EmailDraft_tenancyId_fkey" FOREIGN KEY ("tenancyId") REFERENCES "Tenancy"("id") ON DELETE CASCADE ON UPDATE CASCADE;apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx (1)
18-19: Replaceanywith a minimal draft type for safety and readabilityAvoid
anyhere; annotate the drafts list items with the minimal shape you use (id/displayName). This improves type safety without over-typing the hook.- const drafts = stackAdminApp.useEmailDrafts(); + type DraftListItem = { id: string; displayName: string }; + const drafts = stackAdminApp.useEmailDrafts() as DraftListItem[];- {drafts.map((draft: any) => ( + {drafts.map((draft) => (Also applies to: 35-35
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (1)
11-11: Initialize editor state after async draft load to avoid empty/stale formIf the draft arrives after mount,
currentCode/selectedThemeIdremain at their initial empty values. Initialize them once when the draft becomes available.-import { Suspense, useEffect, useMemo, useState } from "react"; +import { Suspense, useEffect, useMemo, useRef, useState } from "react";const drafts = stackAdminApp.useEmailDrafts() as EmailDraft[]; const draft = useMemo(() => drafts.find((d) => d.id === draftId), [drafts, draftId]); const [currentCode, setCurrentCode] = useState<string>(draft?.tsxSource ?? ""); const [stage, setStage] = useState<"edit" | "send">("edit"); const [selectedThemeId, setSelectedThemeId] = useState<string | undefined | false>(draft?.themeId); + const initializedFromDraft = useRef(false); + + useEffect(() => { + if (!draft || initializedFromDraft.current) return; + setCurrentCode(draft.tsxSource); + setSelectedThemeId(draft.themeId); + initializedFromDraft.current = true; + }, [draft]);Also applies to: 23-27
apps/backend/src/lib/ai-chat/email-draft-adapter.ts (1)
41-41: Escape template interpolation inside the backticked description stringThe
${user.displayName}within a backtick string will be interpolated at runtime, causing a ReferenceError. Escape it so it renders literally in the tool description.- <Subject value={`Hello ${user.displayName}!`} /> + <Subject value={`Hello \${user.displayName}!`} />
🧹 Nitpick comments (30)
apps/dashboard/src/components/assistant-ui/thread.tsx (1)
81-81: Commented-out welcome suggestions: consider adding a TODO or feature flagIf this is a temporary removal for the drafts UX, add context so it’s not lost. Alternatively, gate it behind a flag for quick re-enable during testing.
Apply this diff to add a clarifying TODO:
- {/* <ThreadWelcomeSuggestions /> */} + {/* TODO(email-drafts): temporarily disabled while we redesign onboarding/suggestions for draft workflows. Re-enable or remove once finalized. */} + {/* <ThreadWelcomeSuggestions /> */}apps/dashboard/src/components/vibe-coding/code-editor.tsx (1)
84-84: Defaulting Props to never makes props.variables unusable; prefer unknownWith
type Props<T = never>,variables: Tresolves toneverwhen no type arg is provided, makingvariablesimpossible to pass/reference in templates authored in the editor. Usingunknownkeeps it safe by default while allowing templates to specifyProps<MyVars>for strong typing.Consider this tweak:
- type Props<T = never> = { + type Props<T = unknown> = { variables: T; project: { displayName: string; }; user: { displayName: string | null; }; };Verification suggestion:
- Open a simple template in the editor using
React.FC<Props>without a type parameter and referenceprops.variables. Withnever, the editor surfaces type errors; withunknown, it remains usable until narrowed by a schema.packages/stack-shared/src/utils/types.tsx (1)
69-74: XOR implementation is overly strict on overlapping keys; use the Without-patternThe current
(A & { [K in keyof B]?: never })forbids all keys of B (including shared keys), which can make otherwise valid shapes impossible if A and B share keys. The conventional XOR usesWithoutto only disallow non-overlapping keys.Apply this diff:
-export type XOR<T extends readonly any[]> = T extends readonly [infer A, infer B, ...infer Rest] - ? Rest extends [] - ? (A & { [K in keyof B]?: never }) | (B & { [K in keyof A]?: never }) - : XOR<[(A & { [K in keyof B]?: never }) | (B & { [K in keyof A]?: never }), ...Rest]> - : T[0]; +type Without<T, U> = { [K in Exclude<keyof T, keyof U>]?: never }; +type _XOR2<A, B> = (A & Without<B, A>) | (B & Without<A, B>); +export type XOR<T extends readonly any[]> = + T extends readonly [infer A, infer B, ...infer Rest] + ? Rest extends [] + ? _XOR2<A, B> + : XOR<[_XOR2<A, B>, ...Rest]> + : T[0];Optionally, add compile-time assertions (outside this hunk) to lock behavior:
// Sanity checks typeAssertExtends<XOR<[ { a: number }, { b: string } ]>, { a: number; b?: never } | { b: string; a?: never }>()(); typeAssertExtends<XOR<[ { a: number; c?: string }, { a?: number; b: boolean } ]>, any>()(); // overlapping 'a' allowed if compatibleapps/backend/src/lib/email-rendering.tsx (1)
124-128: Preserve the error message for better diagnosabilitySwallowing the execution error into a generic string makes debugging hard (especially in preview). Returning the error’s message keeps UX informative without leaking stack traces.
Apply this diff:
- try { - const output = await freestyle.executeScript(result.data, { nodeModules }); - return Result.ok(output.result as { html: string, text: string, subject: string, notificationCategory: string }); - } catch (error) { - return Result.error("Unable to render email"); - } + try { + const output = await freestyle.executeScript(result.data, { nodeModules }); + return Result.ok(output.result as { html: string, text: string, subject: string, notificationCategory: string }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unable to render email"; + return Result.error(message); + }packages/stack-shared/src/interface/server-interface.ts (1)
800-809: Consider encoding recipient/content XOR at the interface boundaryThe server will validate, but we can catch invalid combinations earlier by adding overloads or a helper type for
optionsthat reflects the XOR constraints (e.g., userIds vs allUsers, html vs templateId vs draftId). This improves DX and reduces runtime 400s.Lightweight runtime guard (optional) to fail fast client-side:
async sendEmail(options: { userIds?: string[], allUsers?: true, themeId?: string | null | false, html?: string, subject?: string, notificationCategoryName?: string, templateId?: string, variables?: Record<string, any>, draftId?: string, }): Promise<Result<void, KnownErrors["RequiresCustomEmailServer"] | KnownErrors["SchemaError"] | KnownErrors["UserIdDoesNotExist"]>> { + if (options.allUsers && options.userIds?.length) { + throw new StackAssertionError("Provide either allUsers or userIds, not both"); + } + if ( + [options.html, options.templateId, options.draftId].filter(v => v !== undefined).length !== 1 + ) { + throw new StackAssertionError("Provide exactly one of html, templateId, or draftId"); + }Verification suggestion:
- Ensure the backend route expects the same wire fields:
all_users,draft_id. The mapping here looks correct.Also applies to: 818-827
packages/template/src/lib/stack-app/email/index.ts (1)
1-1: Consider importing XOR from the package root (optional).To avoid mixing import styles across the repo, consider importing from the package root (re-export) instead of the /dist path, if available.
apps/backend/src/app/api/latest/emails/send-email/route.tsx (3)
24-26: Redundant .nullable() on templateThemeIdSchematemplateThemeIdSchema already accepts undefined, false, null, and a UUID string (per shared snippet). Applying .nullable() again is redundant and can be dropped to simplify.
- theme_id: templateThemeIdSchema.nullable().meta({ + theme_id: templateThemeIdSchema.meta({ openapiField: { description: "The theme to use for the email. If not specified, the default theme will be used." } }),
115-122: Dead code: userSendErrors is populated but never read or returnedYou collect per-user errors in userSendErrors (e.g., missing primary email, rendering error, notifications disabled, send failure), but never surface them in results or logs. This adds overhead without value and obscures failures.
Two reasonable options:
- Keep results minimal: remove userSendErrors entirely and prune the set() calls (continue statements already control flow).
- Expose per-user errors: extend response schema and results with an optional error field, and include values from userSendErrors. This improves observability but requires updating response typing and tests.
198-201: Prefer using the computed userPrimaryEmails map consistentlyYou already filled userPrimaryEmails earlier. The fallback search across contactChannels repeats work and can hide subtle inconsistencies. Use the map exclusively for consistency and simpler code.
- const results: UserResult[] = Array.from(userMap.values()).map((user) => ({ - user_id: user.projectUserId, - user_email: userPrimaryEmails.get(user.projectUserId) ?? user.contactChannels.find((c) => c.isPrimary === "TRUE")?.value, - })); + const results: UserResult[] = Array.from(userMap.values()).map((user) => ({ + user_id: user.projectUserId, + user_email: userPrimaryEmails.get(user.projectUserId), + }));apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts (1)
592-624: Snapshot brittleness: duplicated “unknown properties: html” linesThe schema details repeat “unknown properties: html” within the same schema block. If the validator output gets de-duplicated later, this snapshot will become brittle.
Suggestion: assert key fragments (e.g., “theme_id is invalid”, “template_id must be defined”) with partial string match or toMatchObject for code and header, and a looser check on details.message contents rather than a strict inline snapshot of the whole block.
apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx (1)
40-40: De-duplicate ContextType literals across API and adapter-registryAvoid hardcoding the union in multiple places. Export a single source of truth (a const array) from the adapter-registry and reference it here to keep validation in sync with the registry.
Apply this diff here:
-import { adaptSchema, yupArray, yupMixed, yupNumber, yupObject, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields"; +import { adaptSchema, yupArray, yupMixed, yupNumber, yupObject, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields"; +import { CONTEXT_TYPES } from "@/lib/ai-chat/adapter-registry";- context_type: yupString().oneOf(["email-theme", "email-template", "email-draft"]).defined(), + context_type: yupString().oneOf(CONTEXT_TYPES as unknown as string[]).defined(),Additionally add this in apps/backend/src/lib/ai-chat/adapter-registry.ts (outside this file’s ranges):
// co-locate with ContextType export const CONTEXT_TYPES = ["email-theme", "email-template", "email-draft"] as const satisfies readonly ContextType[];apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page.tsx (1)
7-10: Fix Page props typing; params is not a Promise in Next.js App Routerparams is provided as a plain object. Awaiting it works at runtime but is misleading and unnecessary. Also, the function doesn’t need to be async.
Apply this diff:
-export default async function Page(props: { params: Promise<{ draftId: string }> }) { - const params = await props.params; - return <PageClient draftId={params.draftId} />; -} +export default function Page({ params }: { params: { draftId: string } }) { + return <PageClient draftId={params.draftId} />; +}apps/backend/prisma/migrations/20250815012830_email_drafts/migration.sql (1)
5-17: Optional constraints and indexes to support correctness and query patterns
- Consider indexing (tenancyId, createdAt) for list/sort queries, and createdAt for admin views.
- If an EmailTheme table exists, consider a composite FK (tenancyId, themeId) to prevent cross-tenant references.
- Consider CHECK constraints for themeMode/themeId consistency:
- themeMode = 'NONE' => themeId IS NULL
- themeMode = 'PROJECT_DEFAULT' => themeId IS NULL
- themeMode = 'CUSTOM' => themeId IS NOT NULL
Example follow-up migration:
-- Indexes CREATE INDEX IF NOT EXISTS "EmailDraft_tenancyId_createdAt_idx" ON "EmailDraft" ("tenancyId", "createdAt"); CREATE INDEX IF NOT EXISTS "EmailDraft_createdAt_idx" ON "EmailDraft" ("createdAt"); -- Optional: constrain theme relationship (adjust table/column types as needed) -- ALTER TABLE "EmailDraft" -- ADD CONSTRAINT "EmailDraft_theme_fkey" -- FOREIGN KEY ("tenancyId", "themeId") REFERENCES "EmailTheme"("tenancyId","id") -- ON DELETE SET NULL ON UPDATE CASCADE; -- Optional: enforce themeMode <-> themeId consistency ALTER TABLE "EmailDraft" ADD CONSTRAINT "EmailDraft_theme_mode_ck" CHECK ( ("themeMode" = 'NONE' AND "themeId" IS NULL) OR ("themeMode" = 'PROJECT_DEFAULT' AND "themeId" IS NULL) OR ("themeMode" = 'CUSTOM' AND "themeId" IS NOT NULL) );apps/backend/src/lib/ai-chat/adapter-registry.ts (1)
17-23: Export a single source of truth for context typesExport a runtime array next to the ContextType union, so API validation (yup.oneOf) can depend on it without duplicating literals.
Apply this diff:
type ContextType = "email-theme" | "email-template" | "email-draft"; +// Keep Runtime and Type in sync +export const CONTEXT_TYPES = ["email-theme", "email-template", "email-draft"] as const satisfies readonly ContextType[]; + const CHAT_ADAPTERS: Record<ContextType, (context: ChatAdapterContext) => ChatAdapter> = {Optionally, defensively guard unexpected keys if this function is ever used outside validated routes:
export function getChatAdapter(contextType: ContextType, tenancy: Tenancy, threadId: string): ChatAdapter { - const adapter = CHAT_ADAPTERS[contextType]; - return adapter({ tenancy, threadId }); + const adapter = CHAT_ADAPTERS[contextType]; + return adapter({ tenancy, threadId }); }(With ContextType + route validation this is already safe; the guard would be redundant.)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx (2)
27-27: Initialize selectedThemeId safely when template.themeId can be nullIf template.themeId is null for “project default”, map it to undefined to match EmailThemeSelector/EmailPreview conventions (project theme = undefined, no theme = false).
Apply this diff:
- const [selectedThemeId, setSelectedThemeId] = useState<string | undefined | false>(template?.themeId); + const [selectedThemeId, setSelectedThemeId] = useState<string | undefined | false>( + (template?.themeId ?? undefined) as string | undefined | false + );
80-84: Normalize theme comparison for the Save button’s disabled stateEnsure “project theme” (null/undefined) comparisons work and that “no theme” (false) remains distinct.
Apply this diff:
- <Button - disabled={currentCode === template.tsxSource && selectedThemeId === template.themeId} + <Button + disabled={ + currentCode === template.tsxSource && + ((selectedThemeId ?? null) === ((template.themeId as unknown as string | undefined | false) ?? null)) + } onClick={handleSaveTemplate} > Save </Button>Alternatively, define a small helper above the return:
const normalizeThemeId = (v: string | undefined | null | false) => (v === null ? undefined : v); const isUnchanged = currentCode === template.tsxSource && normalizeThemeId(selectedThemeId) === normalizeThemeId(template.themeId as any);and use disabled={isUnchanged}.
apps/backend/prisma/schema.prisma (2)
672-687: Add tenancy-scoped indexes for common list/sort queries on EmailDraftDrafts will often be listed and sorted per tenancy; adding indexes will keep those queries snappy at scale.
Apply this diff to add pragmatic indexes:
model EmailDraft { tenancyId String @db.Uuid id String @default(uuid()) @db.Uuid displayName String themeMode DraftThemeMode @default(PROJECT_DEFAULT) themeId String? tsxSource String sentAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@id([tenancyId, id]) + @@index([tenancyId, createdAt(sort: Desc)], name: "EmailDraft_createdAt_desc") + @@index([tenancyId, displayName(sort: Asc)], name: "EmailDraft_displayName_asc") }
679-680: Enforce themeMode/themeId invariant (CUSTOM requires themeId; others must not)To avoid inconsistent state:
- themeMode = CUSTOM ⇒ themeId must be set
- themeMode in {PROJECT_DEFAULT, NONE} ⇒ themeId must be null
Prisma can’t natively express conditional CHECK constraints; enforce this in create/update handlers and in
email-drafts.tsxutilities.Would you like me to add Zod validators to the internal email-drafts routes? I can open an issue or provide a patch.
apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx (1)
19-21: Add an aria-label to the icon-only button for accessibilityScreen readers won’t announce intent on an icon-only button.
Apply this diff:
- <Button variant="ghost" size="icon" onClick={() => setCurrentCode(args.content)}> + <Button + variant="ghost" + size="icon" + aria-label="Insert generated draft into editor" + onClick={() => setCurrentCode(args.content)} + >apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (2)
203-221: Dynamic breadcrumb for draft detail: good additionThe two-part crumb improves navigation. Consider a fallback label when the draft isn’t yet loaded so the crumb doesn’t render empty.
You can simplify the fallback by returning a static label when not found:
- if (match) { - item = <DraftBreadcrumbItem key='draft-display-name' draftId={match[1]} />; + if (match) { + item = <DraftBreadcrumbItem key='draft-display-name' draftId={match[1]} />; href = `/email-drafts/${match[1]}`; } else { item = "Draft"; href = ""; }And inside DraftBreadcrumbItem (below) return "Draft" if not found.
352-360: Prefer a fallback label in DraftBreadcrumbItem to avoid empty crumbIf the drafts list hasn’t loaded yet or the ID is unknown, returning null yields a blank crumb.
Apply this minimal change:
function DraftBreadcrumbItem(props: { draftId: string }) { const stackAdminApp = useAdminApp(); const drafts = stackAdminApp.useEmailDrafts(); const draft = drafts.find((d) => d.id === props.draftId); - if (!draft) { - return null; - } - return draft.displayName; + if (!draft) { + return "Draft"; + } + return draft.displayName; }apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx (1)
96-98: Tighten validation: require non-empty name
.defined()allows empty strings. Use.trim().min(1)to enforce a non-empty name and better UX.- formSchema={yup.object({ - name: yup.string().defined(), - })} + formSchema={yup.object({ + name: yup.string().trim().min(1, "Name is required").defined(), + })}apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (2)
12-14: Optional: Show a friendly “not found” state if the draft id is invalidHelps users understand why the editor is empty. Add a simple fallback.
import { useAdminApp } from "../../use-admin-app"; import { EmailDraftUI } from "@/components/vibe-coding/draft-tool-components"; +import { PageLayout } from "../../page-layout";return ( - <> + <> + {!draft ? ( + <PageLayout title="Email Draft Not Found" /> + ) : null} {stage === "edit" ? (Also applies to: 52-55
18-19: Avoid name shadowing between importedtoastanduseToast().toastShadowing can confuse readers and some linters. Rename the local binding for clarity.
- const { toast } = useToast(); + const { toast: pushToast } = useToast();- if (error instanceof KnownErrors.EmailRenderingError) { - toast({ title: "Failed to save draft", variant: "destructive", description: error.message }); + if (error instanceof KnownErrors.EmailRenderingError) { + pushToast({ title: "Failed to save draft", variant: "destructive", description: error.message }); return; } - toast({ title: "Failed to save draft", variant: "destructive", description: "Unknown error" }); + pushToast({ title: "Failed to save draft", variant: "destructive", description: "Unknown error" });Also applies to: 44-49
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (2)
304-315: DRY up draft mapping with a shared helper to keep types/logic consistentThe draft-to-public mapping is duplicated between useEmailDrafts() and listEmailDrafts(). Create a single helper (like _adminEmailTemplateFromCrud) and reuse it to minimize drift and make future changes safer.
Apply diffs to reuse a helper:
@@ - useEmailDrafts(): { id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null }[] { - const crud = useAsyncCache(this._adminEmailDraftsCache, [], "useEmailDrafts()"); - return useMemo(() => { - return crud.map((draft) => ({ - id: draft.id, - displayName: draft.display_name, - themeId: draft.theme_id, - tsxSource: draft.tsx_source, - sentAt: draft.sent_at_millis ? new Date(draft.sent_at_millis) : null, - })); - }, [crud]); - } + useEmailDrafts(): { id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null }[] { + const crud = useAsyncCache(this._adminEmailDraftsCache, [], "useEmailDrafts()"); + return useMemo(() => crud.map((d) => this._adminEmailDraftFromCrud(d)), [crud]); + } @@ - async listEmailDrafts(): Promise<{ id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null }[]> { - const crud = Result.orThrow(await this._adminEmailDraftsCache.getOrWait([], "write-only")); - return crud.map((draft) => ({ - id: draft.id, - displayName: draft.display_name, - themeId: draft.theme_id, - tsxSource: draft.tsx_source, - sentAt: draft.sent_at_millis ? new Date(draft.sent_at_millis) : null, - })); - } + async listEmailDrafts(): Promise<{ id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null }[]> { + const crud = Result.orThrow(await this._adminEmailDraftsCache.getOrWait([], "write-only")); + return crud.map((d) => this._adminEmailDraftFromCrud(d)); + }Add this helper inside the class (near other CRUD mappers):
// Converts internal draft JSON to the public shape used by the template layer protected _adminEmailDraftFromCrud(draft: { id: string; display_name: string; tsx_source: string; theme_id: string | false | undefined | null; sent_at_millis?: number | null; }): { id: string; displayName: string; themeId: string | false | undefined; tsxSource: string; sentAt: Date | null } { return { id: draft.id, displayName: draft.display_name, // Normalize possible `null` from backend to `undefined` in the public shape themeId: draft.theme_id === null ? undefined : draft.theme_id, tsxSource: draft.tsx_source, sentAt: draft.sent_at_millis ? new Date(draft.sent_at_millis) : null, }; }Also applies to: 335-344
487-495: Unify themeId null/undefined semantics across surfacesIn this client layer, themeId uses string | false | undefined, while other places (e.g., email preview options allow null; backend schema allows null). This can cause subtle mismatches.
- Consider normalizing to: string | false | undefined (where undefined means “project default”) and avoid null entirely on the client/public surface.
- If null must be supported by some APIs, normalize null -> undefined at the edges.
Would you like me to send a follow-up PR to harmonize these types and add null->undefined normalization where needed?
Also applies to: 304-315, 335-344, 519-526
apps/backend/src/app/api/latest/internal/email-drafts/route.tsx (4)
31-35: Reduce payloads and query cost on list: select explicit fields and consider excluding tsx_source in listReturning tsx_source for up to 50 drafts can be heavy. If the UI only needs metadata for listing, omit tsx_source here and fetch it via GET /email-drafts/:id when editing. Also, select only needed fields to avoid loading large columns.
@@ - const items = await prisma.emailDraft.findMany({ - where: { tenancyId: tenancy.id }, - orderBy: { updatedAt: "desc" }, - take: 50, - }); + const items = await prisma.emailDraft.findMany({ + where: { tenancyId: tenancy.id }, + orderBy: { updatedAt: "desc" }, + take: 50, + select: { + id: true, + displayName: true, + // tsxSource: false, // Consider omitting from the list response + tsxSource: true, // set to false if the UI does not need it here + themeMode: true, + themeId: true, + sentAt: true, + }, + }); @@ - tsx_source: d.tsxSource, + tsx_source: d.tsxSource,If you decide to omit tsx_source from the list, update the response schema accordingly and rely on the existing GET /internal/email-drafts/[id] to fetch the source for the selected draft.
Also applies to: 40-46
31-35: Pagination: hard-coded take: 50 may truncate resultsFor tenants with many drafts, a fixed take: 50 without cursor/offset will silently truncate. Consider cursor-based pagination (id or updatedAt) and expose query params for page size.
20-27: Schema allows null for theme_id, but responses never return nullthemeModeToTemplateThemeId never emits null; it returns undefined/false/string. Keeping response schema aligned with actual values reduces confusion. Optional: narrow schema to exclude null for the response.
53-67: Default draft source looks fine; consider a slimmer templateThe default includes both Subject and NotificationCategory imports. If you want the lightest possible starter, you can drop NotificationCategory or comment it out to reduce noise for new drafts. Optional.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (32)
apps/backend/prisma/migrations/20250815012830_email_drafts/migration.sql(1 hunks)apps/backend/prisma/schema.prisma(2 hunks)apps/backend/src/app/api/latest/emails/render-email/route.tsx(3 hunks)apps/backend/src/app/api/latest/emails/send-email/route.tsx(4 hunks)apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx(1 hunks)apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx(1 hunks)apps/backend/src/app/api/latest/internal/email-drafts/route.tsx(1 hunks)apps/backend/src/lib/ai-chat/adapter-registry.ts(2 hunks)apps/backend/src/lib/ai-chat/email-draft-adapter.ts(1 hunks)apps/backend/src/lib/email-drafts.tsx(1 hunks)apps/backend/src/lib/email-rendering.tsx(5 hunks)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx(1 hunks)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page.tsx(1 hunks)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx(1 hunks)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page.tsx(1 hunks)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx(3 hunks)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx(4 hunks)apps/dashboard/src/components/assistant-ui/thread.tsx(1 hunks)apps/dashboard/src/components/email-preview.tsx(2 hunks)apps/dashboard/src/components/email-theme-selector.tsx(1 hunks)apps/dashboard/src/components/vibe-coding/chat-adapters.ts(1 hunks)apps/dashboard/src/components/vibe-coding/code-editor.tsx(1 hunks)apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx(1 hunks)apps/e2e/tests/backend/endpoints/api/v1/render-email.test.ts(1 hunks)apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts(6 hunks)apps/e2e/tests/js/email.test.ts(1 hunks)packages/stack-shared/src/interface/admin-interface.ts(2 hunks)packages/stack-shared/src/interface/server-interface.ts(2 hunks)packages/stack-shared/src/utils/types.tsx(1 hunks)packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts(4 hunks)packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts(3 hunks)packages/template/src/lib/stack-app/email/index.ts(2 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
apps/backend/src/app/api/latest/**/*
📄 CodeRabbit Inference Engine (CLAUDE.md)
apps/backend/src/app/api/latest/**/*: Main API routes are located in /apps/backend/src/app/api/latest
The project uses a custom route handler system in the backend for consistent API responses
Files:
apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsxapps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsxapps/backend/src/app/api/latest/emails/send-email/route.tsxapps/backend/src/app/api/latest/internal/email-drafts/route.tsxapps/backend/src/app/api/latest/emails/render-email/route.tsx
apps/backend/prisma/schema.prisma
📄 CodeRabbit Inference Engine (CLAUDE.md)
Database models use Prisma
Files:
apps/backend/prisma/schema.prisma
🧠 Learnings (1)
📚 Learning: 2025-08-04T22:25:51.260Z
Learnt from: CR
PR: stack-auth/stack-auth#0
File: CLAUDE.md:0-0
Timestamp: 2025-08-04T22:25:51.260Z
Learning: Applies to apps/backend/prisma/schema.prisma : Database models use Prisma
Applied to files:
apps/backend/prisma/schema.prismaapps/backend/src/lib/email-rendering.tsx
🧬 Code Graph Analysis (19)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page.tsx (2)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page.tsx (2)
metadata(3-5)Page(7-10)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx (1)
PageClient(13-80)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page.tsx (3)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page.tsx (2)
metadata(3-5)Page(7-11)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx (1)
PageClient(21-98)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (1)
PageClient(15-88)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx (5)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx (1)
useAdminApp(27-34)apps/dashboard/src/components/router.tsx (1)
useRouter(15-33)packages/stack-ui/src/components/action-dialog.tsx (1)
ActionDialog(31-135)apps/dashboard/src/components/form-dialog.tsx (1)
FormDialog(53-133)apps/dashboard/src/components/form-fields.tsx (1)
InputField(59-97)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (11)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx (1)
PageClient(21-98)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx (1)
useAdminApp(27-34)packages/stack-ui/src/components/ui/use-toast.tsx (2)
useToast(195-195)toast(195-195)apps/dashboard/src/components/vibe-coding/chat-adapters.ts (3)
ToolCallContent(9-9)createHistoryAdapter(63-76)createChatAdapter(15-61)apps/dashboard/src/components/vibe-coding/vibe-code-layout.tsx (1)
VibeCodeLayout(13-37)apps/dashboard/src/components/email-preview.tsx (1)
EmailPreview(91-122)apps/dashboard/src/components/vibe-coding/code-editor.tsx (1)
CodeEditor(15-144)apps/dashboard/src/components/email-theme-selector.tsx (1)
EmailThemeSelector(17-39)apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx (1)
EmailDraftUI(9-28)apps/dashboard/src/components/data-table/team-member-search-table.tsx (1)
TeamMemberSearchTable(9-68)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes/[themeId]/page-client.tsx (1)
PageClient(18-78)
apps/backend/src/lib/ai-chat/email-draft-adapter.ts (1)
apps/backend/src/lib/ai-chat/adapter-registry.ts (1)
ChatAdapterContext(7-10)
apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx (1)
packages/stack-shared/src/schema-fields.ts (1)
yupString(185-188)
apps/dashboard/src/components/email-theme-selector.tsx (2)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx (1)
useAdminApp(27-34)packages/stack-ui/src/components/ui/select.tsx (5)
Select(160-160)SelectTrigger(160-160)SelectValue(160-160)SelectContent(160-160)SelectItem(160-160)
apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx (4)
apps/backend/src/app/api/latest/internal/email-drafts/route.tsx (1)
GET(8-50)packages/stack-shared/src/schema-fields.ts (4)
yupObject(245-249)yupString(185-188)yupNumber(189-192)templateThemeIdSchema(477-477)apps/backend/src/prisma-client.tsx (1)
getPrismaClientForTenancy(51-53)apps/backend/src/lib/email-drafts.tsx (2)
themeModeToTemplateThemeId(25-33)templateThemeIdToThemeMode(15-23)
apps/backend/src/lib/ai-chat/adapter-registry.ts (3)
apps/backend/src/lib/ai-chat/email-theme-adapter.ts (1)
emailThemeAdapter(7-18)apps/backend/src/lib/ai-chat/email-template-adapter.ts (1)
emailTemplateAdapter(10-20)apps/backend/src/lib/ai-chat/email-draft-adapter.ts (1)
emailDraftAdapter(10-20)
packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts (1)
packages/template/src/lib/stack-app/common.ts (1)
AsyncStoreProperty(8-10)
apps/backend/src/app/api/latest/emails/send-email/route.tsx (3)
packages/stack-shared/src/schema-fields.ts (1)
templateThemeIdSchema(477-477)apps/backend/src/prisma-client.tsx (1)
getPrismaClientForTenancy(51-53)apps/backend/src/lib/email-drafts.tsx (2)
getEmailDraft(3-13)themeModeToTemplateThemeId(25-33)
apps/backend/src/app/api/latest/internal/email-drafts/route.tsx (5)
apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx (1)
GET(6-41)packages/stack-shared/src/schema-fields.ts (4)
yupObject(245-249)yupString(185-188)yupNumber(189-192)templateThemeIdSchema(477-477)apps/backend/src/prisma-client.tsx (1)
getPrismaClientForTenancy(51-53)apps/backend/src/lib/email-drafts.tsx (2)
themeModeToTemplateThemeId(25-33)templateThemeIdToThemeMode(15-23)packages/stack-shared/src/utils/strings.tsx (1)
deindent(235-238)
packages/template/src/lib/stack-app/email/index.ts (1)
packages/stack-shared/src/utils/types.tsx (1)
XOR(69-73)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx (2)
apps/dashboard/src/components/email-preview.tsx (1)
EmailPreview(91-122)apps/dashboard/src/components/email-theme-selector.tsx (1)
EmailThemeSelector(17-39)
apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts (4)
packages/template/src/lib/stack-app/projects/index.ts (1)
Project(9-13)apps/e2e/tests/helpers.ts (1)
it(10-10)packages/template/src/lib/stack-app/users/index.ts (1)
User(236-236)apps/e2e/tests/backend/backend-helpers.ts (1)
niceBackendFetch(107-165)
apps/backend/src/lib/email-rendering.tsx (1)
packages/stack-shared/src/utils/results.tsx (1)
error(36-41)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (1)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx (1)
useAdminApp(27-34)
apps/backend/src/app/api/latest/emails/render-email/route.tsx (2)
packages/stack-shared/src/schema-fields.ts (3)
yupObject(245-249)yupString(185-188)templateThemeIdSchema(477-477)packages/stack-shared/src/known-errors.tsx (2)
KnownErrors(1490-1492)KnownErrors(1494-1609)
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (1)
packages/template/src/lib/stack-app/apps/implementations/common.ts (2)
createCache(22-27)useAsyncCache(145-190)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (7)
- GitHub Check: all-good
- GitHub Check: lint_and_build (latest)
- GitHub Check: build (22.x)
- GitHub Check: build (22.x)
- GitHub Check: restart-dev-and-test
- GitHub Check: setup-tests
- GitHub Check: Security Check
🔇 Additional comments (37)
apps/backend/src/lib/email-rendering.tsx (2)
3-3: Import cleanup LGTMUsing only
getEnvVariableis fine; the removal of unused env helpers keeps deps tight.
46-46: Parameter rename and bundler mapping align with draft/content support
templateOrDraftComponentand the/template.tsxmapping make the content source explicit and draft-friendly. Good change.Also applies to: 71-71
packages/template/src/lib/stack-app/email/index.ts (2)
12-16: Good factoring of common fields into SendEmailOptionsBase.This keeps the XOR groups clean and reduces duplication.
19-31: XOR-based exclusivity for recipients and content is solid; draftId path looks correct.
- Recipient XOR: exactly one of userIds or allUsers.
- Content XOR: exactly one of html, templateId(+variables), or draftId.
packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts (1)
66-66: Adding "email-draft" to chat contextType looks correct.Matches the backend adapter registry and new draft tooling.
packages/stack-shared/src/interface/admin-interface.ts (2)
155-167: LGTM on updateEmailDraft signature and request wiring.Accepts nullable sent_at_millis and nullable/false theme_id as expected; consistent with backend routes.
352-352: Extending chat contextType with "email-draft" is consistent with UI/back-end adapters.No issues found.
apps/dashboard/src/components/vibe-coding/chat-adapters.ts (1)
18-18: Adapter now accepts "email-draft" context — looks good.Flow remains unchanged; forwards to adminApp.sendChatMessage with the new context type.
apps/backend/src/app/api/latest/emails/render-email/route.tsx (5)
4-4: Good: using schema helpers (union/object/string) from the shared schema module.Keeps API route validation consistent with the project’s route handler system.
59-71: Template resolution and errors look correct.
- Accepts direct TSX or resolves by id.
- Returns 400 for unknown template id; defensive SchemaError for missing content source is fine.
73-73: renderEmailWithTemplate call matches the resolved sources.Passing contentSource and themeSource aligns with the updated rendering flow.
80-80: Correctly mapping renderer errors into KnownErrors.EmailRenderingError.Integrates with the standardized error handling model.
47-57: Theme resolution logic confirmed.
getEmailThemeForTemplatecorrectly handles all inputs:
- For a valid string ID, returns that theme’s
tsxSource.- For
false, returnsemptyEmailTheme.- For
undefinedornull, falls back to the active (default) theme.No changes required.
apps/e2e/tests/backend/endpoints/api/v1/render-email.test.ts (1)
69-105: Snapshot update aligns with new multi-schema validation shapeThe structured SCHEMA_ERROR (with details.message and error using deindent) matches the new union-based validation in the render-email route. Looks good.
apps/backend/src/app/api/latest/emails/send-email/route.tsx (3)
39-50: Body schema union is clear and enforces mutual exclusivityThe three-mode union (html | template_id+variables | draft_id) built from bodyBase reads cleanly and pairs well with the behavioral guard later. Good use of a shared base to keep common fields centralized.
70-73: Good guard: enforce exactly one of user_ids or all_usersThis ensures mutually exclusive recipient selection and returns a KnownErrors.SchemaError for invalid combinations. Matches the new e2e coverage.
85-92: Draft flow is correct and respects explicit theme_id
- Fetches the draft by tenancy/id (with a clear 400 on not found).
- Derives template theme via themeModeToTemplateThemeId.
- Only overrides theme when body.theme_id is undefined, preserving explicit caller intent. This is the right precedence.
apps/e2e/tests/js/email.test.ts (1)
180-191: Update to multi-schema error message is correctThe assertion now matches the union-based validation (html | template_id | draft_id). Clear and aligned with backend changes.
apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts (4)
90-91: Good setup fix for shared-config scenarioCreating and switching the project before the “shared email config” test ensures the proper configuration context. This resolves potential flakiness.
376-398: Validation snapshot matches union-based schemaThe SCHEMA_ERROR block listing all three schema requirements is accurate and matches the route’s yupUnion enforcement.
437-531: Great coverage for all_users
- 400 when both selectors present confirms the guard behavior.
- 200 for all_users exercises the broadcast path and mailbox verification. This will pass once the backend query drops the in: undefined bug.
After merging the backend fix suggested in route.tsx, please re-run this suite to confirm the all_users tests pass consistently.
548-559: Message wording change is consistent with backendSwitching to "No template found with given template_id" aligns with the new error text in the route. Looks good.
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page.tsx (1)
1-11: Minimal, correct App Router pageStatic metadata and delegating to PageClient is idiomatic. No issues.
apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx (1)
40-40: Accepting "email-draft" looks correct and aligns with the new adapterThe new context_type is validated and will route correctly via getChatAdapter.
apps/backend/src/lib/ai-chat/adapter-registry.ts (1)
5-5: Registering the email-draft adapter is correctImport and registration are consistent with the expanded union and routing.
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx (1)
41-52: No change required —updateEmailTemplateacceptsfalsefor “No theme”I verified the codebase:
updateEmailTemplate'sthemeIdparameter is typed asstring | null | false, so passingselectedThemeId === falseis supported. Evidence:
- packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts — updateEmailTemplate(..., themeId: string | null | false)
- packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts — async updateEmailTemplate(..., themeId: string | null | false) (forwards to the interface)
- packages/stack-shared/src/interface/admin-interface.ts — updateEmailTemplate(..., themeId: string | null | false)
- Call site: apps/dashboard/src/app/.../page-client.tsx (current call at line ~43)
No coercion to
nullis necessary; keep the call as-is.apps/backend/prisma/schema.prisma (2)
689-694: DraftThemeMode enum looks good and matches the intended semanticsValues align with the conversion helpers noted in the PR (inherit/default vs none vs custom).
752-757: Subscription model change appears formatting-onlyField order changes don’t affect Prisma migrations; no behavioral impact expected.
apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx (1)
14-15: No action required:createEmailTemplateandsuccessevent match adapter
The dashboard UI’stoolName: "createEmailTemplate"aligns with theemailTemplateAdapterin the backend, and it emits a"success"event carrying the{ content }payload. ThemakeAssistantToolUIhook will trigger correctly.apps/dashboard/src/components/email-preview.tsx (1)
47-51: Type change tostring | undefined | falseis consistent with EmailThemeSelectorNo runtime changes; this harmonizes the themeId contract across components.
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (2)
26-43: Icon updates align with new IAUsing FilePen for Drafts and LayoutTemplate for Templates reads well in the sidebar.
181-187: New “Drafts” nav entry: LGTMRoute, icon, and regex match the drafts index page. No gating is applied; if needed you can wrap with your dev feature flag later.
apps/dashboard/src/components/email-theme-selector.tsx (2)
10-15: Correct null/undefined/false mapping for Select interopUsing JSON.stringify/parse is a pragmatic way to shuttle string | undefined | false through a string-only Select value, and
?? undefinedappropriately normalizesnullback toundefinedwhile preservingfalse.
29-37: Option set covers all states (“No theme”, “Project theme”, explicit theme)The values map cleanly back to the union via JSON.parse. No changes needed.
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (3)
46-48: Email drafts cache wiring — LGTMCache is correctly scoped and uses the existing AsyncCache pattern to hit listInternalEmailDrafts(). No concerns.
477-485: Create draft mutation + cache refresh — LGTMThe mapping of public -> wire fields and post-mutation cache invalidation are correct.
496-503: ContextType alignment verified“I’ve confirmed that the
"email-draft"literal appears in:
packages/template/src/lib/stack-app/apps/interfaces/admin-app.tspackages/template/src/lib/stack-app/apps/implementations/admin-app-impl.tspackages/stack-shared/src/interface/admin-interface.ts- Front-end chat adapters (
apps/dashboard/.../chat-adapters.ts)- The email-draft page client (
page-client.tsx)- Backend adapter registry (
apps/backend/src/lib/ai-chat/adapter-registry.ts)- API route validation (
apps/backend/src/app/api/.../route.tsx)All layers accept this union member, so no additional changes are needed.
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx
Show resolved
Hide resolved
|
Claude finished @BilalG1's task —— View job Code Review in Progress
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 4
🔭 Outside diff range comments (1)
apps/backend/src/app/api/latest/emails/send-email/route.tsx (1)
57-60: Update response schema to include error field.The response schema should be updated to match the suggested UserResult type changes for proper API documentation.
results: yupArray(yupObject({ user_id: yupString().defined(), user_email: yupString().optional(), + error: yupString().optional(), })).defined(),
♻️ Duplicate comments (4)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (1)
27-35: Unsaved-changes guard can remain stuck; compute “dirty” and tie to stageThe effect early-returns without resetting the confirm flag, which can leave confirmation enabled after save or stage change. Prior review suggested deriving a “dirty” flag and setting needConfirm deterministically.
Apply this diff:
-useEffect(() => { - if (!draft) return; - if (draft.tsxSource === currentCode && draft.themeId === selectedThemeId) return; - if (stage !== "edit") return; - - setNeedConfirm(true); - return () => setNeedConfirm(false); -}, [setNeedConfirm, draft, currentCode, selectedThemeId, stage]); +useEffect(() => { + const isDirty = !!draft && (draft.tsxSource !== currentCode || draft.themeId !== selectedThemeId); + setNeedConfirm(stage === "edit" && isDirty); + return () => setNeedConfirm(false); +}, [setNeedConfirm, draft, currentCode, selectedThemeId, stage]);apps/backend/src/app/api/latest/emails/send-email/route.tsx (3)
70-72: Fix the database query to properly handle theall_userscase.When
all_usersis true,body.user_idsis undefined, but the query still tries to filter by it. This will cause the query to return no users.Apply this fix to handle both cases correctly:
const users = await prisma.projectUser.findMany({ where: { tenancyId: auth.tenancy.id, - projectUserId: { - in: body.user_ids - }, + ...(body.user_ids && { + projectUserId: { + in: body.user_ids + }, + }), }, include: { contactChannels: true, }, });
14-17: Expose per-user errors in the API response.The function collects
userSendErrorsbut never surfaces them to the caller. This makes it impossible to distinguish between successful and failed recipients in partial failure scenarios.Update the type to include errors:
type UserResult = { user_id: string, user_email?: string, + error?: string, };
198-201: Include per-user errors in the response.Complete the error reporting by including the captured errors in the results.
const results: UserResult[] = Array.from(userMap.values()).map((user) => ({ user_id: user.projectUserId, user_email: userPrimaryEmails.get(user.projectUserId) ?? user.contactChannels.find((c) => c.isPrimary === "TRUE")?.value, + error: userSendErrors.get(user.projectUserId), }));
🧹 Nitpick comments (3)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (3)
61-73: Disable Next until loaded/dirty and show saving statePrevents accidental overwrite and double clicks.
Apply this diff:
- <Button onClick={handleNext}>Next</Button> + <Button + onClick={handleNext} + disabled={!draft || !initializedFromDraft || saving || !dirty} + > + {saving ? "Saving..." : "Next"} + </Button>Optionally, render a lightweight loading state while the draft is loading to avoid showing an empty editor:
if (!draft) { return ( <div className="p-4"> <Skeleton className="h-64" /> </div> ); }
36-38: Confirm intended UX: auto-applying tool results may clobber in-progress editshandleToolUpdate applies tool output immediately, while EmailDraftUI also exposes an “apply” button. This dual behavior can overwrite user edits without confirmation.
If the intent is manual application only, consider no-op’ing onToolCall and relying on the Tool UI:
-const handleToolUpdate = (toolCall: ToolCallContent) => { - setCurrentCode(toolCall.args.content); -}; +const handleToolUpdate = () => {};Or gate auto-apply behind a confirmation or only when not dirty.
Also applies to: 77-81
96-111: Avoid duplicate sends: add isSending gateThe Send button is clickable repeatedly; add an isSending flag and disable while in-flight.
Apply this diff:
function SendStage({ draftId }: { draftId: string }) { const stackAdminApp = useAdminApp(); const [scope, setScope] = useState<"all" | "users">("all"); const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]); + const [isSending, setIsSending] = useState(false); const handleSubmit = async () => { - const result = await stackAdminApp.sendEmail( + setIsSending(true); + try { + const result = await stackAdminApp.sendEmail( scope === "users" ? { draftId, userIds: selectedUserIds } : { draftId, allUsers: true } ); if (result.status === "ok") { toast({ title: "Email sent", variant: "success" }); return; } if (result.error instanceof KnownErrors.RequiresCustomEmailServer) { toast({ title: "Action requires custom email server", variant: "destructive", description: "Please setup a custom email server and try again." }); } else { toast({ title: "Failed to send email", variant: "destructive", description: "Unknown error" }); } + } finally { + setIsSending(false); + } };- <Button - disabled={scope === "users" && selectedUserIds.length === 0} - onClick={handleSubmit} - > - Send - </Button> + <Button + disabled={isSending || (scope === "users" && selectedUserIds.length === 0)} + onClick={handleSubmit} + > + {isSending ? "Sending..." : "Send"} + </Button>Also applies to: 157-163
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (5)
apps/backend/src/app/api/latest/emails/send-email/route.tsx(4 hunks)apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx(1 hunks)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx(1 hunks)packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts(4 hunks)packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts(3 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
- packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts
- apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx
- packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts
🧰 Additional context used
📓 Path-based instructions (1)
apps/backend/src/app/api/latest/**/*
📄 CodeRabbit Inference Engine (CLAUDE.md)
apps/backend/src/app/api/latest/**/*: Main API routes are located in /apps/backend/src/app/api/latest
The project uses a custom route handler system in the backend for consistent API responses
Files:
apps/backend/src/app/api/latest/emails/send-email/route.tsx
🧬 Code Graph Analysis (2)
apps/backend/src/app/api/latest/emails/send-email/route.tsx (5)
packages/stack-shared/src/schema-fields.ts (10)
yupObject(245-249)yupArray(211-214)yupString(185-188)yupBoolean(193-196)templateThemeIdSchema(477-477)serverOrHigherAuthTypeSchema(429-429)adaptSchema(328-328)yupUnion(255-279)yupRecord(281-320)yupMixed(208-210)apps/backend/src/prisma-client.tsx (1)
getPrismaClientForTenancy(51-53)apps/backend/src/lib/notification-categories.ts (2)
getNotificationCategoryByName(25-27)hasNotificationEnabled(29-48)apps/backend/src/lib/email-rendering.tsx (3)
getEmailThemeForTemplate(23-32)createTemplateComponentFromHtml(34-43)renderEmailWithTemplate(45-129)apps/backend/src/lib/email-drafts.tsx (2)
getEmailDraft(3-13)themeModeToTemplateThemeId(25-33)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (9)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx (1)
useAdminApp(27-34)packages/stack-ui/src/components/ui/use-toast.tsx (2)
useToast(195-195)toast(195-195)apps/dashboard/src/components/vibe-coding/chat-adapters.ts (3)
ToolCallContent(9-9)createHistoryAdapter(63-76)createChatAdapter(15-61)apps/dashboard/src/components/vibe-coding/vibe-code-layout.tsx (1)
VibeCodeLayout(13-37)apps/dashboard/src/components/email-preview.tsx (1)
EmailPreview(91-122)apps/dashboard/src/components/vibe-coding/code-editor.tsx (1)
CodeEditor(15-144)apps/dashboard/src/components/email-theme-selector.tsx (1)
EmailThemeSelector(17-39)apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx (1)
EmailDraftUI(9-28)apps/dashboard/src/components/data-table/team-member-search-table.tsx (1)
TeamMemberSearchTable(9-68)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (9)
- GitHub Check: Security Check
- GitHub Check: lint_and_build (latest)
- GitHub Check: all-good
- GitHub Check: setup-tests
- GitHub Check: build (22.x)
- GitHub Check: restart-dev-and-test
- GitHub Check: build (22.x)
- GitHub Check: docker
- GitHub Check: docker
🔇 Additional comments (2)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (1)
10-10: Confirmed:toastIs Exported by the PackageI checked
use-toast.tsxand saw:
export { useToast, toast };inpackages/stack-ui/src/components/ui/use-toast.tsx- The root
index.tsincludesexport * from "./components/ui/use-toast";Therefore,
toastis available from"@stackframe/stack-ui", and the existing imports are correct. No changes are needed here.apps/backend/src/app/api/latest/emails/send-email/route.tsx (1)
85-94: LGTM! Clean implementation of draft support.The draft integration is well-structured:
- Properly retrieves the draft using the helper function
- Correctly resolves theme based on draft settings
- Falls back to body.theme_id when provided
- Has appropriate error handling for missing drafts
...board/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx
Show resolved
Hide resolved
...board/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx
Show resolved
Hide resolved
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
♻️ Duplicate comments (2)
packages/stack-shared/src/interface/admin-interface.ts (2)
134-138: Standardize theme_id type; remove redundant| undefined.Align with prior feedback and the rest of the file: use
string | null | falseand drop the redundant| undefined.- async listInternalEmailDrafts(): Promise<{ id: string, display_name: string, theme_id?: string | undefined | false, tsx_source: string, sent_at_millis?: number | null }[]> { + async listInternalEmailDrafts(): Promise<{ id: string, display_name: string, theme_id?: string | null | false, tsx_source: string, sent_at_millis?: number | null }[]> { const response = await this.sendAdminRequest(`/internal/email-drafts`, {}, null); - const result = await response.json() as { drafts: { id: string, display_name: string, theme_id?: string | undefined | false, tsx_source: string, sent_at_millis?: number | null }[] }; + const result = await response.json() as { drafts: { id: string, display_name: string, theme_id?: string | null | false, tsx_source: string, sent_at_millis?: number | null }[] }; return result.drafts; }Optional: introduce a named type (e.g., EmailDraftListItem) to avoid duplicating this inline shape in both places.
140-153: Make display name required and use camelCase options with snake_case payload mapping.
- Make
display_namerequired (as per backend) — incorporates earlier feedback.- Keep union consistent:
themeId?: string | null | false.- Match surrounding style: accept camelCase in the TS API and map to snake_case for the request body.
- async createEmailDraft(options: { display_name?: string, theme_id?: string | false, tsx_source?: string }): Promise<{ id: string }> { + async createEmailDraft(options: { displayName: string, themeId?: string | null | false, tsxSource?: string }): Promise<{ id: string }> { const response = await this.sendAdminRequest( `/internal/email-drafts`, { method: "POST", headers: { "content-type": "application/json", }, - body: JSON.stringify(options), + body: JSON.stringify({ + display_name: options.displayName, + theme_id: options.themeId, + tsx_source: options.tsxSource, + }), }, null, ); return await response.json(); }#!/bin/bash # Verify callsites to update for the new camelCase options and required displayName. rg -nP -C2 '\bcreateEmailDraft\s*\(' # Confirm backend accepts `null` for project-default theme and the fields we send. rg -nP -C3 'email-drafts|DraftThemeMode|theme_id|display_name|tsx_source'
🧹 Nitpick comments (1)
packages/stack-shared/src/interface/admin-interface.ts (1)
155-168: Use camelCase for the TS API surface and map to snake_case inupdateEmailDraft; backend already supportssent_at_millis
In
packages/stack-shared/src/interface/admin-interface.ts:
• Change the method signature toasync updateEmailDraft( id: string, data: { displayName?: string; themeId?: string | null | false; tsxSource?: string; sentAtMillis?: number | null; } ): Promise<void>• Replace
JSON.stringify(data)with an explicit mapping to snake_case keys in the request body.In
packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts:
• Update theupdateEmailDraftsignature to match the new camelCase props (and includesentAtMillis?: number | nullif needed).In
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts:
• Remove the manual snake_case mapping and simply forward your camelCase props to the shared interface:await this._interface.updateEmailDraft(id, { displayName: data.displayName, themeId: data.themeId, tsxSource: data.tsxSource, // include sentAtMillis if applicable });Confirmed via the backend PATCH handler (
apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx) thatsent_at_millisis declared optional in the Yup schema and mapped in the handler, so the API supports overriding the timestamp.Optional refactor diff for
admin-interface.ts:--- a/packages/stack-shared/src/interface/admin-interface.ts +++ b/packages/stack-shared/src/interface/admin-interface.ts @@ -155,7 +155,14 @@ - async updateEmailDraft(id: string, data: { display_name?: string, theme_id?: string | null | false, tsx_source?: string, sent_at_millis?: number | null }): Promise<void> { - await this.sendAdminRequest( - `/internal/email-drafts/${id}`, - { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify(data), - }, - null, - ); - } + async updateEmailDraft( + id: string, + data: { + displayName?: string; + themeId?: string | null | false; + tsxSource?: string; + sentAtMillis?: number | null; + } + ): Promise<void> { + await this.sendAdminRequest( + `/internal/email-drafts/${id}`, + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + display_name: data.displayName, + theme_id: data.themeId, + tsx_source: data.tsxSource, + sent_at_millis: data.sentAtMillis, + }), + }, + null, + ); + }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (5)
apps/backend/prisma/schema.prisma(1 hunks)packages/stack-shared/src/interface/admin-interface.ts(2 hunks)packages/stack-shared/src/utils/types.tsx(1 hunks)packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts(4 hunks)packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts(3 hunks)
🚧 Files skipped from review as they are similar to previous changes (4)
- packages/stack-shared/src/utils/types.tsx
- apps/backend/prisma/schema.prisma
- packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts
- packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
Prefer ES6 Map over Record where feasible
Files:
packages/stack-shared/src/interface/admin-interface.ts
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (9)
- GitHub Check: all-good
- GitHub Check: setup-tests
- GitHub Check: docker
- GitHub Check: docker
- GitHub Check: lint_and_build (latest)
- GitHub Check: restart-dev-and-test
- GitHub Check: build (22.x)
- GitHub Check: build (22.x)
- GitHub Check: Security Check
🔇 Additional comments (1)
packages/stack-shared/src/interface/admin-interface.ts (1)
369-369: Adding "email-draft" context to chat: LGTM.Union extension is consistent with existing values.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
🧹 Nitpick comments (1)
examples/supabase/package.json (1)
9-9: Standardizenext startscripts to use-pand respectPORTenv across the monorepoOur search found that nearly every
package.jsoninexamples/,apps/, anddocs/hardcodes a port via--port <number>rather than the shorter-pflag, and none honor thePORTenvironment variable.
To improve consistency and PaaS compatibility, we should:
- Prefer the short flag
-p(as documented in the Next.js CLI).- Use
PORT=${PORT:-<fallback>}so deployers can inject their port at runtime.- Apply this change to all affected
package.jsonstart scripts.Affected files include (but may not be limited to):
• examples/supabase/package.json
• examples/middleware/package.json
• examples/partial-prerendering/package.json
• examples/docs-examples/package.json
• examples/demo/package.json
• examples/e-commerce/package.json
• examples/cjs-test/package.json
• apps/dashboard/package.json
• apps/backend/package.json
• docs/package.jsonFor example, in
examples/supabase/package.json:- "start": "next start --port 8115" + "start": "PORT=${PORT:-8115} next start -p \"$PORT\""Please apply a similar pattern in each
startscript to ensure consistency and avoid hardcoding ports.
For Windows environments, you may need to usecross-envor maintain separatestart:localscripts as appropriate.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (1)
examples/supabase/package.json(1 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (9)
- GitHub Check: build (22.x)
- GitHub Check: lint_and_build (latest)
- GitHub Check: setup-tests
- GitHub Check: restart-dev-and-test
- GitHub Check: all-good
- GitHub Check: docker
- GitHub Check: build (22.x)
- GitHub Check: docker
- GitHub Check: Security Check
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 2
♻️ Duplicate comments (2)
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (2)
493-501: Let create accept null to intentionally select project theme; coalesce undefined → nullPrevents accidental “unset” vs “project theme” ambiguity and matches backend.
Apply:
- async createEmailDraft(options: { displayName: string, themeId?: string | false, tsxSource?: string }): Promise<{ id: string }> { + async createEmailDraft(options: { displayName: string, themeId?: string | null | false, tsxSource?: string }): Promise<{ id: string }> { const result = await this._interface.createEmailDraft({ display_name: options.displayName, - theme_id: options.themeId, + // use null for “project theme”; preserve false/string + theme_id: options.themeId ?? null, tsx_source: options.tsxSource, }); await this._adminEmailDraftsCache.refresh([]); return result; }
503-510: Standardize update to use string | null | false; don’t send theme_id when absentRemoves undefined from the union, uses null for “project theme”, and avoids overwriting when the field is omitted.
Apply:
- async updateEmailDraft(id: string, data: { displayName?: string, themeId?: string | undefined | false, tsxSource?: string }): Promise<void> { - await this._interface.updateEmailDraft(id, { - display_name: data.displayName, - theme_id: data.themeId, - tsx_source: data.tsxSource, - }); + async updateEmailDraft(id: string, data: { displayName?: string, themeId?: string | null | false, tsxSource?: string }): Promise<void> { + const payload: { + display_name?: string; + tsx_source?: string; + theme_id?: string | null | false; + } = { + display_name: data.displayName, + tsx_source: data.tsxSource, + }; + // Only include when provided; use null to explicitly select project theme + if (Object.prototype.hasOwnProperty.call(data, "themeId")) { + payload.theme_id = data.themeId ?? null; + } + await this._interface.updateEmailDraft(id, payload); await this._adminEmailDraftsCache.refresh([]); }Run to spot remaining undefined unions for themeId across the repo:
#!/bin/bash set -euo pipefail echo "Occurrences of themeId typed with undefined|false:" rg -nP --type ts --type tsx -C2 '\bthemeId\?\s*:\s*string\s*\|\s*undefined\s*\|\s*false' echo echo "Occurrences of drafts returning themeId with undefined:" rg -nP --type ts --type tsx -C2 'listEmailDrafts\(\).*themeId: string \| undefined \| false|useEmailDrafts\(\).*themeId: string \| undefined \| false' echo echo "Occurrences already using null (for reference):" rg -nP --type ts --type tsx -C2 '\bthemeId\?\s*:\s*string\s*\|\s*null\s*\|\s*false'
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (3)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx(5 hunks)packages/stack-shared/src/interface/admin-interface.ts(2 hunks)packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts(4 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
- packages/stack-shared/src/interface/admin-interface.ts
- apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
Prefer ES6 Map over Record where feasible
Files:
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts
🧬 Code graph analysis (1)
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (1)
packages/template/src/lib/stack-app/apps/implementations/common.ts (2)
createCache(22-27)useAsyncCache(145-190)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (9)
- GitHub Check: build (22.x)
- GitHub Check: lint_and_build (latest)
- GitHub Check: restart-dev-and-test
- GitHub Check: setup-tests
- GitHub Check: docker
- GitHub Check: all-good
- GitHub Check: build (22.x)
- GitHub Check: docker
- GitHub Check: Security Check
🔇 Additional comments (2)
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (2)
45-47: Drafts cache wiring looks goodCache creation aligns with existing patterns and uses the correct interface method.
514-514: Add “email-draft” to chat context — LGTMMatches new draft workflows and keeps typing aligned.
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts
Show resolved
Hide resolved
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
♻️ Duplicate comments (1)
apps/backend/prisma/schema.prisma (1)
676-691: Add DB-level CHECK to enforce themeMode/themeId consistencyMirror the business rule (CUSTOM => themeId IS NOT NULL; PROJECT_DEFAULT/NONE => themeId IS NULL) with a PostgreSQL CHECK in the migration to prevent inconsistent rows. Also keep app-layer validation.
Example migration snippet:
ALTER TABLE "EmailDraft" ADD CONSTRAINT "emaildraft_theme_consistency" CHECK ( ("themeMode" = 'CUSTOM' AND "themeId" IS NOT NULL) OR ("themeMode" IN ('PROJECT_DEFAULT','NONE') AND "themeId" IS NULL) );
🧹 Nitpick comments (2)
apps/backend/prisma/schema.prisma (1)
676-691: Add helpful indexes for common queries (list/sort/filter)If you list drafts per tenancy ordered by recency or filter by sent/unsent, these indexes help.
model EmailDraft { tenancyId String @db.Uuid id String @default(uuid()) @db.Uuid displayName String themeMode DraftThemeMode @default(PROJECT_DEFAULT) themeId String? tsxSource String sentAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@id([tenancyId, id]) + @@id([tenancyId, id]) + @@index([tenancyId, updatedAt(sort: Desc)], name: "EmailDraft_updatedAt_desc") + @@index([tenancyId, sentAt], name: "EmailDraft_sentAt") }apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (1)
203-222: Prevent blank breadcrumb when draft isn’t loadedIf useEmailDrafts() hasn’t resolved or draft is missing, the crumb renders empty. Fall back to “Draft”.
function DraftBreadcrumbItem(props: { draftId: string }) { const stackAdminApp = useAdminApp(); const drafts = stackAdminApp.useEmailDrafts(); const draft = drafts.find((d) => d.id === props.draftId); - if (!draft) { - return null; - } - return draft.displayName; + return draft?.displayName ?? "Draft"; }Also applies to: 365-373
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (6)
apps/backend/prisma/schema.prisma(1 hunks)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx(5 hunks)examples/supabase/package.json(1 hunks)packages/stack-shared/src/interface/admin-interface.ts(2 hunks)packages/stack-shared/src/interface/server-interface.ts(2 hunks)packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts(4 hunks)
🚧 Files skipped from review as they are similar to previous changes (4)
- packages/stack-shared/src/interface/server-interface.ts
- packages/stack-shared/src/interface/admin-interface.ts
- packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts
- examples/supabase/package.json
🧰 Additional context used
📓 Path-based instructions (3)
{apps/dashboard,apps/dev-launchpad,packages/stack-ui,packages/react}/**/*.{tsx,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
For blocking alerts and errors in UI, do not use toast notifications; use alerts instead
Files:
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx
**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
Prefer ES6 Map over Record when representing key–value collections
Files:
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx
{apps/dashboard,apps/dev-launchpad,packages/stack-ui,packages/react}/**/*.{tsx,jsx,css}
📄 CodeRabbit inference engine (AGENTS.md)
Keep hover/click animations snappy; avoid pre-transition delays on hover and apply transitions after the action (e.g., fade-out on hover end)
Files:
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx
🧬 Code graph analysis (1)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (1)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx (1)
useAdminApp(27-34)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (9)
- GitHub Check: setup-tests
- GitHub Check: lint_and_build (latest)
- GitHub Check: build (22.x)
- GitHub Check: restart-dev-and-test
- GitHub Check: all-good
- GitHub Check: docker
- GitHub Check: build (22.x)
- GitHub Check: docker
- GitHub Check: Security Check
🔇 Additional comments (5)
apps/backend/prisma/schema.prisma (2)
693-697: Enum looks goodDefault(PROJECT_DEFAULT) matches the intended behavior.
683-683: Drop UUID annotation suggestion for themeId
themeId references themes in tenancy.config.emails.themes (not a Prisma model), so keeping it asString?is correct.Likely an incorrect or invalid review comment.
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (3)
27-27: Icon import updates are correctNew icons are referenced below; imports are consistent.
Also applies to: 30-30, 42-42
182-188: Sidebar: Add “Drafts” under Emails — LGTMRoute/regex and icon are consistent with existing items.
193-193: Templates icon swap — LGTMLayoutTemplate is clearer for templates.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
🧹 Nitpick comments (1)
apps/e2e/tests/js/email.test.ts (1)
165-171: Pragmatic cast to any to exercise XOR runtime check — OK; add lint guard to avoid CI noise.The
as anycast is appropriate here to bypass TS XOR types and assert runtimeSchemaError. If your ESLint rules forbid explicitany, add a one-line suppression.Apply this minimal diff:
await expect(serverApp.sendEmail({ userIds: [user.id], html: "<p>Test Email</p>", templateId: DEFAULT_TEMPLATE_IDS.sign_in_invitation, subject: "Test Email", - } as any)).rejects.toThrow(KnownErrors.SchemaError); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any)).rejects.toThrow(KnownErrors.SchemaError);Optional: if you prefer avoiding inline disables, introduce a tiny test helper once and reuse:
// test-utils/assert.ts export const unsafe = <T>(v: unknown) => v as any as T;Then call with
unsafe<SomeSendEmailInput>({ ...invalidPayload }).
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (7)
apps/backend/prisma/schema.prisma(1 hunks)apps/backend/src/lib/email-rendering.tsx(3 hunks)apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx(5 hunks)apps/e2e/tests/js/email.test.ts(1 hunks)examples/supabase/package.json(1 hunks)packages/stack-shared/src/interface/server-interface.ts(2 hunks)packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts(4 hunks)
🚧 Files skipped from review as they are similar to previous changes (6)
- apps/backend/prisma/schema.prisma
- apps/backend/src/lib/email-rendering.tsx
- examples/supabase/package.json
- apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx
- packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts
- packages/stack-shared/src/interface/server-interface.ts
🧰 Additional context used
📓 Path-based instructions (2)
**/*.test.{ts,tsx,js}
📄 CodeRabbit inference engine (AGENTS.md)
In tests, prefer .toMatchInlineSnapshot where possible; refer to snapshot-serializer.ts for snapshot formatting and handling of non-deterministic values
Files:
apps/e2e/tests/js/email.test.ts
**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
Prefer ES6 Map over Record when representing key–value collections
Files:
apps/e2e/tests/js/email.test.ts
🧬 Code graph analysis (1)
apps/e2e/tests/js/email.test.ts (1)
packages/stack-shared/src/known-errors.tsx (2)
KnownErrors(1570-1572)KnownErrors(1574-1696)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (9)
- GitHub Check: setup-tests
- GitHub Check: build (22.x)
- GitHub Check: build (22.x)
- GitHub Check: docker
- GitHub Check: docker
- GitHub Check: restart-dev-and-test
- GitHub Check: lint_and_build (latest)
- GitHub Check: all-good
- GitHub Check: Security Check
...board/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 3
♻️ Duplicate comments (3)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (3)
27-34: Make unsaved-changes guard reflect “edit + dirty” onlyAvoid sticky confirms by deriving from dirty and stage.
-useEffect(() => { - if (!draft) return; - if (draft.tsxSource === currentCode && draft.themeId === selectedThemeId) return; - if (stage !== "edit") return; - - setNeedConfirm(true); - return () => setNeedConfirm(false); -}, [setNeedConfirm, draft, currentCode, selectedThemeId, stage]); +useEffect(() => { + setNeedConfirm(stage === "edit" && !!dirty); + return () => setNeedConfirm(false); +}, [setNeedConfirm, stage, dirty]);
23-26: Fix async hydration and themeId null typing to prevent data losscurrentCode/selectedThemeId are only initialized at mount; if the draft arrives later, clicking Next can overwrite server content with empty code. Also, EmailThemeSelector returns null (“Project theme”) but the state excludes null, causing type/logic mismatches. Add one-time hydration when draft loads, include null in the union, and introduce saving/saveError + a derived dirty flag.
- const [currentCode, setCurrentCode] = useState<string>(draft?.tsxSource ?? ""); - const [stage, setStage] = useState<"edit" | "send">("edit"); - const [selectedThemeId, setSelectedThemeId] = useState<string | undefined | false>(draft?.themeId); + const [currentCode, setCurrentCode] = useState<string>(draft?.tsxSource ?? ""); + const [stage, setStage] = useState<"edit" | "send">("edit"); + // Allow null (project theme), false (no theme), undefined (uninitialized) + const [selectedThemeId, setSelectedThemeId] = useState<string | null | false | undefined>(draft?.themeId); + const [initializedFromDraft, setInitializedFromDraft] = useState(false); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState<string | null>(null); + + // hydrate once when the draft becomes available to avoid overwriting server content + useEffect(() => { + if (!draft || initializedFromDraft) return; + setCurrentCode(draft.tsxSource); + setSelectedThemeId(draft.themeId); + setInitializedFromDraft(true); + }, [draft, initializedFromDraft]); + + // local edits vs server draft + const dirty = useMemo( + () => !!draft && (currentCode !== draft.tsxSource || selectedThemeId !== draft.themeId), + [draft, currentCode, selectedThemeId] + );
40-51: Guard save before load, prevent double submits, and use inline alerts (no toasts) for blocking errors
- If draft hasn’t loaded, bail.
- Prevent duplicate submissions with a saving flag.
- Per dashboard UI guideline, show blocking errors as alerts, not toasts.
-const handleNext = async () => { - try { - await stackAdminApp.updateEmailDraft(draftId, { tsxSource: currentCode, themeId: selectedThemeId }); - setStage("send"); - } catch (error) { - if (error instanceof KnownErrors.EmailRenderingError) { - toast({ title: "Failed to save draft", variant: "destructive", description: error.message }); - return; - } - toast({ title: "Failed to save draft", variant: "destructive", description: "Unknown error" }); - } -}; +const handleNext = async () => { + if (!draft || saving) return; + setSaveError(null); + setSaving(true); + try { + await stackAdminApp.updateEmailDraft(draftId, { tsxSource: currentCode, themeId: selectedThemeId }); + setStage("send"); + } catch (error) { + if (error instanceof KnownErrors.EmailRenderingError) { + setSaveError(error.message); + return; + } + setSaveError("Failed to save draft due to an unknown error."); + } finally { + setSaving(false); + } +};
🧹 Nitpick comments (1)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (1)
10-10: Clean up toast usage and imports per guideline
- Add Alert for inline error display.
- Drop useToast to avoid shadowing the imported toast and unused var after switching to alerts here.
-import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Skeleton, toast, Typography, useToast } from "@stackframe/stack-ui"; +import { Alert, Badge, Button, Card, CardContent, CardHeader, CardTitle, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Skeleton, toast, Typography } from "@stackframe/stack-ui";- const { toast } = useToast(); + // switched to inline <Alert> for blocking errorsAlso applies to: 18-18
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx(1 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
{apps/dashboard,apps/dev-launchpad,packages/stack-ui,packages/react}/**/*.{tsx,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
For blocking alerts and errors in UI, do not use toast notifications; use alerts instead
Files:
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx
**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
Prefer ES6 Map over Record when representing key–value collections
Files:
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx
{apps/dashboard,apps/dev-launchpad,packages/stack-ui,packages/react}/**/*.{tsx,jsx,css}
📄 CodeRabbit inference engine (AGENTS.md)
Keep hover/click animations snappy; avoid pre-transition delays on hover and apply transitions after the action (e.g., fade-out on hover end)
Files:
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx
🧬 Code graph analysis (1)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (9)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx (1)
useAdminApp(27-34)apps/dashboard/src/components/router.tsx (1)
useRouterConfirm(35-37)apps/dashboard/src/components/vibe-coding/chat-adapters.ts (3)
ToolCallContent(9-9)createHistoryAdapter(63-76)createChatAdapter(15-61)apps/dashboard/src/components/vibe-coding/vibe-code-layout.tsx (1)
VibeCodeLayout(13-37)apps/dashboard/src/components/email-preview.tsx (1)
EmailPreview(91-122)apps/dashboard/src/components/vibe-coding/code-editor.tsx (1)
CodeEditor(15-144)apps/dashboard/src/components/email-theme-selector.tsx (1)
EmailThemeSelector(17-39)apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx (1)
EmailDraftUI(9-28)apps/dashboard/src/components/data-table/team-member-search-table.tsx (1)
TeamMemberSearchTable(9-68)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (9)
- GitHub Check: lint_and_build (latest)
- GitHub Check: setup-tests
- GitHub Check: restart-dev-and-test
- GitHub Check: docker
- GitHub Check: all-good
- GitHub Check: docker
- GitHub Check: build (22.x)
- GitHub Check: build (22.x)
- GitHub Check: Security Check

https://www.loom.com/share/cc379c5372244a169f3ae1d2cc91eae5?sid=ec5bc438-56d8-4cca-9bbc-6cf6c6d313ad
Important
Introduce email draft functionality with AI assistance, including creation, editing, and sending capabilities, and update APIs and UI components accordingly.
emailDraftAdapterinemail-draft-adapter.ts.send-emailandrender-emailAPIs to support drafts.EmailDraftmodel inschema.prisma.email-drafts/route.tsxandemail-drafts/[id]/route.tsx.render-email/route.tsxandsend-email/route.tsxto handle draft inputs.email-drafts/page-client.tsxandemail-drafts/[draftId]/page-client.tsx.EmailThemeSelectorinemail-theme-selector.tsx.sidebar-layout.tsxto include drafts.email-drafts.test.tsandsend-email.test.ts.admin-interface.tsandserver-interface.tsto support draft operations.XORtype utility intypes.tsxfor exclusive option handling.This description was created by
for c7ebb00. You can customize this summary. It will automatically update as commits are pushed.
Summary by CodeRabbit
New Features
Improvements
Tests