feat: Full petmaster CRM with NestJS API and React frontend
- Client management (CRUD with JSONB custom fields) - Pet profiles - Service catalog with per-client pricing - Booking calendar with recurring appointments - Invoicing with Stripe integration - Real-time messaging (WebSocket) - Self-booking portal - Staff assignment - Docker multi-stage builds - Prisma schema + seed
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
DATABASE_URL=postgresql://petmaster:petmaster_secret@postgres:5432/petmaster
|
||||
STRIPE_SECRET_KEY=sk_test_placeholder
|
||||
STRIPE_WEBHOOK_SECRET=whsec_placeholder
|
||||
JWT_SECRET=change-me-in-production
|
||||
CORS_ORIGIN=http://localhost:3001
|
||||
PORT=3000
|
||||
@@ -0,0 +1,23 @@
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json yarn.lock* ./
|
||||
RUN yarn install --frozen-lockfile 2>/dev/null || npm install
|
||||
|
||||
COPY . .
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json yarn.lock* ./
|
||||
RUN yarn install --production --frozen-lockfile 2>/dev/null || npm install --production
|
||||
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "dist/main"]
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "petmaster-api",
|
||||
"version": "0.1.0",
|
||||
"description": "Petmaster CRM backend API",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint 'src/**/*.ts' --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.4.0",
|
||||
"@nestjs/core": "^10.4.0",
|
||||
"@nestjs/platform-express": "^10.4.0",
|
||||
"@nestjs/platform-ws": "^10.4.0",
|
||||
"@nestjs/websockets": "^10.4.0",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/passport": "^10.0.0",
|
||||
"@nestjs/swagger": "^7.4.0",
|
||||
"@prisma/client": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"stripe": "^17.0.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.0",
|
||||
"@nestjs/schematics": "^10.1.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/ws": "^8.5.10",
|
||||
"prisma": "^6.0.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
model Client {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
firstName String
|
||||
lastName String
|
||||
email String @unique
|
||||
phone String?
|
||||
address String?
|
||||
notes String? @default("")
|
||||
customFields Json?
|
||||
isActive Boolean @default(true)
|
||||
|
||||
pets Pet[]
|
||||
bookings Booking[]
|
||||
invoices Invoice[]
|
||||
messages Message[] @relation("ClientMessages")
|
||||
|
||||
@@map("clients")
|
||||
}
|
||||
|
||||
model Pet {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
name String
|
||||
species String
|
||||
breed String?
|
||||
age Int?
|
||||
weight String? // e.g. "55 lbs"
|
||||
color String?
|
||||
tags String[] @default([])
|
||||
notes String? @default("")
|
||||
customFields Json?
|
||||
clientId String
|
||||
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("pets")
|
||||
}
|
||||
|
||||
model Service {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
name String
|
||||
description String @default("")
|
||||
duration Int // minutes
|
||||
basePrice Float // dollars
|
||||
|
||||
perClientPricing ClientPricing[]
|
||||
|
||||
@@map("services")
|
||||
}
|
||||
|
||||
model ClientPricing {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
price Float
|
||||
discountType String @default("none") // none, percentage, flat
|
||||
discountValue Float @default(0)
|
||||
|
||||
serviceId String
|
||||
service Service @relation(fields: [serviceId], references: [id], onDelete: Cascade)
|
||||
clientId String
|
||||
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([serviceId, clientId])
|
||||
@@map("client_pricing")
|
||||
}
|
||||
|
||||
model Booking {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
startAt DateTime
|
||||
endAt DateTime
|
||||
status BookingStatus @default(scheduled)
|
||||
notes String? @default("")
|
||||
customFields Json?
|
||||
|
||||
clientId String
|
||||
client Client @relation(fields: [clientId], references: [id])
|
||||
serviceId String
|
||||
service Service @relation(fields: [serviceId], references: [id])
|
||||
assignedStaff String?
|
||||
priceOverride Float?
|
||||
petId String?
|
||||
pet Pet? @relation(fields: [petId], references: [id])
|
||||
invoice Invoice?
|
||||
|
||||
recurringRule Json? // { interval: "weekly" | "biweekly" | "monthly", count: Int, endDate: DateTime? }
|
||||
|
||||
@@map("bookings")
|
||||
}
|
||||
|
||||
enum BookingStatus {
|
||||
scheduled
|
||||
confirmed
|
||||
in_progress
|
||||
completed
|
||||
cancelled
|
||||
}
|
||||
|
||||
model Invoice {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
invoiceNumber String @unique
|
||||
dueDate DateTime
|
||||
status InvoiceStatus @default(draft)
|
||||
stripePaymentIntentId String?
|
||||
subtotal Float @default(0)
|
||||
tax Float @default(0)
|
||||
total Float @default(0)
|
||||
notes String? @default("")
|
||||
|
||||
clientId String
|
||||
client Client @relation(fields: [clientId], references: [id])
|
||||
bookingId String?
|
||||
booking Booking? @relation(fields: [bookingId], references: [id])
|
||||
|
||||
items InvoiceItem[]
|
||||
|
||||
@@map("invoices")
|
||||
}
|
||||
|
||||
enum InvoiceStatus {
|
||||
draft
|
||||
sent
|
||||
paid
|
||||
overdue
|
||||
cancelled
|
||||
}
|
||||
|
||||
model InvoiceItem {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
description String
|
||||
quantity Int @default(1)
|
||||
unitPrice Float
|
||||
amount Float
|
||||
|
||||
invoiceId String
|
||||
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("invoice_items")
|
||||
}
|
||||
|
||||
model Message {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
readAt DateTime?
|
||||
content String
|
||||
isFromStaff Boolean @default(false)
|
||||
|
||||
clientId String
|
||||
client Client @relation("ClientMessages", fields: [clientId], references: [id])
|
||||
staffId String // staff member identifier
|
||||
|
||||
@@map("messages")
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
// Create default services
|
||||
const services = [
|
||||
{ name: 'Full Groom', description: 'Complete bath, haircut, nail trim', duration: 90, basePrice: 75 },
|
||||
{ name: 'Bath & Brush', description: 'Thorough bath with brushing', duration: 45, basePrice: 45 },
|
||||
{ name: 'Nail Trim', description: 'Quick nail clipping', duration: 15, basePrice: 15 },
|
||||
{ name: 'Dog Walking', description: '30 minute supervised walk', duration: 30, basePrice: 25 },
|
||||
{ name: 'Boarding (Standard)', description: 'Overnight boarding with feeding', duration: 1440, basePrice: 55 },
|
||||
{ name: 'Drop-in Visit', description: '20 min play/walk visit', duration: 20, basePrice: 30 },
|
||||
];
|
||||
|
||||
for (const svc of services) {
|
||||
await prisma.service.upsert({
|
||||
where: { name: svc.name },
|
||||
update: svc,
|
||||
create: svc,
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Seed completed: default services created');
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
}).finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get('health')
|
||||
getHealth() {
|
||||
return { status: 'ok', service: 'petmaster-api' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ClientsModule } from './clients/clients.module';
|
||||
import { PetsModule } from './pets/pets.module';
|
||||
import { ServicesModule } from './services/services.module';
|
||||
import { BookingsModule } from './bookings/bookings.module';
|
||||
import { InvoicesModule } from './invoices/invoices.module';
|
||||
import { MessagesModule } from './messages/messages.module';
|
||||
import { SelfBookingModule } from './self-booking/self-booking.module';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ClientsModule,
|
||||
PetsModule,
|
||||
ServicesModule,
|
||||
BookingsModule,
|
||||
InvoicesModule,
|
||||
MessagesModule,
|
||||
SelfBookingModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Petmaster API is running';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
Controller, Get, Post, Put, Delete, Param, Body, Query, ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { CreateBookingDto, UpdateBookingDto, ListBookingsQueryDto } from './dto/booking.dto';
|
||||
|
||||
@Controller('bookings')
|
||||
export class BookingsController {
|
||||
constructor(private readonly bookingsService: BookingsService) {}
|
||||
|
||||
@Post()
|
||||
async create(@Body() dto: CreateBookingDto) {
|
||||
return this.bookingsService.create(dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async findAll(@Query() query: ListBookingsQueryDto) {
|
||||
return this.bookingsService.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.findOne(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateBookingDto) {
|
||||
return this.bookingsService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.remove(id);
|
||||
}
|
||||
|
||||
@Put(':id/assign')
|
||||
async assignStaff(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('staffId') staffId: string,
|
||||
) {
|
||||
return this.bookingsService.assignStaff(id, staffId);
|
||||
}
|
||||
|
||||
@Put(':id/status')
|
||||
async updateStatus(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('status') status: string,
|
||||
) {
|
||||
return this.bookingsService.updateStatus(id, status);
|
||||
}
|
||||
|
||||
@Post(':id/recurring')
|
||||
async generateRecurring(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.generateRecurring(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BookingsController } from './bookings.controller';
|
||||
import { BookingsService } from './bookings.service';
|
||||
|
||||
@Module({
|
||||
controllers: [BookingsController],
|
||||
providers: [BookingsService],
|
||||
exports: [BookingsService],
|
||||
})
|
||||
export class BookingsModule {}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateBookingDto, UpdateBookingDto, ListBookingsQueryDto } from './dto/booking.dto';
|
||||
|
||||
@Injectable()
|
||||
export class BookingsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async create(dto: CreateBookingDto) {
|
||||
// Validate service exists
|
||||
await this.prisma.service.findUniqueOrThrow({ where: { id: dto.serviceId } });
|
||||
|
||||
// Validate client exists
|
||||
await this.prisma.client.findUniqueOrThrow({ where: { id: dto.clientId } });
|
||||
|
||||
// Validate pet if provided
|
||||
if (dto.petId) {
|
||||
await this.prisma.pet.findUniqueOrThrow({ where: { id: dto.petId } });
|
||||
}
|
||||
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
...dto,
|
||||
startAt: new Date(dto.startAt),
|
||||
endAt: new Date(dto.endAt),
|
||||
status: dto.status || 'scheduled',
|
||||
recurringRule: dto.recurringRule,
|
||||
customFields: dto.customFields,
|
||||
},
|
||||
include: {
|
||||
client: true,
|
||||
service: true,
|
||||
pet: true,
|
||||
},
|
||||
});
|
||||
|
||||
return booking;
|
||||
}
|
||||
|
||||
async findAll(query: ListBookingsQueryDto) {
|
||||
const { page = 1, limit = 20, startDate, endDate, clientId, status, assignedStaff } = query;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = {};
|
||||
|
||||
if (startDate || endDate) {
|
||||
where.startAt = {};
|
||||
if (startDate) where.startAt.gte = new Date(startDate);
|
||||
if (endDate) where.startAt.lte = new Date(endDate);
|
||||
}
|
||||
|
||||
if (clientId) where.clientId = clientId;
|
||||
if (status) where.status = status;
|
||||
if (assignedStaff) where.assignedStaff = assignedStaff;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.booking.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: +limit,
|
||||
orderBy: { startAt: 'asc' },
|
||||
include: { client: true, service: true, pet: true },
|
||||
}),
|
||||
this.prisma.booking.count({ where }),
|
||||
]);
|
||||
|
||||
return { items, total, page, limit: +limit };
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id },
|
||||
include: { client: true, service: true, pet: true, invoice: true },
|
||||
});
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
return booking;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateBookingDto) {
|
||||
await this.prisma.booking.findUniqueOrThrow({ where: { id } });
|
||||
const updateData: any = {};
|
||||
for (const [key, value] of Object.entries(dto)) {
|
||||
if (key === 'startAt' || key === 'endAt') {
|
||||
updateData[key] = new Date(value as string);
|
||||
} else {
|
||||
updateData[key] = value;
|
||||
}
|
||||
}
|
||||
return this.prisma.booking.update({ where: { id }, data: updateData });
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
await this.prisma.booking.findUniqueOrThrow({ where: { id } });
|
||||
return this.prisma.booking.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async assignStaff(id: string, staffId: string) {
|
||||
await this.prisma.booking.findUniqueOrThrow({ where: { id } });
|
||||
return this.prisma.booking.update({
|
||||
where: { id },
|
||||
data: { assignedStaff: staffId },
|
||||
});
|
||||
}
|
||||
|
||||
async updateStatus(id: string, status: string) {
|
||||
await this.prisma.booking.findUniqueOrThrow({ where: { id } });
|
||||
return this.prisma.booking.update({
|
||||
where: { id },
|
||||
data: { status },
|
||||
});
|
||||
}
|
||||
|
||||
async generateRecurring(bookingId: string) {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
});
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
|
||||
const rule = booking.recurringRule;
|
||||
if (!rule || typeof rule !== 'object') throw new Error('No recurring rule');
|
||||
|
||||
const nextDate = new Date(booking.startAt);
|
||||
const interval = rule.interval;
|
||||
let repeatCount = rule.count || 0;
|
||||
const maxDate = rule.endDate ? new Date(rule.endDate) : new Date(nextDate.getTime() + 365 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const created: any[] = [];
|
||||
|
||||
while (repeatCount > 0 && nextDate < maxDate) {
|
||||
const newStart = new Date(nextDate);
|
||||
const duration = booking.endAt.getTime() - booking.startAt.getTime();
|
||||
const newEnd = new Date(newStart.getTime() + duration);
|
||||
|
||||
if (newEnd > maxDate && rule.endDate) break;
|
||||
|
||||
const createdBooking = await this.prisma.booking.create({
|
||||
data: {
|
||||
startAt: newStart,
|
||||
endAt: newEnd,
|
||||
serviceId: booking.serviceId,
|
||||
clientId: booking.clientId,
|
||||
assignedStaff: booking.assignedStaff,
|
||||
petId: booking.petId,
|
||||
status: 'scheduled',
|
||||
recurringRule: { ...rule, count: repeatCount - 1, startDate: newStart.toISOString() },
|
||||
},
|
||||
});
|
||||
created.push(createdBooking);
|
||||
repeatCount--;
|
||||
|
||||
// Increment date
|
||||
switch (interval) {
|
||||
case 'weekly':
|
||||
newStart.setDate(newStart.getDate() + 7);
|
||||
break;
|
||||
case 'biweekly':
|
||||
newStart.setDate(newStart.getDate() + 14);
|
||||
break;
|
||||
case 'monthly':
|
||||
newStart.setMonth(newStart.getMonth() + 1);
|
||||
break;
|
||||
default:
|
||||
return created;
|
||||
}
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
IsString, IsNotEmpty, IsOptional, IsDateString, IsEnum, IsUUID,
|
||||
} from 'class-validator';
|
||||
|
||||
export enum BookingStatus {
|
||||
SCHEDULED = 'scheduled',
|
||||
CONFIRMED = 'confirmed',
|
||||
IN_PROGRESS = 'in_progress',
|
||||
COMPLETED = 'completed',
|
||||
CANCELLED = 'cancelled',
|
||||
}
|
||||
|
||||
export class CreateBookingDto {
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
clientId: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
serviceId: string;
|
||||
|
||||
@IsDateString()
|
||||
@IsNotEmpty()
|
||||
startAt: string;
|
||||
|
||||
@IsDateString()
|
||||
@IsNotEmpty()
|
||||
endAt: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
petId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
assignedStaff?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
recurringRule?: Record<string, any>;
|
||||
|
||||
@IsOptional()
|
||||
customFields?: Record<string, any>;
|
||||
}
|
||||
|
||||
export class UpdateBookingDto {
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
startAt?: string;
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
endAt?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
status?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
notes?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
assignedStaff?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
petId?: string;
|
||||
|
||||
@IsOptional()
|
||||
customFields?: Record<string, any>;
|
||||
}
|
||||
|
||||
export class ListBookingsQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
page?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
limit?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
startDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
endDate?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
clientId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
status?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
assignedStaff?: string;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
Controller, Get, Post, Put, Delete, Param, Body, Query,
|
||||
ParseUUIDPipe, BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { ClientsService } from './clients.service';
|
||||
import { CreateClientDto, UpdateClientDto, ListClientsQueryDto } from './dto/client.dto';
|
||||
import { Client } from '@prisma/client';
|
||||
|
||||
@Controller('clients')
|
||||
export class ClientsController {
|
||||
constructor(private readonly clientsService: ClientsService) {}
|
||||
|
||||
@Post()
|
||||
async create(@Body() dto: CreateClientDto) {
|
||||
return this.clientsService.create(dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async findAll(@Query() query: ListClientsQueryDto) {
|
||||
return this.clientsService.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.clientsService.findOne(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateClientDto) {
|
||||
return this.clientsService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.clientsService.remove(id);
|
||||
}
|
||||
|
||||
@Put(':id/custom-fields')
|
||||
async updateCustomFields(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() customFields: Record<string, any>,
|
||||
) {
|
||||
return this.clientsService.updateCustomFields(id, customFields);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ClientsController } from './clients.controller';
|
||||
import { ClientsService } from './clients.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ClientsController],
|
||||
providers: [ClientsService],
|
||||
exports: [ClientsService],
|
||||
})
|
||||
export class ClientsModule {}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateClientDto, UpdateClientDto, ListClientsQueryDto } from './dto/client.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ClientsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async create(dto: CreateClientDto) {
|
||||
return this.prisma.client.create({ data: dto });
|
||||
}
|
||||
|
||||
async findAll(query: ListClientsQueryDto) {
|
||||
const { page = 1, limit = 20, search, isActive } = query;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = {};
|
||||
if (isActive !== undefined) where.isActive = isActive === 'true';
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ firstName: { contains: search, mode: 'insensitive' } },
|
||||
{ lastName: { contains: search, mode: 'insensitive' } },
|
||||
{ email: { contains: search, mode: 'insensitive' } },
|
||||
{ phone: { contains: search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.client.findMany({ where, skip, take: +limit, orderBy: { createdAt: 'desc' } }),
|
||||
this.prisma.client.count({ where }),
|
||||
]);
|
||||
|
||||
return { items, total, page, limit: +limit };
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const client = await this.prisma.client.findUnique({
|
||||
where: { id },
|
||||
include: { pets: true },
|
||||
});
|
||||
if (!client) throw new NotFoundException('Client not found');
|
||||
return client;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateClientDto) {
|
||||
await this.prisma.client.findUniqueOrThrow({ where: { id } });
|
||||
return this.prisma.client.update({ where: { id }, data: dto });
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
await this.prisma.client.findUniqueOrThrow({ where: { id } });
|
||||
return this.prisma.client.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async updateCustomFields(id: string, customFields: Record<string, any>) {
|
||||
const client = await this.prisma.client.findUniqueOrThrow({ where: { id } });
|
||||
return this.prisma.client.update({
|
||||
where: { id },
|
||||
data: { customFields: { ...client.customFields, ...customFields } as any },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { IsEmail, IsOptional, IsString, IsBoolean } from 'class-validator';
|
||||
|
||||
export class CreateClientDto {
|
||||
@IsString()
|
||||
firstName: string;
|
||||
|
||||
@IsString()
|
||||
lastName: string;
|
||||
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
phone?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
address?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
customFields?: Record<string, any>;
|
||||
}
|
||||
|
||||
export class UpdateClientDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
firstName?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
lastName?: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsOptional()
|
||||
email?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
phone?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
address?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
customFields?: Record<string, any>;
|
||||
}
|
||||
|
||||
export class ListClientsQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
page?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
limit?: string;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
IsString, IsNotEmpty, IsUUID, IsOptional, IsInt, Min, IsDateString,
|
||||
} from 'class-validator';
|
||||
|
||||
export class InvoiceItemDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
description: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
quantity: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
unitPrice: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export class CreateInvoiceDto {
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
clientId: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
bookingId?: string;
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
dueDate?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
notes?: string;
|
||||
|
||||
@IsArray()
|
||||
items: InvoiceItemDto[];
|
||||
}
|
||||
|
||||
export class ListInvoicesQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
page?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
limit?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
status?: string;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
Controller, Get, Post, Put, Delete, Param, Body, Query,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { InvoicesService } from './invoices.service';
|
||||
import { CreateInvoiceDto, InvoiceItemDto, ListInvoicesQueryDto } from './dto/invoice.dto';
|
||||
|
||||
@Controller('invoices')
|
||||
export class InvoicesController {
|
||||
constructor(private readonly invoicesService: InvoicesService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@Query() query: ListInvoicesQueryDto) {
|
||||
return this.invoicesService.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.invoicesService.findOne(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@Body() dto: CreateInvoiceDto) {
|
||||
return this.invoicesService.create(dto);
|
||||
}
|
||||
|
||||
@Post(':id/from-booking')
|
||||
async createFromBooking(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.invoicesService.createFromBooking(id);
|
||||
}
|
||||
|
||||
@Post(':id/stripe')
|
||||
async sendStripePayment(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.invoicesService.sendStripePayment(id);
|
||||
}
|
||||
|
||||
@Put(':id/pay')
|
||||
async markAsPaid(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.invoicesService.markAsPaid(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.invoicesService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { InvoicesController } from './invoices.controller';
|
||||
import { InvoicesService } from './invoices.service';
|
||||
|
||||
@Module({
|
||||
controllers: [InvoicesController],
|
||||
providers: [InvoicesService],
|
||||
exports: [InvoicesService],
|
||||
})
|
||||
export class InvoicesModule {}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateInvoiceDto, InvoiceItemDto, ListInvoicesQueryDto } from './dto/invoice.dto';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder', {
|
||||
apiVersion: '2024-12-18.acacia' as any,
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class InvoicesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async findAll(query: ListInvoicesQueryDto) {
|
||||
const { page = 1, limit = 20, status } = query;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = {};
|
||||
if (status) where.status = status;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.invoice.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: +limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { client: true, items: true, booking: true },
|
||||
}),
|
||||
this.prisma.invoice.count({ where }),
|
||||
]);
|
||||
|
||||
return { items, total, page, limit: +limit };
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const invoice = await this.prisma.invoice.findUnique({
|
||||
where: { id },
|
||||
include: { client: true, items: true, booking: true },
|
||||
});
|
||||
if (!invoice) throw new NotFoundException('Invoice not found');
|
||||
return invoice;
|
||||
}
|
||||
|
||||
async create(dto: CreateInvoiceDto) {
|
||||
// Validate client
|
||||
await this.prisma.client.findUniqueOrThrow({ where: { id: dto.clientId } });
|
||||
|
||||
// Calculate totals from items
|
||||
let subtotal = 0;
|
||||
const items = dto.items.map((item) => {
|
||||
const amount = item.quantity * item.unitPrice;
|
||||
subtotal += amount;
|
||||
return item;
|
||||
});
|
||||
const tax = subtotal * 0.1; // 10% tax default
|
||||
const total = subtotal + tax;
|
||||
|
||||
const invoice = await this.prisma.invoice.create({
|
||||
data: {
|
||||
...dto,
|
||||
subtotal,
|
||||
tax,
|
||||
total,
|
||||
items: { create: items },
|
||||
status: 'draft',
|
||||
invoiceNumber: `INV-${Date.now().toString().slice(-6)}`,
|
||||
},
|
||||
include: { items: true },
|
||||
});
|
||||
|
||||
return invoice;
|
||||
}
|
||||
|
||||
async createFromBooking(bookingId: string) {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: { client: true, service: true, pet: true },
|
||||
});
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
|
||||
if (booking.invoiceId) {
|
||||
return this.findOne(booking.invoiceId);
|
||||
}
|
||||
|
||||
const unitPrice = booking.priceOverride ?? booking.service.basePrice;
|
||||
|
||||
const invoice = await this.prisma.invoice.create({
|
||||
data: {
|
||||
clientId: booking.clientId,
|
||||
bookingId: booking.id,
|
||||
invoiceNumber: `INV-${Date.now().toString().slice(-6)}`,
|
||||
dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
|
||||
notes: `Invoice for ${booking.service.name} - ${booking.pet?.name || 'no pet listed'}`,
|
||||
items: {
|
||||
create: [{
|
||||
description: `${booking.service.name} (${booking.duration} min)`,
|
||||
quantity: 1,
|
||||
unitPrice,
|
||||
amount: unitPrice,
|
||||
}],
|
||||
},
|
||||
status: 'draft',
|
||||
},
|
||||
include: { items: true },
|
||||
});
|
||||
|
||||
// Update booking with invoice link
|
||||
await this.prisma.booking.update({
|
||||
where: { id: bookingId },
|
||||
data: { invoiceId: invoice.id },
|
||||
});
|
||||
|
||||
return invoice;
|
||||
}
|
||||
|
||||
async sendStripePayment(invoiceId: string) {
|
||||
const invoice = await this.findOne(invoiceId);
|
||||
|
||||
if (invoice.stripePaymentIntentId) {
|
||||
return { paymentIntentId: invoice.stripePaymentIntentId, url: null };
|
||||
}
|
||||
|
||||
const paymentIntent = await stripe.paymentIntents.create({
|
||||
amount: Math.round(invoice.total * 100), // convert to cents
|
||||
currency: 'usd',
|
||||
metadata: { invoiceId: invoice.id, invoiceNumber: invoice.invoiceNumber },
|
||||
});
|
||||
|
||||
await this.prisma.invoice.update({
|
||||
where: { id: invoiceId },
|
||||
data: { stripePaymentIntentId: paymentIntent.id, status: 'sent' },
|
||||
});
|
||||
|
||||
return {
|
||||
paymentIntentId: paymentIntent.id,
|
||||
url: paymentIntent.client_secret,
|
||||
};
|
||||
}
|
||||
|
||||
async markAsPaid(invoiceId: string) {
|
||||
return this.prisma.invoice.update({
|
||||
where: { id: invoiceId },
|
||||
data: { status: 'paid' },
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
await this.prisma.invoice.findUniqueOrThrow({ where: { id } });
|
||||
return this.prisma.invoice.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// Global validation
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||
|
||||
// CORS
|
||||
const corsOrigin = process.env.CORS_ORIGIN || '*';
|
||||
app.enableCors({ origin: corsOrigin.split(',') });
|
||||
|
||||
// Swagger
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Petmaster CRM API')
|
||||
.setDescription('Pet grooming/sitting/boarding/walking CRM API')
|
||||
.setVersion('0.1.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api/docs', app, document);
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
await app.listen(port);
|
||||
console.log(`API running on http://localhost:${port}`);
|
||||
}
|
||||
bootstrap();
|
||||
@@ -0,0 +1,15 @@
|
||||
import { IsString, IsNotEmpty, IsUUID } from 'class-validator';
|
||||
|
||||
export class SendMessageDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
content: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
clientId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
staffId: string;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
Controller, Get, Post, Put, Param, Body, Query,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { MessagesService } from './messages.service';
|
||||
import { SendMessageDto } from './dto/message.dto';
|
||||
|
||||
@Controller('messages')
|
||||
export class MessagesController {
|
||||
constructor(private readonly messagesService: MessagesService) {}
|
||||
|
||||
@Get('conversation/:clientId')
|
||||
async getConversation(
|
||||
@Param('clientId', ParseUUIDPipe) clientId: string,
|
||||
@Query('staffId') staffId: string,
|
||||
) {
|
||||
return this.messagesService.getConversation(clientId, staffId);
|
||||
}
|
||||
|
||||
@Get('conversation/:clientId/unread')
|
||||
async getUnreadCount(
|
||||
@Param('clientId', ParseUUIDPipe) clientId: string,
|
||||
@Query('staffId') staffId: string,
|
||||
) {
|
||||
return this.messagesService.getUnreadCount(clientId, staffId);
|
||||
}
|
||||
|
||||
@Put('read')
|
||||
async markAsRead(
|
||||
@Param('clientId') clientId: string,
|
||||
@Body('messageIds') messageIds: string[],
|
||||
) {
|
||||
return this.messagesService.markAsRead(clientId, messageIds);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MessagesController } from './messages.controller';
|
||||
import { MessagesService } from './messages.service';
|
||||
import { MessagesGateway } from '../websockets/messages.gateway';
|
||||
|
||||
@Module({
|
||||
controllers: [MessagesController],
|
||||
providers: [MessagesService, MessagesGateway],
|
||||
exports: [MessagesService],
|
||||
})
|
||||
export class MessagesModule {}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SendMessageDto } from './dto/message.dto';
|
||||
|
||||
@Injectable()
|
||||
export class MessagesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async getConversation(clientId: string, staffId: string) {
|
||||
await this.prisma.client.findUniqueOrThrow({ where: { id: clientId } });
|
||||
|
||||
return this.prisma.message.findMany({
|
||||
where: {
|
||||
clientId,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getUnreadCount(clientId: string, staffId: string) {
|
||||
const count = await this.prisma.message.count({
|
||||
where: {
|
||||
clientId,
|
||||
readAt: null,
|
||||
},
|
||||
});
|
||||
return { unreadCount: count };
|
||||
}
|
||||
|
||||
async markAsRead(clientId: string, messageIds: string[]) {
|
||||
await this.prisma.client.findUniqueOrThrow({ where: { id: clientId } });
|
||||
|
||||
return this.prisma.message.updateMany({
|
||||
where: {
|
||||
id: { in: messageIds },
|
||||
clientId,
|
||||
},
|
||||
data: { readAt: new Date() },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsInt, Min } from 'class-validator';
|
||||
|
||||
export class CreatePetDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
species: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
breed?: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
age?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
weight?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
color?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
tags?: string[];
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
customFields?: Record<string, any>;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
export class UpdatePetDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
species?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
breed?: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
age?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
weight?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
color?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
tags?: string[];
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
customFields?: Record<string, any>;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Controller, Get, Post, Put, Delete, Param, Body, Query } from '@nestjs/common';
|
||||
import { PetsService } from './pets.service';
|
||||
import { CreatePetDto, UpdatePetDto } from './dto/pet.dto';
|
||||
|
||||
@Controller('pets')
|
||||
export class PetsController {
|
||||
constructor(private readonly petsService: PetsService) {}
|
||||
|
||||
@Post()
|
||||
async create(@Body() dto: CreatePetDto) {
|
||||
return this.petsService.create(dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async findAll() {
|
||||
return this.petsService.findAll();
|
||||
}
|
||||
|
||||
@Get('client/:clientId')
|
||||
async findByClient(@Param('clientId') clientId: string) {
|
||||
return this.petsService.findByClient(clientId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string) {
|
||||
return this.petsService.findOne(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(@Param('id') id: string, @Body() dto: UpdatePetDto) {
|
||||
return this.petsService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@Param('id') id: string) {
|
||||
return this.petsService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PetsController } from './pets.controller';
|
||||
import { PetsService } from './pets.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PetsController],
|
||||
providers: [PetsService],
|
||||
exports: [PetsService],
|
||||
})
|
||||
export class PetsModule {}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreatePetDto, UpdatePetDto } from './dto/pet.dto';
|
||||
|
||||
@Injectable()
|
||||
export class PetsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async create(dto: CreatePetDto) {
|
||||
return this.prisma.pet.create({ data: dto });
|
||||
}
|
||||
|
||||
async findAll() {
|
||||
return this.prisma.pet.findMany({
|
||||
include: { client: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findByClient(clientId: string) {
|
||||
return this.prisma.pet.findMany({
|
||||
where: { clientId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const pet = await this.prisma.pet.findUnique({ where: { id } });
|
||||
if (!pet) throw new NotFoundException('Pet not found');
|
||||
return pet;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdatePetDto) {
|
||||
await this.prisma.pet.findUniqueOrThrow({ where: { id } });
|
||||
return this.prisma.pet.update({ where: { id }, data: dto });
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
await this.prisma.pet.findUniqueOrThrow({ where: { id } });
|
||||
return this.prisma.pet.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { IsString, IsEmail, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class SubmitServiceRequestDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
firstName: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
lastName: string;
|
||||
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
phone: string;
|
||||
|
||||
@IsUUID()
|
||||
serviceId: string;
|
||||
|
||||
@IsString()
|
||||
requestedDate: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
notes: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
petId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
petName?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
species?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
breed?: string;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
Controller, Post, Param, Body, Get, Query,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { SelfBookingService } from './self-booking.service';
|
||||
import { SubmitServiceRequestDto } from './dto/request.dto';
|
||||
|
||||
@Controller('self-booking')
|
||||
export class SelfBookingController {
|
||||
constructor(private readonly selfBookingService: SelfBookingService) {}
|
||||
|
||||
@Get('services')
|
||||
async getServices() {
|
||||
return this.selfBookingService.getAvailableServices();
|
||||
}
|
||||
|
||||
@Post('request')
|
||||
async submitRequest(@Body() dto: SubmitServiceRequestDto) {
|
||||
return this.selfBookingService.submitServiceRequest(dto);
|
||||
}
|
||||
|
||||
@Get('requests')
|
||||
async getAllRequests(
|
||||
@Query('page') page = '1',
|
||||
@Query('limit') limit = '20',
|
||||
) {
|
||||
return this.selfBookingService.getAllRequests(+page, +limit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SelfBookingController } from './self-booking.controller';
|
||||
import { SelfBookingService } from './self-booking.service';
|
||||
|
||||
@Module({
|
||||
controllers: [SelfBookingController],
|
||||
providers: [SelfBookingService],
|
||||
})
|
||||
export class SelfBookingModule {}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SubmitServiceRequestDto } from './dto/request.dto';
|
||||
|
||||
@Injectable()
|
||||
export class SelfBookingService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async getAvailableServices() {
|
||||
return this.prisma.service.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async submitServiceRequest(dto: SubmitServiceRequestDto) {
|
||||
// Find or create client by email
|
||||
let client = await this.prisma.client.findUnique({
|
||||
where: { email: dto.email },
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
client = await this.prisma.client.create({
|
||||
data: {
|
||||
firstName: dto.firstName || '',
|
||||
lastName: dto.lastName || '',
|
||||
email: dto.email,
|
||||
phone: dto.phone,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Find or create pet if provided
|
||||
let petId = dto.petId;
|
||||
if (!petId && dto.petName && dto.species) {
|
||||
const existingPet = await this.prisma.pet.findFirst({
|
||||
where: {
|
||||
clientId: client.id,
|
||||
name: dto.petName,
|
||||
species: dto.species,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingPet) {
|
||||
petId = existingPet.id;
|
||||
} else {
|
||||
const pet = await this.prisma.pet.create({
|
||||
data: {
|
||||
name: dto.petName,
|
||||
species: dto.species,
|
||||
breed: dto.breed,
|
||||
clientId: client.id,
|
||||
},
|
||||
});
|
||||
petId = pet.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Get effective pricing
|
||||
const pricing = await this.prisma.clientPricing.findUnique({
|
||||
where: {
|
||||
serviceId_clientId: {
|
||||
serviceId: dto.serviceId,
|
||||
clientId: client.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const service = await this.prisma.service.findUnique({
|
||||
where: { id: dto.serviceId },
|
||||
});
|
||||
|
||||
if (!service) throw new Error('Service not found');
|
||||
|
||||
const effectivePrice = pricing ? pricing.price : service.basePrice;
|
||||
|
||||
// Create booking
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
clientId: client.id,
|
||||
serviceId: dto.serviceId,
|
||||
startAt: new Date(dto.requestedDate),
|
||||
endAt: new Date(
|
||||
new Date(dto.requestedDate).getTime() + service.duration * 60 * 1000,
|
||||
),
|
||||
status: 'scheduled',
|
||||
assignedStaff: null,
|
||||
petId: petId || null,
|
||||
notes: dto.notes,
|
||||
priceOverride: effectivePrice,
|
||||
},
|
||||
include: {
|
||||
client: true,
|
||||
service: true,
|
||||
pet: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
booking,
|
||||
message: petId ? 'Service request submitted successfully' : 'New client created. Service request submitted.',
|
||||
isNewClient: !pricing,
|
||||
};
|
||||
}
|
||||
|
||||
async getAllRequests(page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.booking.findMany({
|
||||
where: {
|
||||
status: 'scheduled',
|
||||
},
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { startAt: 'asc' },
|
||||
include: {
|
||||
client: true,
|
||||
service: true,
|
||||
pet: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.booking.count({
|
||||
where: { status: 'scheduled' },
|
||||
}),
|
||||
]);
|
||||
|
||||
return { items, total, page, limit };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { IsString, IsNotEmpty, IsInt, Min, IsOptional, IsEnum } from 'class-validator';
|
||||
import { IsFloat } from 'class-validator';
|
||||
|
||||
export class CreateServiceDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
duration: number;
|
||||
|
||||
@IsFloat()
|
||||
@Min(0)
|
||||
basePrice: number;
|
||||
}
|
||||
|
||||
export class UpdateServiceDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
duration?: number;
|
||||
|
||||
@IsFloat()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
basePrice?: number;
|
||||
}
|
||||
|
||||
export class SetClientPricingDto {
|
||||
@IsFloat()
|
||||
@Min(0)
|
||||
price: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@IsEnum(['none', 'percentage', 'flat'])
|
||||
discountType?: 'none' | 'percentage' | 'flat';
|
||||
|
||||
@IsFloat()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
discountValue?: number;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
Controller, Get, Post, Put, Delete, Param, Body,
|
||||
} from '@nestjs/common';
|
||||
import { ServicesService } from './services.service';
|
||||
import { CreateServiceDto, UpdateServiceDto, SetClientPricingDto } from './dto/service.dto';
|
||||
|
||||
@Controller('services')
|
||||
export class ServicesController {
|
||||
constructor(private readonly servicesService: ServicesService) {}
|
||||
|
||||
@Post()
|
||||
async create(@Body() dto: CreateServiceDto) {
|
||||
return this.servicesService.create(dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async findAll() {
|
||||
return this.servicesService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string) {
|
||||
return this.servicesService.findOne(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(@Param('id') id: string, @Body() dto: UpdateServiceDto) {
|
||||
return this.servicesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@Param('id') id: string) {
|
||||
return this.servicesService.remove(id);
|
||||
}
|
||||
|
||||
@Post('pricing/:clientId/:serviceId')
|
||||
async setClientPricing(
|
||||
@Param('clientId') clientId: string,
|
||||
@Param('serviceId') serviceId: string,
|
||||
@Body() dto: SetClientPricingDto,
|
||||
) {
|
||||
return this.servicesService.setClientPricing(clientId, serviceId, dto);
|
||||
}
|
||||
|
||||
@Get('pricing/:clientId')
|
||||
async getClientPricing(@Param('clientId') clientId: string) {
|
||||
return this.servicesService.getClientPricing(clientId);
|
||||
}
|
||||
|
||||
@Get('pricing/:clientId/:serviceId')
|
||||
async getEffectivePrice(
|
||||
@Param('clientId') clientId: string,
|
||||
@Param('serviceId') serviceId: string,
|
||||
) {
|
||||
return this.servicesService.getEffectivePrice(serviceId, clientId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ServicesController } from './services.controller';
|
||||
import { ServicesService } from './services.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ServicesController],
|
||||
providers: [ServicesService],
|
||||
exports: [ServicesService],
|
||||
})
|
||||
export class ServicesModule {}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateServiceDto, UpdateServiceDto, SetClientPricingDto } from './dto/service.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ServicesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async create(dto: CreateServiceDto) {
|
||||
return this.prisma.service.create({ data: dto });
|
||||
}
|
||||
|
||||
async findAll() {
|
||||
return this.prisma.service.findMany({ orderBy: { name: 'asc' } });
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const service = await this.prisma.service.findUnique({ where: { id } });
|
||||
if (!service) throw new NotFoundException('Service not found');
|
||||
return service;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateServiceDto) {
|
||||
await this.prisma.service.findUniqueOrThrow({ where: { id } });
|
||||
return this.prisma.service.update({ where: { id }, data: dto });
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
await this.prisma.service.findUniqueOrThrow({ where: { id } });
|
||||
return this.prisma.service.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async setClientPricing(clientId: string, serviceId: string, dto: SetClientPricingDto) {
|
||||
return this.prisma.clientPricing.upsert({
|
||||
where: { serviceId_clientId: { serviceId, clientId } },
|
||||
create: { serviceId, clientId, ...dto },
|
||||
update: { ...dto },
|
||||
});
|
||||
}
|
||||
|
||||
async getClientPricing(clientId: string) {
|
||||
return this.prisma.clientPricing.findMany({
|
||||
where: { clientId },
|
||||
include: { service: true },
|
||||
});
|
||||
}
|
||||
|
||||
async getEffectivePrice(serviceId: string, clientId: string): Promise<{ basePrice: number; effectivePrice: number }> {
|
||||
const service = await this.prisma.service.findUnique({ where: { id: serviceId } });
|
||||
if (!service) throw new NotFoundException('Service not found');
|
||||
|
||||
const pricing = await this.prisma.clientPricing.findUnique({
|
||||
where: { serviceId_clientId: { serviceId, clientId } },
|
||||
});
|
||||
|
||||
if (!pricing) return { basePrice: service.basePrice, effectivePrice: service.basePrice };
|
||||
|
||||
let effectivePrice = pricing.price;
|
||||
if (pricing.discountType === 'percentage') {
|
||||
effectivePrice = effectivePrice * (1 - pricing.discountValue / 100);
|
||||
} else if (pricing.discountType === 'flat') {
|
||||
effectivePrice = effectivePrice - pricing.discountValue;
|
||||
}
|
||||
|
||||
return { basePrice: service.basePrice, effectivePrice: Math.max(0, effectivePrice) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
WebSocketGateway,
|
||||
SubscribeMessage,
|
||||
MessageBody,
|
||||
ConnectedSocket,
|
||||
OnGatewayInit,
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
WebSocketServer,
|
||||
} from '@nestjs/websockets';
|
||||
import { Server as WSServer, WebSocket } from 'ws';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SendMessageDto } from '../messages/dto/message.dto';
|
||||
|
||||
@WebSocketGateway({
|
||||
cors: true,
|
||||
path: '/ws',
|
||||
})
|
||||
export class MessagesGateway
|
||||
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
|
||||
{
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
@WebSocketServer()
|
||||
server: WSServer;
|
||||
|
||||
private clients = new Map<string, WebSocket>();
|
||||
|
||||
afterInit(server: WSServer) {
|
||||
this.server = server;
|
||||
}
|
||||
|
||||
handleConnection(client: WebSocket, request: any) {
|
||||
const url = new URL(request.url, 'http://localhost');
|
||||
const staffId = url.searchParams.get('staffId');
|
||||
if (staffId) {
|
||||
this.clients.set(staffId, client);
|
||||
}
|
||||
}
|
||||
|
||||
handleDisconnect(client: WebSocket) {
|
||||
for (const [key, ws] of this.clients.entries()) {
|
||||
if (ws === client) {
|
||||
this.clients.delete(key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage('sendMessage')
|
||||
async handleMessage(
|
||||
@MessageBody() dto: SendMessageDto,
|
||||
@ConnectedSocket() client: WebSocket,
|
||||
) {
|
||||
const message = await this.prisma.message.create({
|
||||
data: {
|
||||
content: dto.content,
|
||||
clientId: dto.clientId,
|
||||
staffId: dto.staffId,
|
||||
isFromStaff: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Broadcast to the specific client's room
|
||||
const room = `client:${dto.clientId}`;
|
||||
this.server.emit(room, message);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
@SubscribeMessage('joinClient')
|
||||
joinClientRoom(@MessageBody() clientId: string, @ConnectedSocket() client: WebSocket) {
|
||||
const room = `client:${clientId}`;
|
||||
const ws = this.clients.get(clientId) || client;
|
||||
ws?.emit('joined', { room });
|
||||
return { event: 'joined', room };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user