@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@press2ai/engine",
3
- "version": "0.4.0",
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",
@@ -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** i **hard delete**: DELETE FROM leads + wpis
26
- * w `opted_out_nips` (blacklist NIP-ów żeby sync nie wprowadził z powrotem).
27
- * RODO art. 17 usunięcie danych osobowych, nie soft flag. Z karty profilu
28
- * przez slug (jeden klik), bez formularza NIP.
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. Świadome uproszczenie: lead z mieszanymi PKDs dostanie
112
- // jeden label. Filtr opted-out zbędny opt-out to hard delete (0010).
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 EXISTS (SELECT 1 FROM lead_categories WHERE lead_nip = l.nip AND category = ?)`;
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 slug = String(form.get('slug') ?? '').trim();
269
- if (!slug) return c.text('Brak slug', 400);
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
- const row = await c.env.DB
278
- .prepare('SELECT nip FROM leads WHERE slug = ?')
279
- .bind(slug)
280
- .first<{ nip: string }>();
281
- if (!row) return c.text('Nie znaleziono wpisu', 404);
282
-
283
- const now = Math.floor(Date.now() / 1000);
284
- const ipHash = await sha256Hex(ip + ':' + slug);
285
- await c.env.DB.batch([
286
- c.env.DB.prepare('INSERT OR IGNORE INTO opted_out_nips (nip, opted_out_at, ip_hash) VALUES (?, ?, ?)').bind(row.nip, now, ipHash),
287
- c.env.DB.prepare('DELETE FROM lead_categories WHERE lead_nip = ?').bind(row.nip),
288
- c.env.DB.prepare('DELETE FROM lead_pkd WHERE nip = ?').bind(row.nip),
289
- c.env.DB.prepare('DELETE FROM leads WHERE nip = ?').bind(row.nip),
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
- }
@@ -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: 'trenerów', icon: 'people' },
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' }, '<h1>404</h1><p>Brak takiego trenera.</p>'),
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