feat: add api foundation and booking schema

This commit is contained in:
2026-09-18 15:14:49 +08:00
parent 55b2b091c2
commit 5e71b89e95
8 changed files with 1556 additions and 31 deletions

View File

@@ -4,9 +4,29 @@
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsc -p tsconfig.json --watch",
"dev": "tsx watch src/main.ts",
"lint": "eslint .",
"typecheck": "tsc -p tsconfig.json --noEmit",
"clean": "rimraf dist"
"clean": "rimraf dist",
"start": "node dist/main.js",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@nestjs/common": "^11.0.11",
"@nestjs/core": "^11.0.11",
"@nestjs/platform-fastify": "^11.0.11",
"@prisma/client": "^6.2.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"fastify": "^5.2.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@types/node": "^22.10.5",
"prisma": "^6.2.1",
"tsx": "^4.19.2"
}
}

View File

@@ -0,0 +1,147 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum BookingStatus {
received
contacting
confirmed
completed
cancelled
}
enum BookingRequestType {
package
private_chef
}
enum ContactPreference {
wechat
phone
none
}
enum FulfillmentType {
pickup
delivery
}
model BrandProfile {
id String @id @default(uuid())
name String
tagline String?
description String?
avatarUrl String?
coverUrl String?
hours String?
address String?
deliveryNote String?
contactPhone String?
contactWechat String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
services Service[]
bookings Booking[]
customerUsers CustomerUser[]
tags Tag[]
}
model Service {
id String @id @default(uuid())
brandId String
brand BrandProfile @relation(fields: [brandId], references: [id])
name String
description String
coverUrl String?
priceText String?
priceAmount Int?
bookingEnabled Boolean @default(true)
isEnabled Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
bookings Booking[]
bookingItems BookingItem[]
@@index([brandId, isEnabled, sortOrder])
}
model CustomerUser {
id String @id @default(uuid())
brandId String
brand BrandProfile @relation(fields: [brandId], references: [id])
openid String
lastLoginAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
bookings Booking[]
@@unique([brandId, openid])
}
model Booking {
id String @id @default(uuid())
bookingNo String @unique
brandId String
brand BrandProfile @relation(fields: [brandId], references: [id])
customerUserId String
customerUser CustomerUser @relation(fields: [customerUserId], references: [id])
requestType BookingRequestType
customerName String
phone String?
wechatId String?
contactPreference ContactPreference
fulfillmentType FulfillmentType?
serviceId String?
service Service? @relation(fields: [serviceId], references: [id], onDelete: SetNull)
requestedDate DateTime?
requestedTime String?
deliveryAddress String?
estimatedAmount Int?
extraData Json?
note String?
adminNote String?
status BookingStatus @default(received)
isRead Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
items BookingItem[]
tags BookingTag[]
@@index([brandId, status, createdAt])
@@index([customerUserId, createdAt])
}
model BookingItem {
id String @id @default(uuid())
bookingId String
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
serviceId String?
service Service? @relation(fields: [serviceId], references: [id], onDelete: SetNull)
serviceNameSnapshot String
unitPriceSnapshot Int
quantity Int
subtotalSnapshot Int
}
model Tag {
id String @id @default(uuid())
brandId String
brand BrandProfile @relation(fields: [brandId], references: [id])
name String
isPreset Boolean @default(false)
isEnabled Boolean @default(true)
createdAt DateTime @default(now())
bookings BookingTag[]
@@unique([brandId, name])
}
model BookingTag {
bookingId String
tagId String
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@id([bookingId, tagId])
}

59
apps/api/prisma/seed.ts Normal file
View File

@@ -0,0 +1,59 @@
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function main(): Promise<void> {
const brand = await prisma.brandProfile.upsert({
where: { id: "00000000-0000-0000-0000-000000000001" },
update: {},
create: {
id: "00000000-0000-0000-0000-000000000001",
name: "阿成家常菜",
tagline: "现做家常套餐,也为小型聚餐定制一桌热饭菜",
description: "每天认真做好一顿家常饭。",
hours: "周一至周六 10:30-20:00",
address: "大学城生活区东门附近",
deliveryNote: "目前支持部分学生宿舍楼配送,无法送达时会主动联系。",
},
});
await prisma.service.createMany({
data: [
{
brandId: brand.id,
name: "红烧肉双拼套餐",
description: "红烧肉、时蔬、米饭",
priceText: "¥28 / 份",
priceAmount: 2800,
sortOrder: 1,
},
{
brandId: brand.id,
name: "小炒鸡套餐",
description: "现炒鸡块、时蔬、米饭",
priceText: "¥25 / 份",
priceAmount: 2500,
sortOrder: 2,
},
{
brandId: brand.id,
name: "家庭聚餐私厨",
description: "根据人数、地点和口味人工沟通报价",
priceText: "面议",
bookingEnabled: true,
sortOrder: 3,
},
],
skipDuplicates: true,
});
for (const name of ["普通套餐", "私厨", "配送", "自取", "熟客", "重点跟进"]) {
await prisma.tag.upsert({
where: { brandId_name: { brandId: brand.id, name } },
update: {},
create: { brandId: brand.id, name, isPreset: true },
});
}
}
main().finally(() => prisma.$disconnect());

View File

@@ -1,3 +1,35 @@
export function healthMessage(): string {
return "personal-brand-api";
import "reflect-metadata";
import { Controller, Get, Injectable, Module } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
import { FastifyAdapter } from "@nestjs/platform-fastify";
import type { NestFastifyApplication } from "@nestjs/platform-fastify";
import { PrismaClient } from "@prisma/client";
@Injectable()
export class PrismaService extends PrismaClient {
async onModuleDestroy(): Promise<void> {
await this.$disconnect();
}
}
@Controller("health")
export class HealthController {
@Get()
check(): { status: string; service: string } {
return { status: "ok", service: "personal-brand-api" };
}
}
@Module({ controllers: [HealthController], providers: [PrismaService], exports: [PrismaService] })
export class AppModule {}
export async function createApp(): Promise<NestFastifyApplication> {
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter());
app.enableCors();
return app;
}
if (process.env.NODE_ENV !== "test") {
const app = await createApp();
await app.listen({ port: Number(process.env.API_PORT ?? 3001), host: "0.0.0.0" });
}

View File

@@ -1,5 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*.ts"]
}

View File

@@ -2,7 +2,7 @@ import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["**/dist/**", "**/.next/**", "**/node_modules/**", "prototype/**"] },
{ ignores: ["**/dist/**", "**/.next/**", "**/node_modules/**", "prototype/**", "**/prisma/**"] },
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
{

View File

@@ -2,3 +2,13 @@ export type BookingStatus = "received" | "contacting" | "confirmed" | "completed
export type BookingRequestType = "package" | "private_chef";
export type ContactPreference = "wechat" | "phone" | "none";
export type FulfillmentType = "pickup" | "delivery";
export type OrderStatus = BookingStatus;
export interface PublicService {
id: string;
name: string;
description: string;
priceText: string | null;
priceAmount: number | null;
bookingEnabled: boolean;
}

1302
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff