cleanup + docs: remove marketing leftovers, full PROMPTS.md, CLAUDE.md
Deleted (live in gov-agreg repo): - src/lib/auth-queries.ts, supabase.ts, platform-queries.ts (marketing only) - src/pages/api/auth/, platform/, traducator.ts Added: - PROMPTS.md: §0 vreau.digital context + full historic (sections §0a-§0e from gov-agreg pre-split — 1367 lines preserved as "Istoric sesiuni") - CLAUDE.md: vreau.digital scope, 17 data sources table, deploy, sister project link Memory dir bootstrapped at ~/Code/claude-memory/projects/vreau-digital/memory/ with 23 .md files (all relevant context from gov-agreg). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,35 +1,90 @@
|
|||||||
# vreau.digital — Platformă de transparență achiziții publice
|
# vreau.digital — Platformă de transparență achiziții publice
|
||||||
|
|
||||||
## Context
|
## Ce e proiectul
|
||||||
|
|
||||||
Spin-off din `gov-agreg` (vreaudigital.ro). Acest repo conține platforma standalone de transparență achiziții publice România.
|
Platforma standalone de transparență pentru achizițiile publice din România. Agregator cross-source via CUI pe 17 surse publice (SEAP, ANAF, ANRE, ANCOM, ASF, AAAS, AEP, CNSC, Curtea Conturi, RegAS, bugetar, ONRC, AFIR, fonduri-ue, CNAS, APIA, GNM).
|
||||||
|
|
||||||
- **Domain**: https://vreau.digital
|
**Spin-off din `gov-agreg` (vreaudigital.ro)** — vreaudigital.ro rămâne hub-ul de marketing/discoverability. Vreau.digital e produsul real, live, cu DB conectat.
|
||||||
- **Hub marketing**: https://vreaudigital.ro (rămâne gov-agreg)
|
|
||||||
- **Backend partajat**: PostgreSQL satra, Photon, Martin (același cluster ca gov-agreg)
|
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- Astro 5 + React 19 + Tailwind 3 + Node @astrojs/node standalone
|
- Astro 5 + React 19 + Tailwind 3.4 (NU v4) + Node @astrojs/node standalone
|
||||||
- PostgreSQL @ satra:5432 (schemas: seap, firms, anaf, anre, ancom, asf, aaas, aep, cnsc, curteacont, bugetar, regas, fonduri, cnas, apia, gnm, public_kpi)
|
- PostgreSQL 17 @ satra:5432 (17+ schemas + public_kpi snapshots)
|
||||||
- MapLibre 5 + Martin tile server @ 10.10.10.166:3010
|
- MapLibre 5 + Martin tile server @ 10.10.10.166:3010
|
||||||
- Docker container @ satra:5096 → Traefik @ proxy 10.10.10.199 → vreau.digital (Cloudflare proxied)
|
- Photon geocoder daemon @ satra:2322
|
||||||
|
- Docker container @ satra:5096 → Traefik @ proxy 10.10.10.199 → Cloudflare → vreau.digital
|
||||||
|
|
||||||
## Routes (toate la rădăcină, nu /achizitii/)
|
## Routes (toate la rădăcină, NU sub /achizitii/)
|
||||||
|
|
||||||
- `/` — landing page
|
| Route | Scop |
|
||||||
- `/cauta` — search SEAP
|
|---|---|
|
||||||
- `/retete/[slug]` — investigative recipes (49+)
|
| `/` | landing — KPI naționale + secțiuni principale |
|
||||||
- `/investigation/[slug]` — narrative leads (15+)
|
| `/cauta` | search SEAP cu MapLibre map, filtre cross-source, CPV autocomplete |
|
||||||
- `/firma/[cui]`, `/autoritate/[cui]` — profile pages
|
| `/retete` + `/retete/[slug]` | rețete investigative (49+) |
|
||||||
- `/red-flags` — cross-source signals
|
| `/investigation` + `/investigation/[slug]` | pagini narative cu cifre live (15+) |
|
||||||
- `/top-contracte`, `/top-firme`, `/fonduri-ue` — leaderboards
|
| `/firma/[cui]`, `/autoritate/[cui]` | profile cu timeline 10 ani financiar + badges cross-source |
|
||||||
- `/api/*` — endpoints (og, cpv, profil, etc.)
|
| `/red-flags` | landing cross-source signals (snapshot table — 207ms) |
|
||||||
|
| `/top-contracte`, `/top-firme`, `/fonduri-ue` | leaderboards |
|
||||||
|
| `/azi` | activitate zilnică SEAP |
|
||||||
|
| `/risc/*` | risk analysis (single-bidder, economii-suspect, termen-scurt, overprice) |
|
||||||
|
| `/explorer` | data browser |
|
||||||
|
| `/harta` | Civic OS map — bani publici per județ |
|
||||||
|
| `/api/og/firma/[cui].png` etc. | OG image generation |
|
||||||
|
| `/api/cpv/search` | CPV nomenclature autocomplete |
|
||||||
|
| `/api/profil/[side]/[cui]` | JSON profile API |
|
||||||
|
| `/api/ocds/*` | Open Contracting Data Standard endpoints |
|
||||||
|
| `/api/harta/*` | Civic OS map data |
|
||||||
|
| `/api/version` | deploy verification |
|
||||||
|
|
||||||
|
## Data sources & freshness
|
||||||
|
|
||||||
|
| Schema | Rows | Cadence | Live? |
|
||||||
|
|---|---:|---|---|
|
||||||
|
| seap.announcements | 781K+ | daily WSP scrapers (T+0) | ✓ |
|
||||||
|
| firms.entities (ONRC) | 4.005M | monthly fresh import | ✓ |
|
||||||
|
| firms.financials | 7.77M (10 ani, 2015-2024) | annual data.gov.ro | ✓ |
|
||||||
|
| anaf.datornici | 47K T1 2026 LIVE | **quarterly via 2captcha** | ✓ |
|
||||||
|
| anaf.lista_alba | 648K T1 2026 LIVE | **quarterly via 2captcha** | ✓ |
|
||||||
|
| anre.licente | 29K | weekly | ✓ |
|
||||||
|
| ancom.operatori | 518 | weekly | ✓ |
|
||||||
|
| asf.entitati | 849 | weekly | ✓ |
|
||||||
|
| aaas.firme | 11 (limit sursă) | weekly | ✓ |
|
||||||
|
| aep.donatii_pj/pf/rvc | 380K | monthly | ✓ |
|
||||||
|
| cnsc.decizii | 29K | weekly | ✓ |
|
||||||
|
| curteacont.rapoarte | 1133 | weekly | ✓ |
|
||||||
|
| regas.ajutoare | 78K | monthly | ✓ |
|
||||||
|
| bugetar.entitate | 18K | quarterly | ✓ |
|
||||||
|
| cnas.furnizori | 36K (50+ PDFs parse) | monthly | ✓ |
|
||||||
|
| apia.fermieri | 191 (snapshot test) | quarterly | ✓ |
|
||||||
|
| fonduri.beneficiar_anunt | 41K | weekly | ✓ |
|
||||||
|
| fonduri.afir_plati | 5.33M | monthly | ✓ |
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
- CI/CD: Gitea webhook → `satra:9867/hooks/vreau-digital-deploy` → `/opt/vreau-digital/deploy.sh`
|
||||||
|
- Build args: BUILD_SHA, BUILD_REF, BUILD_TIME → expose via /api/version
|
||||||
|
- Secrete: Infisical Machine Identity `/vreaudigital` (DATABASE_URL, TWOCAPTCHA_KEY)
|
||||||
|
- Verifică `/api/version` post-push
|
||||||
|
|
||||||
## Reguli operaționale
|
## Reguli operaționale
|
||||||
|
|
||||||
- Backend secrets în Infisical, path `/vreaudigital` (shared cu gov-agreg deocamdată — DATABASE_URL e identic)
|
- `npx astro check` + `npm run build` PASS înainte de commit
|
||||||
- TWOCAPTCHA_KEY pentru ANAF datornici/lista_alba scrapers
|
- DACĂ scp pe satra → reset ÎNAINTE de push (deploy.sh git pull --ff-only fails)
|
||||||
- `npx astro check` + `npm run build` ÎNAINTE de commit
|
- Pre-create `/var/log/vreau-digital-*.log` cu chown bulibasa pentru scrapere noi
|
||||||
- Push triggers webhook → satra:9867 → /opt/vreau-digital/deploy.sh
|
- NU echo niciun secret. `$VAR` unexpanded. `--env-file` (NU `-e`)
|
||||||
- Verify deploy via `/api/version`
|
- DB queries: `/tmp/govq.sh` reads SQL stdin → architools_db pe satra
|
||||||
|
- Memory: scrie DIRECT în `~/Code/claude-memory/projects/vreau-digital/memory/` + git commit + push (sync cron clobbers `~/.claude/.../memory/` every 60s)
|
||||||
|
- systemd OnCalendar: `Sun *-*-*` (cu SPACE după ziua), NU `Sun*-*-*`
|
||||||
|
- Comit-uri mici, focusate. NU amend.
|
||||||
|
|
||||||
|
## Sister project: gov-agreg (vreaudigital.ro)
|
||||||
|
|
||||||
|
Hub-ul de marketing/discoverability — listează produsele, idei, propuneri. Conține REDIRECTS de la /achizitii/* la vreau.digital.
|
||||||
|
|
||||||
|
- **Repo:** `gitadmin/gov-agreg`
|
||||||
|
- **Domain:** `https://vreaudigital.ro`
|
||||||
|
- **Local path:** `/home/orchestrator/Code/gov-agreg/`
|
||||||
|
|
||||||
|
NU atinge marketing pages (idei, despre, contribuie, produse) — alea sunt în gov-agreg.
|
||||||
|
|
||||||
|
Vezi `PROMPTS.md` pentru context tehnic complet + istoric sesiuni.
|
||||||
|
|||||||
+54
@@ -1,3 +1,57 @@
|
|||||||
|
# Prompts pentru sesiuni Claude — vreau.digital
|
||||||
|
|
||||||
|
## 0. Context comun (citește PRIMUL)
|
||||||
|
|
||||||
|
```
|
||||||
|
Lucrăm la vreau.digital — platforma de transparență achiziții publice
|
||||||
|
România. Repo: git.beletage.ro/gitadmin/vreau-digital.
|
||||||
|
|
||||||
|
SPLIT MAJOR 2026-05-13: spin-off din `gov-agreg` (vreaudigital.ro). Acest
|
||||||
|
repo conține platforma standalone — toate scrapers, recipes, investigation
|
||||||
|
pages, SQL migrations, Postgres queries cross-source.
|
||||||
|
|
||||||
|
vreaudigital.ro = hub marketing/discoverability (gov-agreg repo)
|
||||||
|
vreau.digital = platforma achiziții (acest repo)
|
||||||
|
|
||||||
|
Stack:
|
||||||
|
- Astro 5 + React 19 + Tailwind 3.4 (NU v4) + Node @astrojs/node standalone
|
||||||
|
- PostgreSQL 17 @ satra:5432 (schema `seap`, `firms`, `anaf`, ... 17 schemas)
|
||||||
|
- MapLibre 5 + Martin tile server @ 10.10.10.166:3010 (gis_uats_z0/z5/z8/z12)
|
||||||
|
- Photon geocoder daemon @ satra:2322
|
||||||
|
- Container Docker @ satra:5096, Traefik @ proxy 10.10.10.199, Cloudflare proxied → vreau.digital
|
||||||
|
|
||||||
|
Date prod (post 2026-05-13):
|
||||||
|
- 4.005M firme ONRC (100% geocoded)
|
||||||
|
- 7.77M financials (10 ani, 2015-2024)
|
||||||
|
- 781K+ anunțuri SEAP/TED
|
||||||
|
- 47K datornici ANAF T1 2026 + 648K lista albă (live via 2captcha)
|
||||||
|
- 17 systemd timers scrapere (daily/weekly/monthly/quarterly)
|
||||||
|
- 49 retete + 15 investigation pages
|
||||||
|
|
||||||
|
Infra:
|
||||||
|
- CI/CD: Gitea webhook → satra:9867/hooks/vreau-digital-deploy → /opt/vreau-digital/deploy.sh
|
||||||
|
- Secrete: Infisical Machine Identity `/vreaudigital` (DATABASE_URL, TWOCAPTCHA_KEY, etc.)
|
||||||
|
- DB queries dev: `/tmp/govq.sh` reads SQL stdin → architools_db pe satra
|
||||||
|
- Verifică `/api/version` post-push
|
||||||
|
|
||||||
|
Routes (toate la rădăcină, NU /achizitii/):
|
||||||
|
- /, /cauta, /retete/[slug], /investigation/[slug], /firma/[cui], /autoritate/[cui]
|
||||||
|
- /red-flags, /top-contracte, /top-firme, /fonduri-ue, /azi, /risc/*, /explorer
|
||||||
|
- /harta (Civic OS), /api/* (og, cpv, profil, ocds, harta, version)
|
||||||
|
|
||||||
|
Reguli operaționale:
|
||||||
|
- `npx astro check` + `npm run build` PASS înainte de commit
|
||||||
|
- DACĂ scp pe satra → reset ÎNAINTE de push (deploy.sh git pull --ff-only fails)
|
||||||
|
- Pre-create /var/log/vreau-digital-*.log cu chown bulibasa pentru scrapere noi
|
||||||
|
- NU echo niciun secret. $VAR unexpanded, --env-file (NU -e)
|
||||||
|
- Memory persistat: scrie DIRECT în ~/Code/claude-memory/projects/vreau-digital/memory/ + git commit + push (sync cron clobbers ~/.claude/.../memory/ every 60s)
|
||||||
|
- systemd OnCalendar: `Sun *-*-*` (cu space după ziua), NU `Sun*-*-*`
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Istoric sesiuni (din gov-agreg, păstrat aici după split 2026-05-13)
|
||||||
|
|
||||||
# Prompts pentru sesiuni Claude — vreaudigital.ro
|
# Prompts pentru sesiuni Claude — vreaudigital.ro
|
||||||
|
|
||||||
Fișier de copy-paste pentru sesiunile viitoare Claude Code, ca să nu reexpli o
|
Fișier de copy-paste pentru sesiunile viitoare Claude Code, ca să nu reexpli o
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
import { query } from './db.js';
|
|
||||||
|
|
||||||
export interface UserProfile {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
name: string | null;
|
|
||||||
city: string | null;
|
|
||||||
role: 'user' | 'developer' | 'admin';
|
|
||||||
bio: string | null;
|
|
||||||
github_url: string | null;
|
|
||||||
website_url: string | null;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureAuthTables() {
|
|
||||||
await query(`
|
|
||||||
CREATE SCHEMA IF NOT EXISTS platform;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS platform.user_profiles (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
email TEXT UNIQUE NOT NULL,
|
|
||||||
name TEXT,
|
|
||||||
city TEXT,
|
|
||||||
role TEXT DEFAULT 'user' CHECK (role IN ('user', 'developer', 'admin')),
|
|
||||||
bio TEXT,
|
|
||||||
github_url TEXT,
|
|
||||||
website_url TEXT,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS platform.product_submissions (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
user_id TEXT REFERENCES platform.user_profiles(id),
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
description TEXT NOT NULL,
|
|
||||||
category TEXT NOT NULL,
|
|
||||||
demo_url TEXT,
|
|
||||||
source_url TEXT,
|
|
||||||
screenshot_url TEXT,
|
|
||||||
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')),
|
|
||||||
solves_ideas INTEGER[] DEFAULT '{}',
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Add solves_product column to ideas if not exists
|
|
||||||
DO $$ BEGIN
|
|
||||||
ALTER TABLE platform.ideas ADD COLUMN IF NOT EXISTS solved_by_product TEXT;
|
|
||||||
ALTER TABLE platform.ideas ADD COLUMN IF NOT EXISTS vote_threshold_reached BOOLEAN DEFAULT FALSE;
|
|
||||||
EXCEPTION WHEN OTHERS THEN NULL;
|
|
||||||
END $$;
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getOrCreateProfile(id: string, email: string): Promise<UserProfile> {
|
|
||||||
const existing = await query<UserProfile>(
|
|
||||||
'SELECT * FROM platform.user_profiles WHERE id = $1', [id]
|
|
||||||
);
|
|
||||||
if (existing.rows[0]) return existing.rows[0];
|
|
||||||
|
|
||||||
const result = await query<UserProfile>(
|
|
||||||
`INSERT INTO platform.user_profiles (id, email) VALUES ($1, $2) RETURNING *`,
|
|
||||||
[id, email]
|
|
||||||
);
|
|
||||||
return result.rows[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateProfile(id: string, data: {
|
|
||||||
name?: string;
|
|
||||||
city?: string;
|
|
||||||
bio?: string;
|
|
||||||
github_url?: string;
|
|
||||||
website_url?: string;
|
|
||||||
}): Promise<UserProfile> {
|
|
||||||
const result = await query<UserProfile>(
|
|
||||||
`UPDATE platform.user_profiles
|
|
||||||
SET name = COALESCE($2, name),
|
|
||||||
city = COALESCE($3, city),
|
|
||||||
bio = COALESCE($4, bio),
|
|
||||||
github_url = COALESCE($5, github_url),
|
|
||||||
website_url = COALESCE($6, website_url)
|
|
||||||
WHERE id = $1 RETURNING *`,
|
|
||||||
[id, data.name, data.city, data.bio, data.github_url, data.website_url]
|
|
||||||
);
|
|
||||||
return result.rows[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function submitProduct(data: {
|
|
||||||
user_id: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
category: string;
|
|
||||||
demo_url?: string;
|
|
||||||
source_url?: string;
|
|
||||||
screenshot_url?: string;
|
|
||||||
solves_ideas?: number[];
|
|
||||||
}) {
|
|
||||||
const result = await query(
|
|
||||||
`INSERT INTO platform.product_submissions
|
|
||||||
(user_id, title, description, category, demo_url, source_url, screenshot_url, solves_ideas)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
|
|
||||||
[data.user_id, data.title, data.description, data.category,
|
|
||||||
data.demo_url || null, data.source_url || null, data.screenshot_url || null,
|
|
||||||
data.solves_ideas || []]
|
|
||||||
);
|
|
||||||
return result.rows[0].id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUserSubmissions(userId: string) {
|
|
||||||
const result = await query(
|
|
||||||
'SELECT * FROM platform.product_submissions WHERE user_id = $1 ORDER BY created_at DESC',
|
|
||||||
[userId]
|
|
||||||
);
|
|
||||||
return result.rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getProfile(id: string): Promise<UserProfile | null> {
|
|
||||||
const result = await query<UserProfile>(
|
|
||||||
'SELECT * FROM platform.user_profiles WHERE id = $1', [id]
|
|
||||||
);
|
|
||||||
return result.rows[0] || null;
|
|
||||||
}
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
import { query } from './db.js';
|
|
||||||
|
|
||||||
export interface Idea {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
problem: string;
|
|
||||||
solution: string | null;
|
|
||||||
category: string;
|
|
||||||
author_name: string | null;
|
|
||||||
author_city: string | null;
|
|
||||||
status: string;
|
|
||||||
votes: number;
|
|
||||||
comment_count?: number;
|
|
||||||
solved_by_product?: string | null;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProductSubmission {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
category: string;
|
|
||||||
demo_url: string;
|
|
||||||
source_url: string | null;
|
|
||||||
screenshot_url: string | null;
|
|
||||||
solves_ideas: string | null;
|
|
||||||
author_name: string;
|
|
||||||
author_email: string;
|
|
||||||
author_location: string | null;
|
|
||||||
author_github: string | null;
|
|
||||||
status: string;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getIdeas(sort: string = 'votes', category?: string): Promise<Idea[]> {
|
|
||||||
const orderBy = sort === 'new' ? 'i.created_at DESC' : 'i.votes DESC, i.created_at DESC';
|
|
||||||
const categoryFilter = category && category !== 'toate' ? 'AND i.category = $1' : '';
|
|
||||||
const params = category && category !== 'toate' ? [category] : [];
|
|
||||||
|
|
||||||
const result = await query<Idea>(`
|
|
||||||
SELECT i.*, COALESCE(c.cnt, 0)::int AS comment_count
|
|
||||||
FROM platform.ideas i
|
|
||||||
LEFT JOIN (SELECT idea_id, COUNT(*) AS cnt FROM platform.comments GROUP BY idea_id) c
|
|
||||||
ON c.idea_id = i.id
|
|
||||||
WHERE 1=1 ${categoryFilter}
|
|
||||||
ORDER BY ${orderBy}
|
|
||||||
`, params);
|
|
||||||
return result.rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getIdea(id: number): Promise<Idea | null> {
|
|
||||||
const result = await query('SELECT * FROM platform.ideas WHERE id = $1', [id]);
|
|
||||||
return result.rows[0] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getIdeaComments(ideaId: number) {
|
|
||||||
const result = await query(
|
|
||||||
'SELECT * FROM platform.comments WHERE idea_id = $1 ORDER BY created_at ASC',
|
|
||||||
[ideaId]
|
|
||||||
);
|
|
||||||
return result.rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createIdea(data: {
|
|
||||||
title: string;
|
|
||||||
problem: string;
|
|
||||||
solution?: string;
|
|
||||||
category?: string;
|
|
||||||
author_name?: string;
|
|
||||||
author_email?: string;
|
|
||||||
author_city?: string;
|
|
||||||
}): Promise<number> {
|
|
||||||
const result = await query(
|
|
||||||
`INSERT INTO platform.ideas (title, problem, solution, category, author_name, author_email, author_city)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
|
|
||||||
[data.title, data.problem, data.solution || null, data.category || 'general',
|
|
||||||
data.author_name || null, data.author_email || null, data.author_city || null]
|
|
||||||
);
|
|
||||||
return result.rows[0].id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function voteIdea(ideaId: number, fingerprint: string): Promise<{ votes: number; alreadyVoted: boolean }> {
|
|
||||||
// Try insert vote
|
|
||||||
try {
|
|
||||||
await query(
|
|
||||||
'INSERT INTO platform.votes (idea_id, fingerprint) VALUES ($1, $2)',
|
|
||||||
[ideaId, fingerprint]
|
|
||||||
);
|
|
||||||
// Increment counter
|
|
||||||
const result = await query(
|
|
||||||
'UPDATE platform.ideas SET votes = votes + 1 WHERE id = $1 RETURNING votes',
|
|
||||||
[ideaId]
|
|
||||||
);
|
|
||||||
return { votes: result.rows[0].votes, alreadyVoted: false };
|
|
||||||
} catch {
|
|
||||||
// Already voted (unique constraint)
|
|
||||||
const result = await query('SELECT votes FROM platform.ideas WHERE id = $1', [ideaId]);
|
|
||||||
return { votes: result.rows[0]?.votes || 0, alreadyVoted: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function addComment(ideaId: number, authorName: string, content: string) {
|
|
||||||
await query(
|
|
||||||
'INSERT INTO platform.comments (idea_id, author_name, content) VALUES ($1, $2, $3)',
|
|
||||||
[ideaId, authorName || 'Anonim', content]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getStats() {
|
|
||||||
const result = await query(`
|
|
||||||
SELECT
|
|
||||||
(SELECT COUNT(*) FROM platform.ideas)::int AS total_ideas,
|
|
||||||
(SELECT COUNT(*) FROM platform.ideas WHERE status = 'in lucru')::int AS in_progress,
|
|
||||||
(SELECT COUNT(*) FROM platform.ideas WHERE status = 'live')::int AS live,
|
|
||||||
(SELECT SUM(votes) FROM platform.ideas)::int AS total_votes
|
|
||||||
`);
|
|
||||||
return result.rows[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getIdeasByVoteThreshold(threshold: number): Promise<Idea[]> {
|
|
||||||
const result = await query<Idea>(`
|
|
||||||
SELECT i.*, COALESCE(c.cnt, 0)::int AS comment_count
|
|
||||||
FROM platform.ideas i
|
|
||||||
LEFT JOIN (SELECT idea_id, COUNT(*) AS cnt FROM platform.comments GROUP BY idea_id) c
|
|
||||||
ON c.idea_id = i.id
|
|
||||||
WHERE i.votes >= $1
|
|
||||||
ORDER BY i.votes DESC
|
|
||||||
`, [threshold]);
|
|
||||||
return result.rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getTopIdeas(limit: number): Promise<Idea[]> {
|
|
||||||
const result = await query<Idea>(`
|
|
||||||
SELECT i.*, COALESCE(c.cnt, 0)::int AS comment_count
|
|
||||||
FROM platform.ideas i
|
|
||||||
LEFT JOIN (SELECT idea_id, COUNT(*) AS cnt FROM platform.comments GROUP BY idea_id) c
|
|
||||||
ON c.idea_id = i.id
|
|
||||||
ORDER BY i.votes DESC, i.created_at DESC
|
|
||||||
LIMIT $1
|
|
||||||
`, [limit]);
|
|
||||||
return result.rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getIdeasForProduct(productSlug: string): Promise<Idea[]> {
|
|
||||||
const result = await query<Idea>(`
|
|
||||||
SELECT i.*, COALESCE(c.cnt, 0)::int AS comment_count
|
|
||||||
FROM platform.ideas i
|
|
||||||
LEFT JOIN (SELECT idea_id, COUNT(*) AS cnt FROM platform.comments GROUP BY idea_id) c
|
|
||||||
ON c.idea_id = i.id
|
|
||||||
WHERE i.solved_by_product = $1
|
|
||||||
ORDER BY i.votes DESC
|
|
||||||
`, [productSlug]);
|
|
||||||
return result.rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function submitProductIdea(data: {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
category: string;
|
|
||||||
demo_url: string;
|
|
||||||
source_url?: string | null;
|
|
||||||
screenshot_url?: string | null;
|
|
||||||
solves_ideas?: string | null;
|
|
||||||
author_name: string;
|
|
||||||
author_email: string;
|
|
||||||
author_location?: string | null;
|
|
||||||
author_github?: string | null;
|
|
||||||
}): Promise<number> {
|
|
||||||
// Create table if not exists (idempotent)
|
|
||||||
await query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS platform.product_submissions (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
title VARCHAR(200) NOT NULL,
|
|
||||||
description TEXT NOT NULL,
|
|
||||||
category VARCHAR(50) NOT NULL,
|
|
||||||
demo_url TEXT NOT NULL,
|
|
||||||
source_url TEXT,
|
|
||||||
screenshot_url TEXT,
|
|
||||||
solves_ideas TEXT,
|
|
||||||
author_name VARCHAR(200) NOT NULL,
|
|
||||||
author_email VARCHAR(200) NOT NULL,
|
|
||||||
author_location VARCHAR(200),
|
|
||||||
author_github VARCHAR(200),
|
|
||||||
status VARCHAR(50) DEFAULT 'pending',
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
const result = await query(
|
|
||||||
`INSERT INTO platform.product_submissions
|
|
||||||
(title, description, category, demo_url, source_url, screenshot_url, solves_ideas, author_name, author_email, author_location, author_github)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
|
||||||
RETURNING id`,
|
|
||||||
[
|
|
||||||
data.title,
|
|
||||||
data.description,
|
|
||||||
data.category,
|
|
||||||
data.demo_url,
|
|
||||||
data.source_url || null,
|
|
||||||
data.screenshot_url || null,
|
|
||||||
data.solves_ideas || null,
|
|
||||||
data.author_name,
|
|
||||||
data.author_email,
|
|
||||||
data.author_location || null,
|
|
||||||
data.author_github || null,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
return result.rows[0].id;
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { createClient } from '@supabase/supabase-js';
|
|
||||||
|
|
||||||
const supabaseUrl = import.meta.env.PUBLIC_SUPABASE_URL || process.env.PUBLIC_SUPABASE_URL || '';
|
|
||||||
const supabaseAnonKey = import.meta.env.PUBLIC_SUPABASE_ANON_KEY || process.env.PUBLIC_SUPABASE_ANON_KEY || '';
|
|
||||||
|
|
||||||
export const supabaseEnabled = !!(supabaseUrl && supabaseAnonKey);
|
|
||||||
|
|
||||||
export const supabase = supabaseEnabled
|
|
||||||
? createClient(supabaseUrl, supabaseAnonKey)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
export function getSupabaseClient() {
|
|
||||||
if (!supabase) throw new Error('Supabase not configured');
|
|
||||||
return supabase;
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import type { APIRoute } from 'astro';
|
|
||||||
import { supabase, supabaseEnabled } from '../../../lib/supabase.js';
|
|
||||||
import { getOrCreateProfile, updateProfile, getUserSubmissions, getProfile } from '../../../lib/auth-queries.js';
|
|
||||||
|
|
||||||
export const prerender = false;
|
|
||||||
|
|
||||||
const json = (data: any, status = 200) =>
|
|
||||||
new Response(JSON.stringify(data), {
|
|
||||||
status,
|
|
||||||
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' },
|
|
||||||
});
|
|
||||||
|
|
||||||
async function getAuthUser(request: Request) {
|
|
||||||
if (!supabaseEnabled || !supabase) {
|
|
||||||
return { user: null, error: 'Supabase not configured' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
|
|
||||||
if (!token) {
|
|
||||||
return { user: null, error: 'No token provided' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: { user }, error } = await supabase!.auth.getUser(token);
|
|
||||||
if (error || !user) {
|
|
||||||
return { user: null, error: error?.message || 'Invalid token' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { user, error: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GET: APIRoute = async ({ params, request }) => {
|
|
||||||
const route = params.route || '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (route === 'profile') {
|
|
||||||
const { user, error } = await getAuthUser(request);
|
|
||||||
if (!user) return json({ error: error || 'Unauthorized' }, 401);
|
|
||||||
|
|
||||||
const profile = await getOrCreateProfile(user.id, user.email!);
|
|
||||||
const submissions = await getUserSubmissions(user.id);
|
|
||||||
|
|
||||||
return json({ profile, submissions });
|
|
||||||
}
|
|
||||||
|
|
||||||
return json({ error: 'Not found' }, 404);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('[auth GET]', err);
|
|
||||||
return json({ error: 'Server error' }, 500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ params, request }) => {
|
|
||||||
const route = params.route || '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (route === 'profile') {
|
|
||||||
const { user, error } = await getAuthUser(request);
|
|
||||||
if (!user) return json({ error: error || 'Unauthorized' }, 401);
|
|
||||||
|
|
||||||
// Ensure profile exists
|
|
||||||
await getOrCreateProfile(user.id, user.email!);
|
|
||||||
|
|
||||||
// If body has update data, apply it
|
|
||||||
let body: any = {};
|
|
||||||
try {
|
|
||||||
body = await request.json();
|
|
||||||
} catch {
|
|
||||||
// Empty body is fine — just ensure profile exists
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasUpdates = body.name || body.city || body.bio || body.github_url || body.website_url;
|
|
||||||
|
|
||||||
if (hasUpdates) {
|
|
||||||
// Validate URLs if provided
|
|
||||||
if (body.github_url && !isValidUrl(body.github_url)) {
|
|
||||||
return json({ error: 'URL GitHub invalid' }, 400);
|
|
||||||
}
|
|
||||||
if (body.website_url && !isValidUrl(body.website_url)) {
|
|
||||||
return json({ error: 'URL website invalid' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanitize text fields
|
|
||||||
const updateData = {
|
|
||||||
name: sanitize(body.name, 100),
|
|
||||||
city: sanitize(body.city, 100),
|
|
||||||
bio: sanitize(body.bio, 500),
|
|
||||||
github_url: body.github_url?.trim().slice(0, 200) || undefined,
|
|
||||||
website_url: body.website_url?.trim().slice(0, 200) || undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const profile = await updateProfile(user.id, updateData);
|
|
||||||
return json({ profile });
|
|
||||||
}
|
|
||||||
|
|
||||||
const profile = await getProfile(user.id);
|
|
||||||
return json({ profile });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (route === 'logout') {
|
|
||||||
// Server-side logout — clear any server session if needed
|
|
||||||
return new Response(JSON.stringify({ message: 'Logged out' }), {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
'Set-Cookie': [
|
|
||||||
'sb-token=; path=/; max-age=0; SameSite=Lax',
|
|
||||||
'sb-refresh-token=; path=/; max-age=0; SameSite=Lax',
|
|
||||||
].join(', '),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return json({ error: 'Not found' }, 404);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('[auth POST]', err);
|
|
||||||
return json({ error: 'Server error' }, 500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function isValidUrl(str: string): boolean {
|
|
||||||
try {
|
|
||||||
const url = new URL(str);
|
|
||||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitize(value: string | undefined, maxLen: number): string | undefined {
|
|
||||||
if (!value) return undefined;
|
|
||||||
return value.trim().slice(0, maxLen).replace(/<[^>]*>/g, '') || undefined;
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
import type { APIRoute } from 'astro';
|
|
||||||
import {
|
|
||||||
getIdeas,
|
|
||||||
getIdea,
|
|
||||||
getIdeaComments,
|
|
||||||
createIdea,
|
|
||||||
voteIdea,
|
|
||||||
addComment,
|
|
||||||
getStats,
|
|
||||||
getTopIdeas,
|
|
||||||
getIdeasByVoteThreshold,
|
|
||||||
submitProductIdea,
|
|
||||||
} from '../../../lib/platform-queries.js';
|
|
||||||
import { createHash } from 'crypto';
|
|
||||||
|
|
||||||
export const prerender = false;
|
|
||||||
|
|
||||||
const json = (data: any, status = 200) =>
|
|
||||||
new Response(JSON.stringify(data), {
|
|
||||||
status,
|
|
||||||
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' },
|
|
||||||
});
|
|
||||||
|
|
||||||
function fingerprint(request: Request): string {
|
|
||||||
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
|
|
||||||
const ua = request.headers.get('user-agent') || '';
|
|
||||||
return createHash('sha256').update(`${ip}:${ua}`).digest('hex').slice(0, 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GET: APIRoute = async ({ params, url }) => {
|
|
||||||
const route = params.route || '';
|
|
||||||
const parts = route.split('/');
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (route === 'ideas') {
|
|
||||||
const ideas = await getIdeas('votes');
|
|
||||||
return json(ideas);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (route === 'ideas/new') {
|
|
||||||
const ideas = await getIdeas('new');
|
|
||||||
return json(ideas);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (route === 'ideas/top') {
|
|
||||||
const limit = parseInt(url.searchParams.get('limit') || '10');
|
|
||||||
const ideas = await getTopIdeas(limit);
|
|
||||||
return json(ideas);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (route === 'ideas/threshold') {
|
|
||||||
const threshold = parseInt(url.searchParams.get('min') || '25');
|
|
||||||
const ideas = await getIdeasByVoteThreshold(threshold);
|
|
||||||
return json(ideas);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parts[0] === 'idea' && parts[1]) {
|
|
||||||
const idea = await getIdea(parseInt(parts[1]));
|
|
||||||
if (!idea) return json({ error: 'Not found' }, 404);
|
|
||||||
const comments = await getIdeaComments(idea.id);
|
|
||||||
return json({ idea, comments });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (route === 'stats') {
|
|
||||||
return json(await getStats());
|
|
||||||
}
|
|
||||||
|
|
||||||
return json({ error: 'Not found' }, 404);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('[platform]', err);
|
|
||||||
return json({ error: 'Server error' }, 500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ params, request }) => {
|
|
||||||
const route = params.route || '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (route === 'ideas') {
|
|
||||||
const body = await request.json();
|
|
||||||
if (!body.title?.trim() || !body.problem?.trim()) {
|
|
||||||
return json({ error: 'Titlul si problema sunt obligatorii.' }, 400);
|
|
||||||
}
|
|
||||||
if (body.title.length > 200 || body.problem.length > 2000) {
|
|
||||||
return json({ error: 'Text prea lung.' }, 400);
|
|
||||||
}
|
|
||||||
const id = await createIdea({
|
|
||||||
title: body.title.trim(),
|
|
||||||
problem: body.problem.trim(),
|
|
||||||
solution: body.solution?.trim(),
|
|
||||||
category: body.category,
|
|
||||||
author_name: body.author_name?.trim(),
|
|
||||||
author_email: body.author_email?.trim(),
|
|
||||||
author_city: body.author_city?.trim(),
|
|
||||||
});
|
|
||||||
return json({ id, message: 'Ideea ta a fost adaugata!' }, 201);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (route === 'vote') {
|
|
||||||
const body = await request.json();
|
|
||||||
if (!body.idea_id) return json({ error: 'Missing idea_id' }, 400);
|
|
||||||
const fp = fingerprint(request);
|
|
||||||
const result = await voteIdea(body.idea_id, fp);
|
|
||||||
return json(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (route === 'comment') {
|
|
||||||
const body = await request.json();
|
|
||||||
if (!body.idea_id || !body.content?.trim()) {
|
|
||||||
return json({ error: 'Continutul e obligatoriu.' }, 400);
|
|
||||||
}
|
|
||||||
if (body.content.length > 1000) {
|
|
||||||
return json({ error: 'Comentariu prea lung.' }, 400);
|
|
||||||
}
|
|
||||||
await addComment(body.idea_id, body.author_name, body.content.trim());
|
|
||||||
return json({ message: 'Comentariu adaugat.' }, 201);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (route === 'submit-product') {
|
|
||||||
const body = await request.json();
|
|
||||||
if (!body.title?.trim() || !body.description?.trim() || !body.category?.trim() || !body.demo_url?.trim()) {
|
|
||||||
return json({ error: 'Titlul, descrierea, categoria si URL-ul demo sunt obligatorii.' }, 400);
|
|
||||||
}
|
|
||||||
if (!body.author_name?.trim() || !body.author_email?.trim()) {
|
|
||||||
return json({ error: 'Numele si email-ul autorului sunt obligatorii.' }, 400);
|
|
||||||
}
|
|
||||||
if (body.title.length > 200) {
|
|
||||||
return json({ error: 'Titlul e prea lung (max 200 caractere).' }, 400);
|
|
||||||
}
|
|
||||||
if (body.description.length > 2000) {
|
|
||||||
return json({ error: 'Descrierea e prea lunga (max 2000 caractere).' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = await submitProductIdea({
|
|
||||||
title: body.title.trim(),
|
|
||||||
description: body.description.trim(),
|
|
||||||
category: body.category.trim(),
|
|
||||||
demo_url: body.demo_url.trim(),
|
|
||||||
source_url: body.source_url?.trim() || null,
|
|
||||||
screenshot_url: body.screenshot_url?.trim() || null,
|
|
||||||
solves_ideas: body.solves_ideas?.trim() || null,
|
|
||||||
author_name: body.author_name.trim(),
|
|
||||||
author_email: body.author_email.trim(),
|
|
||||||
author_location: body.author_location?.trim() || null,
|
|
||||||
author_github: body.author_github?.trim() || null,
|
|
||||||
});
|
|
||||||
return json({ id, message: 'Produsul a fost trimis pentru revizuire.' }, 201);
|
|
||||||
}
|
|
||||||
|
|
||||||
return json({ error: 'Not found' }, 404);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('[platform POST]', err);
|
|
||||||
return json({ error: 'Server error' }, 500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import type { APIRoute } from 'astro';
|
|
||||||
|
|
||||||
export const prerender = false;
|
|
||||||
|
|
||||||
const SYSTEM_PROMPT = `Ești un traducător din limbaj birocratic/juridic românesc în limbaj simplu, pe înțelesul oricui.
|
|
||||||
|
|
||||||
Reguli:
|
|
||||||
1. Primești un text oficial (lege, hotărâre, adresă, contract, etc.) și îl explici în limba română normală.
|
|
||||||
2. Folosește propoziții scurte și clare. Evită jargonul.
|
|
||||||
3. Dacă textul cere o acțiune din partea cititorului, spune clar CE trebuie să facă.
|
|
||||||
4. Evidențiază termenii cheie și explică-i pe scurt între paranteze dacă e necesar.
|
|
||||||
5. Păstrează structura logică: dacă textul original are mai multe puncte, menține-le.
|
|
||||||
6. NU inventa informații. Dacă ceva e ambiguu în text, spune-o.
|
|
||||||
7. Răspunde DOAR cu traducerea/explicația. Fără introduceri de genul "Iată traducerea:" sau "Textul original spune:".
|
|
||||||
8. Tonul: prietenos, direct, ca și cum ai explica unui prieten.
|
|
||||||
9. Dacă textul conține referințe la legi (ex: "art. 7 alin. (2) din Legea nr. 50/1991"), menționează-le pe scurt dar explică ce înseamnă practic.`;
|
|
||||||
|
|
||||||
// Ollama (local Gemma 3 12B) — primary
|
|
||||||
// Claude API — fallback if Ollama unavailable
|
|
||||||
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434';
|
|
||||||
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'gemma3:12b';
|
|
||||||
|
|
||||||
async function translateWithOllama(text: string): Promise<string> {
|
|
||||||
const res = await fetch(`${OLLAMA_URL}/api/chat`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: OLLAMA_MODEL,
|
|
||||||
messages: [
|
|
||||||
{ role: 'system', content: SYSTEM_PROMPT },
|
|
||||||
{ role: 'user', content: `Traduce următorul text oficial în limbaj simplu:\n\n${text}` },
|
|
||||||
],
|
|
||||||
stream: false,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) throw new Error(`Ollama error: ${res.status}`);
|
|
||||||
const data = await res.json();
|
|
||||||
return data.message?.content || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function translateWithClaude(text: string, apiKey: string): Promise<string> {
|
|
||||||
const { default: Anthropic } = await import('@anthropic-ai/sdk');
|
|
||||||
const client = new Anthropic({ apiKey });
|
|
||||||
|
|
||||||
const message = await client.messages.create({
|
|
||||||
model: 'claude-haiku-4-5-20251001',
|
|
||||||
max_tokens: 2048,
|
|
||||||
system: SYSTEM_PROMPT,
|
|
||||||
messages: [
|
|
||||||
{ role: 'user', content: `Traduce următorul text oficial în limbaj simplu:\n\n${text}` },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const content = message.content[0];
|
|
||||||
return content.type === 'text' ? content.text : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
|
||||||
let body: { text?: string };
|
|
||||||
try {
|
|
||||||
body = await request.json();
|
|
||||||
} catch {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Request invalid.' }),
|
|
||||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = body.text?.trim();
|
|
||||||
if (!text || text.length < 10) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Textul trebuie să aibă cel puțin 10 caractere.' }),
|
|
||||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (text.length > 5000) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Textul nu poate depăși 5000 de caractere.' }),
|
|
||||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try Ollama first (local, free), fall back to Claude API
|
|
||||||
try {
|
|
||||||
const translation = await translateWithOllama(text);
|
|
||||||
if (translation) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ translation, provider: 'local' }),
|
|
||||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Ollama unavailable, trying Claude fallback:', (err as Error).message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: Claude API
|
|
||||||
const apiKey = import.meta.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY;
|
|
||||||
if (apiKey) {
|
|
||||||
try {
|
|
||||||
const translation = await translateWithClaude(text, apiKey);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ translation, provider: 'cloud' }),
|
|
||||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Claude API error:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Serviciul de traducere nu este disponibil momentan. Încearcă din nou.' }),
|
|
||||||
{ status: 503, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user