@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,569 @@
|
|
|
1
|
+
# Skill: Configure Notifications
|
|
2
|
+
|
|
3
|
+
Add an in-app notification system with Prisma storage, API routes, client components, and optional email digests to a MARS application.
|
|
4
|
+
|
|
5
|
+
## When to Use
|
|
6
|
+
|
|
7
|
+
Use this skill when the user asks to add notifications, alerts, an activity feed, a notification bell, unread badges, or user messaging.
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
- `appConfig.features.notifications` set to `true` (add it if missing)
|
|
12
|
+
- Prisma configured with a PostgreSQL database
|
|
13
|
+
- Auth system set up (notifications are user-scoped)
|
|
14
|
+
|
|
15
|
+
## Step 1: Prisma Schema
|
|
16
|
+
|
|
17
|
+
```prisma
|
|
18
|
+
// prisma/schema/notification.prisma
|
|
19
|
+
model Notification {
|
|
20
|
+
id String @id @default(cuid())
|
|
21
|
+
userId String
|
|
22
|
+
type String
|
|
23
|
+
title String
|
|
24
|
+
body String?
|
|
25
|
+
resourceId String?
|
|
26
|
+
resourceType String?
|
|
27
|
+
read Boolean @default(false)
|
|
28
|
+
readAt DateTime?
|
|
29
|
+
createdAt DateTime @default(now())
|
|
30
|
+
|
|
31
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
32
|
+
|
|
33
|
+
@@index([userId, read])
|
|
34
|
+
@@index([userId, createdAt])
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Update the `User` model in `prisma/schema/auth.prisma` to add the relation:
|
|
39
|
+
|
|
40
|
+
```prisma
|
|
41
|
+
model User {
|
|
42
|
+
// ... existing fields
|
|
43
|
+
notifications Notification[]
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Run `yarn db:push` to sync.
|
|
48
|
+
|
|
49
|
+
## Step 2: Define Notification Types
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
// src/features/notifications/types.ts
|
|
53
|
+
|
|
54
|
+
export const NOTIFICATION_TYPES = {
|
|
55
|
+
WELCOME: 'welcome',
|
|
56
|
+
BILLING_SUCCESS: 'billing_success',
|
|
57
|
+
BILLING_FAILED: 'billing_failed',
|
|
58
|
+
PASSWORD_CHANGED: 'password_changed',
|
|
59
|
+
ROLE_CHANGED: 'role_changed',
|
|
60
|
+
SYSTEM_ANNOUNCEMENT: 'system_announcement',
|
|
61
|
+
} as const;
|
|
62
|
+
|
|
63
|
+
export type NotificationType = (typeof NOTIFICATION_TYPES)[keyof typeof NOTIFICATION_TYPES];
|
|
64
|
+
|
|
65
|
+
export interface NotificationPayload {
|
|
66
|
+
type: NotificationType;
|
|
67
|
+
title: string;
|
|
68
|
+
body?: string;
|
|
69
|
+
resourceId?: string;
|
|
70
|
+
resourceType?: string;
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Step 3: Server Functions
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
// src/features/notifications/server/index.ts
|
|
78
|
+
import 'server-only';
|
|
79
|
+
|
|
80
|
+
import { prisma } from '@/lib/prisma';
|
|
81
|
+
import type { NotificationType } from '../types';
|
|
82
|
+
|
|
83
|
+
export async function createNotification(
|
|
84
|
+
userId: string,
|
|
85
|
+
type: NotificationType,
|
|
86
|
+
title: string,
|
|
87
|
+
body?: string,
|
|
88
|
+
resourceId?: string,
|
|
89
|
+
resourceType?: string,
|
|
90
|
+
) {
|
|
91
|
+
return prisma.notification.create({
|
|
92
|
+
data: { userId, type, title, body, resourceId, resourceType },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function listNotifications(
|
|
97
|
+
userId: string,
|
|
98
|
+
options: { page?: number; limit?: number; unreadOnly?: boolean } = {},
|
|
99
|
+
) {
|
|
100
|
+
const { page = 1, limit = 20, unreadOnly = false } = options;
|
|
101
|
+
const skip = (page - 1) * limit;
|
|
102
|
+
|
|
103
|
+
const where = {
|
|
104
|
+
userId,
|
|
105
|
+
...(unreadOnly && { read: false }),
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const [notifications, total] = await prisma.$transaction([
|
|
109
|
+
prisma.notification.findMany({
|
|
110
|
+
where,
|
|
111
|
+
orderBy: { createdAt: 'desc' },
|
|
112
|
+
skip,
|
|
113
|
+
take: limit,
|
|
114
|
+
}),
|
|
115
|
+
prisma.notification.count({ where }),
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
notifications,
|
|
120
|
+
pagination: {
|
|
121
|
+
page,
|
|
122
|
+
limit,
|
|
123
|
+
total,
|
|
124
|
+
totalPages: Math.ceil(total / limit),
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function markAsRead(notificationId: string, userId: string) {
|
|
130
|
+
return prisma.notification.updateMany({
|
|
131
|
+
where: { id: notificationId, userId },
|
|
132
|
+
data: { read: true, readAt: new Date() },
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function markAllAsRead(userId: string) {
|
|
137
|
+
return prisma.notification.updateMany({
|
|
138
|
+
where: { userId, read: false },
|
|
139
|
+
data: { read: true, readAt: new Date() },
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function getUnreadCount(userId: string): Promise<number> {
|
|
144
|
+
return prisma.notification.count({
|
|
145
|
+
where: { userId, read: false },
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Step 4: Validation Schemas
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
// src/features/notifications/validation/schemas.ts
|
|
154
|
+
import { z } from 'zod';
|
|
155
|
+
|
|
156
|
+
export const notificationSchemas = {
|
|
157
|
+
list: z.object({
|
|
158
|
+
page: z.coerce.number().int().positive().default(1),
|
|
159
|
+
limit: z.coerce.number().int().min(1).max(100).default(20),
|
|
160
|
+
unreadOnly: z.coerce.boolean().default(false),
|
|
161
|
+
}),
|
|
162
|
+
};
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Step 5: API Routes
|
|
166
|
+
|
|
167
|
+
### List Notifications
|
|
168
|
+
|
|
169
|
+
```typescript
|
|
170
|
+
// src/app/api/protected/notifications/route.ts
|
|
171
|
+
import { handleApiError, withAuthNoParams, type AuthenticatedRequest } from '@/lib/mars';
|
|
172
|
+
import { listNotifications } from '@/features/notifications/server';
|
|
173
|
+
import { notificationSchemas } from '@/features/notifications/validation/schemas';
|
|
174
|
+
import { NextResponse } from 'next/server';
|
|
175
|
+
|
|
176
|
+
export const GET = withAuthNoParams(async (request: AuthenticatedRequest) => {
|
|
177
|
+
try {
|
|
178
|
+
const url = new URL(request.url);
|
|
179
|
+
const params = notificationSchemas.list.parse({
|
|
180
|
+
page: url.searchParams.get('page'),
|
|
181
|
+
limit: url.searchParams.get('limit'),
|
|
182
|
+
unreadOnly: url.searchParams.get('unreadOnly'),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const result = await listNotifications(request.session.userId, params);
|
|
186
|
+
return NextResponse.json(result);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
return handleApiError(error, { endpoint: '/api/protected/notifications' });
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Mark One as Read
|
|
194
|
+
|
|
195
|
+
```typescript
|
|
196
|
+
// src/app/api/protected/notifications/[id]/read/route.ts
|
|
197
|
+
import { handleApiError, withAuth, type AuthenticatedRequest } from '@/lib/mars';
|
|
198
|
+
import { markAsRead } from '@/features/notifications/server';
|
|
199
|
+
import { NextResponse } from 'next/server';
|
|
200
|
+
|
|
201
|
+
export const PATCH = withAuth(
|
|
202
|
+
async (request: AuthenticatedRequest, { params }: { params: { id: string } }) => {
|
|
203
|
+
try {
|
|
204
|
+
await markAsRead(params.id, request.session.userId);
|
|
205
|
+
return NextResponse.json({ success: true });
|
|
206
|
+
} catch (error) {
|
|
207
|
+
return handleApiError(error, { endpoint: '/api/protected/notifications/[id]/read' });
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Mark All as Read
|
|
214
|
+
|
|
215
|
+
```typescript
|
|
216
|
+
// src/app/api/protected/notifications/read-all/route.ts
|
|
217
|
+
import { handleApiError, withAuthNoParams, type AuthenticatedRequest } from '@/lib/mars';
|
|
218
|
+
import { markAllAsRead } from '@/features/notifications/server';
|
|
219
|
+
import { NextResponse } from 'next/server';
|
|
220
|
+
|
|
221
|
+
export const PATCH = withAuthNoParams(async (request: AuthenticatedRequest) => {
|
|
222
|
+
try {
|
|
223
|
+
await markAllAsRead(request.session.userId);
|
|
224
|
+
return NextResponse.json({ success: true });
|
|
225
|
+
} catch (error) {
|
|
226
|
+
return handleApiError(error, { endpoint: '/api/protected/notifications/read-all' });
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### Unread Count
|
|
232
|
+
|
|
233
|
+
```typescript
|
|
234
|
+
// src/app/api/protected/notifications/count/route.ts
|
|
235
|
+
import { handleApiError, withAuthNoParams, type AuthenticatedRequest } from '@/lib/mars';
|
|
236
|
+
import { getUnreadCount } from '@/features/notifications/server';
|
|
237
|
+
import { NextResponse } from 'next/server';
|
|
238
|
+
|
|
239
|
+
export const GET = withAuthNoParams(async (request: AuthenticatedRequest) => {
|
|
240
|
+
try {
|
|
241
|
+
const count = await getUnreadCount(request.session.userId);
|
|
242
|
+
return NextResponse.json({ count });
|
|
243
|
+
} catch (error) {
|
|
244
|
+
return handleApiError(error, { endpoint: '/api/protected/notifications/count' });
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## Step 6: Client Components
|
|
250
|
+
|
|
251
|
+
### useNotifications Hook
|
|
252
|
+
|
|
253
|
+
```typescript
|
|
254
|
+
// src/features/notifications/hooks/use-notifications.ts
|
|
255
|
+
'use client';
|
|
256
|
+
|
|
257
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
258
|
+
|
|
259
|
+
interface Notification {
|
|
260
|
+
id: string;
|
|
261
|
+
type: string;
|
|
262
|
+
title: string;
|
|
263
|
+
body: string | null;
|
|
264
|
+
resourceId: string | null;
|
|
265
|
+
resourceType: string | null;
|
|
266
|
+
read: boolean;
|
|
267
|
+
readAt: string | null;
|
|
268
|
+
createdAt: string;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
interface UseNotificationsOptions {
|
|
272
|
+
pollInterval?: number;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function useNotifications(options: UseNotificationsOptions = {}) {
|
|
276
|
+
const { pollInterval = 30_000 } = options;
|
|
277
|
+
const [notifications, setNotifications] = useState<Notification[]>([]);
|
|
278
|
+
const [unreadCount, setUnreadCount] = useState(0);
|
|
279
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
280
|
+
|
|
281
|
+
const fetchNotifications = useCallback(async () => {
|
|
282
|
+
try {
|
|
283
|
+
const res = await fetch('/api/protected/notifications?limit=20');
|
|
284
|
+
if (!res.ok) return;
|
|
285
|
+
const data = await res.json();
|
|
286
|
+
setNotifications(data.notifications);
|
|
287
|
+
} catch {
|
|
288
|
+
// Silently fail — polling will retry
|
|
289
|
+
}
|
|
290
|
+
}, []);
|
|
291
|
+
|
|
292
|
+
const fetchUnreadCount = useCallback(async () => {
|
|
293
|
+
try {
|
|
294
|
+
const res = await fetch('/api/protected/notifications/count');
|
|
295
|
+
if (!res.ok) return;
|
|
296
|
+
const data = await res.json();
|
|
297
|
+
setUnreadCount(data.count);
|
|
298
|
+
} catch {
|
|
299
|
+
// Silently fail
|
|
300
|
+
}
|
|
301
|
+
}, []);
|
|
302
|
+
|
|
303
|
+
const markAsRead = useCallback(async (id: string) => {
|
|
304
|
+
await fetch(`/api/protected/notifications/${id}/read`, { method: 'PATCH' });
|
|
305
|
+
setNotifications((prev) =>
|
|
306
|
+
prev.map((n) => (n.id === id ? { ...n, read: true, readAt: new Date().toISOString() } : n)),
|
|
307
|
+
);
|
|
308
|
+
setUnreadCount((prev) => Math.max(0, prev - 1));
|
|
309
|
+
}, []);
|
|
310
|
+
|
|
311
|
+
const markAllAsRead = useCallback(async () => {
|
|
312
|
+
await fetch('/api/protected/notifications/read-all', { method: 'PATCH' });
|
|
313
|
+
setNotifications((prev) => prev.map((n) => ({ ...n, read: true, readAt: new Date().toISOString() })));
|
|
314
|
+
setUnreadCount(0);
|
|
315
|
+
}, []);
|
|
316
|
+
|
|
317
|
+
useEffect(() => {
|
|
318
|
+
setIsLoading(true);
|
|
319
|
+
Promise.all([fetchNotifications(), fetchUnreadCount()]).finally(() => setIsLoading(false));
|
|
320
|
+
|
|
321
|
+
const interval = setInterval(() => {
|
|
322
|
+
fetchUnreadCount();
|
|
323
|
+
}, pollInterval);
|
|
324
|
+
|
|
325
|
+
return () => clearInterval(interval);
|
|
326
|
+
}, [fetchNotifications, fetchUnreadCount, pollInterval]);
|
|
327
|
+
|
|
328
|
+
return { notifications, unreadCount, isLoading, markAsRead, markAllAsRead, refetch: fetchNotifications };
|
|
329
|
+
}
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
### NotificationBell Component
|
|
333
|
+
|
|
334
|
+
```typescript
|
|
335
|
+
// src/features/notifications/components/notification-bell.tsx
|
|
336
|
+
'use client';
|
|
337
|
+
|
|
338
|
+
import { useNotifications } from '../hooks/use-notifications';
|
|
339
|
+
import { Button } from '@mars-stack/ui';
|
|
340
|
+
|
|
341
|
+
interface NotificationBellProps {
|
|
342
|
+
onOpen: () => void;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function NotificationBell({ onOpen }: NotificationBellProps) {
|
|
346
|
+
const { unreadCount } = useNotifications();
|
|
347
|
+
|
|
348
|
+
return (
|
|
349
|
+
<Button variant="ghost" size="icon" onClick={onOpen} aria-label="Notifications" className="relative">
|
|
350
|
+
<BellIcon className="h-5 w-5" />
|
|
351
|
+
{unreadCount > 0 && (
|
|
352
|
+
<span className="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-brand-primary text-xs text-white">
|
|
353
|
+
{unreadCount > 99 ? '99+' : unreadCount}
|
|
354
|
+
</span>
|
|
355
|
+
)}
|
|
356
|
+
</Button>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function BellIcon({ className }: { className?: string }) {
|
|
361
|
+
return (
|
|
362
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className}>
|
|
363
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
|
|
364
|
+
</svg>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
### NotificationPanel Component
|
|
370
|
+
|
|
371
|
+
```typescript
|
|
372
|
+
// src/features/notifications/components/notification-panel.tsx
|
|
373
|
+
'use client';
|
|
374
|
+
|
|
375
|
+
import { useNotifications } from '../hooks/use-notifications';
|
|
376
|
+
import { Button } from '@mars-stack/ui';
|
|
377
|
+
import { formatDistanceToNow } from 'date-fns';
|
|
378
|
+
|
|
379
|
+
interface NotificationPanelProps {
|
|
380
|
+
isOpen: boolean;
|
|
381
|
+
onClose: () => void;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export function NotificationPanel({ isOpen, onClose }: NotificationPanelProps) {
|
|
385
|
+
const { notifications, isLoading, markAsRead, markAllAsRead } = useNotifications();
|
|
386
|
+
|
|
387
|
+
if (!isOpen) return null;
|
|
388
|
+
|
|
389
|
+
return (
|
|
390
|
+
<div className="absolute right-0 top-full mt-2 w-96 rounded-lg border border-border-default bg-surface-card shadow-lg z-50">
|
|
391
|
+
<div className="flex items-center justify-between border-b border-border-default p-4">
|
|
392
|
+
<h3 className="text-sm font-semibold text-text-primary">Notifications</h3>
|
|
393
|
+
<Button variant="ghost" size="sm" onClick={markAllAsRead}>
|
|
394
|
+
Mark all as read
|
|
395
|
+
</Button>
|
|
396
|
+
</div>
|
|
397
|
+
|
|
398
|
+
<div className="max-h-96 overflow-y-auto">
|
|
399
|
+
{isLoading ? (
|
|
400
|
+
<div className="p-4 text-center text-text-muted text-sm">Loading…</div>
|
|
401
|
+
) : notifications.length === 0 ? (
|
|
402
|
+
<div className="p-8 text-center text-text-muted text-sm">No notifications</div>
|
|
403
|
+
) : (
|
|
404
|
+
notifications.map((notification) => (
|
|
405
|
+
<button
|
|
406
|
+
key={notification.id}
|
|
407
|
+
onClick={() => markAsRead(notification.id)}
|
|
408
|
+
className={`w-full text-left p-4 border-b border-border-default hover:bg-ghost-hover transition-colors ${
|
|
409
|
+
!notification.read ? 'bg-surface-input/50' : ''
|
|
410
|
+
}`}
|
|
411
|
+
>
|
|
412
|
+
<div className="flex items-start gap-3">
|
|
413
|
+
{!notification.read && (
|
|
414
|
+
<span className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-brand-primary" />
|
|
415
|
+
)}
|
|
416
|
+
<div className="flex-1 min-w-0">
|
|
417
|
+
<p className="text-sm font-medium text-text-primary truncate">
|
|
418
|
+
{notification.title}
|
|
419
|
+
</p>
|
|
420
|
+
{notification.body && (
|
|
421
|
+
<p className="text-sm text-text-secondary mt-0.5 line-clamp-2">
|
|
422
|
+
{notification.body}
|
|
423
|
+
</p>
|
|
424
|
+
)}
|
|
425
|
+
<p className="text-xs text-text-muted mt-1">
|
|
426
|
+
{formatDistanceToNow(new Date(notification.createdAt), { addSuffix: true })}
|
|
427
|
+
</p>
|
|
428
|
+
</div>
|
|
429
|
+
</div>
|
|
430
|
+
</button>
|
|
431
|
+
))
|
|
432
|
+
)}
|
|
433
|
+
</div>
|
|
434
|
+
</div>
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
## Step 7: Trigger Notifications from Other Features
|
|
440
|
+
|
|
441
|
+
```typescript
|
|
442
|
+
import { createNotification } from '@/features/notifications/server';
|
|
443
|
+
import { NOTIFICATION_TYPES } from '@/features/notifications/types';
|
|
444
|
+
|
|
445
|
+
// After a successful password change
|
|
446
|
+
await createNotification(
|
|
447
|
+
userId,
|
|
448
|
+
NOTIFICATION_TYPES.PASSWORD_CHANGED,
|
|
449
|
+
'Password changed',
|
|
450
|
+
'Your password was successfully updated. If this wasn't you, contact support immediately.',
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
// After a billing event
|
|
454
|
+
await createNotification(
|
|
455
|
+
userId,
|
|
456
|
+
NOTIFICATION_TYPES.BILLING_SUCCESS,
|
|
457
|
+
'Payment successful',
|
|
458
|
+
`Your subscription payment of $${amount} was processed.`,
|
|
459
|
+
subscriptionId,
|
|
460
|
+
'subscription',
|
|
461
|
+
);
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
## Step 8: Email Digest (Optional)
|
|
465
|
+
|
|
466
|
+
If the `configure-jobs` skill is set up, add a daily digest job:
|
|
467
|
+
|
|
468
|
+
```typescript
|
|
469
|
+
// src/features/notifications/server/digest.ts
|
|
470
|
+
import 'server-only';
|
|
471
|
+
|
|
472
|
+
import { prisma } from '@/lib/prisma';
|
|
473
|
+
import { sendEmail } from '@/lib/mars';
|
|
474
|
+
|
|
475
|
+
export async function sendNotificationDigest(userId: string): Promise<void> {
|
|
476
|
+
const since = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
|
477
|
+
|
|
478
|
+
const unread = await prisma.notification.findMany({
|
|
479
|
+
where: { userId, read: false, createdAt: { gte: since } },
|
|
480
|
+
orderBy: { createdAt: 'desc' },
|
|
481
|
+
take: 20,
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
if (unread.length === 0) return;
|
|
485
|
+
|
|
486
|
+
const user = await prisma.user.findUnique({
|
|
487
|
+
where: { id: userId },
|
|
488
|
+
select: { email: true, name: true },
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
if (!user) return;
|
|
492
|
+
|
|
493
|
+
const summaryItems = unread.map((n) => `• ${n.title}${n.body ? `: ${n.body}` : ''}`).join('\n');
|
|
494
|
+
|
|
495
|
+
await sendEmail({
|
|
496
|
+
to: user.email,
|
|
497
|
+
subject: `You have ${unread.length} unread notification${unread.length > 1 ? 's' : ''}`,
|
|
498
|
+
text: `Hi ${user.name ?? 'there'},\n\nHere's your daily notification summary:\n\n${summaryItems}\n\nView all notifications in your dashboard.`,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
## Step 9: Feature Directory Structure
|
|
504
|
+
|
|
505
|
+
```
|
|
506
|
+
src/features/notifications/
|
|
507
|
+
├── types.ts # NotificationType, NotificationPayload
|
|
508
|
+
├── server/
|
|
509
|
+
│ ├── index.ts # CRUD functions (createNotification, listNotifications, etc.)
|
|
510
|
+
│ └── digest.ts # Email digest sender (optional)
|
|
511
|
+
├── hooks/
|
|
512
|
+
│ └── use-notifications.ts # Client-side hook with polling
|
|
513
|
+
├── components/
|
|
514
|
+
│ ├── notification-bell.tsx # Bell icon with unread badge
|
|
515
|
+
│ └── notification-panel.tsx # Dropdown notification list
|
|
516
|
+
└── validation/
|
|
517
|
+
└── schemas.ts # Zod schemas for API input
|
|
518
|
+
|
|
519
|
+
src/app/api/protected/notifications/
|
|
520
|
+
├── route.ts # GET - list notifications
|
|
521
|
+
├── [id]/
|
|
522
|
+
│ └── read/
|
|
523
|
+
│ └── route.ts # PATCH - mark one as read
|
|
524
|
+
├── read-all/
|
|
525
|
+
│ └── route.ts # PATCH - mark all as read
|
|
526
|
+
└── count/
|
|
527
|
+
└── route.ts # GET - unread count
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
## Tests
|
|
531
|
+
|
|
532
|
+
```typescript
|
|
533
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
534
|
+
import { GET } from './route';
|
|
535
|
+
import { mockAuth } from '@mars-stack/core/test-utils';
|
|
536
|
+
|
|
537
|
+
vi.mock('@/lib/prisma', () => ({
|
|
538
|
+
prisma: {
|
|
539
|
+
notification: {
|
|
540
|
+
findMany: vi.fn(),
|
|
541
|
+
count: vi.fn(),
|
|
542
|
+
create: vi.fn(),
|
|
543
|
+
updateMany: vi.fn(),
|
|
544
|
+
},
|
|
545
|
+
$transaction: vi.fn((fns: (() => unknown)[]) => Promise.all(fns.map((fn) => fn()))),
|
|
546
|
+
},
|
|
547
|
+
}));
|
|
548
|
+
|
|
549
|
+
vi.mock('@/lib/mars', () => ({
|
|
550
|
+
verifySessionForAPI: vi.fn(() => Promise.resolve(mockAuth)),
|
|
551
|
+
handleApiError: vi.fn((error: Error) => Response.json({ error: error.message }, { status: 500 })),
|
|
552
|
+
}));
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
## Checklist
|
|
556
|
+
|
|
557
|
+
- [ ] Prisma `Notification` model added and `db:push` run
|
|
558
|
+
- [ ] User model updated with `notifications` relation
|
|
559
|
+
- [ ] Notification types defined in `types.ts`
|
|
560
|
+
- [ ] Server functions import `'server-only'`
|
|
561
|
+
- [ ] All queries scoped by session `userId`
|
|
562
|
+
- [ ] API routes use `withAuth` / `withAuthNoParams` and `handleApiError`
|
|
563
|
+
- [ ] Zod validation on list parameters
|
|
564
|
+
- [ ] `useNotifications` hook with polling
|
|
565
|
+
- [ ] `NotificationBell` and `NotificationPanel` components created
|
|
566
|
+
- [ ] Bell integrated into the authenticated layout header
|
|
567
|
+
- [ ] Feature flag checked (`appConfig.features.notifications`)
|
|
568
|
+
- [ ] Email digest function created (optional, requires email provider)
|
|
569
|
+
- [ ] Tests written for API routes
|