initial booking
This commit is contained in:
BIN
prisma/data/app.db
Normal file
BIN
prisma/data/app.db
Normal file
Binary file not shown.
135
prisma/schema.prisma
Normal file
135
prisma/schema.prisma
Normal file
@@ -0,0 +1,135 @@
|
||||
// Boka Gasol247 — Prisma schema
|
||||
// SQLite for MVP. Migrating to Postgres later: change provider + DATABASE_URL.
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// Singleton settings row. Always accessed with id = "singleton" so we never
|
||||
// have more than one row. Add new fields as feature flags grow.
|
||||
model Settings {
|
||||
id String @id @default("singleton")
|
||||
pickupEnabled Boolean @default(true)
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Admin {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String
|
||||
passwordHash String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Product {
|
||||
id String @id @default(cuid())
|
||||
sku String @unique
|
||||
nameSv String
|
||||
nameEn String
|
||||
descriptionSv String
|
||||
descriptionEn String
|
||||
// Price in öre (SEK * 100) to avoid float issues.
|
||||
priceOre Int
|
||||
// VAT in basis points (e.g. 2500 = 25.00%).
|
||||
vatBp Int @default(2500)
|
||||
active Boolean @default(true)
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
bookingItems BookingItem[]
|
||||
}
|
||||
|
||||
model PickupSlot {
|
||||
id String @id @default(cuid())
|
||||
// Human label fallback (e.g. "Måndag förmiddag").
|
||||
labelSv String
|
||||
labelEn String
|
||||
startsAt DateTime
|
||||
endsAt DateTime
|
||||
capacity Int @default(50)
|
||||
active Boolean @default(true)
|
||||
bookings Booking[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// BookingStatus values (string, since SQLite has no enums):
|
||||
// PENDING | CONFIRMED
|
||||
// DELIVERED_PARTIAL | DELIVERED (handout out, none returned)
|
||||
// RETURNED_PARTIAL | RETURNED (everything has come back)
|
||||
// INVOICED (billing done, terminal)
|
||||
// CANCELLED (terminal)
|
||||
// Derived statuses (DELIVERED*/RETURNED*) are normally auto-set from item
|
||||
// counters; INVOICED and CANCELLED are set explicitly by admin.
|
||||
model Booking {
|
||||
id String @id @default(cuid())
|
||||
bookingNumber String @unique
|
||||
status String @default("CONFIRMED")
|
||||
|
||||
// Contact
|
||||
contactName String
|
||||
email String
|
||||
phone String
|
||||
|
||||
// Organization
|
||||
orgName String
|
||||
orgNumber String
|
||||
|
||||
// Invoice address
|
||||
address String
|
||||
postalCode String
|
||||
city String
|
||||
country String @default("SE")
|
||||
|
||||
// Pickup
|
||||
pickupSlotId String?
|
||||
pickupSlot PickupSlot? @relation(fields: [pickupSlotId], references: [id])
|
||||
|
||||
notes String?
|
||||
|
||||
// Snapshots in öre at booking time
|
||||
subtotalOre Int
|
||||
vatOre Int
|
||||
totalOre Int
|
||||
|
||||
locale String @default("sv")
|
||||
|
||||
items BookingItem[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([createdAt])
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
model BookingItem {
|
||||
id String @id @default(cuid())
|
||||
bookingId String
|
||||
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
|
||||
productId String
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
|
||||
// Snapshots so historical bookings don't break if a product changes
|
||||
sku String
|
||||
nameSv String
|
||||
nameEn String
|
||||
unitPriceOre Int
|
||||
vatBp Int
|
||||
quantity Int
|
||||
lineTotalOre Int
|
||||
|
||||
// Fulfillment tracking — set by admin as cylinders go out and come back.
|
||||
// Invariants: 0 <= deliveredQuantity <= quantity
|
||||
// 0 <= returnedQuantity <= deliveredQuantity
|
||||
deliveredQuantity Int @default(0)
|
||||
returnedQuantity Int @default(0)
|
||||
|
||||
@@index([bookingId])
|
||||
}
|
||||
121
prisma/seed.ts
Normal file
121
prisma/seed.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
// -- Settings singleton --
|
||||
await prisma.settings.upsert({
|
||||
where: { id: 'singleton' },
|
||||
update: {},
|
||||
create: { id: 'singleton', pickupEnabled: true },
|
||||
});
|
||||
console.log('✓ Settings singleton ensured');
|
||||
|
||||
// -- Initial admin --
|
||||
const email = (process.env.SEED_ADMIN_EMAIL ?? 'admin@example.com').toLowerCase();
|
||||
const password = process.env.SEED_ADMIN_PASSWORD ?? 'change-me';
|
||||
const name = process.env.SEED_ADMIN_NAME ?? 'Admin';
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
await prisma.admin.upsert({
|
||||
where: { email },
|
||||
update: {},
|
||||
create: { email, name, passwordHash },
|
||||
});
|
||||
console.log(`✓ Admin ensured: ${email}`);
|
||||
|
||||
// -- Products (example LPG cylinders) --
|
||||
const products = [
|
||||
{
|
||||
sku: 'P6',
|
||||
nameSv: 'Gasoltub P6',
|
||||
nameEn: 'LPG cylinder P6',
|
||||
descriptionSv: '6 kg gasol — för mindre kök och campingkök.',
|
||||
descriptionEn: '6 kg LPG — for smaller stoves and camping cooktops.',
|
||||
priceOre: 39900, // 399 kr
|
||||
sortOrder: 10,
|
||||
},
|
||||
{
|
||||
sku: 'P11',
|
||||
nameSv: 'Gasoltub P11',
|
||||
nameEn: 'LPG cylinder P11',
|
||||
descriptionSv: '11 kg gasol — vanligaste storleken för matlagning på läger.',
|
||||
descriptionEn: '11 kg LPG — most common size for cooking at camps.',
|
||||
priceOre: 64900, // 649 kr
|
||||
sortOrder: 20,
|
||||
},
|
||||
{
|
||||
sku: 'P19',
|
||||
nameSv: 'Gasoltub P19',
|
||||
nameEn: 'LPG cylinder P19',
|
||||
descriptionSv: '19 kg gasol — för större kök eller längre vistelse.',
|
||||
descriptionEn: '19 kg LPG — for larger kitchens or longer stays.',
|
||||
priceOre: 99900, // 999 kr
|
||||
sortOrder: 30,
|
||||
},
|
||||
];
|
||||
|
||||
for (const p of products) {
|
||||
await prisma.product.upsert({
|
||||
where: { sku: p.sku },
|
||||
update: {
|
||||
nameSv: p.nameSv,
|
||||
nameEn: p.nameEn,
|
||||
descriptionSv: p.descriptionSv,
|
||||
descriptionEn: p.descriptionEn,
|
||||
priceOre: p.priceOre,
|
||||
sortOrder: p.sortOrder,
|
||||
},
|
||||
create: { ...p, active: true, vatBp: 2500 },
|
||||
});
|
||||
}
|
||||
console.log(`✓ Seeded ${products.length} products`);
|
||||
|
||||
// -- Pickup slots (example: 3 days × 2 slots) --
|
||||
const existing = await prisma.pickupSlot.count();
|
||||
if (existing === 0) {
|
||||
const baseDay = new Date();
|
||||
baseDay.setUTCHours(8, 0, 0, 0);
|
||||
// Place slots ~6 months out so they don't expire immediately.
|
||||
baseDay.setUTCMonth(baseDay.getUTCMonth() + 6);
|
||||
|
||||
const slots = [
|
||||
{ offsetDays: 0, startH: 9, endH: 12, labelSv: 'Dag 1 — Förmiddag', labelEn: 'Day 1 — Morning' },
|
||||
{ offsetDays: 0, startH: 13, endH: 17, labelSv: 'Dag 1 — Eftermiddag', labelEn: 'Day 1 — Afternoon' },
|
||||
{ offsetDays: 1, startH: 9, endH: 12, labelSv: 'Dag 2 — Förmiddag', labelEn: 'Day 2 — Morning' },
|
||||
{ offsetDays: 1, startH: 13, endH: 17, labelSv: 'Dag 2 — Eftermiddag', labelEn: 'Day 2 — Afternoon' },
|
||||
{ offsetDays: 2, startH: 9, endH: 12, labelSv: 'Dag 3 — Förmiddag', labelEn: 'Day 3 — Morning' },
|
||||
];
|
||||
for (const s of slots) {
|
||||
const startsAt = new Date(baseDay);
|
||||
startsAt.setUTCDate(baseDay.getUTCDate() + s.offsetDays);
|
||||
startsAt.setUTCHours(s.startH, 0, 0, 0);
|
||||
const endsAt = new Date(startsAt);
|
||||
endsAt.setUTCHours(s.endH, 0, 0, 0);
|
||||
|
||||
await prisma.pickupSlot.create({
|
||||
data: {
|
||||
labelSv: s.labelSv,
|
||||
labelEn: s.labelEn,
|
||||
startsAt,
|
||||
endsAt,
|
||||
capacity: 50,
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log(`✓ Seeded ${slots.length} pickup slots`);
|
||||
} else {
|
||||
console.log(`• Pickup slots already exist (${existing}) — skipping`);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
Reference in New Issue
Block a user