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:
Robert Perez
2026-07-11 00:17:49 +00:00
commit 8d742518f8
65 changed files with 3365 additions and 0 deletions
+6
View File
@@ -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
+23
View File
@@ -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"]
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+42
View File
@@ -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"
}
}
+162
View File
@@ -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")
}
+32
View File
@@ -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();
});
+12
View File
@@ -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' };
}
}
+25
View File
@@ -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 {}
+8
View File
@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Petmaster API is running';
}
}
+56
View File
@@ -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);
}
}
+10
View File
@@ -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 {}
+169
View File
@@ -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;
}
}
+106
View File
@@ -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;
}
+45
View File
@@ -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);
}
}
+10
View File
@@ -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 {}
+62
View File
@@ -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 },
});
}
}
+74
View File
@@ -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;
}
+56
View File
@@ -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;
}
+46
View File
@@ -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);
}
}
+10
View File
@@ -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 {}
+151
View File
@@ -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 } });
}
}
+30
View File
@@ -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();
+15
View File
@@ -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;
}
+35
View File
@@ -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);
}
}
+11
View File
@@ -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 {}
+41
View File
@@ -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() },
});
}
}
+81
View File
@@ -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>;
}
+38
View File
@@ -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);
}
}
+10
View File
@@ -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 {}
+42
View File
@@ -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 } });
}
}
+8
View File
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+12
View File
@@ -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();
}
}
+44
View File
@@ -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 };
}
}
+56
View File
@@ -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;
}
+57
View File
@@ -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);
}
}
+10
View File
@@ -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 {}
+67
View File
@@ -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) };
}
}
+78
View File
@@ -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 };
}
}
+27
View File
@@ -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"]
}