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)
|