@press2ai/engine 0.4.0 → 0.4.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 +1 -1
- package/src/ceidg-vertical.ts +33 -34
- package/src/template-trener.ts +8 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@press2ai/engine",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Multi-tenant runtime + template contracts dla otwarty-* verticali. SSR na Cloudflare Workers, isomorphic renderer (SSG/browser w roadmapie).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/ceidg-vertical.ts
CHANGED
|
@@ -22,10 +22,10 @@
|
|
|
22
22
|
* i — fix względem starego trener-landinga — `cities` query też filtruje
|
|
23
23
|
* po kategorii (wcześniej trener pokazywał miasta z całej tabeli leads,
|
|
24
24
|
* mieszając wszystkie wertykale).
|
|
25
|
-
* - Opt-out jest **globalny**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
25
|
+
* - Opt-out jest **globalny** — UPDATE leads bez filtra category. RODO
|
|
26
|
+
* sprzeciw to usunięcie danych osobowych, nie selektywne ukrycie w jednej
|
|
27
|
+
* kategorii. Soft flag + filtr w queries — hard delete bez proof-of-ownership
|
|
28
|
+
* jest podatny na sabotage (patrz NEXT-STEPS manifest-1/manifest-2 notatka).
|
|
29
29
|
*
|
|
30
30
|
* Hono jest peer dependency (każdy wertykal i tak go ma). Engine core nie
|
|
31
31
|
* importuje Hono — tylko ten subpath go używa.
|
|
@@ -54,6 +54,7 @@ export type CeidgLead = {
|
|
|
54
54
|
claimed: number;
|
|
55
55
|
external_site_url: string | null;
|
|
56
56
|
fetched_at: number;
|
|
57
|
+
opted_out_at: number | null;
|
|
57
58
|
};
|
|
58
59
|
|
|
59
60
|
export type CeidgBindings = { DB: D1Database; CACHE: KVNamespace };
|
|
@@ -76,6 +77,10 @@ export interface VerticalConfig {
|
|
|
76
77
|
heroTitle: string;
|
|
77
78
|
heroSubtitle?: string;
|
|
78
79
|
searchPlaceholder?: string;
|
|
80
|
+
/** Plural label for count in statBar (e.g. 'trenerów', 'terapeutów'). Default 'wpisów'. */
|
|
81
|
+
itemsLabel?: string;
|
|
82
|
+
/** Singular fallback for 404 (e.g. 'trenera', 'terapeuty'). Default 'wpisu'. */
|
|
83
|
+
itemSingular?: string;
|
|
79
84
|
};
|
|
80
85
|
/** PKD → human-readable jobTitle. Per-wertykal mapping. */
|
|
81
86
|
pkdToJobTitle(pkd: string | null | undefined): string;
|
|
@@ -108,15 +113,17 @@ export function createVerticalApp(config: VerticalConfig): Hono<{ Bindings: Ceid
|
|
|
108
113
|
// ─────────── SQL ───────────
|
|
109
114
|
// EXISTS subselect zamiast JOIN: lead z N PKDs nie multiplikuje wierszy.
|
|
110
115
|
// (SELECT pkd ... LIMIT 1) wybiera jeden PKD per lead — wystarczy do
|
|
111
|
-
// pkdToJobTitle.
|
|
112
|
-
//
|
|
116
|
+
// pkdToJobTitle. To jest świadomy uproszczenie: lead z mieszanymi PKDs
|
|
117
|
+
// (np. fizjoterapia + psychologia) dostanie tylko jeden label.
|
|
113
118
|
const leadsQuery = `
|
|
114
119
|
SELECT l.nip, l.first_name, l.last_name, c.name as city,
|
|
115
120
|
l.company_name, l.slug, l.claimed, l.external_site_url, l.fetched_at,
|
|
121
|
+
l.opted_out_at,
|
|
116
122
|
(SELECT pkd FROM lead_pkd WHERE nip = l.nip LIMIT 1) as pkd
|
|
117
123
|
FROM leads l
|
|
118
124
|
LEFT JOIN cities c ON l.city_id = c.id
|
|
119
|
-
WHERE
|
|
125
|
+
WHERE l.opted_out_at IS NULL
|
|
126
|
+
AND EXISTS (SELECT 1 FROM lead_categories WHERE lead_nip = l.nip AND category = ?)`;
|
|
120
127
|
|
|
121
128
|
// Cities query filtruje po kategorii — fix względem starego trener landinga
|
|
122
129
|
// który pokazywał miasta z całej tabeli leads (mieszane wertykale).
|
|
@@ -125,6 +132,7 @@ export function createVerticalApp(config: VerticalConfig): Hono<{ Bindings: Ceid
|
|
|
125
132
|
FROM leads l
|
|
126
133
|
JOIN cities c ON l.city_id = c.id
|
|
127
134
|
INNER JOIN lead_categories lc ON l.nip = lc.lead_nip AND lc.category = ?
|
|
135
|
+
WHERE l.opted_out_at IS NULL
|
|
128
136
|
GROUP BY c.name ORDER BY count DESC LIMIT 8`;
|
|
129
137
|
|
|
130
138
|
// ─────────── Content loader ───────────
|
|
@@ -155,6 +163,8 @@ export function createVerticalApp(config: VerticalConfig): Hono<{ Bindings: Ceid
|
|
|
155
163
|
heroTitle: copy.heroTitle,
|
|
156
164
|
heroSubtitle: copy.heroSubtitle,
|
|
157
165
|
searchPlaceholder: copy.searchPlaceholder ?? 'Szukaj...',
|
|
166
|
+
itemsLabel: copy.itemsLabel ?? 'wpisów',
|
|
167
|
+
itemSingular: copy.itemSingular ?? 'wpisu',
|
|
158
168
|
},
|
|
159
169
|
profiles: (leadsRes.results ?? []).map(leadToProfile),
|
|
160
170
|
cities: citiesRes.results ?? [],
|
|
@@ -258,15 +268,10 @@ export function createVerticalApp(config: VerticalConfig): Hono<{ Bindings: Ceid
|
|
|
258
268
|
}
|
|
259
269
|
app.get('/opt-out', (c) => c.html(renderStatic(pages.optOutForm)));
|
|
260
270
|
|
|
261
|
-
// One-click opt-out: przycisk na karcie profilu POST-uje slug. Hard delete
|
|
262
|
-
// z `leads` (cascade do `lead_pkd`/`lead_categories`) + insert do blacklisty
|
|
263
|
-
// `opted_out_nips` żeby sync nie wprowadził NIP-a z powrotem. Rate limit
|
|
264
|
-
// 5/h/IP przeciw abuse — slug jest publiczny, ktokolwiek może nukeować,
|
|
265
|
-
// ale katalog to publiczne wizytówki z CEIDG, nie konta.
|
|
266
271
|
app.post('/opt-out', async (c) => {
|
|
267
272
|
const form = await c.req.formData();
|
|
268
|
-
const
|
|
269
|
-
if (
|
|
273
|
+
const nip = String(form.get('nip') ?? '').replace(/\D/g, '');
|
|
274
|
+
if (nip.length !== 10) return c.text('Nieprawidłowy NIP', 400);
|
|
270
275
|
|
|
271
276
|
const ip = c.req.header('cf-connecting-ip') ?? '0.0.0.0';
|
|
272
277
|
const rlKey = `vertical:${category}:rl:optout:${ip}`;
|
|
@@ -274,21 +279,20 @@ export function createVerticalApp(config: VerticalConfig): Hono<{ Bindings: Ceid
|
|
|
274
279
|
if (rlVal >= OPTOUT_LIMIT) return c.text('Zbyt wiele prób. Spróbuj ponownie za godzinę.', 429);
|
|
275
280
|
await c.env.CACHE.put(rlKey, String(rlVal + 1), { expirationTtl: OPTOUT_WINDOW_SEC });
|
|
276
281
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
282
|
+
// Opt-out jest GLOBALNY — usuwa też z innych wertykali. RODO sprzeciw to
|
|
283
|
+
// usunięcie danych osobowych, nie selektywne ukrycie w jednej kategorii.
|
|
284
|
+
// Soft flag (nie DELETE) — defense in depth przeciw masowej sabotage:
|
|
285
|
+
// catalog.json emituje slug+imię+miasto bez NIP, więc adversary musi
|
|
286
|
+
// zmapować slug→NIP przez CEIDG pod rate limit (6000 req/h); dodatkowo
|
|
287
|
+
// soft flag jest natychmiast odwracalny jednym UPDATE gdyby coś poszło nie tak.
|
|
288
|
+
const res = await c.env.DB
|
|
289
|
+
.prepare(
|
|
290
|
+
'UPDATE leads SET opted_out_at = ?, opted_out_ip = ?, opted_out_ua = ? WHERE nip = ? AND opted_out_at IS NULL',
|
|
291
|
+
)
|
|
292
|
+
.bind(Math.floor(Date.now() / 1000), ip, c.req.header('user-agent') ?? '', nip)
|
|
293
|
+
.run();
|
|
294
|
+
|
|
295
|
+
if (!res.meta.changes) return c.text('Nie znaleziono wpisu', 404);
|
|
292
296
|
return c.html(renderStatic(pages.optOutDone));
|
|
293
297
|
});
|
|
294
298
|
|
|
@@ -310,8 +314,3 @@ export function createVerticalApp(config: VerticalConfig): Hono<{ Bindings: Ceid
|
|
|
310
314
|
|
|
311
315
|
return app;
|
|
312
316
|
}
|
|
313
|
-
|
|
314
|
-
async function sha256Hex(input: string): Promise<string> {
|
|
315
|
-
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
|
|
316
|
-
return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
317
|
-
}
|
package/src/template-trener.ts
CHANGED
|
@@ -67,6 +67,10 @@ const trenerContentSchema = z.object({
|
|
|
67
67
|
heroTitle: z.string(),
|
|
68
68
|
heroSubtitle: z.string().optional(),
|
|
69
69
|
searchPlaceholder: z.string().default('Szukaj...'),
|
|
70
|
+
/** Plural label for the count in statBar (e.g. 'trenerów', 'terapeutów'). */
|
|
71
|
+
itemsLabel: z.string().default('wpisów'),
|
|
72
|
+
/** Singular fallback shown on 404 (e.g. 'trenera', 'terapeuty'). */
|
|
73
|
+
itemSingular: z.string().default('wpisu'),
|
|
70
74
|
}),
|
|
71
75
|
profiles: z.array(profileSchema),
|
|
72
76
|
cities: z.array(z.object({ name: z.string(), count: z.number() })).default([]),
|
|
@@ -96,6 +100,8 @@ export const trenerTemplate: PresenceTemplate<TrenerContent, TrenerTheme> = {
|
|
|
96
100
|
heroTitle: 'Znajdź trenera w Twojej okolicy',
|
|
97
101
|
heroSubtitle: 'Otwarty katalog trenerów. Bezpłatnie i bez rejestracji.',
|
|
98
102
|
searchPlaceholder: 'Szukaj po nazwisku, mieście lub firmie...',
|
|
103
|
+
itemsLabel: 'wpisów',
|
|
104
|
+
itemSingular: 'wpisu',
|
|
99
105
|
},
|
|
100
106
|
profiles: [],
|
|
101
107
|
cities: [],
|
|
@@ -196,7 +202,7 @@ function renderIndex(req: RenderRequest<TrenerContent>, ctx: RenderContext<Trene
|
|
|
196
202
|
});
|
|
197
203
|
|
|
198
204
|
const stats = t.statBar([
|
|
199
|
-
{ value: total.toLocaleString('pl'), label:
|
|
205
|
+
{ value: total.toLocaleString('pl'), label: content.copy.itemsLabel, icon: 'people' },
|
|
200
206
|
{ value: String(content.cities.length), label: 'miast', icon: 'city' },
|
|
201
207
|
{ value: '100%', label: 'bezpłatnie', icon: 'free' },
|
|
202
208
|
]);
|
|
@@ -241,7 +247,7 @@ function renderProfile(req: RenderRequest<TrenerContent>, ctx: RenderContext<Tre
|
|
|
241
247
|
if (!profile) {
|
|
242
248
|
return {
|
|
243
249
|
status: 404,
|
|
244
|
-
body: ctx.theme.layout({ title: 'Nie znaleziono' },
|
|
250
|
+
body: ctx.theme.layout({ title: 'Nie znaleziono' }, `<h1>404</h1><p>Brak takiego ${req.content.copy.itemSingular}.</p>`),
|
|
245
251
|
};
|
|
246
252
|
}
|
|
247
253
|
|