Files
boc/iom/quixzoom-app/scripts/seed-sverige-launch.ts
T
Bernt 6989a98d75 feat: Passwordless cross-device authentication
- 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
2026-07-07 07:11:50 +00:00

341 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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: 2472 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: 2472 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)