@mars-stack/cli 0.2.0 → 0.2.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/package.json +2 -2
- 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 +373 -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 +17 -0
- package/template/src/styles/globals.css +6 -0
- package/template/tsconfig.json +59 -0
- package/template/vitest.config.ts +41 -0
- package/template/vitest.setup.ts +24 -0
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
# Skill: Configure Search
|
|
2
|
+
|
|
3
|
+
Set up search functionality in a MARS application using Postgres full-text search, Algolia, or Meilisearch.
|
|
4
|
+
|
|
5
|
+
## When to Use
|
|
6
|
+
|
|
7
|
+
Use this skill when the user asks to add search, full-text search, search bar, autocomplete, or indexing (e.g., "add search to the app", "let users search posts", "add Algolia").
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
- `appConfig.services.search.provider` set to `'postgres'`, `'algolia'`, or `'meilisearch'`
|
|
12
|
+
- For Algolia/Meilisearch: an account with the respective provider
|
|
13
|
+
- At least one Prisma model with searchable text fields
|
|
14
|
+
|
|
15
|
+
## Provider Interface
|
|
16
|
+
|
|
17
|
+
All providers implement a common interface:
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
// src/features/search/types.ts
|
|
21
|
+
export interface SearchResult<T = Record<string, unknown>> {
|
|
22
|
+
id: string;
|
|
23
|
+
score: number;
|
|
24
|
+
data: T;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SearchOptions {
|
|
28
|
+
model: string;
|
|
29
|
+
page?: number;
|
|
30
|
+
limit?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SearchProvider {
|
|
34
|
+
search(query: string, options: SearchOptions): Promise<{
|
|
35
|
+
results: SearchResult[];
|
|
36
|
+
total: number;
|
|
37
|
+
page: number;
|
|
38
|
+
totalPages: number;
|
|
39
|
+
}>;
|
|
40
|
+
index(model: string, id: string, data: Record<string, unknown>): Promise<void>;
|
|
41
|
+
remove(model: string, id: string): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Option A: Postgres Full-Text Search
|
|
48
|
+
|
|
49
|
+
No external dependencies. Uses PostgreSQL's built-in `tsvector` and `tsquery` capabilities.
|
|
50
|
+
|
|
51
|
+
### Step 1: Add GIN Index to Prisma Schema
|
|
52
|
+
|
|
53
|
+
Prisma does not natively support `tsvector` columns, so use a raw migration. First, add a standard index as a placeholder, then apply a raw SQL migration.
|
|
54
|
+
|
|
55
|
+
```prisma
|
|
56
|
+
// In your model's .prisma file, add a comment for reference:
|
|
57
|
+
// Full-text search index applied via migration (see Step 2)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Step 2: Create Raw SQL Migration
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
yarn prisma migrate dev --name add-search-index --create-only
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Edit the generated migration SQL:
|
|
67
|
+
|
|
68
|
+
```sql
|
|
69
|
+
-- Add tsvector column and GIN index for full-text search
|
|
70
|
+
ALTER TABLE "Post" ADD COLUMN IF NOT EXISTS search_vector tsvector
|
|
71
|
+
GENERATED ALWAYS AS (
|
|
72
|
+
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
|
|
73
|
+
setweight(to_tsvector('english', coalesce(body, '')), 'B')
|
|
74
|
+
) STORED;
|
|
75
|
+
|
|
76
|
+
CREATE INDEX IF NOT EXISTS "Post_search_vector_idx" ON "Post" USING GIN (search_vector);
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Then apply: `yarn prisma migrate dev`
|
|
80
|
+
|
|
81
|
+
### Step 3: Search Service
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
// src/features/search/server/postgres.ts
|
|
85
|
+
import 'server-only';
|
|
86
|
+
|
|
87
|
+
import { prisma } from '@/lib/prisma';
|
|
88
|
+
import { Prisma } from '@db';
|
|
89
|
+
import type { SearchResult, SearchOptions } from '@/features/search/types';
|
|
90
|
+
|
|
91
|
+
function sanitizeQuery(query: string): string {
|
|
92
|
+
return query
|
|
93
|
+
.replace(/[^\w\s]/g, ' ')
|
|
94
|
+
.trim()
|
|
95
|
+
.split(/\s+/)
|
|
96
|
+
.filter(Boolean)
|
|
97
|
+
.join(' & ');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function searchPostgres(
|
|
101
|
+
query: string,
|
|
102
|
+
options: SearchOptions,
|
|
103
|
+
): Promise<{ results: SearchResult[]; total: number; page: number; totalPages: number }> {
|
|
104
|
+
const { model, page = 1, limit = 20 } = options;
|
|
105
|
+
const sanitized = sanitizeQuery(query);
|
|
106
|
+
|
|
107
|
+
if (!sanitized) {
|
|
108
|
+
return { results: [], total: 0, page, totalPages: 0 };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const offset = (page - 1) * limit;
|
|
112
|
+
const tableName = Prisma.raw(`"${model}"`);
|
|
113
|
+
|
|
114
|
+
const results = await prisma.$queryRaw<Array<{ id: string; rank: number }>>`
|
|
115
|
+
SELECT id, ts_rank(search_vector, plainto_tsquery('english', ${sanitized})) AS rank
|
|
116
|
+
FROM ${tableName}
|
|
117
|
+
WHERE search_vector @@ plainto_tsquery('english', ${sanitized})
|
|
118
|
+
ORDER BY rank DESC
|
|
119
|
+
LIMIT ${limit}
|
|
120
|
+
OFFSET ${offset}
|
|
121
|
+
`;
|
|
122
|
+
|
|
123
|
+
const countResult = await prisma.$queryRaw<[{ count: bigint }]>`
|
|
124
|
+
SELECT COUNT(*) as count
|
|
125
|
+
FROM ${tableName}
|
|
126
|
+
WHERE search_vector @@ plainto_tsquery('english', ${sanitized})
|
|
127
|
+
`;
|
|
128
|
+
|
|
129
|
+
const total = Number(countResult[0].count);
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
results: results.map((r) => ({ id: r.id, score: r.rank, data: r as Record<string, unknown> })),
|
|
133
|
+
total,
|
|
134
|
+
page,
|
|
135
|
+
totalPages: Math.ceil(total / limit),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Key rules:
|
|
141
|
+
- Always sanitize input to prevent SQL injection -- strip non-word characters and join with `&` for AND semantics.
|
|
142
|
+
- Use `plainto_tsquery` for user-facing search (safer than `to_tsquery`).
|
|
143
|
+
- Use `ts_rank` for relevance scoring.
|
|
144
|
+
- Weighted columns: `'A'` weight for titles, `'B'` for body text.
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Option B: Algolia
|
|
149
|
+
|
|
150
|
+
### Step 1: Install Algolia
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
yarn add algoliasearch
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Step 2: Environment Variables
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
# Server-side (secret -- never expose to client)
|
|
160
|
+
ALGOLIA_APP_ID="your-app-id"
|
|
161
|
+
ALGOLIA_API_KEY="your-admin-api-key"
|
|
162
|
+
|
|
163
|
+
# Client-side (search-only key -- safe to expose)
|
|
164
|
+
NEXT_PUBLIC_ALGOLIA_APP_ID="your-app-id"
|
|
165
|
+
NEXT_PUBLIC_ALGOLIA_SEARCH_KEY="your-search-only-key"
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Step 3: Algolia Service
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
// src/features/search/server/algolia.ts
|
|
172
|
+
import 'server-only';
|
|
173
|
+
|
|
174
|
+
import { algoliasearch } from 'algoliasearch';
|
|
175
|
+
|
|
176
|
+
let _client: ReturnType<typeof algoliasearch> | null = null;
|
|
177
|
+
|
|
178
|
+
function getAlgoliaClient() {
|
|
179
|
+
if (_client) return _client;
|
|
180
|
+
|
|
181
|
+
const appId = process.env.ALGOLIA_APP_ID;
|
|
182
|
+
const apiKey = process.env.ALGOLIA_API_KEY;
|
|
183
|
+
|
|
184
|
+
if (!appId || !apiKey) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
'ALGOLIA_APP_ID and ALGOLIA_API_KEY are required.\n'
|
|
187
|
+
+ ' → Get your keys from https://dashboard.algolia.com/account/api-keys\n'
|
|
188
|
+
+ ' → Add them to your .env file',
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
_client = algoliasearch(appId, apiKey);
|
|
193
|
+
return _client;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function getIndexName(model: string): string {
|
|
197
|
+
const env = process.env.NODE_ENV || 'development';
|
|
198
|
+
const appName = process.env.APP_NAME || 'mars';
|
|
199
|
+
return `${appName}_${model}_${env}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function indexDocument(model: string, id: string, data: Record<string, unknown>) {
|
|
203
|
+
const client = getAlgoliaClient();
|
|
204
|
+
await client.saveObject({
|
|
205
|
+
indexName: getIndexName(model),
|
|
206
|
+
body: { objectID: id, ...data },
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function removeDocument(model: string, id: string) {
|
|
211
|
+
const client = getAlgoliaClient();
|
|
212
|
+
await client.deleteObject({
|
|
213
|
+
indexName: getIndexName(model),
|
|
214
|
+
objectID: id,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function searchAlgolia(query: string, model: string, page: number = 0, hitsPerPage: number = 20) {
|
|
219
|
+
const client = getAlgoliaClient();
|
|
220
|
+
const result = await client.searchSingleIndex({
|
|
221
|
+
indexName: getIndexName(model),
|
|
222
|
+
searchParams: { query, page, hitsPerPage },
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
results: (result.hits || []).map((hit: Record<string, unknown>) => ({
|
|
227
|
+
id: hit.objectID as string,
|
|
228
|
+
score: 1,
|
|
229
|
+
data: hit,
|
|
230
|
+
})),
|
|
231
|
+
total: result.nbHits ?? 0,
|
|
232
|
+
page: (result.page ?? 0) + 1,
|
|
233
|
+
totalPages: result.nbPages ?? 0,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Step 4: Sync on Create/Update/Delete
|
|
239
|
+
|
|
240
|
+
Add indexing calls to your server mutations:
|
|
241
|
+
|
|
242
|
+
```typescript
|
|
243
|
+
// In your feature's server module
|
|
244
|
+
import { indexDocument, removeDocument } from '@/features/search/server/algolia';
|
|
245
|
+
|
|
246
|
+
export async function createPost(userId: string, data: CreatePostInput) {
|
|
247
|
+
const post = await prisma.post.create({ data: { ...data, userId } });
|
|
248
|
+
|
|
249
|
+
await indexDocument('Post', post.id, {
|
|
250
|
+
title: post.title,
|
|
251
|
+
body: post.body,
|
|
252
|
+
createdAt: post.createdAt.toISOString(),
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
return post;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export async function deletePost(userId: string, postId: string) {
|
|
259
|
+
await prisma.post.delete({ where: { id: postId, userId } });
|
|
260
|
+
await removeDocument('Post', postId);
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### Step 5: Client-Side Search Component
|
|
265
|
+
|
|
266
|
+
```typescript
|
|
267
|
+
// src/features/search/components/search-input.tsx
|
|
268
|
+
'use client';
|
|
269
|
+
|
|
270
|
+
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
271
|
+
import { algoliasearch } from 'algoliasearch/lite';
|
|
272
|
+
|
|
273
|
+
const searchClient = algoliasearch(
|
|
274
|
+
process.env.NEXT_PUBLIC_ALGOLIA_APP_ID!,
|
|
275
|
+
process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_KEY!,
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
interface SearchInputProps {
|
|
279
|
+
indexName: string;
|
|
280
|
+
onResults: (hits: Array<Record<string, unknown>>) => void;
|
|
281
|
+
placeholder?: string;
|
|
282
|
+
debounceMs?: number;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function SearchInput({ indexName, onResults, placeholder = 'Search...', debounceMs = 300 }: SearchInputProps) {
|
|
286
|
+
const [query, setQuery] = useState('');
|
|
287
|
+
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
|
288
|
+
|
|
289
|
+
const performSearch = useCallback(async (q: string) => {
|
|
290
|
+
if (!q.trim()) {
|
|
291
|
+
onResults([]);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const { hits } = await searchClient.searchSingleIndex({
|
|
296
|
+
indexName,
|
|
297
|
+
searchParams: { query: q },
|
|
298
|
+
});
|
|
299
|
+
onResults(hits as Array<Record<string, unknown>>);
|
|
300
|
+
}, [indexName, onResults]);
|
|
301
|
+
|
|
302
|
+
useEffect(() => {
|
|
303
|
+
clearTimeout(timerRef.current);
|
|
304
|
+
timerRef.current = setTimeout(() => performSearch(query), debounceMs);
|
|
305
|
+
return () => clearTimeout(timerRef.current);
|
|
306
|
+
}, [query, debounceMs, performSearch]);
|
|
307
|
+
|
|
308
|
+
return (
|
|
309
|
+
<input
|
|
310
|
+
type="search"
|
|
311
|
+
value={query}
|
|
312
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
313
|
+
placeholder={placeholder}
|
|
314
|
+
className="w-full rounded-lg border border-border-primary bg-surface-primary px-4 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:ring-2 focus:ring-brand-primary"
|
|
315
|
+
/>
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
## Option C: Meilisearch
|
|
323
|
+
|
|
324
|
+
### Step 1: Install Meilisearch Client
|
|
325
|
+
|
|
326
|
+
```bash
|
|
327
|
+
yarn add meilisearch
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
### Step 2: Environment Variables
|
|
331
|
+
|
|
332
|
+
```bash
|
|
333
|
+
# Server-side
|
|
334
|
+
MEILISEARCH_HOST="http://localhost:7700"
|
|
335
|
+
MEILISEARCH_MASTER_KEY="your-master-key"
|
|
336
|
+
|
|
337
|
+
# Client-side (search-only)
|
|
338
|
+
NEXT_PUBLIC_MEILISEARCH_HOST="http://localhost:7700"
|
|
339
|
+
NEXT_PUBLIC_MEILISEARCH_SEARCH_KEY="your-search-api-key"
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
### Step 3: Meilisearch Service
|
|
343
|
+
|
|
344
|
+
```typescript
|
|
345
|
+
// src/features/search/server/meilisearch.ts
|
|
346
|
+
import 'server-only';
|
|
347
|
+
|
|
348
|
+
import { MeiliSearch } from 'meilisearch';
|
|
349
|
+
|
|
350
|
+
let _client: MeiliSearch | null = null;
|
|
351
|
+
|
|
352
|
+
function getMeilisearchClient(): MeiliSearch {
|
|
353
|
+
if (_client) return _client;
|
|
354
|
+
|
|
355
|
+
const host = process.env.MEILISEARCH_HOST;
|
|
356
|
+
const apiKey = process.env.MEILISEARCH_MASTER_KEY;
|
|
357
|
+
|
|
358
|
+
if (!host) {
|
|
359
|
+
throw new Error(
|
|
360
|
+
'MEILISEARCH_HOST is required.\n'
|
|
361
|
+
+ ' → Run Meilisearch locally: docker run -p 7700:7700 getmeili/meilisearch\n'
|
|
362
|
+
+ ' → Add MEILISEARCH_HOST to your .env file',
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
_client = new MeiliSearch({ host, apiKey });
|
|
367
|
+
return _client;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function getIndexName(model: string): string {
|
|
371
|
+
const env = process.env.NODE_ENV || 'development';
|
|
372
|
+
return `${model.toLowerCase()}_${env}`;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export async function configureIndex(model: string, searchableAttributes: string[]) {
|
|
376
|
+
const client = getMeilisearchClient();
|
|
377
|
+
const index = client.index(getIndexName(model));
|
|
378
|
+
await index.updateSearchableAttributes(searchableAttributes);
|
|
379
|
+
await index.updateFilterableAttributes(['userId', 'status', 'createdAt']);
|
|
380
|
+
await index.updateSortableAttributes(['createdAt']);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export async function indexDocument(model: string, id: string, data: Record<string, unknown>) {
|
|
384
|
+
const client = getMeilisearchClient();
|
|
385
|
+
await client.index(getIndexName(model)).addDocuments([{ id, ...data }]);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export async function removeDocument(model: string, id: string) {
|
|
389
|
+
const client = getMeilisearchClient();
|
|
390
|
+
await client.index(getIndexName(model)).deleteDocument(id);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export async function searchMeilisearch(
|
|
394
|
+
query: string,
|
|
395
|
+
model: string,
|
|
396
|
+
page: number = 1,
|
|
397
|
+
limit: number = 20,
|
|
398
|
+
) {
|
|
399
|
+
const client = getMeilisearchClient();
|
|
400
|
+
const result = await client.index(getIndexName(model)).search(query, {
|
|
401
|
+
offset: (page - 1) * limit,
|
|
402
|
+
limit,
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
results: result.hits.map((hit) => ({
|
|
407
|
+
id: hit.id as string,
|
|
408
|
+
score: 1,
|
|
409
|
+
data: hit as Record<string, unknown>,
|
|
410
|
+
})),
|
|
411
|
+
total: result.estimatedTotalHits ?? 0,
|
|
412
|
+
page,
|
|
413
|
+
totalPages: Math.ceil((result.estimatedTotalHits ?? 0) / limit),
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
### Step 4: Index Configuration
|
|
419
|
+
|
|
420
|
+
Run once on setup or in a seed script:
|
|
421
|
+
|
|
422
|
+
```typescript
|
|
423
|
+
import { configureIndex } from '@/features/search/server/meilisearch';
|
|
424
|
+
|
|
425
|
+
await configureIndex('Post', ['title', 'body']);
|
|
426
|
+
await configureIndex('User', ['name', 'email']);
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
---
|
|
430
|
+
|
|
431
|
+
## Search API Route
|
|
432
|
+
|
|
433
|
+
Regardless of provider, expose a unified search endpoint:
|
|
434
|
+
|
|
435
|
+
```typescript
|
|
436
|
+
// src/app/api/protected/search/route.ts
|
|
437
|
+
import { handleApiError, withAuthNoParams, type AuthenticatedRequest } from '@/lib/mars';
|
|
438
|
+
import { NextResponse } from 'next/server';
|
|
439
|
+
import { z } from 'zod';
|
|
440
|
+
|
|
441
|
+
const searchSchema = z.object({
|
|
442
|
+
q: z.string().min(1).max(200),
|
|
443
|
+
model: z.string().min(1),
|
|
444
|
+
page: z.coerce.number().int().positive().default(1),
|
|
445
|
+
limit: z.coerce.number().int().positive().max(100).default(20),
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
export const GET = withAuthNoParams(async (request: AuthenticatedRequest) => {
|
|
449
|
+
try {
|
|
450
|
+
const url = new URL(request.url);
|
|
451
|
+
const params = searchSchema.parse({
|
|
452
|
+
q: url.searchParams.get('q'),
|
|
453
|
+
model: url.searchParams.get('model'),
|
|
454
|
+
page: url.searchParams.get('page') ?? 1,
|
|
455
|
+
limit: url.searchParams.get('limit') ?? 20,
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// Import the appropriate provider based on appConfig
|
|
459
|
+
// Example with Postgres:
|
|
460
|
+
const { searchPostgres } = await import('@/features/search/server/postgres');
|
|
461
|
+
const results = await searchPostgres(params.q, {
|
|
462
|
+
model: params.model,
|
|
463
|
+
page: params.page,
|
|
464
|
+
limit: params.limit,
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
return NextResponse.json(results);
|
|
468
|
+
} catch (error) {
|
|
469
|
+
return handleApiError(error, { endpoint: '/api/protected/search' });
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
## Debounced Search Hook
|
|
475
|
+
|
|
476
|
+
A reusable hook for client-side search with debounce, usable with any provider:
|
|
477
|
+
|
|
478
|
+
```typescript
|
|
479
|
+
// src/features/search/hooks/use-search.ts
|
|
480
|
+
'use client';
|
|
481
|
+
|
|
482
|
+
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
483
|
+
|
|
484
|
+
interface UseSearchOptions {
|
|
485
|
+
model: string;
|
|
486
|
+
debounceMs?: number;
|
|
487
|
+
limit?: number;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export function useSearch<T = Record<string, unknown>>({ model, debounceMs = 300, limit = 20 }: UseSearchOptions) {
|
|
491
|
+
const [query, setQuery] = useState('');
|
|
492
|
+
const [results, setResults] = useState<T[]>([]);
|
|
493
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
494
|
+
const [total, setTotal] = useState(0);
|
|
495
|
+
const [page, setPage] = useState(1);
|
|
496
|
+
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
|
497
|
+
|
|
498
|
+
const performSearch = useCallback(async (q: string, p: number) => {
|
|
499
|
+
if (!q.trim()) {
|
|
500
|
+
setResults([]);
|
|
501
|
+
setTotal(0);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
setIsLoading(true);
|
|
506
|
+
try {
|
|
507
|
+
const params = new URLSearchParams({ q, model, page: String(p), limit: String(limit) });
|
|
508
|
+
const response = await fetch(`/api/protected/search?${params}`);
|
|
509
|
+
const data = await response.json();
|
|
510
|
+
setResults(data.results?.map((r: { data: T }) => r.data) ?? []);
|
|
511
|
+
setTotal(data.total ?? 0);
|
|
512
|
+
} finally {
|
|
513
|
+
setIsLoading(false);
|
|
514
|
+
}
|
|
515
|
+
}, [model, limit]);
|
|
516
|
+
|
|
517
|
+
useEffect(() => {
|
|
518
|
+
clearTimeout(timerRef.current);
|
|
519
|
+
timerRef.current = setTimeout(() => performSearch(query, page), debounceMs);
|
|
520
|
+
return () => clearTimeout(timerRef.current);
|
|
521
|
+
}, [query, page, debounceMs, performSearch]);
|
|
522
|
+
|
|
523
|
+
return { query, setQuery, results, isLoading, total, page, setPage };
|
|
524
|
+
}
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
## Config Integration
|
|
528
|
+
|
|
529
|
+
Add to `src/config/app.config.ts`:
|
|
530
|
+
|
|
531
|
+
```typescript
|
|
532
|
+
export const appConfig = {
|
|
533
|
+
// ... existing config
|
|
534
|
+
services: {
|
|
535
|
+
// ... existing services
|
|
536
|
+
search: {
|
|
537
|
+
provider: 'postgres' as const, // 'postgres' | 'algolia' | 'meilisearch'
|
|
538
|
+
},
|
|
539
|
+
},
|
|
540
|
+
};
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
## Testing
|
|
544
|
+
|
|
545
|
+
```typescript
|
|
546
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
547
|
+
|
|
548
|
+
// Mock search service
|
|
549
|
+
vi.mock('@/features/search/server/postgres', () => ({
|
|
550
|
+
searchPostgres: vi.fn().mockResolvedValue({
|
|
551
|
+
results: [{ id: '1', score: 0.9, data: { title: 'Test' } }],
|
|
552
|
+
total: 1,
|
|
553
|
+
page: 1,
|
|
554
|
+
totalPages: 1,
|
|
555
|
+
}),
|
|
556
|
+
}));
|
|
557
|
+
|
|
558
|
+
// For Algolia/Meilisearch, mock the client:
|
|
559
|
+
vi.mock('algoliasearch', () => ({
|
|
560
|
+
algoliasearch: vi.fn(() => ({
|
|
561
|
+
searchSingleIndex: vi.fn().mockResolvedValue({ hits: [], nbHits: 0 }),
|
|
562
|
+
saveObject: vi.fn(),
|
|
563
|
+
deleteObject: vi.fn(),
|
|
564
|
+
})),
|
|
565
|
+
}));
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
## Checklist
|
|
569
|
+
|
|
570
|
+
- [ ] Provider chosen and configured in `app.config.ts`
|
|
571
|
+
- [ ] Environment variables set for chosen provider
|
|
572
|
+
- [ ] Search service module created with `import 'server-only'`
|
|
573
|
+
- [ ] For Postgres: tsvector column and GIN index added via migration
|
|
574
|
+
- [ ] For Algolia/Meilisearch: documents indexed on create/update/delete
|
|
575
|
+
- [ ] Search API route created at `GET /api/protected/search`
|
|
576
|
+
- [ ] Input sanitized to prevent injection (Postgres) or excessive queries
|
|
577
|
+
- [ ] Zod validation on search parameters
|
|
578
|
+
- [ ] Debounced search hook or component created for client-side use
|
|
579
|
+
- [ ] Uses auth wrapper (`withAuthNoParams`) on API route
|
|
580
|
+
- [ ] Design tokens used for search component styling (no raw color classes)
|
|
581
|
+
- [ ] Tests written for search service and API route
|