@ucptools/validator 1.0.0
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/CLAUDE.md +109 -0
- package/CONTRIBUTING.md +113 -0
- package/LICENSE +21 -0
- package/README.md +203 -0
- package/api/analyze-feed.js +140 -0
- package/api/badge.js +185 -0
- package/api/benchmark.js +177 -0
- package/api/directory-stats.ts +29 -0
- package/api/directory.ts +73 -0
- package/api/generate-compliance.js +143 -0
- package/api/generate-schema.js +457 -0
- package/api/generate.js +132 -0
- package/api/security-scan.js +133 -0
- package/api/simulate.js +187 -0
- package/api/tsconfig.json +10 -0
- package/api/validate.js +1351 -0
- package/apify-actor/.actor/actor.json +68 -0
- package/apify-actor/.actor/input_schema.json +32 -0
- package/apify-actor/APIFY-STORE-LISTING.md +412 -0
- package/apify-actor/Dockerfile +8 -0
- package/apify-actor/README.md +166 -0
- package/apify-actor/main.ts +111 -0
- package/apify-actor/package.json +17 -0
- package/apify-actor/src/main.js +199 -0
- package/docs/BRAND-IDENTITY.md +238 -0
- package/docs/BRAND-STYLE-GUIDE.md +356 -0
- package/drizzle/0000_black_king_cobra.sql +39 -0
- package/drizzle/meta/0000_snapshot.json +309 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +10 -0
- package/examples/full-profile.json +70 -0
- package/examples/minimal-profile.json +23 -0
- package/package.json +69 -0
- package/public/.well-known/ucp +25 -0
- package/public/android-chrome-192x192.png +0 -0
- package/public/android-chrome-512x512.png +0 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/brand.css +321 -0
- package/public/directory.html +701 -0
- package/public/favicon-16x16.png +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/guides/bigcommerce.html +743 -0
- package/public/guides/fastucp.html +838 -0
- package/public/guides/magento.html +779 -0
- package/public/guides/shopify.html +726 -0
- package/public/guides/squarespace.html +749 -0
- package/public/guides/wix.html +747 -0
- package/public/guides/woocommerce.html +733 -0
- package/public/index.html +3835 -0
- package/public/learn.html +396 -0
- package/public/logo.jpeg +0 -0
- package/public/og-image-icon.png +0 -0
- package/public/og-image.png +0 -0
- package/public/robots.txt +6 -0
- package/public/site.webmanifest +31 -0
- package/public/sitemap.xml +69 -0
- package/public/social/linkedin-banner-1128x191.png +0 -0
- package/public/social/temp.PNG +0 -0
- package/public/social/x-header-1500x500.png +0 -0
- package/public/verify.html +410 -0
- package/scripts/generate-favicons.js +44 -0
- package/scripts/generate-ico.js +23 -0
- package/scripts/generate-og-image.js +45 -0
- package/scripts/reset-db.ts +77 -0
- package/scripts/seed-db.ts +71 -0
- package/scripts/setup-benchmark-db.js +70 -0
- package/src/api/server.ts +266 -0
- package/src/cli/index.ts +302 -0
- package/src/compliance/compliance-generator.ts +452 -0
- package/src/compliance/index.ts +28 -0
- package/src/compliance/templates.ts +338 -0
- package/src/compliance/types.ts +170 -0
- package/src/db/index.ts +28 -0
- package/src/db/schema.ts +84 -0
- package/src/feed-analyzer/feed-analyzer.ts +726 -0
- package/src/feed-analyzer/index.ts +34 -0
- package/src/feed-analyzer/types.ts +354 -0
- package/src/generator/index.ts +7 -0
- package/src/generator/key-generator.ts +124 -0
- package/src/generator/profile-builder.ts +402 -0
- package/src/hosting/artifacts-generator.ts +679 -0
- package/src/hosting/index.ts +6 -0
- package/src/index.ts +105 -0
- package/src/security/index.ts +15 -0
- package/src/security/security-scanner.ts +604 -0
- package/src/security/types.ts +55 -0
- package/src/services/directory.ts +434 -0
- package/src/simulator/agent-simulator.ts +941 -0
- package/src/simulator/index.ts +7 -0
- package/src/simulator/types.ts +170 -0
- package/src/types/generator.ts +140 -0
- package/src/types/index.ts +7 -0
- package/src/types/ucp-profile.ts +140 -0
- package/src/types/validation.ts +89 -0
- package/src/validator/index.ts +194 -0
- package/src/validator/network-validator.ts +417 -0
- package/src/validator/rules-validator.ts +297 -0
- package/src/validator/sdk-validator.ts +330 -0
- package/src/validator/structural-validator.ts +476 -0
- package/tests/fixtures/non-compliant-profile.json +25 -0
- package/tests/fixtures/official-sample-profile.json +75 -0
- package/tests/integration/benchmark.test.ts +207 -0
- package/tests/integration/database.test.ts +163 -0
- package/tests/integration/directory-api.test.ts +268 -0
- package/tests/integration/simulate-api.test.ts +230 -0
- package/tests/integration/validate-api.test.ts +269 -0
- package/tests/setup.ts +15 -0
- package/tests/unit/agent-simulator.test.ts +575 -0
- package/tests/unit/compliance-generator.test.ts +374 -0
- package/tests/unit/directory-service.test.ts +272 -0
- package/tests/unit/feed-analyzer.test.ts +517 -0
- package/tests/unit/lint-suggestions.test.ts +423 -0
- package/tests/unit/official-samples.test.ts +211 -0
- package/tests/unit/pdf-report.test.ts +390 -0
- package/tests/unit/sdk-validator.test.ts +531 -0
- package/tests/unit/security-scanner.test.ts +410 -0
- package/tests/unit/validation.test.ts +390 -0
- package/tsconfig.json +20 -0
- package/vercel.json +34 -0
- package/vitest.config.ts +22 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Directory Service
|
|
3
|
+
* Business logic for the UCP Merchant Directory
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { eq, ilike, sql, desc, asc, and, count, SQL } from 'drizzle-orm';
|
|
7
|
+
import { getDb, merchants, type Merchant, type NewMerchant } from '../db/index.js';
|
|
8
|
+
|
|
9
|
+
// Types
|
|
10
|
+
export interface ListMerchantsParams {
|
|
11
|
+
page?: number;
|
|
12
|
+
limit?: number;
|
|
13
|
+
category?: string;
|
|
14
|
+
country?: string;
|
|
15
|
+
search?: string;
|
|
16
|
+
sort?: 'score' | 'domain' | 'displayName' | 'createdAt';
|
|
17
|
+
order?: 'asc' | 'desc';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ListMerchantsResult {
|
|
21
|
+
merchants: Merchant[];
|
|
22
|
+
pagination: {
|
|
23
|
+
page: number;
|
|
24
|
+
limit: number;
|
|
25
|
+
total: number;
|
|
26
|
+
totalPages: number;
|
|
27
|
+
};
|
|
28
|
+
filters: {
|
|
29
|
+
categories: { name: string; count: number }[];
|
|
30
|
+
countries: { code: string; count: number }[];
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SubmitMerchantParams {
|
|
35
|
+
domain: string;
|
|
36
|
+
displayName?: string;
|
|
37
|
+
description?: string;
|
|
38
|
+
logoUrl?: string;
|
|
39
|
+
websiteUrl?: string;
|
|
40
|
+
category?: string;
|
|
41
|
+
countryCode?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface DirectoryStats {
|
|
45
|
+
totalMerchants: number;
|
|
46
|
+
verifiedMerchants: number;
|
|
47
|
+
avgScore: number;
|
|
48
|
+
totalCategories: number;
|
|
49
|
+
totalCountries: number;
|
|
50
|
+
gradeDistribution: { grade: string; count: number }[];
|
|
51
|
+
topCategories: { name: string; count: number }[];
|
|
52
|
+
recentAdditions: { domain: string; displayName: string; grade: string | null; addedAt: Date }[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ValidationResult {
|
|
56
|
+
valid: boolean;
|
|
57
|
+
error?: string;
|
|
58
|
+
score?: number;
|
|
59
|
+
grade?: string;
|
|
60
|
+
transports?: string;
|
|
61
|
+
ucpVersion?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// UCP Profile structure (minimal typing for validation)
|
|
65
|
+
interface UcpProfile {
|
|
66
|
+
ucp?: {
|
|
67
|
+
version?: string;
|
|
68
|
+
services?: Record<string, { rest?: unknown; mcp?: unknown; a2a?: unknown; embedded?: unknown }>;
|
|
69
|
+
capabilities?: Array<{ name: string }>;
|
|
70
|
+
};
|
|
71
|
+
signing_keys?: unknown[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Fetch UCP profile from a URL, return null if not valid JSON
|
|
76
|
+
*/
|
|
77
|
+
async function tryFetchProfile(url: string): Promise<UcpProfile | null> {
|
|
78
|
+
try {
|
|
79
|
+
const res = await fetch(url, {
|
|
80
|
+
headers: {
|
|
81
|
+
Accept: 'application/json',
|
|
82
|
+
'User-Agent': 'UCP-Directory/1.0 (https://ucptools.dev)',
|
|
83
|
+
},
|
|
84
|
+
signal: AbortSignal.timeout(10000),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (!res.ok) return null;
|
|
88
|
+
|
|
89
|
+
const text = await res.text();
|
|
90
|
+
// Check if response looks like JSON (not HTML)
|
|
91
|
+
if (text.trim().startsWith('<')) return null;
|
|
92
|
+
|
|
93
|
+
return JSON.parse(text) as UcpProfile;
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Validate a domain's UCP profile
|
|
101
|
+
*/
|
|
102
|
+
export async function validateDomain(domain: string): Promise<ValidationResult> {
|
|
103
|
+
// Try both /.well-known/ucp and /.well-known/ucp.json
|
|
104
|
+
const urls = [
|
|
105
|
+
`https://${domain}/.well-known/ucp`,
|
|
106
|
+
`https://${domain}/.well-known/ucp.json`,
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
let profile: UcpProfile | null = null;
|
|
110
|
+
|
|
111
|
+
for (const url of urls) {
|
|
112
|
+
profile = await tryFetchProfile(url);
|
|
113
|
+
if (profile) break;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
if (!profile) {
|
|
118
|
+
return { valid: false, error: 'No UCP profile found at /.well-known/ucp or /.well-known/ucp.json' };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Basic validation
|
|
122
|
+
if (!profile?.ucp?.version || !profile?.ucp?.services) {
|
|
123
|
+
return { valid: false, error: 'Invalid UCP profile structure' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Extract transports
|
|
127
|
+
const transports = new Set<string>();
|
|
128
|
+
for (const svc of Object.values(profile.ucp.services || {})) {
|
|
129
|
+
if (svc.rest) transports.add('REST');
|
|
130
|
+
if (svc.mcp) transports.add('MCP');
|
|
131
|
+
if (svc.a2a) transports.add('A2A');
|
|
132
|
+
if (svc.embedded) transports.add('Embedded');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Simple scoring
|
|
136
|
+
let score = 50;
|
|
137
|
+
const capabilities = profile.ucp.capabilities || [];
|
|
138
|
+
if (capabilities.some((c) => c.name === 'dev.ucp.shopping.checkout')) score += 20;
|
|
139
|
+
if (capabilities.some((c) => c.name === 'dev.ucp.shopping.cart')) score += 10;
|
|
140
|
+
if (capabilities.some((c) => c.name === 'dev.ucp.shopping.order')) score += 10;
|
|
141
|
+
if (profile.signing_keys && profile.signing_keys.length > 0) score += 10;
|
|
142
|
+
|
|
143
|
+
const grade = score >= 90 ? 'A' : score >= 80 ? 'B' : score >= 70 ? 'C' : score >= 60 ? 'D' : 'F';
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
valid: true,
|
|
147
|
+
score,
|
|
148
|
+
grade,
|
|
149
|
+
transports: Array.from(transports).join(','),
|
|
150
|
+
ucpVersion: profile.ucp.version,
|
|
151
|
+
};
|
|
152
|
+
} catch (e: unknown) {
|
|
153
|
+
const message = e instanceof Error ? e.message : 'Failed to fetch profile';
|
|
154
|
+
return { valid: false, error: message };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* List merchants with pagination and filters
|
|
160
|
+
*/
|
|
161
|
+
export async function listMerchants(params: ListMerchantsParams): Promise<ListMerchantsResult> {
|
|
162
|
+
const db = getDb();
|
|
163
|
+
|
|
164
|
+
const page = Math.max(1, params.page || 1);
|
|
165
|
+
const limit = Math.min(100, Math.max(1, params.limit || 20));
|
|
166
|
+
const offset = (page - 1) * limit;
|
|
167
|
+
|
|
168
|
+
// Build where conditions
|
|
169
|
+
const conditions: SQL[] = [eq(merchants.isPublic, true)];
|
|
170
|
+
|
|
171
|
+
if (params.category) {
|
|
172
|
+
conditions.push(eq(merchants.category, params.category));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (params.country) {
|
|
176
|
+
conditions.push(eq(merchants.countryCode, params.country.toUpperCase()));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (params.search) {
|
|
180
|
+
conditions.push(
|
|
181
|
+
sql`(${merchants.domain} ILIKE ${`%${params.search}%`} OR ${merchants.displayName} ILIKE ${`%${params.search}%`})`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const whereClause = and(...conditions);
|
|
186
|
+
|
|
187
|
+
// Determine sort column and order
|
|
188
|
+
const sortColumn =
|
|
189
|
+
params.sort === 'domain'
|
|
190
|
+
? merchants.domain
|
|
191
|
+
: params.sort === 'displayName'
|
|
192
|
+
? merchants.displayName
|
|
193
|
+
: params.sort === 'createdAt'
|
|
194
|
+
? merchants.createdAt
|
|
195
|
+
: merchants.ucpScore;
|
|
196
|
+
|
|
197
|
+
const orderFn = params.order === 'asc' ? asc : desc;
|
|
198
|
+
|
|
199
|
+
// Get total count
|
|
200
|
+
const countResult = await db
|
|
201
|
+
.select({ total: count() })
|
|
202
|
+
.from(merchants)
|
|
203
|
+
.where(whereClause);
|
|
204
|
+
|
|
205
|
+
const total = countResult[0]?.total || 0;
|
|
206
|
+
|
|
207
|
+
// Get merchants
|
|
208
|
+
const merchantList = await db
|
|
209
|
+
.select()
|
|
210
|
+
.from(merchants)
|
|
211
|
+
.where(whereClause)
|
|
212
|
+
.orderBy(orderFn(sortColumn))
|
|
213
|
+
.limit(limit)
|
|
214
|
+
.offset(offset);
|
|
215
|
+
|
|
216
|
+
// Get category counts
|
|
217
|
+
const categoryResult = await db
|
|
218
|
+
.select({
|
|
219
|
+
category: merchants.category,
|
|
220
|
+
count: count(),
|
|
221
|
+
})
|
|
222
|
+
.from(merchants)
|
|
223
|
+
.where(and(eq(merchants.isPublic, true), sql`${merchants.category} IS NOT NULL`))
|
|
224
|
+
.groupBy(merchants.category)
|
|
225
|
+
.orderBy(desc(count()));
|
|
226
|
+
|
|
227
|
+
// Get country counts
|
|
228
|
+
const countryResult = await db
|
|
229
|
+
.select({
|
|
230
|
+
countryCode: merchants.countryCode,
|
|
231
|
+
count: count(),
|
|
232
|
+
})
|
|
233
|
+
.from(merchants)
|
|
234
|
+
.where(and(eq(merchants.isPublic, true), sql`${merchants.countryCode} IS NOT NULL`))
|
|
235
|
+
.groupBy(merchants.countryCode)
|
|
236
|
+
.orderBy(desc(count()));
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
merchants: merchantList,
|
|
240
|
+
pagination: {
|
|
241
|
+
page,
|
|
242
|
+
limit,
|
|
243
|
+
total,
|
|
244
|
+
totalPages: Math.ceil(total / limit),
|
|
245
|
+
},
|
|
246
|
+
filters: {
|
|
247
|
+
categories: categoryResult.map((r) => ({ name: r.category!, count: r.count })),
|
|
248
|
+
countries: countryResult.map((r) => ({ code: r.countryCode!, count: r.count })),
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Get a single merchant by domain
|
|
255
|
+
*/
|
|
256
|
+
export async function getMerchantByDomain(domain: string): Promise<Merchant | null> {
|
|
257
|
+
const db = getDb();
|
|
258
|
+
const result = await db.select().from(merchants).where(eq(merchants.domain, domain.toLowerCase())).limit(1);
|
|
259
|
+
return result[0] || null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Submit a new merchant to the directory
|
|
264
|
+
*/
|
|
265
|
+
export async function submitMerchant(
|
|
266
|
+
params: SubmitMerchantParams
|
|
267
|
+
): Promise<{ success: boolean; merchant?: Merchant; error?: string; details?: string }> {
|
|
268
|
+
const db = getDb();
|
|
269
|
+
|
|
270
|
+
// Clean domain
|
|
271
|
+
const cleanDomain = params.domain
|
|
272
|
+
.replace(/^https?:\/\//, '')
|
|
273
|
+
.replace(/\/$/, '')
|
|
274
|
+
.split('/')[0]
|
|
275
|
+
.toLowerCase();
|
|
276
|
+
|
|
277
|
+
// Check if domain already exists
|
|
278
|
+
const existing = await getMerchantByDomain(cleanDomain);
|
|
279
|
+
if (existing) {
|
|
280
|
+
return {
|
|
281
|
+
success: false,
|
|
282
|
+
error: 'Domain already registered',
|
|
283
|
+
details: `Merchant ID: ${existing.id}`,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Validate the domain has a valid UCP profile
|
|
288
|
+
const validation = await validateDomain(cleanDomain);
|
|
289
|
+
|
|
290
|
+
if (!validation.valid) {
|
|
291
|
+
return {
|
|
292
|
+
success: false,
|
|
293
|
+
error: 'Invalid UCP profile',
|
|
294
|
+
details: validation.error,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Insert merchant
|
|
299
|
+
const newMerchant: NewMerchant = {
|
|
300
|
+
domain: cleanDomain,
|
|
301
|
+
displayName: params.displayName || cleanDomain,
|
|
302
|
+
description: params.description || null,
|
|
303
|
+
logoUrl: params.logoUrl || null,
|
|
304
|
+
websiteUrl: params.websiteUrl || `https://${cleanDomain}`,
|
|
305
|
+
category: params.category || null,
|
|
306
|
+
countryCode: params.countryCode?.toUpperCase() || null,
|
|
307
|
+
ucpScore: validation.score || null,
|
|
308
|
+
ucpGrade: validation.grade || null,
|
|
309
|
+
transports: validation.transports || null,
|
|
310
|
+
isPublic: true,
|
|
311
|
+
isVerified: false,
|
|
312
|
+
lastValidatedAt: new Date(),
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const result = await db.insert(merchants).values(newMerchant).returning();
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
success: true,
|
|
319
|
+
merchant: result[0],
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Get directory statistics
|
|
325
|
+
*/
|
|
326
|
+
export async function getDirectoryStats(): Promise<DirectoryStats> {
|
|
327
|
+
const db = getDb();
|
|
328
|
+
|
|
329
|
+
// Overall stats
|
|
330
|
+
const statsResult = await db
|
|
331
|
+
.select({
|
|
332
|
+
totalMerchants: count(),
|
|
333
|
+
verifiedMerchants: sql<number>`COUNT(*) FILTER (WHERE ${merchants.isVerified} = true)`,
|
|
334
|
+
avgScore: sql<number>`COALESCE(AVG(${merchants.ucpScore}), 0)`,
|
|
335
|
+
})
|
|
336
|
+
.from(merchants)
|
|
337
|
+
.where(eq(merchants.isPublic, true));
|
|
338
|
+
|
|
339
|
+
// Count distinct categories and countries
|
|
340
|
+
const categoryCountResult = await db
|
|
341
|
+
.select({ count: sql<number>`COUNT(DISTINCT ${merchants.category})` })
|
|
342
|
+
.from(merchants)
|
|
343
|
+
.where(and(eq(merchants.isPublic, true), sql`${merchants.category} IS NOT NULL`));
|
|
344
|
+
|
|
345
|
+
const countryCountResult = await db
|
|
346
|
+
.select({ count: sql<number>`COUNT(DISTINCT ${merchants.countryCode})` })
|
|
347
|
+
.from(merchants)
|
|
348
|
+
.where(and(eq(merchants.isPublic, true), sql`${merchants.countryCode} IS NOT NULL`));
|
|
349
|
+
|
|
350
|
+
// Grade distribution
|
|
351
|
+
const gradeResult = await db
|
|
352
|
+
.select({
|
|
353
|
+
grade: merchants.ucpGrade,
|
|
354
|
+
count: count(),
|
|
355
|
+
})
|
|
356
|
+
.from(merchants)
|
|
357
|
+
.where(and(eq(merchants.isPublic, true), sql`${merchants.ucpGrade} IS NOT NULL`))
|
|
358
|
+
.groupBy(merchants.ucpGrade)
|
|
359
|
+
.orderBy(merchants.ucpGrade);
|
|
360
|
+
|
|
361
|
+
// Top categories
|
|
362
|
+
const topCategoriesResult = await db
|
|
363
|
+
.select({
|
|
364
|
+
name: merchants.category,
|
|
365
|
+
count: count(),
|
|
366
|
+
})
|
|
367
|
+
.from(merchants)
|
|
368
|
+
.where(and(eq(merchants.isPublic, true), sql`${merchants.category} IS NOT NULL`))
|
|
369
|
+
.groupBy(merchants.category)
|
|
370
|
+
.orderBy(desc(count()))
|
|
371
|
+
.limit(10);
|
|
372
|
+
|
|
373
|
+
// Recent additions
|
|
374
|
+
const recentResult = await db
|
|
375
|
+
.select({
|
|
376
|
+
domain: merchants.domain,
|
|
377
|
+
displayName: merchants.displayName,
|
|
378
|
+
grade: merchants.ucpGrade,
|
|
379
|
+
addedAt: merchants.createdAt,
|
|
380
|
+
})
|
|
381
|
+
.from(merchants)
|
|
382
|
+
.where(eq(merchants.isPublic, true))
|
|
383
|
+
.orderBy(desc(merchants.createdAt))
|
|
384
|
+
.limit(5);
|
|
385
|
+
|
|
386
|
+
const stats = statsResult[0];
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
totalMerchants: stats?.totalMerchants || 0,
|
|
390
|
+
verifiedMerchants: stats?.verifiedMerchants || 0,
|
|
391
|
+
avgScore: Math.round(stats?.avgScore || 0),
|
|
392
|
+
totalCategories: categoryCountResult[0]?.count || 0,
|
|
393
|
+
totalCountries: countryCountResult[0]?.count || 0,
|
|
394
|
+
gradeDistribution: gradeResult.map((r) => ({ grade: r.grade!, count: r.count })),
|
|
395
|
+
topCategories: topCategoriesResult.map((r) => ({ name: r.name!, count: r.count })),
|
|
396
|
+
recentAdditions: recentResult.map((r) => ({
|
|
397
|
+
domain: r.domain,
|
|
398
|
+
displayName: r.displayName,
|
|
399
|
+
grade: r.grade,
|
|
400
|
+
addedAt: r.addedAt,
|
|
401
|
+
})),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Re-validate a merchant's UCP profile and update their record
|
|
407
|
+
*/
|
|
408
|
+
export async function revalidateMerchant(domain: string): Promise<{ success: boolean; error?: string }> {
|
|
409
|
+
const db = getDb();
|
|
410
|
+
|
|
411
|
+
const merchant = await getMerchantByDomain(domain);
|
|
412
|
+
if (!merchant) {
|
|
413
|
+
return { success: false, error: 'Merchant not found' };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const validation = await validateDomain(domain);
|
|
417
|
+
|
|
418
|
+
if (!validation.valid) {
|
|
419
|
+
return { success: false, error: validation.error };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
await db
|
|
423
|
+
.update(merchants)
|
|
424
|
+
.set({
|
|
425
|
+
ucpScore: validation.score || null,
|
|
426
|
+
ucpGrade: validation.grade || null,
|
|
427
|
+
transports: validation.transports || null,
|
|
428
|
+
lastValidatedAt: new Date(),
|
|
429
|
+
updatedAt: new Date(),
|
|
430
|
+
})
|
|
431
|
+
.where(eq(merchants.domain, domain.toLowerCase()));
|
|
432
|
+
|
|
433
|
+
return { success: true };
|
|
434
|
+
}
|