Initial commit: Meshkee CMS API

NestJS backend with Prisma, Docker Compose for Postgres/Redis, and deploy docs for the production VM.
This commit is contained in:
Ali Reza
2026-07-21 17:52:36 +03:30
commit bb59d5e9ba
254 changed files with 37031 additions and 0 deletions
@@ -0,0 +1,45 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { AuthUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { ContactSubmissionsService } from './contact-submissions.service';
import { CreateContactSubmissionDto } from './dto/create-contact-submission.dto';
import { ListContactSubmissionsDto } from './dto/list-contact-submissions.dto';
@Controller('tenants/:host/contact-submissions')
export class PublicContactSubmissionsController {
constructor(private readonly service: ContactSubmissionsService) {}
@Post()
create(@Param('host') host: string, @Body() dto: CreateContactSubmissionDto) {
return this.service.createPublic(host, dto);
}
}
@Controller('businesses/:businessId/contact-submissions')
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
export class ContactSubmissionsController {
constructor(private readonly service: ContactSubmissionsService) {}
@Get()
@RequireBusinessPermission('business.read')
list(
@Param('businessId') businessId: string,
@Query() query: ListContactSubmissionsDto,
@CurrentUser() user: AuthUser,
) {
return this.service.list(businessId, query, user);
}
@Get(':submissionId')
@RequireBusinessPermission('business.read')
getOne(
@Param('businessId') businessId: string,
@Param('submissionId') submissionId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.getOne(businessId, submissionId, user);
}
}
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { TenantModule } from '../tenant/tenant.module';
import {
ContactSubmissionsController,
PublicContactSubmissionsController,
} from './contact-submissions.controller';
import { ContactSubmissionsService } from './contact-submissions.service';
@Module({
imports: [AuthModule, TenantModule],
controllers: [ContactSubmissionsController, PublicContactSubmissionsController],
providers: [ContactSubmissionsService],
})
export class ContactSubmissionsModule {}
@@ -0,0 +1,155 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { PrismaService } from '../prisma/prisma.service';
import { TenantService } from '../tenant/tenant.service';
import { CreateContactSubmissionDto } from './dto/create-contact-submission.dto';
import { ListContactSubmissionsDto } from './dto/list-contact-submissions.dto';
type ContactSubmissionRow = {
id: bigint;
title: string;
name: string;
email: string | null;
cellNumber: string | null;
text: string;
createdAt: Date;
};
@Injectable()
export class ContactSubmissionsService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
private readonly tenant: TenantService,
) {}
async createPublic(host: string, dto: CreateContactSubmissionDto) {
const business = await this.tenant.resolveBusinessByDomain(host);
const created = await this.prisma.contactSubmission.create({
data: {
businessId: business.id,
title: dto.title.trim(),
name: dto.name.trim(),
email: dto.email?.trim() || null,
cellNumber: dto.cellNumber?.trim() || null,
text: dto.text.trim(),
},
});
return {
submission: this.serialize(created),
message: 'Contact form submitted successfully',
};
}
async list(
businessIdRaw: string,
query: ListContactSubmissionsDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'business.read');
const page = query.page ?? 1;
const pageSize = Math.min(Math.max(query.pageSize ?? 20, 1), 100);
const skip = (page - 1) * pageSize;
const qLike = query.q?.trim() ? `%${query.q.trim()}%` : null;
const where = Prisma.sql`
WHERE cs.business_id = ${businessId}
${qLike ? Prisma.sql`
AND (
cs.title ILIKE ${qLike}
OR cs.name ILIKE ${qLike}
OR cs.email ILIKE ${qLike}
OR cs.cell_number ILIKE ${qLike}
OR cs.text ILIKE ${qLike}
)
` : Prisma.empty}
`;
const [items, totalRow] = await Promise.all([
this.prisma.$queryRaw<ContactSubmissionRow[]>(Prisma.sql`
SELECT
cs.id AS "id",
cs.title AS "title",
cs.name AS "name",
cs.email AS "email",
cs.cell_number AS "cellNumber",
cs.text AS "text",
cs.created_at AS "createdAt"
FROM contact_submissions cs
${where}
ORDER BY cs.created_at DESC
LIMIT ${pageSize} OFFSET ${skip}
`),
this.prisma.$queryRaw<{ total: number }[]>(Prisma.sql`
SELECT COUNT(*)::int AS "total"
FROM contact_submissions cs
${where}
`),
]);
return {
items: items.map((row) => this.serialize(row)),
total: totalRow[0]?.total ?? 0,
page,
pageSize,
};
}
async getOne(businessIdRaw: string, submissionIdRaw: string, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
const submissionId = BigInt(submissionIdRaw);
await this.assertPermission(businessId, actor.id, 'business.read');
const submission = await this.prisma.contactSubmission.findFirst({
where: { id: submissionId, businessId },
});
if (!submission) {
throw new NotFoundException('Contact submission not found');
}
return { submission: this.serialize(submission) };
}
private serialize(row: ContactSubmissionRow | {
id: bigint;
title: string;
name: string;
email: string | null;
cellNumber: string | null;
text: string;
createdAt: Date;
}) {
return {
id: row.id.toString(),
title: row.title,
name: row.name,
email: row.email,
cellNumber: row.cellNumber,
text: row.text,
createdAt: row.createdAt.toISOString(),
};
}
private async assertPermission(
businessId: bigint,
userId: bigint,
permission: string,
) {
const allowed = await this.permissions.hasBusinessPermission(
userId,
businessId,
permission,
);
if (!allowed) {
throw new ForbiddenException('Insufficient permissions');
}
}
}
@@ -0,0 +1,27 @@
import { IsEmail, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class CreateContactSubmissionDto {
@IsString()
@MinLength(1)
@MaxLength(255)
title!: string;
@IsString()
@MinLength(1)
@MaxLength(255)
name!: string;
@IsOptional()
@IsEmail()
@MaxLength(255)
email?: string;
@IsOptional()
@IsString()
@MaxLength(20)
cellNumber?: string;
@IsString()
@MinLength(1)
text!: string;
}
@@ -0,0 +1,20 @@
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Min } from 'class-validator';
export class ListContactSubmissionsDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number;
@IsOptional()
@IsString()
q?: string;
}