@mars-stack/cli 0.2.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +137 -12
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- package/template/.cursor/rules/composition-patterns.mdc +186 -0
- package/template/.cursor/rules/data-access.mdc +29 -0
- package/template/.cursor/rules/project-structure.mdc +34 -0
- package/template/.cursor/rules/security.mdc +25 -0
- package/template/.cursor/rules/testing.mdc +24 -0
- package/template/.cursor/rules/ui-conventions.mdc +29 -0
- package/template/.cursor/skills/add-api-route/SKILL.md +122 -0
- package/template/.cursor/skills/add-audit-log/SKILL.md +375 -0
- package/template/.cursor/skills/add-blog/SKILL.md +447 -0
- package/template/.cursor/skills/add-command-palette/SKILL.md +438 -0
- package/template/.cursor/skills/add-component/SKILL.md +158 -0
- package/template/.cursor/skills/add-crud-routes/SKILL.md +221 -0
- package/template/.cursor/skills/add-e2e-test/SKILL.md +227 -0
- package/template/.cursor/skills/add-error-boundary/SKILL.md +472 -0
- package/template/.cursor/skills/add-feature/SKILL.md +174 -0
- package/template/.cursor/skills/add-middleware/SKILL.md +135 -0
- package/template/.cursor/skills/add-page/SKILL.md +151 -0
- package/template/.cursor/skills/add-prisma-model/SKILL.md +148 -0
- package/template/.cursor/skills/add-protected-resource/SKILL.md +192 -0
- package/template/.cursor/skills/add-role/SKILL.md +156 -0
- package/template/.cursor/skills/add-server-action/SKILL.md +167 -0
- package/template/.cursor/skills/add-webhook/SKILL.md +192 -0
- package/template/.cursor/skills/build-complete-feature/SKILL.md +227 -0
- package/template/.cursor/skills/build-dashboard/SKILL.md +211 -0
- package/template/.cursor/skills/build-data-table/SKILL.md +283 -0
- package/template/.cursor/skills/build-form/SKILL.md +231 -0
- package/template/.cursor/skills/build-landing-page/SKILL.md +248 -0
- package/template/.cursor/skills/configure-ai/SKILL.md +617 -0
- package/template/.cursor/skills/configure-analytics/SKILL.md +413 -0
- package/template/.cursor/skills/configure-dark-mode/SKILL.md +309 -0
- package/template/.cursor/skills/configure-email/SKILL.md +170 -0
- package/template/.cursor/skills/configure-email-verification/SKILL.md +333 -0
- package/template/.cursor/skills/configure-feature-flags/SKILL.md +361 -0
- package/template/.cursor/skills/configure-i18n/SKILL.md +518 -0
- package/template/.cursor/skills/configure-jobs/SKILL.md +500 -0
- package/template/.cursor/skills/configure-magic-links/SKILL.md +385 -0
- package/template/.cursor/skills/configure-multi-tenancy/SKILL.md +611 -0
- package/template/.cursor/skills/configure-notifications/SKILL.md +569 -0
- package/template/.cursor/skills/configure-oauth/SKILL.md +217 -0
- package/template/.cursor/skills/configure-onboarding/SKILL.md +483 -0
- package/template/.cursor/skills/configure-payments/SKILL.md +243 -0
- package/template/.cursor/skills/configure-realtime/SKILL.md +733 -0
- package/template/.cursor/skills/configure-search/SKILL.md +581 -0
- package/template/.cursor/skills/configure-storage/SKILL.md +273 -0
- package/template/.cursor/skills/configure-two-factor/SKILL.md +518 -0
- package/template/.cursor/skills/create-execution-plan/SKILL.md +204 -0
- package/template/.cursor/skills/create-seed/SKILL.md +191 -0
- package/template/.cursor/skills/deploy-to-vercel/SKILL.md +300 -0
- package/template/.cursor/skills/design-tokens/SKILL.md +138 -0
- package/template/.cursor/skills/mars-capture-conversation-context/SKILL.md +119 -0
- package/template/.cursor/skills/setup-billing/SKILL.md +322 -0
- package/template/.cursor/skills/setup-project/SKILL.md +104 -0
- package/template/.cursor/skills/setup-teams/SKILL.md +682 -0
- package/template/.cursor/skills/test-api-route/SKILL.md +219 -0
- package/template/.cursor/skills/update-architecture-docs/SKILL.md +99 -0
- package/template/AGENTS.md +104 -0
- package/template/ARCHITECTURE.md +102 -0
- package/template/docs/QUALITY_SCORE.md +20 -0
- package/template/docs/design-docs/conversation-as-system-record.md +70 -0
- package/template/docs/design-docs/core-beliefs.md +43 -0
- package/template/docs/design-docs/index.md +8 -0
- package/template/docs/exec-plans/active/.gitkeep +0 -0
- package/template/docs/exec-plans/completed/.gitkeep +0 -0
- package/template/docs/exec-plans/tech-debt.md +7 -0
- package/template/docs/generated/.gitkeep +0 -0
- package/template/docs/product-specs/index.md +7 -0
- package/template/docs/references/index.md +18 -0
- package/template/e2e/api.spec.ts +20 -0
- package/template/e2e/auth.spec.ts +24 -0
- package/template/e2e/public.spec.ts +25 -0
- package/template/eslint.config.mjs +24 -0
- package/template/next-env.d.ts +6 -0
- package/template/next.config.ts +45 -0
- package/template/package.json +80 -0
- package/template/playwright.config.ts +31 -0
- package/template/postcss.config.mjs +8 -0
- package/template/prisma/generated/prisma/browser.ts +49 -0
- package/template/prisma/generated/prisma/client.ts +73 -0
- package/template/prisma/generated/prisma/commonInputTypes.ts +406 -0
- package/template/prisma/generated/prisma/enums.ts +15 -0
- package/template/prisma/generated/prisma/internal/class.ts +254 -0
- package/template/prisma/generated/prisma/internal/prismaNamespace.ts +1240 -0
- package/template/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
- package/template/prisma/generated/prisma/models/Account.ts +1543 -0
- package/template/prisma/generated/prisma/models/File.ts +1529 -0
- package/template/prisma/generated/prisma/models/Session.ts +1415 -0
- package/template/prisma/generated/prisma/models/Subscription.ts +1455 -0
- package/template/prisma/generated/prisma/models/User.ts +2235 -0
- package/template/prisma/generated/prisma/models/VerificationToken.ts +1099 -0
- package/template/prisma/generated/prisma/models.ts +17 -0
- package/template/prisma/schema/auth.prisma +69 -0
- package/template/prisma/schema/base.prisma +8 -0
- package/template/prisma/schema/file.prisma +15 -0
- package/template/prisma/schema/subscription.prisma +17 -0
- package/template/prisma.config.ts +13 -0
- package/template/scripts/check-architecture.ts +221 -0
- package/template/scripts/check-doc-freshness.ts +242 -0
- package/template/scripts/ensure-db.mjs +291 -0
- package/template/scripts/generate-docs.ts +143 -0
- package/template/scripts/generate-env-example.ts +89 -0
- package/template/scripts/seed.ts +56 -0
- package/template/scripts/update-quality-score.ts +263 -0
- package/template/src/__tests__/architecture.test.ts +114 -0
- package/template/src/app/(auth)/forgotten-password/page.tsx +92 -0
- package/template/src/app/(auth)/layout.tsx +11 -0
- package/template/src/app/(auth)/register/page.tsx +162 -0
- package/template/src/app/(auth)/reset-password/page.tsx +109 -0
- package/template/src/app/(auth)/sign-in/page.tsx +122 -0
- package/template/src/app/(auth)/verify/[token]/page.tsx +87 -0
- package/template/src/app/(auth)/verify/page.tsx +56 -0
- package/template/src/app/(protected)/admin/page.tsx +108 -0
- package/template/src/app/(protected)/dashboard/loading.tsx +20 -0
- package/template/src/app/(protected)/dashboard/page.tsx +22 -0
- package/template/src/app/(protected)/layout.tsx +262 -0
- package/template/src/app/(protected)/settings/page.tsx +370 -0
- package/template/src/app/api/auth/forgot/route.ts +63 -0
- package/template/src/app/api/auth/login/route.ts +121 -0
- package/template/src/app/api/auth/logout/route.ts +19 -0
- package/template/src/app/api/auth/me/route.ts +30 -0
- package/template/src/app/api/auth/reset/route.ts +45 -0
- package/template/src/app/api/auth/signup/route.ts +85 -0
- package/template/src/app/api/auth/verify/route.ts +46 -0
- package/template/src/app/api/csrf/route.ts +12 -0
- package/template/src/app/api/health/route.ts +10 -0
- package/template/src/app/api/protected/admin/users/route.ts +24 -0
- package/template/src/app/api/protected/billing/checkout/route.ts +83 -0
- package/template/src/app/api/protected/billing/portal/route.ts +39 -0
- package/template/src/app/api/protected/files/[fileId]/route.ts +86 -0
- package/template/src/app/api/protected/files/upload/route.ts +64 -0
- package/template/src/app/api/protected/user/password/route.ts +63 -0
- package/template/src/app/api/protected/user/profile/route.ts +35 -0
- package/template/src/app/api/protected/user/sessions/[sessionId]/route.ts +33 -0
- package/template/src/app/api/protected/user/sessions/route.ts +22 -0
- package/template/src/app/api/readiness/route.ts +15 -0
- package/template/src/app/api/webhooks/stripe/route.ts +166 -0
- package/template/src/app/error.tsx +33 -0
- package/template/src/app/layout.tsx +29 -0
- package/template/src/app/not-found.tsx +20 -0
- package/template/src/app/page.tsx +136 -0
- package/template/src/app/privacy/page.tsx +178 -0
- package/template/src/app/providers.tsx +8 -0
- package/template/src/app/terms/page.tsx +139 -0
- package/template/src/config/app.config.ts +70 -0
- package/template/src/config/routes.ts +17 -0
- package/template/src/features/admin/index.ts +11 -0
- package/template/src/features/admin/permissions.ts +64 -0
- package/template/src/features/auth/context/AuthContext.tsx +96 -0
- package/template/src/features/auth/context/index.ts +2 -0
- package/template/src/features/auth/index.ts +3 -0
- package/template/src/features/auth/server/consent.ts +66 -0
- package/template/src/features/auth/server/session-revocation.ts +20 -0
- package/template/src/features/auth/server/sessions.ts +66 -0
- package/template/src/features/auth/server/user.ts +166 -0
- package/template/src/features/auth/types.ts +19 -0
- package/template/src/features/auth/validators.ts +29 -0
- package/template/src/features/billing/server/index.ts +66 -0
- package/template/src/features/billing/types.ts +43 -0
- package/template/src/features/uploads/server/index.ts +49 -0
- package/template/src/features/uploads/types.ts +26 -0
- package/template/src/lib/core/email/templates/base-layout.ts +122 -0
- package/template/src/lib/core/email/templates/index.ts +4 -0
- package/template/src/lib/core/email/templates/password-reset-email.ts +42 -0
- package/template/src/lib/core/email/templates/verification-email.ts +41 -0
- package/template/src/lib/core/email/templates/welcome-email.ts +40 -0
- package/template/src/lib/mars.ts +56 -0
- package/template/src/lib/prisma.ts +19 -0
- package/template/src/proxy.ts +92 -0
- package/template/src/styles/brand.css +15 -0
- package/template/src/styles/globals.css +7 -0
- package/template/tsconfig.json +59 -0
- package/template/vitest.config.ts +41 -0
- package/template/vitest.setup.ts +24 -0
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
# Skill: Add Error Boundary
|
|
2
|
+
|
|
3
|
+
Set up React error boundaries with fallback UI, Next.js `error.tsx` files for route segments, error reporting integration, and toast notifications for caught errors.
|
|
4
|
+
|
|
5
|
+
## When to Use
|
|
6
|
+
|
|
7
|
+
Use this skill when the user asks to add error handling, error boundaries, crash recovery, error reporting, Sentry integration, or global error handling to the application.
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
- Read `src/app/layout.tsx` to see the current root layout structure.
|
|
12
|
+
- Read `src/app/(protected)/layout.tsx` to see the protected layout.
|
|
13
|
+
|
|
14
|
+
## Step 1: Error Boundary Component
|
|
15
|
+
|
|
16
|
+
```tsx
|
|
17
|
+
// src/features/error-handling/components/ErrorBoundary.tsx
|
|
18
|
+
'use client';
|
|
19
|
+
|
|
20
|
+
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
|
21
|
+
|
|
22
|
+
interface ErrorBoundaryProps {
|
|
23
|
+
children: ReactNode;
|
|
24
|
+
fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode);
|
|
25
|
+
onError?: (error: Error, errorInfo: ErrorInfo) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface ErrorBoundaryState {
|
|
29
|
+
hasError: boolean;
|
|
30
|
+
error: Error | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
34
|
+
constructor(props: ErrorBoundaryProps) {
|
|
35
|
+
super(props);
|
|
36
|
+
this.state = { hasError: false, error: null };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
40
|
+
return { hasError: true, error };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
|
44
|
+
this.props.onError?.(error, errorInfo);
|
|
45
|
+
reportError(error, errorInfo);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
reset = (): void => {
|
|
49
|
+
this.setState({ hasError: false, error: null });
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
render(): ReactNode {
|
|
53
|
+
if (this.state.hasError && this.state.error) {
|
|
54
|
+
if (typeof this.props.fallback === 'function') {
|
|
55
|
+
return this.props.fallback(this.state.error, this.reset);
|
|
56
|
+
}
|
|
57
|
+
if (this.props.fallback) {
|
|
58
|
+
return this.props.fallback;
|
|
59
|
+
}
|
|
60
|
+
return <DefaultErrorFallback error={this.state.error} reset={this.reset} />;
|
|
61
|
+
}
|
|
62
|
+
return this.props.children;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function DefaultErrorFallback({ error, reset }: { error: Error; reset: () => void }) {
|
|
67
|
+
return (
|
|
68
|
+
<div className="flex min-h-[300px] flex-col items-center justify-center gap-4 p-6">
|
|
69
|
+
<div className="rounded-lg border border-border-primary bg-surface-secondary p-6 text-center">
|
|
70
|
+
<h2 className="mb-2 text-lg font-semibold text-text-primary">
|
|
71
|
+
Something went wrong
|
|
72
|
+
</h2>
|
|
73
|
+
<p className="mb-4 text-sm text-text-secondary">
|
|
74
|
+
{error.message || 'An unexpected error occurred.'}
|
|
75
|
+
</p>
|
|
76
|
+
<button
|
|
77
|
+
onClick={reset}
|
|
78
|
+
className="rounded-md bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2"
|
|
79
|
+
>
|
|
80
|
+
Try again
|
|
81
|
+
</button>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function reportError(error: Error, errorInfo: ErrorInfo): void {
|
|
88
|
+
if (typeof window !== 'undefined' && window.Sentry) {
|
|
89
|
+
window.Sentry.captureException(error, {
|
|
90
|
+
extra: { componentStack: errorInfo.componentStack },
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
declare global {
|
|
96
|
+
interface Window {
|
|
97
|
+
Sentry?: {
|
|
98
|
+
captureException: (error: unknown, context?: Record<string, unknown>) => void;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Step 2: Route-Level Error Files
|
|
105
|
+
|
|
106
|
+
### Global error handler
|
|
107
|
+
|
|
108
|
+
```tsx
|
|
109
|
+
// src/app/global-error.tsx
|
|
110
|
+
'use client';
|
|
111
|
+
|
|
112
|
+
export default function GlobalError({
|
|
113
|
+
error,
|
|
114
|
+
reset,
|
|
115
|
+
}: {
|
|
116
|
+
error: Error & { digest?: string };
|
|
117
|
+
reset: () => void;
|
|
118
|
+
}) {
|
|
119
|
+
return (
|
|
120
|
+
<html>
|
|
121
|
+
<body>
|
|
122
|
+
<div style={{
|
|
123
|
+
display: 'flex',
|
|
124
|
+
minHeight: '100vh',
|
|
125
|
+
alignItems: 'center',
|
|
126
|
+
justifyContent: 'center',
|
|
127
|
+
fontFamily: 'system-ui, sans-serif',
|
|
128
|
+
}}>
|
|
129
|
+
<div style={{ textAlign: 'center', maxWidth: '400px', padding: '2rem' }}>
|
|
130
|
+
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, marginBottom: '0.5rem' }}>
|
|
131
|
+
Application Error
|
|
132
|
+
</h1>
|
|
133
|
+
<p style={{ color: '#666', marginBottom: '1.5rem' }}>
|
|
134
|
+
A critical error occurred. Please try refreshing the page.
|
|
135
|
+
</p>
|
|
136
|
+
{error.digest && (
|
|
137
|
+
<p style={{ color: '#999', fontSize: '0.75rem', marginBottom: '1rem' }}>
|
|
138
|
+
Error ID: {error.digest}
|
|
139
|
+
</p>
|
|
140
|
+
)}
|
|
141
|
+
<button
|
|
142
|
+
onClick={reset}
|
|
143
|
+
style={{
|
|
144
|
+
padding: '0.5rem 1.5rem',
|
|
145
|
+
borderRadius: '0.375rem',
|
|
146
|
+
border: 'none',
|
|
147
|
+
backgroundColor: '#2563eb',
|
|
148
|
+
color: 'white',
|
|
149
|
+
cursor: 'pointer',
|
|
150
|
+
fontSize: '0.875rem',
|
|
151
|
+
fontWeight: 500,
|
|
152
|
+
}}
|
|
153
|
+
>
|
|
154
|
+
Refresh
|
|
155
|
+
</button>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
</body>
|
|
159
|
+
</html>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Protected route error handler
|
|
165
|
+
|
|
166
|
+
```tsx
|
|
167
|
+
// src/app/(protected)/error.tsx
|
|
168
|
+
'use client';
|
|
169
|
+
|
|
170
|
+
import { Button } from '@mars-stack/ui';
|
|
171
|
+
import { H1, Paragraph } from '@mars-stack/ui';
|
|
172
|
+
|
|
173
|
+
export default function ProtectedError({
|
|
174
|
+
error,
|
|
175
|
+
reset,
|
|
176
|
+
}: {
|
|
177
|
+
error: Error & { digest?: string };
|
|
178
|
+
reset: () => void;
|
|
179
|
+
}) {
|
|
180
|
+
return (
|
|
181
|
+
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 p-6">
|
|
182
|
+
<H1>Something went wrong</H1>
|
|
183
|
+
<Paragraph className="max-w-md text-center text-text-secondary">
|
|
184
|
+
{error.message || 'An unexpected error occurred. Please try again.'}
|
|
185
|
+
</Paragraph>
|
|
186
|
+
{error.digest && (
|
|
187
|
+
<p className="text-xs text-text-tertiary">Error ID: {error.digest}</p>
|
|
188
|
+
)}
|
|
189
|
+
<div className="flex gap-3">
|
|
190
|
+
<Button onClick={reset} variant="primary">
|
|
191
|
+
Try again
|
|
192
|
+
</Button>
|
|
193
|
+
<Button onClick={() => window.location.href = '/'} variant="secondary">
|
|
194
|
+
Go home
|
|
195
|
+
</Button>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### Auth route error handler
|
|
203
|
+
|
|
204
|
+
```tsx
|
|
205
|
+
// src/app/(auth)/error.tsx
|
|
206
|
+
'use client';
|
|
207
|
+
|
|
208
|
+
import { Button } from '@mars-stack/ui';
|
|
209
|
+
|
|
210
|
+
export default function AuthError({
|
|
211
|
+
error,
|
|
212
|
+
reset,
|
|
213
|
+
}: {
|
|
214
|
+
error: Error & { digest?: string };
|
|
215
|
+
reset: () => void;
|
|
216
|
+
}) {
|
|
217
|
+
return (
|
|
218
|
+
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 p-6">
|
|
219
|
+
<h2 className="text-lg font-semibold text-text-primary">Authentication Error</h2>
|
|
220
|
+
<p className="text-sm text-text-secondary">
|
|
221
|
+
{error.message || 'Something went wrong during authentication.'}
|
|
222
|
+
</p>
|
|
223
|
+
<Button onClick={reset} variant="primary">
|
|
224
|
+
Try again
|
|
225
|
+
</Button>
|
|
226
|
+
</div>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## Step 3: Not Found Page
|
|
232
|
+
|
|
233
|
+
```tsx
|
|
234
|
+
// src/app/not-found.tsx
|
|
235
|
+
import { Button } from '@mars-stack/ui';
|
|
236
|
+
import { H1, Paragraph } from '@mars-stack/ui';
|
|
237
|
+
import Link from 'next/link';
|
|
238
|
+
|
|
239
|
+
export default function NotFound() {
|
|
240
|
+
return (
|
|
241
|
+
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 p-6">
|
|
242
|
+
<H1>Page not found</H1>
|
|
243
|
+
<Paragraph className="text-text-secondary">
|
|
244
|
+
The page you're looking for doesn't exist or has been moved.
|
|
245
|
+
</Paragraph>
|
|
246
|
+
<Link href="/">
|
|
247
|
+
<Button variant="primary">Go home</Button>
|
|
248
|
+
</Link>
|
|
249
|
+
</div>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## Step 4: Sentry Integration (Optional)
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
yarn add @sentry/nextjs
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
```typescript
|
|
261
|
+
// sentry.client.config.ts
|
|
262
|
+
import * as Sentry from '@sentry/nextjs';
|
|
263
|
+
|
|
264
|
+
Sentry.init({
|
|
265
|
+
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
|
266
|
+
tracesSampleRate: 0.1,
|
|
267
|
+
replaysSessionSampleRate: 0.1,
|
|
268
|
+
replaysOnErrorSampleRate: 1.0,
|
|
269
|
+
environment: process.env.NODE_ENV,
|
|
270
|
+
enabled: process.env.NODE_ENV === 'production',
|
|
271
|
+
});
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
```typescript
|
|
275
|
+
// sentry.server.config.ts
|
|
276
|
+
import * as Sentry from '@sentry/nextjs';
|
|
277
|
+
|
|
278
|
+
Sentry.init({
|
|
279
|
+
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
|
280
|
+
tracesSampleRate: 0.1,
|
|
281
|
+
environment: process.env.NODE_ENV,
|
|
282
|
+
enabled: process.env.NODE_ENV === 'production',
|
|
283
|
+
});
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
```typescript
|
|
287
|
+
// instrumentation.ts
|
|
288
|
+
export async function register() {
|
|
289
|
+
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
|
290
|
+
await import('./sentry.server.config');
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export const onRequestError = async (
|
|
295
|
+
error: { digest: string },
|
|
296
|
+
request: { method: string; url: string; headers: Record<string, string> },
|
|
297
|
+
context: { routerKind: string; routePath: string; routeType: string; renderSource: string },
|
|
298
|
+
) => {
|
|
299
|
+
const Sentry = await import('@sentry/nextjs');
|
|
300
|
+
Sentry.captureRequestError(error, request, context);
|
|
301
|
+
};
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Update `next.config.ts`:
|
|
305
|
+
|
|
306
|
+
```typescript
|
|
307
|
+
import { withSentryConfig } from '@sentry/nextjs';
|
|
308
|
+
|
|
309
|
+
const nextConfig = { /* existing config */ };
|
|
310
|
+
|
|
311
|
+
export default withSentryConfig(nextConfig, {
|
|
312
|
+
org: process.env.SENTRY_ORG,
|
|
313
|
+
project: process.env.SENTRY_PROJECT,
|
|
314
|
+
silent: true,
|
|
315
|
+
});
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
## Step 5: Toast Notifications for Caught Errors
|
|
319
|
+
|
|
320
|
+
Use the existing Toast pattern from `@mars-stack/ui`:
|
|
321
|
+
|
|
322
|
+
```tsx
|
|
323
|
+
// src/features/error-handling/hooks/useErrorHandler.ts
|
|
324
|
+
'use client';
|
|
325
|
+
|
|
326
|
+
import { useCallback } from 'react';
|
|
327
|
+
import { useToast } from '@mars-stack/ui/hooks';
|
|
328
|
+
|
|
329
|
+
export function useErrorHandler() {
|
|
330
|
+
const { addToast } = useToast();
|
|
331
|
+
|
|
332
|
+
const handleError = useCallback((error: unknown, context?: string) => {
|
|
333
|
+
const message = error instanceof Error ? error.message : 'An unexpected error occurred';
|
|
334
|
+
|
|
335
|
+
addToast({
|
|
336
|
+
type: 'error',
|
|
337
|
+
title: context ?? 'Error',
|
|
338
|
+
message,
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
if (process.env.NODE_ENV === 'development') {
|
|
342
|
+
console.error(`[${context ?? 'Error'}]`, error);
|
|
343
|
+
}
|
|
344
|
+
}, [addToast]);
|
|
345
|
+
|
|
346
|
+
return { handleError };
|
|
347
|
+
}
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Usage in components:
|
|
351
|
+
|
|
352
|
+
```tsx
|
|
353
|
+
'use client';
|
|
354
|
+
|
|
355
|
+
import { useErrorHandler } from '@/features/error-handling/hooks/useErrorHandler';
|
|
356
|
+
|
|
357
|
+
export function SaveButton() {
|
|
358
|
+
const { handleError } = useErrorHandler();
|
|
359
|
+
|
|
360
|
+
async function handleSave() {
|
|
361
|
+
try {
|
|
362
|
+
await fetch('/api/protected/resource', { method: 'POST', body: JSON.stringify(data) });
|
|
363
|
+
} catch (error) {
|
|
364
|
+
handleError(error, 'Failed to save');
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return <Button onClick={handleSave}>Save</Button>;
|
|
369
|
+
}
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
## Step 6: API Error Handler Enhancement
|
|
373
|
+
|
|
374
|
+
The existing `handleApiError` from `@/lib/mars` handles server-side API errors. For client-side API calls, create a typed fetch wrapper:
|
|
375
|
+
|
|
376
|
+
```typescript
|
|
377
|
+
// src/features/error-handling/utils/api-client.ts
|
|
378
|
+
export class ApiError extends Error {
|
|
379
|
+
constructor(
|
|
380
|
+
message: string,
|
|
381
|
+
public status: number,
|
|
382
|
+
public code?: string,
|
|
383
|
+
) {
|
|
384
|
+
super(message);
|
|
385
|
+
this.name = 'ApiError';
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export async function apiFetch<T>(
|
|
390
|
+
url: string,
|
|
391
|
+
options?: RequestInit,
|
|
392
|
+
): Promise<T> {
|
|
393
|
+
const response = await fetch(url, {
|
|
394
|
+
...options,
|
|
395
|
+
headers: {
|
|
396
|
+
'Content-Type': 'application/json',
|
|
397
|
+
...options?.headers,
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
if (!response.ok) {
|
|
402
|
+
const body = await response.json().catch(() => ({}));
|
|
403
|
+
throw new ApiError(
|
|
404
|
+
body.error ?? `Request failed with status ${response.status}`,
|
|
405
|
+
response.status,
|
|
406
|
+
body.code,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return response.json();
|
|
411
|
+
}
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
## Testing
|
|
415
|
+
|
|
416
|
+
```typescript
|
|
417
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
418
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
419
|
+
import { ErrorBoundary } from './ErrorBoundary';
|
|
420
|
+
|
|
421
|
+
function ThrowingComponent({ shouldThrow }: { shouldThrow: boolean }) {
|
|
422
|
+
if (shouldThrow) throw new Error('Test error');
|
|
423
|
+
return <div>Content</div>;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
describe('ErrorBoundary', () => {
|
|
427
|
+
it('renders children when no error', () => {
|
|
428
|
+
render(
|
|
429
|
+
<ErrorBoundary>
|
|
430
|
+
<ThrowingComponent shouldThrow={false} />
|
|
431
|
+
</ErrorBoundary>,
|
|
432
|
+
);
|
|
433
|
+
expect(screen.getByText('Content')).toBeDefined();
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('renders fallback on error', () => {
|
|
437
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
438
|
+
render(
|
|
439
|
+
<ErrorBoundary>
|
|
440
|
+
<ThrowingComponent shouldThrow={true} />
|
|
441
|
+
</ErrorBoundary>,
|
|
442
|
+
);
|
|
443
|
+
expect(screen.getByText('Something went wrong')).toBeDefined();
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('calls onError callback', () => {
|
|
447
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
448
|
+
const onError = vi.fn();
|
|
449
|
+
render(
|
|
450
|
+
<ErrorBoundary onError={onError}>
|
|
451
|
+
<ThrowingComponent shouldThrow={true} />
|
|
452
|
+
</ErrorBoundary>,
|
|
453
|
+
);
|
|
454
|
+
expect(onError).toHaveBeenCalled();
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
## Checklist
|
|
460
|
+
|
|
461
|
+
- [ ] `ErrorBoundary` component created in `src/features/error-handling/components/`
|
|
462
|
+
- [ ] `global-error.tsx` created at app root (uses inline styles, no imports)
|
|
463
|
+
- [ ] `error.tsx` created for `(protected)` route group
|
|
464
|
+
- [ ] `error.tsx` created for `(auth)` route group
|
|
465
|
+
- [ ] `not-found.tsx` created at app root
|
|
466
|
+
- [ ] Error components use design system tokens (except `global-error.tsx`)
|
|
467
|
+
- [ ] Sentry configured (if error reporting is needed)
|
|
468
|
+
- [ ] `useErrorHandler` hook created for toast-based error display
|
|
469
|
+
- [ ] `apiFetch` utility created for typed client-side API calls
|
|
470
|
+
- [ ] Error boundaries wrap key feature sections
|
|
471
|
+
- [ ] Tests written for ErrorBoundary component
|
|
472
|
+
- [ ] Error digest IDs displayed for user-reportable errors
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# Skill: Add a Feature Module
|
|
2
|
+
|
|
3
|
+
Add a new feature module to the MARS application, following the established conventions for structure, imports, and testing.
|
|
4
|
+
|
|
5
|
+
## When to Use
|
|
6
|
+
|
|
7
|
+
Use this skill when the user asks to add a new feature, domain, or module to the app (e.g., "add billing", "add a blog", "add notifications").
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
- Read `src/config/app.config.ts` to check if the feature already has a flag defined.
|
|
12
|
+
|
|
13
|
+
## Steps
|
|
14
|
+
|
|
15
|
+
### 1. Create the Feature Directory
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
src/features/<name>/
|
|
19
|
+
├── components/ # React components with business logic
|
|
20
|
+
│ └── index.ts # Barrel export
|
|
21
|
+
├── server/ # Server-only logic (queries, mutations, services)
|
|
22
|
+
│ └── index.ts # Barrel export
|
|
23
|
+
├── hooks/ # Feature-specific React hooks (optional)
|
|
24
|
+
│ └── index.ts
|
|
25
|
+
├── validation/ # Zod schemas for API input validation
|
|
26
|
+
│ └── schemas.ts
|
|
27
|
+
└── types.ts # Feature-specific TypeScript types
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### 2. Server Module Pattern
|
|
31
|
+
|
|
32
|
+
Every server module MUST start with `import 'server-only'` to prevent client bundling.
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
import 'server-only';
|
|
36
|
+
|
|
37
|
+
import { prisma } from '@/lib/prisma';
|
|
38
|
+
|
|
39
|
+
export async function findWidgetsByUserId(userId: string) {
|
|
40
|
+
return prisma.widget.findMany({
|
|
41
|
+
where: { userId },
|
|
42
|
+
orderBy: { createdAt: 'desc' },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function createWidget(userId: string, data: CreateWidgetInput) {
|
|
47
|
+
return prisma.widget.create({
|
|
48
|
+
data: { ...data, userId },
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Key rules:
|
|
54
|
+
- Always scope queries by `userId` from the session, never from request params.
|
|
55
|
+
- Use `$transaction` for multi-step writes.
|
|
56
|
+
- Import `prisma` from `@/lib/prisma`.
|
|
57
|
+
|
|
58
|
+
### 3. Validation Schemas
|
|
59
|
+
|
|
60
|
+
Define Zod schemas for all API inputs:
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
import { z } from 'zod';
|
|
64
|
+
|
|
65
|
+
export const widgetSchemas = {
|
|
66
|
+
create: z.object({
|
|
67
|
+
name: z.string().min(1, 'Name is required').max(100),
|
|
68
|
+
description: z.string().max(500).optional(),
|
|
69
|
+
}),
|
|
70
|
+
update: z.object({
|
|
71
|
+
name: z.string().min(1).max(100).optional(),
|
|
72
|
+
description: z.string().max(500).optional(),
|
|
73
|
+
}),
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type CreateWidgetInput = z.infer<typeof widgetSchemas.create>;
|
|
77
|
+
export type UpdateWidgetInput = z.infer<typeof widgetSchemas.update>;
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### 4. API Routes
|
|
81
|
+
|
|
82
|
+
Create routes under `src/app/api/protected/<name>/`:
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
import { handleApiError, withAuth, withAuthNoParams } from '@/lib/mars';
|
|
86
|
+
import { findWidgetsByUserId, createWidget } from '@/features/<name>/server';
|
|
87
|
+
import { widgetSchemas } from '@/features/<name>/validation/schemas';
|
|
88
|
+
import { NextResponse } from 'next/server';
|
|
89
|
+
|
|
90
|
+
export const GET = withAuthNoParams(async (request) => {
|
|
91
|
+
try {
|
|
92
|
+
const widgets = await findWidgetsByUserId(request.session.userId);
|
|
93
|
+
return NextResponse.json(widgets);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
return handleApiError(error, { endpoint: '/api/protected/<name>' });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
export const POST = withAuthNoParams(async (request) => {
|
|
100
|
+
try {
|
|
101
|
+
const body = widgetSchemas.create.parse(await request.json());
|
|
102
|
+
const widget = await createWidget(request.session.userId, body);
|
|
103
|
+
return NextResponse.json(widget, { status: 201 });
|
|
104
|
+
} catch (error) {
|
|
105
|
+
return handleApiError(error, { endpoint: '/api/protected/<name>' });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Key rules:
|
|
111
|
+
- Protected routes use `withAuth` / `withAuthNoParams` / `withRole`.
|
|
112
|
+
- All catch blocks use `handleApiError` from `@/lib/mars`.
|
|
113
|
+
- Parse input with Zod schemas before use.
|
|
114
|
+
|
|
115
|
+
### 5. Prisma Schema
|
|
116
|
+
|
|
117
|
+
Add a new schema file at `prisma/schema/<name>.prisma`:
|
|
118
|
+
|
|
119
|
+
```prisma
|
|
120
|
+
model Widget {
|
|
121
|
+
id String @id @default(cuid())
|
|
122
|
+
name String
|
|
123
|
+
description String?
|
|
124
|
+
userId String
|
|
125
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
126
|
+
createdAt DateTime @default(now())
|
|
127
|
+
updatedAt DateTime @updatedAt
|
|
128
|
+
|
|
129
|
+
@@index([userId])
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Then update the User model in `prisma/schema/auth.prisma` to add the relation.
|
|
134
|
+
|
|
135
|
+
Run `yarn db:push` to sync.
|
|
136
|
+
|
|
137
|
+
### 6. Feature Flag (Optional)
|
|
138
|
+
|
|
139
|
+
If the feature should be toggleable, add it to `appConfig.features` in `src/config/app.config.ts` and gate runtime behaviour on it.
|
|
140
|
+
|
|
141
|
+
### 7. Tests
|
|
142
|
+
|
|
143
|
+
Create `route.test.ts` next to each API route. Mock Prisma and auth:
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
import { mockAuth } from '@mars-stack/core/test-utils';
|
|
147
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
148
|
+
import { GET, POST } from './route';
|
|
149
|
+
|
|
150
|
+
vi.mock('@/lib/prisma', () => ({
|
|
151
|
+
prisma: {
|
|
152
|
+
widget: {
|
|
153
|
+
findMany: vi.fn(),
|
|
154
|
+
create: vi.fn(),
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
}));
|
|
158
|
+
|
|
159
|
+
vi.mock('@/lib/mars', () => ({
|
|
160
|
+
verifySessionForAPI: vi.fn(() => Promise.resolve(mockAuth)),
|
|
161
|
+
}));
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Checklist
|
|
165
|
+
|
|
166
|
+
- [ ] Feature directory created with correct structure
|
|
167
|
+
- [ ] Server modules import `'server-only'`
|
|
168
|
+
- [ ] Queries scoped by session `userId`
|
|
169
|
+
- [ ] Zod schemas for all inputs
|
|
170
|
+
- [ ] API routes use auth wrappers and `handleApiError`
|
|
171
|
+
- [ ] Prisma schema added and `db:push` run
|
|
172
|
+
- [ ] Feature flag added to `app.config.ts` if toggleable
|
|
173
|
+
- [ ] Barrel exports created
|
|
174
|
+
- [ ] Tests written for API routes
|