6989a98d75
- Arkitektur: docs/auth/passwordless-architecture.md - Backend: iom/quixzoom-auth-service/ (FastAPI + Redis) - Webb: quixzoom-market-pages/se/login/ (QR-kod + polling) - App: iom/quixzoom-app/src/features/auth/ (push + deep links) Flöde: QR-kod → app-godkännande → webb-inloggad
341 lines
12 KiB
TypeScript
341 lines
12 KiB
TypeScript
/**
|
||
* seed-sverige-launch.ts
|
||
*
|
||
* Skapar 50 seed-uppdrag i Stockholms skärgård för quiXzoom Sverige-launch (juni 2026).
|
||
* Koordinater runt Vaxholm, Sandhamn, Grinda, Möja och angränsande öar.
|
||
*
|
||
* Usage:
|
||
* QUIXZOOM_API_URL=https://api.quixzoom.com \
|
||
* QUIXZOOM_ADMIN_TOKEN=<admin-jwt> \
|
||
* npx ts-node scripts/seed-sverige-launch.ts
|
||
*/
|
||
|
||
const API_BASE = process.env.QUIXZOOM_API_URL ?? 'https://api.quixzoom.com'
|
||
const ADMIN_TOKEN = process.env.QUIXZOOM_ADMIN_TOKEN ?? ''
|
||
|
||
// ─── Geografiska ankarpunkter ──────────────────────────────────────────────
|
||
|
||
interface AnchorPoint {
|
||
name: string
|
||
lat: number
|
||
lng: number
|
||
}
|
||
|
||
const ANCHORS: AnchorPoint[] = [
|
||
{ name: 'Vaxholm', lat: 59.4028, lng: 18.3512 },
|
||
{ name: 'Sandhamn', lat: 59.2939, lng: 18.9127 },
|
||
{ name: 'Grinda', lat: 59.3544, lng: 18.6884 },
|
||
{ name: 'Möja', lat: 59.4456, lng: 18.7989 },
|
||
{ name: 'Utö', lat: 58.9697, lng: 17.9901 },
|
||
{ name: 'Fjäderholmarna', lat: 59.3295, lng: 18.1778 },
|
||
{ name: 'Värmdö', lat: 59.3241, lng: 18.5301 },
|
||
{ name: 'Nämdö', lat: 59.1936, lng: 18.8001 },
|
||
{ name: 'Runmarö', lat: 59.2581, lng: 18.7890 },
|
||
{ name: 'Ingarö', lat: 59.3102, lng: 18.4623 },
|
||
]
|
||
|
||
// ─── Kategorier & mallar ──────────────────────────────────────────────────
|
||
|
||
type Category = 'infrastruktur' | 'vägar' | 'hamnar' | 'bryggor' | 'fritidshus'
|
||
|
||
interface MissionTemplate {
|
||
category: Category
|
||
titles: string[]
|
||
descriptions: string[]
|
||
instructions: string
|
||
requirements: string[]
|
||
rewardRange: [number, number] // SEK
|
||
}
|
||
|
||
const TEMPLATES: MissionTemplate[] = [
|
||
{
|
||
category: 'hamnar',
|
||
titles: [
|
||
'Dokumentera hamninloppet',
|
||
'Hamnens parkeringsytor',
|
||
'Biljettbox och infoskyltning',
|
||
'Hamnkapacitet vid högsäsong',
|
||
'Gästhamns facilitetsstatus',
|
||
],
|
||
descriptions: [
|
||
'Fotografera hamninloppet från kajen och visa djupmarkeringar, bojar och eventuella hinder.',
|
||
'Dokumentera tillgängliga parkeringsplatser för bilar och båptrailers vid hamnen.',
|
||
'Fota biljettautomater, informationsskyltar och eventuella QR-koder i hamnen.',
|
||
'Visa antal båtplatser, beläggningsgrad och kösituation under högsäsong.',
|
||
'Dokumentera toaletter, duschar, sophantering och laddstationer för båtar.',
|
||
],
|
||
instructions: 'Ta minst 3 bilder från olika vinklar. Inkludera en vid bild och en närbild av skyltar/utrustning.',
|
||
requirements: [
|
||
'Minst 3 bilder krävs',
|
||
'Inkludera en översiktsbild',
|
||
'Tydliga bilder utan oskärpa',
|
||
],
|
||
rewardRange: [200, 350],
|
||
},
|
||
{
|
||
category: 'bryggor',
|
||
titles: [
|
||
'Brygga skick och bärighet',
|
||
'Förtöjningsutrustning',
|
||
'Färjebrygga dokumentation',
|
||
'Privat brygga tillståndskoll',
|
||
'Brygga efter vinterlagring',
|
||
],
|
||
descriptions: [
|
||
'Dokumentera bryggornas skick — räcken, plankor, belysning och eventuella skador.',
|
||
'Fotografera förtöjningspollare, ringar och eluttag längs bryggan.',
|
||
'Fota färjebryggan, rampen, väntutrymmen och informationsskyltar.',
|
||
'Dokumentera privat brygga vid angiven fastighet för försäkringsändamål.',
|
||
'Kontrollera bryggas skick efter vintersäsongen — lös plankor, rostiga delar, sättningar.',
|
||
],
|
||
instructions: 'Gå ut på bryggan om det är säkert. Ta bilder rakt ned för att visa plankors skick.',
|
||
requirements: [
|
||
'Bilder från bryggans kant och mitt',
|
||
'Närbild på eventuella skador',
|
||
'Inga personuppgifter i bild',
|
||
],
|
||
rewardRange: [150, 280],
|
||
},
|
||
{
|
||
category: 'vägar',
|
||
titles: [
|
||
'Vägunderhåll kontroll',
|
||
'Vägskyltning dokumentation',
|
||
'Grusväg efter tjällossning',
|
||
'Brovägsstatus',
|
||
'Vinterväghållning uppföljning',
|
||
],
|
||
descriptions: [
|
||
'Fotografera vägbeläggningens skick — sprickor, gropar, kantskador.',
|
||
'Dokumentera hastighetsskyltar, vägvisare och varningsmärken längs rutten.',
|
||
'Kontrollera och fotografera grusvägens skick efter vårens tjällossning.',
|
||
'Dokumentera brobanans skick, räcken och eventuella sättningar.',
|
||
'Fotografera vägens skick och snöröjning/sandning efter vinterperiod.',
|
||
],
|
||
instructions: 'Fotografera vägen i körfältets riktning. Inkludera referensobjekt för skala.',
|
||
requirements: [
|
||
'Minst 4 bilder längs sträckan',
|
||
'GPS-position ska matcha uppdraget',
|
||
'Inga bilar med läsbar registreringsskylt',
|
||
],
|
||
rewardRange: [180, 320],
|
||
},
|
||
{
|
||
category: 'infrastruktur',
|
||
titles: [
|
||
'Elnätsmast status',
|
||
'Avloppspumpstation',
|
||
'Vattenreservoar inspektion',
|
||
'Fiberkabel markering',
|
||
'Mobilmaster täckning',
|
||
],
|
||
descriptions: [
|
||
'Dokumentera elnätsmastens skick, eventuella skador och omgivande vegetation.',
|
||
'Fotografera avloppspumpstationen, larm och omgärdning.',
|
||
'Dokumentera vattenreservoarens yttre skick, lås och skyltning.',
|
||
'Markera och fotografera fiberkabelmarkeringar längs given sträcka.',
|
||
'Dokumentera mobilmastens placering, eventuella skador och omgivande byggnader.',
|
||
],
|
||
instructions: 'Fotografera enbart utsidan av anläggningar. Gå inte in i inhägnade områden.',
|
||
requirements: [
|
||
'Håll säkerhetsavstånd till elinstallationer',
|
||
'Fotografera ej inre av installationer',
|
||
'Inkludera omgivningsbild',
|
||
],
|
||
rewardRange: [250, 400],
|
||
},
|
||
{
|
||
category: 'fritidshus',
|
||
titles: [
|
||
'Fastighetsdokumentation exteriör',
|
||
'Stormskador efter höststormar',
|
||
'Renovering före/efter',
|
||
'Fastighetsläge och utsikt',
|
||
'Tomt och omgivning dokumentation',
|
||
],
|
||
descriptions: [
|
||
'Fotografera fastighetens exteriör från alla fyra sidor för försäkrings- och värderingsändamål.',
|
||
'Dokumentera eventuella stormskador på tak, fasad, fönster och omgivande träd.',
|
||
'Fotografera renoveringsarbeten på angiven fastighet för projektuppföljning.',
|
||
'Dokumentera fastighetens läge, utsikt, strandlinje och angränsande fastigheter.',
|
||
'Fotografera tomt, staket, sjöbod, brygga och angränsande skog/mark.',
|
||
],
|
||
instructions: 'Fotografera enbart utsidan. Inga interiörbilder. Respektera privatpersoners integritet.',
|
||
requirements: [
|
||
'Enbart exteriörbilder',
|
||
'Alla fyra fasader om möjligt',
|
||
'Inga privatpersoner i bild',
|
||
'GDPR-kompatibelt material',
|
||
],
|
||
rewardRange: [200, 380],
|
||
},
|
||
]
|
||
|
||
// ─── Hjälpfunktioner ───────────────────────────────────────────────────────
|
||
|
||
function randomBetween(min: number, max: number): number {
|
||
return Math.random() * (max - min) + min
|
||
}
|
||
|
||
function jitter(value: number, maxDelta: number): number {
|
||
return value + randomBetween(-maxDelta, maxDelta)
|
||
}
|
||
|
||
function pickRandom<T>(arr: T[]): T {
|
||
return arr[Math.floor(Math.random() * arr.length)]
|
||
}
|
||
|
||
function deadlineFromNow(hours: number): string {
|
||
return new Date(Date.now() + hours * 3600 * 1000).toISOString()
|
||
}
|
||
|
||
// ─── Uppdragsgenerator ────────────────────────────────────────────────────
|
||
|
||
interface SeedMission {
|
||
title: string
|
||
description: string
|
||
instructions: string
|
||
requirements: string[]
|
||
category: Category
|
||
latitude: number
|
||
longitude: number
|
||
city: string
|
||
reward_amount: number // öre
|
||
reward_currency: string
|
||
deadline_at: string
|
||
status: 'open'
|
||
max_submissions: number
|
||
tags: string[]
|
||
}
|
||
|
||
function generateMissions(count: number): SeedMission[] {
|
||
const missions: SeedMission[] = []
|
||
|
||
for (let i = 0; i < count; i++) {
|
||
const anchor = pickRandom(ANCHORS)
|
||
const template = pickRandom(TEMPLATES)
|
||
const titleIdx = Math.floor(Math.random() * template.titles.length)
|
||
const descIdx = Math.floor(Math.random() * template.descriptions.length)
|
||
|
||
// Sprid ut koordinaterna kring ankarpunkten (±0.05 grader ≈ ±5 km)
|
||
const lat = jitter(anchor.lat, 0.05)
|
||
const lng = jitter(anchor.lng, 0.08)
|
||
|
||
// Belöning i öre (SEK * 100), rundad till 50 kr steg
|
||
const rewardSEK = Math.round(randomBetween(
|
||
template.rewardRange[0],
|
||
template.rewardRange[1],
|
||
) / 50) * 50
|
||
const rewardOre = rewardSEK * 100
|
||
|
||
// Deadline: 24–72 timmar
|
||
const deadlineHours = Math.floor(randomBetween(24, 72))
|
||
|
||
missions.push({
|
||
title: template.titles[titleIdx],
|
||
description: template.descriptions[descIdx],
|
||
instructions: template.instructions,
|
||
requirements: template.requirements,
|
||
category: template.category,
|
||
latitude: Math.round(lat * 100000) / 100000,
|
||
longitude: Math.round(lng * 100000) / 100000,
|
||
city: anchor.name,
|
||
reward_amount: rewardOre,
|
||
reward_currency: 'SEK',
|
||
deadline_at: deadlineFromNow(deadlineHours),
|
||
status: 'open',
|
||
max_submissions: Math.floor(randomBetween(1, 4)),
|
||
tags: ['sverige-launch-2026', 'stockholms-skärgård', anchor.name.toLowerCase()],
|
||
})
|
||
}
|
||
|
||
return missions
|
||
}
|
||
|
||
// ─── API POST ──────────────────────────────────────────────────────────────
|
||
|
||
async function postMission(mission: SeedMission): Promise<void> {
|
||
const res = await fetch(`${API_BASE}/missions`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${ADMIN_TOKEN}`,
|
||
},
|
||
body: JSON.stringify(mission),
|
||
})
|
||
if (!res.ok) {
|
||
const body = await res.text()
|
||
throw new Error(`HTTP ${res.status}: ${body}`)
|
||
}
|
||
const created = await res.json() as { id: string }
|
||
console.log(`✅ ${mission.city.padEnd(20)} [${mission.category.padEnd(14)}] ${(mission.reward_amount / 100)} SEK → id=${created.id}`)
|
||
}
|
||
|
||
// ─── Main ──────────────────────────────────────────────────────────────────
|
||
|
||
async function main() {
|
||
console.log('🇸🇪 quiXzoom Sverige-launch seed — 50 uppdrag i Stockholms skärgård\n')
|
||
|
||
if (!ADMIN_TOKEN) {
|
||
console.warn('⚠️ QUIXZOOM_ADMIN_TOKEN inte satt — kör i DRY-RUN-läge (skriver ej till API)\n')
|
||
}
|
||
|
||
const missions = generateMissions(50)
|
||
|
||
// Visa fördelning
|
||
const byCat: Record<string, number> = {}
|
||
const byCity: Record<string, number> = {}
|
||
for (const m of missions) {
|
||
byCat[m.category] = (byCat[m.category] ?? 0) + 1
|
||
byCity[m.city] = (byCity[m.city] ?? 0) + 1
|
||
}
|
||
console.log('Kategorifördelning:')
|
||
for (const [cat, n] of Object.entries(byCat).sort((a, b) => b[1] - a[1])) {
|
||
console.log(` ${cat.padEnd(20)} ${n} uppdrag`)
|
||
}
|
||
console.log('\nPlatsfördelning:')
|
||
for (const [city, n] of Object.entries(byCity).sort((a, b) => b[1] - a[1])) {
|
||
console.log(` ${city.padEnd(20)} ${n} uppdrag`)
|
||
}
|
||
|
||
const totalSEK = missions.reduce((s, m) => s + m.reward_amount / 100, 0)
|
||
const avgSEK = totalSEK / missions.length
|
||
console.log(`\nTotalt utlagt: ${totalSEK.toFixed(0)} SEK | Snitt: ${avgSEK.toFixed(0)} SEK/uppdrag`)
|
||
console.log(`Deadline-spann: 24–72 timmar\n`)
|
||
|
||
if (!ADMIN_TOKEN) {
|
||
console.log('DRY-RUN: Visar första 3 uppdrag:')
|
||
for (const m of missions.slice(0, 3)) {
|
||
console.log(JSON.stringify(m, null, 2))
|
||
}
|
||
console.log('\nSätt QUIXZOOM_ADMIN_TOKEN för att posta till API.')
|
||
return
|
||
}
|
||
|
||
// Post i batchar om 5 med fördröjning
|
||
const BATCH_SIZE = 5
|
||
const DELAY_MS = 300
|
||
|
||
let success = 0
|
||
let failed = 0
|
||
|
||
for (let i = 0; i < missions.length; i += BATCH_SIZE) {
|
||
const batch = missions.slice(i, i + BATCH_SIZE)
|
||
await Promise.allSettled(
|
||
batch.map(m =>
|
||
postMission(m)
|
||
.then(() => { success++ })
|
||
.catch(e => { console.error(`❌ ${m.title}: ${e.message}`); failed++ }),
|
||
),
|
||
)
|
||
if (i + BATCH_SIZE < missions.length) {
|
||
await new Promise(r => setTimeout(r, DELAY_MS))
|
||
}
|
||
}
|
||
|
||
console.log(`\n✅ Klar! ${success} uppdrag skapade, ${failed} misslyckades.`)
|
||
console.log('🚀 Sverige-launch redo för juni 2026!')
|
||
}
|
||
|
||
main().catch(console.error)
|