sync
This commit is contained in:
@@ -9,9 +9,11 @@ import { StorageService } from "./infra/storage.service"
|
||||
import { InternalImagesController } from "./internal-images/internal-images.controller"
|
||||
import { InternalImagesService } from "./internal-images/internal-images.service"
|
||||
import { PresetsController } from "./presets/presets.controller"
|
||||
import { ProjectsController } from "./projects/projects.controller"
|
||||
import { ProjectsService } from "./projects/projects.service"
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController, AssetsController, InternalImagesController, PresetsController],
|
||||
providers: [AssetsService, DatabaseService, InternalImagesService, QueueService, StorageService],
|
||||
controllers: [HealthController, AssetsController, InternalImagesController, PresetsController, ProjectsController],
|
||||
providers: [AssetsService, DatabaseService, InternalImagesService, ProjectsService, QueueService, StorageService],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -87,6 +87,52 @@ export class AssetVariantsResponseDto {
|
||||
variants!: AssetVariantResponseDto[]
|
||||
}
|
||||
|
||||
export class AssetVersionResponseDto {
|
||||
@ApiProperty({ description: "Внутренний UUID версии source image.", example: "3b5da974-bb7f-4d73-b172-d6ad9c244528" })
|
||||
id!: string
|
||||
|
||||
@ApiProperty({ description: "Номер версии source image.", example: 2 })
|
||||
version!: number
|
||||
|
||||
@ApiProperty({ description: "Является ли версия текущей для asset.", example: true })
|
||||
isCurrent!: boolean
|
||||
|
||||
@ApiProperty({ description: "Source URL версии.", example: "https://storage.yandexcloud.net/shared1318/img/1.jpg" })
|
||||
sourceUrl!: string
|
||||
|
||||
@ApiProperty({ description: "Hostname source URL версии.", example: "storage.yandexcloud.net" })
|
||||
sourceHost!: string
|
||||
|
||||
@ApiProperty({ description: "Базовый Gateway path для версии.", example: "/images/asset_demo/v2/card" })
|
||||
imageBasePath!: string
|
||||
|
||||
@ApiPropertyOptional({ description: "Ширина оригинального изображения, если уже определена Worker.", example: 1200, nullable: true, type: Number })
|
||||
width!: number | null
|
||||
|
||||
@ApiPropertyOptional({ description: "Высота оригинального изображения, если уже определена Worker.", example: 800, nullable: true, type: Number })
|
||||
height!: number | null
|
||||
|
||||
@ApiPropertyOptional({ description: "Content-Type оригинального изображения, если уже определён Worker.", example: "image/jpeg", nullable: true, type: String })
|
||||
contentType!: string | null
|
||||
|
||||
@ApiPropertyOptional({ description: "Размер оригинального изображения в bytes, если уже определён Worker.", example: 245760, nullable: true, type: Number })
|
||||
sizeBytes!: number | null
|
||||
|
||||
@ApiProperty({ description: "Дата создания версии.", example: "2026-05-05T12:00:00.000Z" })
|
||||
createdAt!: string
|
||||
}
|
||||
|
||||
export class AssetVersionsResponseDto {
|
||||
@ApiProperty({ description: "Публичный идентификатор asset.", example: "asset_demo" })
|
||||
publicId!: string
|
||||
|
||||
@ApiProperty({ description: "Текущая версия source image.", example: 2 })
|
||||
currentVersion!: number
|
||||
|
||||
@ApiProperty({ description: "История версий source image.", type: [AssetVersionResponseDto] })
|
||||
versions!: AssetVersionResponseDto[]
|
||||
}
|
||||
|
||||
export class AssetsListResponseDto {
|
||||
@ApiProperty({ description: "Список assets.", type: [AssetResponseDto] })
|
||||
assets!: AssetResponseDto[]
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
|
||||
import { AssetsService } from "./assets.service"
|
||||
import { AssetPictureResponseDto } from "./asset-picture-response.dto"
|
||||
import { AssetResponseDto, AssetVariantsResponseDto, AssetsListResponseDto } from "./asset-response.dto"
|
||||
import { AssetResponseDto, AssetVariantsResponseDto, AssetVersionsResponseDto, AssetsListResponseDto } from "./asset-response.dto"
|
||||
import { CreateAssetVersionRequestDto, CreateAssetVersionResponseDto } from "./create-asset-version.dto"
|
||||
import { CreateAssetVariantsRequestDto, CreateAssetVariantsResponseDto } from "./create-asset-variants.dto"
|
||||
import { CreateAssetRequestDto, CreateAssetResponseDto } from "./create-asset.dto"
|
||||
@@ -60,6 +60,19 @@ export class AssetsController {
|
||||
return this.assets.getAsset(publicId)
|
||||
}
|
||||
|
||||
@Get(":publicId/versions")
|
||||
@ApiOperation({
|
||||
summary: "получить историю версий source image",
|
||||
description:
|
||||
"Возвращает все зарегистрированные source versions asset с current marker и versioned Gateway base path для каждой версии.",
|
||||
})
|
||||
@ApiParam({ description: "Публичный идентификатор asset.", example: "asset_demo", name: "publicId" })
|
||||
@ApiOkResponse({ description: "История версий source image возвращена.", type: AssetVersionsResponseDto })
|
||||
@ApiNotFoundResponse({ description: "Asset не найден." })
|
||||
listAssetVersions(@Param("publicId") publicId: string): Promise<AssetVersionsResponseDto> {
|
||||
return this.assets.listAssetVersions(publicId)
|
||||
}
|
||||
|
||||
@Post(":publicId/versions")
|
||||
@ApiOperation({
|
||||
summary: "создать новую версию source image",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common"
|
||||
import { imageAssets, imageAssetVersions, imageVariants } from "@image-platform/database"
|
||||
import { imageAssets, imageAssetVersions, imageProjects, imageVariants } from "@image-platform/database"
|
||||
import {
|
||||
CUSTOM_PRESET_NAME,
|
||||
ImageTransformConfigError,
|
||||
@@ -17,8 +17,15 @@ import { createHash, randomUUID } from "node:crypto"
|
||||
|
||||
import { DatabaseService } from "../infra/database.service"
|
||||
import { QueueService } from "../infra/queue.service"
|
||||
import { normalizeProjectSlug } from "../projects/project-slug"
|
||||
import type { AssetPictureResponseDto } from "./asset-picture-response.dto"
|
||||
import type { AssetResponseDto, AssetVariantResponseDto, AssetVariantsResponseDto, AssetsListResponseDto } from "./asset-response.dto"
|
||||
import type {
|
||||
AssetResponseDto,
|
||||
AssetVariantResponseDto,
|
||||
AssetVariantsResponseDto,
|
||||
AssetVersionsResponseDto,
|
||||
AssetsListResponseDto,
|
||||
} from "./asset-response.dto"
|
||||
import type { CreateAssetVersionRequestDto, CreateAssetVersionResponseDto } from "./create-asset-version.dto"
|
||||
import type { CreateAssetVariantsRequestDto, CreateAssetVariantsResponseDto } from "./create-asset-variants.dto"
|
||||
import type { CreateAssetRequestDto, CreateAssetResponseDto } from "./create-asset.dto"
|
||||
@@ -61,10 +68,39 @@ export class AssetsService {
|
||||
private readonly queue: QueueService,
|
||||
) {}
|
||||
|
||||
async listAssets(input: { limit?: string; offset?: string }): Promise<AssetsListResponseDto> {
|
||||
async listAssets(input: { limit?: string; offset?: string; projectSlug?: string }): Promise<AssetsListResponseDto> {
|
||||
const limit = parsePaginationInteger(input.limit, 50, 1, 100)
|
||||
const offset = parsePaginationInteger(input.offset, 0, 0, 10_000)
|
||||
|
||||
if (input.projectSlug) {
|
||||
const project = await this.loadProject(input.projectSlug)
|
||||
|
||||
const rows = await this.database.db
|
||||
.select({
|
||||
createdAt: imageAssets.createdAt,
|
||||
currentVersion: imageAssets.currentVersion,
|
||||
id: imageAssets.id,
|
||||
publicId: imageAssets.publicId,
|
||||
sourceHost: imageAssetVersions.sourceHost,
|
||||
sourceUrl: imageAssetVersions.sourceUrl,
|
||||
status: imageAssets.status,
|
||||
updatedAt: imageAssets.updatedAt,
|
||||
})
|
||||
.from(imageAssets)
|
||||
.innerJoin(
|
||||
imageAssetVersions,
|
||||
and(eq(imageAssetVersions.assetId, imageAssets.id), eq(imageAssetVersions.version, imageAssets.currentVersion)),
|
||||
)
|
||||
.where(eq(imageAssets.projectId, project.id))
|
||||
.orderBy(desc(imageAssets.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
return {
|
||||
assets: rows.map(mapAssetResponse),
|
||||
}
|
||||
}
|
||||
|
||||
const rows = await this.database.db
|
||||
.select({
|
||||
createdAt: imageAssets.createdAt,
|
||||
@@ -90,18 +126,19 @@ export class AssetsService {
|
||||
}
|
||||
}
|
||||
|
||||
async createAsset(request: CreateAssetRequestDto): Promise<CreateAssetResponseDto> {
|
||||
async createAsset(request: CreateAssetRequestDto, projectSlug?: string): Promise<CreateAssetResponseDto> {
|
||||
const source = normalizeSourceUrl(request.sourceUrl)
|
||||
await this.assertAllowedHost(source.hostname)
|
||||
|
||||
const publicId = request.publicId ? normalizePublicId(request.publicId) : generatePublicId()
|
||||
const sourceHash = createSourceHash(source.sourceUrl)
|
||||
const project = projectSlug ? await this.loadProject(projectSlug) : null
|
||||
|
||||
try {
|
||||
const result = await this.database.db.transaction(async (tx) => {
|
||||
const [asset] = await tx
|
||||
.insert(imageAssets)
|
||||
.values({ publicId })
|
||||
.values(project ? { projectId: project.id, publicId } : { publicId })
|
||||
.returning({ id: imageAssets.id, publicId: imageAssets.publicId })
|
||||
|
||||
if (!asset) {
|
||||
@@ -148,6 +185,58 @@ export class AssetsService {
|
||||
return mapAssetResponse(asset)
|
||||
}
|
||||
|
||||
async listAssetVersions(publicId: string): Promise<AssetVersionsResponseDto> {
|
||||
const normalizedPublicId = normalizePublicId(publicId)
|
||||
const [asset] = await this.database.db
|
||||
.select({
|
||||
currentVersion: imageAssets.currentVersion,
|
||||
id: imageAssets.id,
|
||||
publicId: imageAssets.publicId,
|
||||
status: imageAssets.status,
|
||||
})
|
||||
.from(imageAssets)
|
||||
.where(eq(imageAssets.publicId, normalizedPublicId))
|
||||
.limit(1)
|
||||
|
||||
if (!asset || asset.status !== "active") {
|
||||
throw new NotFoundException("asset not found")
|
||||
}
|
||||
|
||||
const versions = await this.database.db
|
||||
.select({
|
||||
contentType: imageAssetVersions.contentType,
|
||||
createdAt: imageAssetVersions.createdAt,
|
||||
height: imageAssetVersions.height,
|
||||
id: imageAssetVersions.id,
|
||||
sizeBytes: imageAssetVersions.sizeBytes,
|
||||
sourceHost: imageAssetVersions.sourceHost,
|
||||
sourceUrl: imageAssetVersions.sourceUrl,
|
||||
version: imageAssetVersions.version,
|
||||
width: imageAssetVersions.width,
|
||||
})
|
||||
.from(imageAssetVersions)
|
||||
.where(eq(imageAssetVersions.assetId, asset.id))
|
||||
.orderBy(desc(imageAssetVersions.version))
|
||||
|
||||
return {
|
||||
currentVersion: asset.currentVersion,
|
||||
publicId: asset.publicId,
|
||||
versions: versions.map((version) => ({
|
||||
contentType: version.contentType,
|
||||
createdAt: version.createdAt.toISOString(),
|
||||
height: version.height,
|
||||
id: version.id,
|
||||
imageBasePath: `/images/${asset.publicId}/v${version.version}/card`,
|
||||
isCurrent: version.version === asset.currentVersion,
|
||||
sizeBytes: version.sizeBytes,
|
||||
sourceHost: version.sourceHost,
|
||||
sourceUrl: version.sourceUrl,
|
||||
version: version.version,
|
||||
width: version.width,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async createAssetVersion(
|
||||
publicId: string,
|
||||
request: CreateAssetVersionRequestDto,
|
||||
@@ -381,6 +470,21 @@ export class AssetsService {
|
||||
return asset
|
||||
}
|
||||
|
||||
private async loadProject(projectSlug: string) {
|
||||
const normalizedSlug = normalizeProjectSlug(projectSlug)
|
||||
const [project] = await this.database.db
|
||||
.select({ id: imageProjects.id, status: imageProjects.status })
|
||||
.from(imageProjects)
|
||||
.where(eq(imageProjects.slug, normalizedSlug))
|
||||
.limit(1)
|
||||
|
||||
if (!project || project.status !== "active") {
|
||||
throw new NotFoundException("project not found")
|
||||
}
|
||||
|
||||
return project
|
||||
}
|
||||
|
||||
private async loadAssetVersion(publicId: string, versionInput: number | null | undefined): Promise<AssetVersionRow> {
|
||||
const normalizedPublicId = normalizePublicId(publicId)
|
||||
const [asset] = await this.database.db
|
||||
|
||||
21
apps/backend/src/projects/project-slug.ts
Normal file
21
apps/backend/src/projects/project-slug.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { BadRequestException } from "@nestjs/common"
|
||||
|
||||
export function normalizeProjectSlug(value: string) {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
|
||||
if (!/^[a-z0-9][a-z0-9_-]{2,63}$/.test(normalized)) {
|
||||
throw new BadRequestException("project slug must be 3-64 chars and contain lowercase letters, digits, _ or -")
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function createProjectSlug(name: string) {
|
||||
const slug = name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9_-]+/g, "-")
|
||||
.replaceAll(/^-+|-+$/g, "")
|
||||
|
||||
return normalizeProjectSlug(slug || "project")
|
||||
}
|
||||
96
apps/backend/src/projects/projects.controller.ts
Normal file
96
apps/backend/src/projects/projects.controller.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common"
|
||||
import {
|
||||
ApiBadRequestResponse,
|
||||
ApiConflictResponse,
|
||||
ApiCreatedResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger"
|
||||
|
||||
import { AssetsListResponseDto } from "../assets/asset-response.dto"
|
||||
import { AssetsService } from "../assets/assets.service"
|
||||
import { CreateAssetRequestDto, CreateAssetResponseDto } from "../assets/create-asset.dto"
|
||||
import { CreateProjectRequestDto, ProjectResponseDto, ProjectsListResponseDto } from "./projects.dto"
|
||||
import { ProjectsService } from "./projects.service"
|
||||
|
||||
@ApiTags("projects")
|
||||
@Controller("projects")
|
||||
export class ProjectsController {
|
||||
constructor(
|
||||
private readonly assets: AssetsService,
|
||||
private readonly projects: ProjectsService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: "получить список проектов",
|
||||
description: "Возвращает проекты верхнего уровня для главной страницы admin.",
|
||||
})
|
||||
@ApiOkResponse({ description: "Список проектов возвращён.", type: ProjectsListResponseDto })
|
||||
listProjects(): Promise<ProjectsListResponseDto> {
|
||||
return this.projects.listProjects()
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({
|
||||
summary: "создать проект",
|
||||
description: "Создаёт проект, внутри которого admin управляет assets и source versions.",
|
||||
})
|
||||
@ApiCreatedResponse({ description: "Проект создан.", type: ProjectResponseDto })
|
||||
@ApiBadRequestResponse({ description: "Некорректные name или slug." })
|
||||
@ApiConflictResponse({ description: "Проект с таким slug уже существует." })
|
||||
createProject(@Body() request: CreateProjectRequestDto): Promise<ProjectResponseDto> {
|
||||
return this.projects.createProject(request)
|
||||
}
|
||||
|
||||
@Get(":projectSlug")
|
||||
@ApiOperation({
|
||||
summary: "получить проект по slug",
|
||||
description: "Возвращает metadata проекта для project-level страницы admin.",
|
||||
})
|
||||
@ApiParam({ description: "Публичный slug проекта.", example: "demo-shop", name: "projectSlug" })
|
||||
@ApiOkResponse({ description: "Проект найден.", type: ProjectResponseDto })
|
||||
@ApiNotFoundResponse({ description: "Проект не найден." })
|
||||
getProject(@Param("projectSlug") projectSlug: string): Promise<ProjectResponseDto> {
|
||||
return this.projects.getProject(projectSlug)
|
||||
}
|
||||
|
||||
@Get(":projectSlug/assets")
|
||||
@ApiOperation({
|
||||
summary: "получить assets проекта",
|
||||
description: "Возвращает assets, созданные внутри выбранного проекта.",
|
||||
})
|
||||
@ApiParam({ description: "Публичный slug проекта.", example: "demo-shop", name: "projectSlug" })
|
||||
@ApiQuery({ description: "Максимальное количество assets в ответе.", example: 50, name: "limit", required: false })
|
||||
@ApiQuery({ description: "Смещение для простого paging.", example: 0, name: "offset", required: false })
|
||||
@ApiOkResponse({ description: "Список assets проекта возвращён.", type: AssetsListResponseDto })
|
||||
@ApiNotFoundResponse({ description: "Проект не найден." })
|
||||
listProjectAssets(
|
||||
@Param("projectSlug") projectSlug: string,
|
||||
@Query("limit") limit?: string,
|
||||
@Query("offset") offset?: string,
|
||||
): Promise<AssetsListResponseDto> {
|
||||
return this.assets.listAssets({ limit, offset, projectSlug })
|
||||
}
|
||||
|
||||
@Post(":projectSlug/assets")
|
||||
@ApiOperation({
|
||||
summary: "создать asset в проекте",
|
||||
description: "Создаёт asset и первую source version внутри выбранного проекта.",
|
||||
})
|
||||
@ApiParam({ description: "Публичный slug проекта.", example: "demo-shop", name: "projectSlug" })
|
||||
@ApiCreatedResponse({ description: "Asset проекта создан.", type: CreateAssetResponseDto })
|
||||
@ApiBadRequestResponse({ description: "Некорректный sourceUrl, publicId или source host запрещён настройками." })
|
||||
@ApiConflictResponse({ description: "Asset с таким publicId уже существует." })
|
||||
@ApiNotFoundResponse({ description: "Проект не найден." })
|
||||
createProjectAsset(
|
||||
@Param("projectSlug") projectSlug: string,
|
||||
@Body() request: CreateAssetRequestDto,
|
||||
): Promise<CreateAssetResponseDto> {
|
||||
return this.assets.createAsset(request, projectSlug)
|
||||
}
|
||||
}
|
||||
37
apps/backend/src/projects/projects.dto.ts
Normal file
37
apps/backend/src/projects/projects.dto.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"
|
||||
|
||||
export class CreateProjectRequestDto {
|
||||
@ApiProperty({ description: "Название проекта в admin UI.", example: "Demo Shop" })
|
||||
name!: string
|
||||
|
||||
@ApiPropertyOptional({ description: "Публичный slug проекта для URL и SDK.", example: "demo-shop" })
|
||||
slug?: string
|
||||
}
|
||||
|
||||
export class ProjectResponseDto {
|
||||
@ApiProperty({ description: "Внутренний UUID проекта.", example: "59fcf4f6-9891-4df4-8bb7-a4dbe570bb66" })
|
||||
id!: string
|
||||
|
||||
@ApiProperty({ description: "Публичный slug проекта.", example: "demo-shop" })
|
||||
slug!: string
|
||||
|
||||
@ApiProperty({ description: "Название проекта.", example: "Demo Shop" })
|
||||
name!: string
|
||||
|
||||
@ApiProperty({ description: "Статус проекта.", enum: ["active", "disabled"], example: "active" })
|
||||
status!: string
|
||||
|
||||
@ApiProperty({ description: "Количество assets в проекте.", example: 12 })
|
||||
assetsCount!: number
|
||||
|
||||
@ApiProperty({ description: "Дата создания проекта.", example: "2026-05-05T12:00:00.000Z" })
|
||||
createdAt!: string
|
||||
|
||||
@ApiProperty({ description: "Дата обновления проекта.", example: "2026-05-05T12:00:00.000Z" })
|
||||
updatedAt!: string
|
||||
}
|
||||
|
||||
export class ProjectsListResponseDto {
|
||||
@ApiProperty({ description: "Список проектов.", type: [ProjectResponseDto] })
|
||||
projects!: ProjectResponseDto[]
|
||||
}
|
||||
107
apps/backend/src/projects/projects.service.ts
Normal file
107
apps/backend/src/projects/projects.service.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common"
|
||||
import { imageAssets, imageProjects } from "@image-platform/database"
|
||||
import { count, desc, eq, inArray } from "drizzle-orm"
|
||||
|
||||
import { DatabaseService } from "../infra/database.service"
|
||||
import { createProjectSlug, normalizeProjectSlug } from "./project-slug"
|
||||
import type { CreateProjectRequestDto, ProjectResponseDto, ProjectsListResponseDto } from "./projects.dto"
|
||||
|
||||
@Injectable()
|
||||
export class ProjectsService {
|
||||
constructor(private readonly database: DatabaseService) {}
|
||||
|
||||
async listProjects(): Promise<ProjectsListResponseDto> {
|
||||
const projects = await this.database.db.select().from(imageProjects).orderBy(desc(imageProjects.createdAt))
|
||||
const assetsCountByProjectId = await this.countAssetsByProjectIds(projects.map((project) => project.id))
|
||||
|
||||
return {
|
||||
projects: projects.map((project) => mapProjectResponse(project, assetsCountByProjectId.get(project.id) ?? 0)),
|
||||
}
|
||||
}
|
||||
|
||||
async createProject(request: CreateProjectRequestDto): Promise<ProjectResponseDto> {
|
||||
const name = normalizeProjectName(request.name)
|
||||
const slug = request.slug ? normalizeProjectSlug(request.slug) : createProjectSlug(name)
|
||||
|
||||
try {
|
||||
const [project] = await this.database.db.insert(imageProjects).values({ name, slug }).returning()
|
||||
|
||||
if (!project) {
|
||||
throw new Error("failed to create project")
|
||||
}
|
||||
|
||||
return mapProjectResponse(project, 0)
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
throw new ConflictException("project slug already exists")
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async getProject(slug: string): Promise<ProjectResponseDto> {
|
||||
const project = await this.loadProject(slug)
|
||||
const assetsCountByProjectId = await this.countAssetsByProjectIds([project.id])
|
||||
|
||||
return mapProjectResponse(project, assetsCountByProjectId.get(project.id) ?? 0)
|
||||
}
|
||||
|
||||
async loadProject(slug: string) {
|
||||
const normalizedSlug = normalizeProjectSlug(slug)
|
||||
const [project] = await this.database.db
|
||||
.select()
|
||||
.from(imageProjects)
|
||||
.where(eq(imageProjects.slug, normalizedSlug))
|
||||
.limit(1)
|
||||
|
||||
if (!project || project.status !== "active") {
|
||||
throw new NotFoundException("project not found")
|
||||
}
|
||||
|
||||
return project
|
||||
}
|
||||
|
||||
private async countAssetsByProjectIds(projectIds: string[]) {
|
||||
if (projectIds.length === 0) {
|
||||
return new Map<string, number>()
|
||||
}
|
||||
|
||||
const rows = await this.database.db
|
||||
.select({
|
||||
assetsCount: count(imageAssets.id),
|
||||
projectId: imageAssets.projectId,
|
||||
})
|
||||
.from(imageAssets)
|
||||
.where(inArray(imageAssets.projectId, projectIds))
|
||||
.groupBy(imageAssets.projectId)
|
||||
|
||||
return new Map(rows.flatMap((row) => (row.projectId ? [[row.projectId, row.assetsCount]] : [])))
|
||||
}
|
||||
}
|
||||
|
||||
function mapProjectResponse(row: typeof imageProjects.$inferSelect, assetsCount: number): ProjectResponseDto {
|
||||
return {
|
||||
assetsCount,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
slug: row.slug,
|
||||
status: row.status,
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProjectName(value: string) {
|
||||
const normalized = value.trim()
|
||||
|
||||
if (!normalized || normalized.length > 120) {
|
||||
throw new BadRequestException("project name must be 1-120 chars")
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function isUniqueViolation(error: unknown) {
|
||||
return typeof error === "object" && error !== null && "code" in error && error.code === "23505"
|
||||
}
|
||||
Reference in New Issue
Block a user