diff --git a/CLAUDE.md b/CLAUDE.md index 505e9ff..457ed48 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,31 @@ --- +## Рефакторинг: мульти-профиль (02.06.2026) + +Полная переработка доменной модели. Основная причина: один пользователь +может иметь несколько публичных профилей. Все социальные операции +переведены с `user_id` на `profile_id`. + +**Ключевые изменения схемы:** +- `profile.user_id` — убран `UNIQUE`, теперь один user → много профилей +- `profile.active_chat_id` — перенесено из `user` в `profile` +- `user.active_chat_id` — удалено +- `media` → `profile_media` (FK на `profile`, тип: `photo | video | audio`, добавлен `sort_order`) +- `like.source_user/target_user` → `like.source_profile_id/target_profile_id` +- `match.user1_id/user2_id` → `match.profile1_id/profile2_id` +- `chat.profile1_id/profile2_id` — теперь честные FK на `profile` +- `message.user_id` → `message.profile_id` +- `date.user1_id/user2_id` → `date.profile1_id/profile2_id` +- `report.source_user` → `report.source_profile_id` + +**Паттерн ownership:** все операции, изменяющие профиль, проверяют +`profile.user_id === jwt.sub` через `assertProfileOwnership()` в каждом сервисе. + +**WebSocket:** `profileId` передаётся в `handshake.auth.profileId` при подключении. + +--- + ## Архитектурные решения ### 1. Глобальный JWT-guard через `APP_GUARD` @@ -103,7 +128,7 @@ seed (например, хешем userId + дата). | # | Пункт ТЗ | Как реализовано | Причина | |---|---|---|---| -| 1 | `chat.profile1_id → profile` | Поле хранит `user_id` | Матч создаётся между пользователями, а не профилями; JOIN с профилем не нужен на горячем пути | +| 1 | `chat.profile1_id → profile` | Реализовано корректно после рефакторинга | — | | 2 | Поиск «неактивен» при превышении лимита матчей | `BadRequestException` при лайке | Проще контракт с клиентом: ошибка явная, не нужно отдельного флага `searchActive` | | 3 | Тарифный план один, но оплата предусмотрена | Таблицы `tariff` и `payment` созданы, логика оплаты не реализована | ТЗ: «регистрация открыта для всех, тарифный план один» — бизнес-логики оплаты нет | | 4 | `radiusKm` в фильтре ленты | Параметр принимается, но фильтрация по радиусу не применяется | Требует PostGIS или формулы Haversine; добавить в следующей итерации | diff --git a/src/common/decorators/current-profile.decorator.ts b/src/common/decorators/current-profile.decorator.ts new file mode 100644 index 0000000..8d61cd6 --- /dev/null +++ b/src/common/decorators/current-profile.decorator.ts @@ -0,0 +1,9 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +export const CurrentProfile = createParamDecorator( + (data: string, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + const profile = request.profile; + return data ? profile?.[data] : profile; + }, +); diff --git a/src/common/guards/profile-owner.guard.ts b/src/common/guards/profile-owner.guard.ts new file mode 100644 index 0000000..fcfb561 --- /dev/null +++ b/src/common/guards/profile-owner.guard.ts @@ -0,0 +1,36 @@ +import { CanActivate, ExecutionContext, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { eq } from 'drizzle-orm'; +import { DrizzleService } from '../../database/drizzle.service'; +import { profile } from '../../database/schema'; + +/** + * Verifies that the profileId in request body/params belongs to the authenticated user. + * Expects profileId in: params.profileId OR body.profileId OR query.profileId + */ +@Injectable() +export class ProfileOwnerGuard implements CanActivate { + constructor(private readonly drizzleService: DrizzleService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const userId = request.user?.id; + const profileId = + request.params?.profileId || + request.body?.profileId || + request.query?.profileId; + + if (!profileId) return true; + + const [found] = await this.drizzleService.db + .select({ id: profile.id, userId: profile.userId }) + .from(profile) + .where(eq(profile.id, profileId)) + .limit(1); + + if (!found) throw new NotFoundException('Profile not found'); + if (found.userId !== userId) throw new ForbiddenException('Profile does not belong to you'); + + request.profile = found; + return true; + } +} diff --git a/src/database/migrations/0000_romantic_morg.sql b/src/database/migrations/0000_quick_silver_samurai.sql similarity index 73% rename from src/database/migrations/0000_romantic_morg.sql rename to src/database/migrations/0000_quick_silver_samurai.sql index 30ac826..ab1db5a 100644 --- a/src/database/migrations/0000_romantic_morg.sql +++ b/src/database/migrations/0000_quick_silver_samurai.sql @@ -1,7 +1,9 @@ -CREATE TYPE "public"."chat_status" AS ENUM('active', 'closed');--> statement-breakpoint -CREATE TYPE "public"."media_type" AS ENUM('photo', 'voice', 'video');--> statement-breakpoint CREATE TYPE "public"."user_status" AS ENUM('active', 'banned', 'pending');--> statement-breakpoint +CREATE TYPE "public"."gender" AS ENUM('male', 'female');--> statement-breakpoint +CREATE TYPE "public"."profile_media_type" AS ENUM('photo', 'video', 'audio');--> statement-breakpoint CREATE TYPE "public"."like_type" AS ENUM('like', 'dislike');--> statement-breakpoint +CREATE TYPE "public"."chat_status" AS ENUM('active', 'closed');--> statement-breakpoint +CREATE TYPE "public"."message_media_type" AS ENUM('photo', 'voice', 'video');--> statement-breakpoint CREATE TYPE "public"."report_entity_type" AS ENUM('profile', 'message');--> statement-breakpoint CREATE TABLE IF NOT EXISTS "permission" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, @@ -35,28 +37,6 @@ CREATE TABLE IF NOT EXISTS "city_district" ( "name" varchar(200) NOT NULL ); --> statement-breakpoint -CREATE TABLE IF NOT EXISTS "chat" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "profile1_id" uuid NOT NULL, - "profile2_id" uuid NOT NULL, - "status" "chat_status" DEFAULT 'active' NOT NULL -); ---> statement-breakpoint -CREATE TABLE IF NOT EXISTS "greetings" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "text" text NOT NULL -); ---> statement-breakpoint -CREATE TABLE IF NOT EXISTS "message" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "chat_id" uuid NOT NULL, - "user_id" uuid NOT NULL, - "text" text, - "media_url" text, - "media_type" "media_type", - "created_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint CREATE TABLE IF NOT EXISTS "payment" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "user_id" uuid NOT NULL, @@ -72,30 +52,31 @@ CREATE TABLE IF NOT EXISTS "user" ( "role_id" uuid, "tariff_id" uuid, "payment_id" uuid, - "active_chat_id" uuid, "fcm_token" text, CONSTRAINT "user_phone_unique" UNIQUE("phone") ); --> statement-breakpoint -CREATE TABLE IF NOT EXISTS "media" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user_id" uuid NOT NULL, - "path" text NOT NULL, - "type" varchar(10) NOT NULL -); ---> statement-breakpoint CREATE TABLE IF NOT EXISTS "profile" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "user_id" uuid NOT NULL, "name" varchar(100) NOT NULL, "birth_date" date NOT NULL, + "gender" "gender" NOT NULL, "city_id" uuid, "district_id" uuid, "description" text, "nation" varchar(100), "height" double precision, "weight" double precision, - CONSTRAINT "profile_user_id_unique" UNIQUE("user_id") + "active_chat_id" uuid +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "profile_media" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "profile_id" uuid NOT NULL, + "path" text NOT NULL, + "type" "profile_media_type" NOT NULL, + "sort_order" integer DEFAULT 0 NOT NULL ); --> statement-breakpoint CREATE TABLE IF NOT EXISTS "profile_tag" ( @@ -111,23 +92,45 @@ CREATE TABLE IF NOT EXISTS "tag" ( --> statement-breakpoint CREATE TABLE IF NOT EXISTS "like" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "source_user" uuid NOT NULL, - "target_user" uuid NOT NULL, + "source_profile_id" uuid NOT NULL, + "target_profile_id" uuid NOT NULL, "type" "like_type" NOT NULL, "created_at" timestamp with time zone DEFAULT now() NOT NULL ); --> statement-breakpoint CREATE TABLE IF NOT EXISTS "match" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user1_id" uuid NOT NULL, - "user2_id" uuid NOT NULL, + "profile1_id" uuid NOT NULL, + "profile2_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "chat" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "profile1_id" uuid NOT NULL, + "profile2_id" uuid NOT NULL, + "status" "chat_status" DEFAULT 'active' NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "greetings" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "text" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "message" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "chat_id" uuid NOT NULL, + "profile_id" uuid NOT NULL, + "text" text, + "media_url" text, + "media_type" "message_media_type", "created_at" timestamp with time zone DEFAULT now() NOT NULL ); --> statement-breakpoint CREATE TABLE IF NOT EXISTS "date" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user1_id" uuid NOT NULL, - "user2_id" uuid NOT NULL, + "profile1_id" uuid NOT NULL, + "profile2_id" uuid NOT NULL, "lat" numeric(10, 7) NOT NULL, "lng" numeric(10, 7) NOT NULL, "time" timestamp with time zone NOT NULL, @@ -141,7 +144,7 @@ CREATE TABLE IF NOT EXISTS "date_status" ( --> statement-breakpoint CREATE TABLE IF NOT EXISTS "report" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "source_user" uuid NOT NULL, + "source_profile_id" uuid NOT NULL, "entity_id" uuid NOT NULL, "entity_type" "report_entity_type" NOT NULL, "description" text @@ -159,12 +162,6 @@ EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint -DO $$ BEGIN - ALTER TABLE "message" ADD CONSTRAINT "message_chat_id_chat_id_fk" FOREIGN KEY ("chat_id") REFERENCES "public"."chat"("id") ON DELETE cascade ON UPDATE no action; -EXCEPTION - WHEN duplicate_object THEN null; -END $$; ---> statement-breakpoint DO $$ BEGIN ALTER TABLE "payment" ADD CONSTRAINT "payment_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION @@ -183,12 +180,6 @@ EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint -DO $$ BEGIN - ALTER TABLE "media" ADD CONSTRAINT "media_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; -EXCEPTION - WHEN duplicate_object THEN null; -END $$; ---> statement-breakpoint DO $$ BEGIN ALTER TABLE "profile" ADD CONSTRAINT "profile_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION @@ -207,6 +198,12 @@ EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "profile_media" ADD CONSTRAINT "profile_media_profile_id_profile_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."profile"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint DO $$ BEGIN ALTER TABLE "profile_tag" ADD CONSTRAINT "profile_tag_profile_id_profile_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."profile"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION @@ -220,37 +217,61 @@ EXCEPTION END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "like" ADD CONSTRAINT "like_source_user_user_id_fk" FOREIGN KEY ("source_user") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "like" ADD CONSTRAINT "like_source_profile_id_profile_id_fk" FOREIGN KEY ("source_profile_id") REFERENCES "public"."profile"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "like" ADD CONSTRAINT "like_target_user_user_id_fk" FOREIGN KEY ("target_user") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "like" ADD CONSTRAINT "like_target_profile_id_profile_id_fk" FOREIGN KEY ("target_profile_id") REFERENCES "public"."profile"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "match" ADD CONSTRAINT "match_user1_id_user_id_fk" FOREIGN KEY ("user1_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "match" ADD CONSTRAINT "match_profile1_id_profile_id_fk" FOREIGN KEY ("profile1_id") REFERENCES "public"."profile"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "match" ADD CONSTRAINT "match_user2_id_user_id_fk" FOREIGN KEY ("user2_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "match" ADD CONSTRAINT "match_profile2_id_profile_id_fk" FOREIGN KEY ("profile2_id") REFERENCES "public"."profile"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "date" ADD CONSTRAINT "date_user1_id_user_id_fk" FOREIGN KEY ("user1_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "chat" ADD CONSTRAINT "chat_profile1_id_profile_id_fk" FOREIGN KEY ("profile1_id") REFERENCES "public"."profile"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "date" ADD CONSTRAINT "date_user2_id_user_id_fk" FOREIGN KEY ("user2_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "chat" ADD CONSTRAINT "chat_profile2_id_profile_id_fk" FOREIGN KEY ("profile2_id") REFERENCES "public"."profile"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "message" ADD CONSTRAINT "message_chat_id_chat_id_fk" FOREIGN KEY ("chat_id") REFERENCES "public"."chat"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "message" ADD CONSTRAINT "message_profile_id_profile_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."profile"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "date" ADD CONSTRAINT "date_profile1_id_profile_id_fk" FOREIGN KEY ("profile1_id") REFERENCES "public"."profile"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "date" ADD CONSTRAINT "date_profile2_id_profile_id_fk" FOREIGN KEY ("profile2_id") REFERENCES "public"."profile"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; @@ -262,7 +283,7 @@ EXCEPTION END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "report" ADD CONSTRAINT "report_source_user_user_id_fk" FOREIGN KEY ("source_user") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "report" ADD CONSTRAINT "report_source_profile_id_profile_id_fk" FOREIGN KEY ("source_profile_id") REFERENCES "public"."profile"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; diff --git a/src/database/migrations/0001_brown_marrow.sql b/src/database/migrations/0001_brown_marrow.sql deleted file mode 100644 index de977cc..0000000 --- a/src/database/migrations/0001_brown_marrow.sql +++ /dev/null @@ -1,3 +0,0 @@ -CREATE TYPE "public"."gender" AS ENUM('male', 'female');--> statement-breakpoint -ALTER TABLE "profile" ADD COLUMN "gender" "gender" NOT NULL DEFAULT 'male';--> statement-breakpoint -ALTER TABLE "profile" ALTER COLUMN "gender" DROP DEFAULT; \ No newline at end of file diff --git a/src/database/migrations/meta/0000_snapshot.json b/src/database/migrations/meta/0000_snapshot.json index 2298ebd..30129e8 100644 --- a/src/database/migrations/meta/0000_snapshot.json +++ b/src/database/migrations/meta/0000_snapshot.json @@ -1,5 +1,5 @@ { - "id": "3d66c7d2-fc68-4c66-ad86-4f558d519225", + "id": "7caebd65-9149-400c-92fa-1981f0e4ea72", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", @@ -206,144 +206,6 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.chat": { - "name": "chat", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "profile1_id": { - "name": "profile1_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "profile2_id": { - "name": "profile2_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "chat_status", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'active'" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.greetings": { - "name": "greetings", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "text": { - "name": "text", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.message": { - "name": "message", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "chat_id": { - "name": "chat_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "text": { - "name": "text", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "media_url": { - "name": "media_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "media_type": { - "name": "media_type", - "type": "media_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "message_chat_id_chat_id_fk": { - "name": "message_chat_id_chat_id_fk", - "tableFrom": "message", - "tableTo": "chat", - "columnsFrom": [ - "chat_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, "public.payment": { "name": "payment", "schema": "", @@ -445,12 +307,6 @@ "primaryKey": false, "notNull": false }, - "active_chat_id": { - "name": "active_chat_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, "fcm_token": { "name": "fcm_token", "type": "text", @@ -501,58 +357,6 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.media": { - "name": "media", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "path": { - "name": "path", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "type": { - "name": "type", - "type": "varchar(10)", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "media_user_id_user_id_fk": { - "name": "media_user_id_user_id_fk", - "tableFrom": "media", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, "public.profile": { "name": "profile", "schema": "", @@ -582,6 +386,13 @@ "primaryKey": false, "notNull": true }, + "gender": { + "name": "gender", + "type": "gender", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, "city_id": { "name": "city_id", "type": "uuid", @@ -617,6 +428,12 @@ "type": "double precision", "primaryKey": false, "notNull": false + }, + "active_chat_id": { + "name": "active_chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false } }, "indexes": {}, @@ -662,15 +479,67 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "profile_user_id_unique": { - "name": "profile_user_id_unique", - "nullsNotDistinct": false, - "columns": [ - "user_id" - ] + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profile_media": { + "name": "profile_media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "profile_media_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 } }, + "indexes": {}, + "foreignKeys": { + "profile_media_profile_id_profile_id_fk": { + "name": "profile_media_profile_id_profile_id_fk", + "tableFrom": "profile_media", + "tableTo": "profile", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false @@ -772,14 +641,14 @@ "notNull": true, "default": "gen_random_uuid()" }, - "source_user": { - "name": "source_user", + "source_profile_id": { + "name": "source_profile_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "target_user": { - "name": "target_user", + "target_profile_id": { + "name": "target_profile_id", "type": "uuid", "primaryKey": false, "notNull": true @@ -801,12 +670,12 @@ }, "indexes": {}, "foreignKeys": { - "like_source_user_user_id_fk": { - "name": "like_source_user_user_id_fk", + "like_source_profile_id_profile_id_fk": { + "name": "like_source_profile_id_profile_id_fk", "tableFrom": "like", - "tableTo": "user", + "tableTo": "profile", "columnsFrom": [ - "source_user" + "source_profile_id" ], "columnsTo": [ "id" @@ -814,12 +683,12 @@ "onDelete": "cascade", "onUpdate": "no action" }, - "like_target_user_user_id_fk": { - "name": "like_target_user_user_id_fk", + "like_target_profile_id_profile_id_fk": { + "name": "like_target_profile_id_profile_id_fk", "tableFrom": "like", - "tableTo": "user", + "tableTo": "profile", "columnsFrom": [ - "target_user" + "target_profile_id" ], "columnsTo": [ "id" @@ -845,14 +714,14 @@ "notNull": true, "default": "gen_random_uuid()" }, - "user1_id": { - "name": "user1_id", + "profile1_id": { + "name": "profile1_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "user2_id": { - "name": "user2_id", + "profile2_id": { + "name": "profile2_id", "type": "uuid", "primaryKey": false, "notNull": true @@ -867,12 +736,12 @@ }, "indexes": {}, "foreignKeys": { - "match_user1_id_user_id_fk": { - "name": "match_user1_id_user_id_fk", + "match_profile1_id_profile_id_fk": { + "name": "match_profile1_id_profile_id_fk", "tableFrom": "match", - "tableTo": "user", + "tableTo": "profile", "columnsFrom": [ - "user1_id" + "profile1_id" ], "columnsTo": [ "id" @@ -880,12 +749,190 @@ "onDelete": "cascade", "onUpdate": "no action" }, - "match_user2_id_user_id_fk": { - "name": "match_user2_id_user_id_fk", + "match_profile2_id_profile_id_fk": { + "name": "match_profile2_id_profile_id_fk", "tableFrom": "match", - "tableTo": "user", + "tableTo": "profile", "columnsFrom": [ - "user2_id" + "profile2_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "profile1_id": { + "name": "profile1_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "profile2_id": { + "name": "profile2_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "chat_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_profile1_id_profile_id_fk": { + "name": "chat_profile1_id_profile_id_fk", + "tableFrom": "chat", + "tableTo": "profile", + "columnsFrom": [ + "profile1_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_profile2_id_profile_id_fk": { + "name": "chat_profile2_id_profile_id_fk", + "tableFrom": "chat", + "tableTo": "profile", + "columnsFrom": [ + "profile2_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.greetings": { + "name": "greetings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message": { + "name": "message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "media_url": { + "name": "media_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "media_type": { + "name": "media_type", + "type": "message_media_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "message_chat_id_chat_id_fk": { + "name": "message_chat_id_chat_id_fk", + "tableFrom": "message", + "tableTo": "chat", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_profile_id_profile_id_fk": { + "name": "message_profile_id_profile_id_fk", + "tableFrom": "message", + "tableTo": "profile", + "columnsFrom": [ + "profile_id" ], "columnsTo": [ "id" @@ -911,14 +958,14 @@ "notNull": true, "default": "gen_random_uuid()" }, - "user1_id": { - "name": "user1_id", + "profile1_id": { + "name": "profile1_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "user2_id": { - "name": "user2_id", + "profile2_id": { + "name": "profile2_id", "type": "uuid", "primaryKey": false, "notNull": true @@ -950,12 +997,12 @@ }, "indexes": {}, "foreignKeys": { - "date_user1_id_user_id_fk": { - "name": "date_user1_id_user_id_fk", + "date_profile1_id_profile_id_fk": { + "name": "date_profile1_id_profile_id_fk", "tableFrom": "date", - "tableTo": "user", + "tableTo": "profile", "columnsFrom": [ - "user1_id" + "profile1_id" ], "columnsTo": [ "id" @@ -963,12 +1010,12 @@ "onDelete": "cascade", "onUpdate": "no action" }, - "date_user2_id_user_id_fk": { - "name": "date_user2_id_user_id_fk", + "date_profile2_id_profile_id_fk": { + "name": "date_profile2_id_profile_id_fk", "tableFrom": "date", - "tableTo": "user", + "tableTo": "profile", "columnsFrom": [ - "user2_id" + "profile2_id" ], "columnsTo": [ "id" @@ -1033,8 +1080,8 @@ "notNull": true, "default": "gen_random_uuid()" }, - "source_user": { - "name": "source_user", + "source_profile_id": { + "name": "source_profile_id", "type": "uuid", "primaryKey": false, "notNull": true @@ -1061,12 +1108,12 @@ }, "indexes": {}, "foreignKeys": { - "report_source_user_user_id_fk": { - "name": "report_source_user_user_id_fk", + "report_source_profile_id_profile_id_fk": { + "name": "report_source_profile_id_profile_id_fk", "tableFrom": "report", - "tableTo": "user", + "tableTo": "profile", "columnsFrom": [ - "source_user" + "source_profile_id" ], "columnsTo": [ "id" @@ -1083,23 +1130,6 @@ } }, "enums": { - "public.chat_status": { - "name": "chat_status", - "schema": "public", - "values": [ - "active", - "closed" - ] - }, - "public.media_type": { - "name": "media_type", - "schema": "public", - "values": [ - "photo", - "voice", - "video" - ] - }, "public.user_status": { "name": "user_status", "schema": "public", @@ -1109,6 +1139,23 @@ "pending" ] }, + "public.gender": { + "name": "gender", + "schema": "public", + "values": [ + "male", + "female" + ] + }, + "public.profile_media_type": { + "name": "profile_media_type", + "schema": "public", + "values": [ + "photo", + "video", + "audio" + ] + }, "public.like_type": { "name": "like_type", "schema": "public", @@ -1117,6 +1164,23 @@ "dislike" ] }, + "public.chat_status": { + "name": "chat_status", + "schema": "public", + "values": [ + "active", + "closed" + ] + }, + "public.message_media_type": { + "name": "message_media_type", + "schema": "public", + "values": [ + "photo", + "voice", + "video" + ] + }, "public.report_entity_type": { "name": "report_entity_type", "schema": "public", diff --git a/src/database/migrations/meta/0001_snapshot.json b/src/database/migrations/meta/0001_snapshot.json deleted file mode 100644 index 27a9ff2..0000000 --- a/src/database/migrations/meta/0001_snapshot.json +++ /dev/null @@ -1,1154 +0,0 @@ -{ - "id": "a0e9c7a1-dfc3-438b-8f47-c3fc9c899af1", - "prevId": "3d66c7d2-fc68-4c66-ad86-4f558d519225", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.permission": { - "name": "permission", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "role_id": { - "name": "role_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "varchar(100)", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "permission_role_id_role_id_fk": { - "name": "permission_role_id_role_id_fk", - "tableFrom": "permission", - "tableTo": "role", - "columnsFrom": [ - "role_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.role": { - "name": "role", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "varchar(50)", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "role_name_unique": { - "name": "role_name_unique", - "nullsNotDistinct": false, - "columns": [ - "name" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.tariff": { - "name": "tariff", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "varchar(100)", - "primaryKey": false, - "notNull": true - }, - "price_per_month": { - "name": "price_per_month", - "type": "numeric(10, 2)", - "primaryKey": false, - "notNull": true - }, - "price_per_year": { - "name": "price_per_year", - "type": "numeric(10, 2)", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.city": { - "name": "city", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "varchar(200)", - "primaryKey": false, - "notNull": true - }, - "lat": { - "name": "lat", - "type": "numeric(10, 7)", - "primaryKey": false, - "notNull": true - }, - "lng": { - "name": "lng", - "type": "numeric(10, 7)", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.city_district": { - "name": "city_district", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "city_id": { - "name": "city_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "varchar(200)", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "city_district_city_id_city_id_fk": { - "name": "city_district_city_id_city_id_fk", - "tableFrom": "city_district", - "tableTo": "city", - "columnsFrom": [ - "city_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.chat": { - "name": "chat", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "profile1_id": { - "name": "profile1_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "profile2_id": { - "name": "profile2_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "chat_status", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'active'" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.greetings": { - "name": "greetings", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "text": { - "name": "text", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.message": { - "name": "message", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "chat_id": { - "name": "chat_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "text": { - "name": "text", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "media_url": { - "name": "media_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "media_type": { - "name": "media_type", - "type": "media_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "message_chat_id_chat_id_fk": { - "name": "message_chat_id_chat_id_fk", - "tableFrom": "message", - "tableTo": "chat", - "columnsFrom": [ - "chat_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.payment": { - "name": "payment", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "varchar(100)", - "primaryKey": false, - "notNull": true - }, - "credentials": { - "name": "credentials", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "payment_user_id_user_id_fk": { - "name": "payment_user_id_user_id_fk", - "tableFrom": "payment", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.user": { - "name": "user", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "phone": { - "name": "phone", - "type": "varchar(20)", - "primaryKey": false, - "notNull": true - }, - "password": { - "name": "password", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "user_status", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'pending'" - }, - "role_id": { - "name": "role_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "tariff_id": { - "name": "tariff_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "payment_id": { - "name": "payment_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "active_chat_id": { - "name": "active_chat_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "fcm_token": { - "name": "fcm_token", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "user_role_id_role_id_fk": { - "name": "user_role_id_role_id_fk", - "tableFrom": "user", - "tableTo": "role", - "columnsFrom": [ - "role_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "user_tariff_id_tariff_id_fk": { - "name": "user_tariff_id_tariff_id_fk", - "tableFrom": "user", - "tableTo": "tariff", - "columnsFrom": [ - "tariff_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "user_phone_unique": { - "name": "user_phone_unique", - "nullsNotDistinct": false, - "columns": [ - "phone" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.media": { - "name": "media", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "path": { - "name": "path", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "type": { - "name": "type", - "type": "varchar(10)", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "media_user_id_user_id_fk": { - "name": "media_user_id_user_id_fk", - "tableFrom": "media", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.profile": { - "name": "profile", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "varchar(100)", - "primaryKey": false, - "notNull": true - }, - "birth_date": { - "name": "birth_date", - "type": "date", - "primaryKey": false, - "notNull": true - }, - "gender": { - "name": "gender", - "type": "gender", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "city_id": { - "name": "city_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "district_id": { - "name": "district_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "nation": { - "name": "nation", - "type": "varchar(100)", - "primaryKey": false, - "notNull": false - }, - "height": { - "name": "height", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "weight": { - "name": "weight", - "type": "double precision", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "profile_user_id_user_id_fk": { - "name": "profile_user_id_user_id_fk", - "tableFrom": "profile", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "profile_city_id_city_id_fk": { - "name": "profile_city_id_city_id_fk", - "tableFrom": "profile", - "tableTo": "city", - "columnsFrom": [ - "city_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "profile_district_id_city_district_id_fk": { - "name": "profile_district_id_city_district_id_fk", - "tableFrom": "profile", - "tableTo": "city_district", - "columnsFrom": [ - "district_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "profile_user_id_unique": { - "name": "profile_user_id_unique", - "nullsNotDistinct": false, - "columns": [ - "user_id" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.profile_tag": { - "name": "profile_tag", - "schema": "", - "columns": { - "profile_id": { - "name": "profile_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "tag_id": { - "name": "tag_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "profile_tag_profile_id_profile_id_fk": { - "name": "profile_tag_profile_id_profile_id_fk", - "tableFrom": "profile_tag", - "tableTo": "profile", - "columnsFrom": [ - "profile_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "profile_tag_tag_id_tag_id_fk": { - "name": "profile_tag_tag_id_tag_id_fk", - "tableFrom": "profile_tag", - "tableTo": "tag", - "columnsFrom": [ - "tag_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.tag": { - "name": "tag", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "value": { - "name": "value", - "type": "varchar(100)", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "tag_value_unique": { - "name": "tag_value_unique", - "nullsNotDistinct": false, - "columns": [ - "value" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.like": { - "name": "like", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "source_user": { - "name": "source_user", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "target_user": { - "name": "target_user", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "type": { - "name": "type", - "type": "like_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "like_source_user_user_id_fk": { - "name": "like_source_user_user_id_fk", - "tableFrom": "like", - "tableTo": "user", - "columnsFrom": [ - "source_user" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "like_target_user_user_id_fk": { - "name": "like_target_user_user_id_fk", - "tableFrom": "like", - "tableTo": "user", - "columnsFrom": [ - "target_user" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.match": { - "name": "match", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user1_id": { - "name": "user1_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user2_id": { - "name": "user2_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "match_user1_id_user_id_fk": { - "name": "match_user1_id_user_id_fk", - "tableFrom": "match", - "tableTo": "user", - "columnsFrom": [ - "user1_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "match_user2_id_user_id_fk": { - "name": "match_user2_id_user_id_fk", - "tableFrom": "match", - "tableTo": "user", - "columnsFrom": [ - "user2_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.date": { - "name": "date", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user1_id": { - "name": "user1_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user2_id": { - "name": "user2_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "lat": { - "name": "lat", - "type": "numeric(10, 7)", - "primaryKey": false, - "notNull": true - }, - "lng": { - "name": "lng", - "type": "numeric(10, 7)", - "primaryKey": false, - "notNull": true - }, - "time": { - "name": "time", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "status_id": { - "name": "status_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "date_user1_id_user_id_fk": { - "name": "date_user1_id_user_id_fk", - "tableFrom": "date", - "tableTo": "user", - "columnsFrom": [ - "user1_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "date_user2_id_user_id_fk": { - "name": "date_user2_id_user_id_fk", - "tableFrom": "date", - "tableTo": "user", - "columnsFrom": [ - "user2_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "date_status_id_date_status_id_fk": { - "name": "date_status_id_date_status_id_fk", - "tableFrom": "date", - "tableTo": "date_status", - "columnsFrom": [ - "status_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.date_status": { - "name": "date_status", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "text": { - "name": "text", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.report": { - "name": "report", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "source_user": { - "name": "source_user", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "entity_id": { - "name": "entity_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "entity_type": { - "name": "entity_type", - "type": "report_entity_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "report_source_user_user_id_fk": { - "name": "report_source_user_user_id_fk", - "tableFrom": "report", - "tableTo": "user", - "columnsFrom": [ - "source_user" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": { - "public.chat_status": { - "name": "chat_status", - "schema": "public", - "values": [ - "active", - "closed" - ] - }, - "public.media_type": { - "name": "media_type", - "schema": "public", - "values": [ - "photo", - "voice", - "video" - ] - }, - "public.user_status": { - "name": "user_status", - "schema": "public", - "values": [ - "active", - "banned", - "pending" - ] - }, - "public.gender": { - "name": "gender", - "schema": "public", - "values": [ - "male", - "female" - ] - }, - "public.like_type": { - "name": "like_type", - "schema": "public", - "values": [ - "like", - "dislike" - ] - }, - "public.report_entity_type": { - "name": "report_entity_type", - "schema": "public", - "values": [ - "profile", - "message" - ] - } - }, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file diff --git a/src/database/migrations/meta/_journal.json b/src/database/migrations/meta/_journal.json index 42ab59d..4e05cba 100644 --- a/src/database/migrations/meta/_journal.json +++ b/src/database/migrations/meta/_journal.json @@ -5,15 +5,8 @@ { "idx": 0, "version": "7", - "when": 1780401435523, - "tag": "0000_romantic_morg", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1780403477744, - "tag": "0001_brown_marrow", + "when": 1780405352119, + "tag": "0000_quick_silver_samurai", "breakpoints": true } ] diff --git a/src/database/schema/chat.schema.ts b/src/database/schema/chat.schema.ts index d139c97..d2a649b 100644 --- a/src/database/schema/chat.schema.ts +++ b/src/database/schema/chat.schema.ts @@ -1,12 +1,17 @@ import { pgEnum, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'; +import { profile } from './profile.schema'; export const chatStatusEnum = pgEnum('chat_status', ['active', 'closed']); -export const mediaTypeEnum = pgEnum('media_type', ['photo', 'voice', 'video']); +export const messageMediaTypeEnum = pgEnum('message_media_type', ['photo', 'voice', 'video']); export const chat = pgTable('chat', { id: uuid('id').primaryKey().defaultRandom(), - profile1Id: uuid('profile1_id').notNull(), - profile2Id: uuid('profile2_id').notNull(), + profile1Id: uuid('profile1_id') + .notNull() + .references(() => profile.id, { onDelete: 'cascade' }), + profile2Id: uuid('profile2_id') + .notNull() + .references(() => profile.id, { onDelete: 'cascade' }), status: chatStatusEnum('status').notNull().default('active'), }); @@ -15,10 +20,12 @@ export const message = pgTable('message', { chatId: uuid('chat_id') .notNull() .references(() => chat.id, { onDelete: 'cascade' }), - userId: uuid('user_id').notNull(), + profileId: uuid('profile_id') + .notNull() + .references(() => profile.id, { onDelete: 'cascade' }), text: text('text'), mediaUrl: text('media_url'), - mediaType: mediaTypeEnum('media_type'), + mediaType: messageMediaTypeEnum('media_type'), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }); diff --git a/src/database/schema/date.schema.ts b/src/database/schema/date.schema.ts index 26180b1..e36ac9d 100644 --- a/src/database/schema/date.schema.ts +++ b/src/database/schema/date.schema.ts @@ -1,5 +1,5 @@ import { decimal, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'; -import { user } from './user.schema'; +import { profile } from './profile.schema'; export const dateStatus = pgTable('date_status', { id: uuid('id').primaryKey().defaultRandom(), @@ -8,12 +8,12 @@ export const dateStatus = pgTable('date_status', { export const date = pgTable('date', { id: uuid('id').primaryKey().defaultRandom(), - user1Id: uuid('user1_id') + profile1Id: uuid('profile1_id') .notNull() - .references(() => user.id, { onDelete: 'cascade' }), - user2Id: uuid('user2_id') + .references(() => profile.id, { onDelete: 'cascade' }), + profile2Id: uuid('profile2_id') .notNull() - .references(() => user.id, { onDelete: 'cascade' }), + .references(() => profile.id, { onDelete: 'cascade' }), lat: decimal('lat', { precision: 10, scale: 7 }).notNull(), lng: decimal('lng', { precision: 10, scale: 7 }).notNull(), time: timestamp('time', { withTimezone: true }).notNull(), diff --git a/src/database/schema/index.ts b/src/database/schema/index.ts index 0be0b40..8b3486f 100644 --- a/src/database/schema/index.ts +++ b/src/database/schema/index.ts @@ -1,9 +1,9 @@ export * from './role.schema'; export * from './tariff.schema'; export * from './city.schema'; -export * from './chat.schema'; export * from './user.schema'; export * from './profile.schema'; export * from './social.schema'; +export * from './chat.schema'; export * from './date.schema'; export * from './report.schema'; diff --git a/src/database/schema/profile.schema.ts b/src/database/schema/profile.schema.ts index a633cde..a584d1d 100644 --- a/src/database/schema/profile.schema.ts +++ b/src/database/schema/profile.schema.ts @@ -1,14 +1,14 @@ -import { date, doublePrecision, pgEnum, pgTable, text, uuid, varchar } from 'drizzle-orm/pg-core'; +import { date, doublePrecision, integer, pgEnum, pgTable, text, uuid, varchar } from 'drizzle-orm/pg-core'; import { user } from './user.schema'; import { city, cityDistrict } from './city.schema'; export const genderEnum = pgEnum('gender', ['male', 'female']); +export const profileMediaTypeEnum = pgEnum('profile_media_type', ['photo', 'video', 'audio']); export const profile = pgTable('profile', { id: uuid('id').primaryKey().defaultRandom(), userId: uuid('user_id') .notNull() - .unique() .references(() => user.id, { onDelete: 'cascade' }), name: varchar('name', { length: 100 }).notNull(), birthDate: date('birth_date').notNull(), @@ -19,6 +19,18 @@ export const profile = pgTable('profile', { nation: varchar('nation', { length: 100 }), height: doublePrecision('height'), weight: doublePrecision('weight'), + activeChatId: uuid('active_chat_id'), +}); + +// Media attachments for a profile (photos, videos, audio) +export const profileMedia = pgTable('profile_media', { + id: uuid('id').primaryKey().defaultRandom(), + profileId: uuid('profile_id') + .notNull() + .references(() => profile.id, { onDelete: 'cascade' }), + path: text('path').notNull(), + type: profileMediaTypeEnum('type').notNull(), + sortOrder: integer('sort_order').notNull().default(0), }); export const tag = pgTable('tag', { @@ -34,12 +46,3 @@ export const profileTag = pgTable('profile_tag', { .notNull() .references(() => tag.id, { onDelete: 'cascade' }), }); - -export const media = pgTable('media', { - id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id') - .notNull() - .references(() => user.id, { onDelete: 'cascade' }), - path: text('path').notNull(), - type: varchar('type', { length: 10 }).notNull(), -}); diff --git a/src/database/schema/report.schema.ts b/src/database/schema/report.schema.ts index d0fe448..e7a1424 100644 --- a/src/database/schema/report.schema.ts +++ b/src/database/schema/report.schema.ts @@ -1,13 +1,13 @@ import { pgEnum, pgTable, text, uuid } from 'drizzle-orm/pg-core'; -import { user } from './user.schema'; +import { profile } from './profile.schema'; export const reportEntityTypeEnum = pgEnum('report_entity_type', ['profile', 'message']); export const report = pgTable('report', { id: uuid('id').primaryKey().defaultRandom(), - sourceUser: uuid('source_user') + sourceProfileId: uuid('source_profile_id') .notNull() - .references(() => user.id, { onDelete: 'cascade' }), + .references(() => profile.id, { onDelete: 'cascade' }), entityId: uuid('entity_id').notNull(), entityType: reportEntityTypeEnum('entity_type').notNull(), description: text('description'), diff --git a/src/database/schema/social.schema.ts b/src/database/schema/social.schema.ts index 7c6b39b..3b4d147 100644 --- a/src/database/schema/social.schema.ts +++ b/src/database/schema/social.schema.ts @@ -1,27 +1,27 @@ import { pgEnum, pgTable, timestamp, uuid } from 'drizzle-orm/pg-core'; -import { user } from './user.schema'; +import { profile } from './profile.schema'; export const likeTypeEnum = pgEnum('like_type', ['like', 'dislike']); export const like = pgTable('like', { id: uuid('id').primaryKey().defaultRandom(), - sourceUser: uuid('source_user') + sourceProfileId: uuid('source_profile_id') .notNull() - .references(() => user.id, { onDelete: 'cascade' }), - targetUser: uuid('target_user') + .references(() => profile.id, { onDelete: 'cascade' }), + targetProfileId: uuid('target_profile_id') .notNull() - .references(() => user.id, { onDelete: 'cascade' }), + .references(() => profile.id, { onDelete: 'cascade' }), type: likeTypeEnum('type').notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }); export const match = pgTable('match', { id: uuid('id').primaryKey().defaultRandom(), - user1Id: uuid('user1_id') + profile1Id: uuid('profile1_id') .notNull() - .references(() => user.id, { onDelete: 'cascade' }), - user2Id: uuid('user2_id') + .references(() => profile.id, { onDelete: 'cascade' }), + profile2Id: uuid('profile2_id') .notNull() - .references(() => user.id, { onDelete: 'cascade' }), + .references(() => profile.id, { onDelete: 'cascade' }), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }); diff --git a/src/database/schema/user.schema.ts b/src/database/schema/user.schema.ts index 0a3c9cf..6eeffbe 100644 --- a/src/database/schema/user.schema.ts +++ b/src/database/schema/user.schema.ts @@ -12,7 +12,6 @@ export const user = pgTable('user', { roleId: uuid('role_id').references(() => role.id, { onDelete: 'set null' }), tariffId: uuid('tariff_id').references(() => tariff.id, { onDelete: 'set null' }), paymentId: uuid('payment_id'), - activeChatId: uuid('active_chat_id'), fcmToken: text('fcm_token'), }); diff --git a/src/gateways/chat.gateway.ts b/src/gateways/chat.gateway.ts index 758df6b..5e911e4 100644 --- a/src/gateways/chat.gateway.ts +++ b/src/gateways/chat.gateway.ts @@ -16,10 +16,7 @@ import { ChatService } from '../modules/chat/chat.service'; import { SendMessageDto } from '../modules/chat/dto/send-message.dto'; @WebSocketGateway({ - cors: { - origin: '*', - credentials: true, - }, + cors: { origin: '*', credentials: true }, namespace: 'chat', }) export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { @@ -27,7 +24,8 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { server: Server; private readonly logger = new Logger(ChatGateway.name); - private connectedUsers = new Map(); + // profileId → socketId + private connectedProfiles = new Map(); constructor( private readonly jwtService: JwtService, @@ -41,28 +39,30 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { client.handshake.auth?.token || client.handshake.headers?.authorization?.replace('Bearer ', ''); - if (!token) { - client.disconnect(); - return; - } + if (!token) { client.disconnect(); return; } const payload = this.jwtService.verify(token, { secret: this.configService.get('jwt.secret'), }); + // profileId must be sent in handshake auth + const profileId = client.handshake.auth?.profileId; + if (!profileId) { client.disconnect(); return; } + client.data.userId = payload.sub; - this.connectedUsers.set(payload.sub, client.id); - this.logger.log(`User ${payload.sub} connected via WebSocket`); + client.data.profileId = profileId; + this.connectedProfiles.set(profileId, client.id); + this.logger.log(`Profile ${profileId} (user ${payload.sub}) connected`); } catch { client.disconnect(); } } handleDisconnect(client: Socket) { - const userId = client.data.userId; - if (userId) { - this.connectedUsers.delete(userId); - this.logger.log(`User ${userId} disconnected`); + const profileId = client.data.profileId; + if (profileId) { + this.connectedProfiles.delete(profileId); + this.logger.log(`Profile ${profileId} disconnected`); } } @@ -71,8 +71,7 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { @ConnectedSocket() client: Socket, @MessageBody() data: { chatId: string }, ) { - const userId = client.data.userId; - if (!userId) throw new WsException('Unauthorized'); + if (!client.data.profileId) throw new WsException('Unauthorized'); await client.join(`chat:${data.chatId}`); return { event: 'joined_chat', chatId: data.chatId }; } @@ -91,12 +90,11 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { @ConnectedSocket() client: Socket, @MessageBody() data: { chatId: string } & SendMessageDto, ) { - const userId = client.data.userId; - if (!userId) throw new WsException('Unauthorized'); + const { userId, profileId } = client.data; + if (!userId || !profileId) throw new WsException('Unauthorized'); const { chatId, ...msgDto } = data; - const newMessage = await this.chatService.sendMessage(userId, chatId, msgDto); - + const newMessage = await this.chatService.sendMessage(userId, profileId, chatId, msgDto); this.server.to(`chat:${chatId}`).emit('new_message', newMessage); return newMessage; } @@ -106,17 +104,12 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { @ConnectedSocket() client: Socket, @MessageBody() data: { chatId: string; isTyping: boolean }, ) { - const userId = client.data.userId; - client.to(`chat:${data.chatId}`).emit('user_typing', { - userId, - isTyping: data.isTyping, - }); + const { profileId } = client.data; + client.to(`chat:${data.chatId}`).emit('user_typing', { profileId, isTyping: data.isTyping }); } - emitToUser(userId: string, event: string, data: any) { - const socketId = this.connectedUsers.get(userId); - if (socketId) { - this.server.to(socketId).emit(event, data); - } + emitToProfile(profileId: string, event: string, data: any) { + const socketId = this.connectedProfiles.get(profileId); + if (socketId) this.server.to(socketId).emit(event, data); } } diff --git a/src/modules/chat/chat.controller.ts b/src/modules/chat/chat.controller.ts index 2dcb456..ace7e44 100644 --- a/src/modules/chat/chat.controller.ts +++ b/src/modules/chat/chat.controller.ts @@ -1,13 +1,4 @@ -import { - Body, - Controller, - Delete, - Get, - Param, - Post, - Query, - UseGuards, -} from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; @@ -24,46 +15,49 @@ export class ChatController { @Post() @ApiOperation({ summary: 'Open a chat for a match' }) - createChat( - @CurrentUser('id') userId: string, - @Body() dto: CreateChatDto, - ) { + createChat(@CurrentUser('id') userId: string, @Body() dto: CreateChatDto) { return this.chatService.createChat(userId, dto); } @Get() - @ApiOperation({ summary: 'Get my active chats' }) - getMyChats(@CurrentUser('id') userId: string) { - return this.chatService.getMyChats(userId); + @ApiOperation({ summary: 'Get active chats for a profile' }) + getChats( + @CurrentUser('id') userId: string, + @Query('profileId') profileId: string, + ) { + return this.chatService.getChatsForProfile(userId, profileId); } @Get(':chatId/messages') @ApiOperation({ summary: 'Get chat messages' }) getMessages( @CurrentUser('id') userId: string, + @Query('profileId') profileId: string, @Param('chatId') chatId: string, @Query('page') page = 1, @Query('limit') limit = 50, ) { - return this.chatService.getChatMessages(userId, chatId, +page, +limit); + return this.chatService.getChatMessages(userId, profileId, chatId, +page, +limit); } @Post(':chatId/messages') @ApiOperation({ summary: 'Send a message' }) sendMessage( @CurrentUser('id') userId: string, + @Query('profileId') profileId: string, @Param('chatId') chatId: string, @Body() dto: SendMessageDto, ) { - return this.chatService.sendMessage(userId, chatId, dto); + return this.chatService.sendMessage(userId, profileId, chatId, dto); } @Delete(':chatId') @ApiOperation({ summary: 'Close a chat' }) closeChat( @CurrentUser('id') userId: string, + @Query('profileId') profileId: string, @Param('chatId') chatId: string, ) { - return this.chatService.closeChat(userId, chatId); + return this.chatService.closeChat(userId, profileId, chatId); } } diff --git a/src/modules/chat/chat.service.ts b/src/modules/chat/chat.service.ts index 20282c4..d2419e2 100644 --- a/src/modules/chat/chat.service.ts +++ b/src/modules/chat/chat.service.ts @@ -19,13 +19,15 @@ export class ChatService { ) {} async createChat(userId: string, dto: CreateChatDto) { + await this.assertProfileOwnership(userId, dto.profileId); + const [foundMatch] = await this.drizzleService.db .select() .from(match) .where( and( eq(match.id, dto.matchId), - or(eq(match.user1Id, userId), eq(match.user2Id, userId)), + or(eq(match.profile1Id, dto.profileId), eq(match.profile2Id, dto.profileId)), ), ) .limit(1); @@ -37,50 +39,46 @@ export class ChatService { .from(chat) .where( or( - and( - eq(chat.profile1Id, foundMatch.user1Id), - eq(chat.profile2Id, foundMatch.user2Id), - ), - and( - eq(chat.profile1Id, foundMatch.user2Id), - eq(chat.profile2Id, foundMatch.user1Id), - ), + and(eq(chat.profile1Id, foundMatch.profile1Id), eq(chat.profile2Id, foundMatch.profile2Id)), + and(eq(chat.profile1Id, foundMatch.profile2Id), eq(chat.profile2Id, foundMatch.profile1Id)), ), ) .limit(1); if (existingChat.length > 0) return existingChat[0]; - const currentUser = await this.drizzleService.db - .select({ activeChatId: user.activeChatId }) - .from(user) - .where(eq(user.id, userId)) + const [currentProfile] = await this.drizzleService.db + .select({ activeChatId: profile.activeChatId }) + .from(profile) + .where(eq(profile.id, dto.profileId)) .limit(1); - if (currentUser[0]?.activeChatId) { + if (currentProfile?.activeChatId) { throw new BadRequestException( - 'You already have an active chat. Close it before opening a new one.', + 'Profile already has an active chat. Close it before opening a new one.', ); } const [newChat] = await this.drizzleService.db .insert(chat) .values({ - profile1Id: foundMatch.user1Id, - profile2Id: foundMatch.user2Id, + profile1Id: foundMatch.profile1Id, + profile2Id: foundMatch.profile2Id, status: 'active', } as any) .returning(); await this.drizzleService.db - .update(user) + .update(profile) .set({ activeChatId: newChat.id } as any) - .where(or(eq(user.id, foundMatch.user1Id), eq(user.id, foundMatch.user2Id))); + .where(or(eq(profile.id, foundMatch.profile1Id), eq(profile.id, foundMatch.profile2Id))); return newChat; } - async closeChat(userId: string, chatId: string) { + async closeChat(userId: string, profileId: string, chatId: string) { + await this.assertProfileOwnership(userId, profileId); + const [foundChat] = await this.drizzleService.db .select() .from(chat) @@ -88,7 +86,7 @@ export class ChatService { .limit(1); if (!foundChat) throw new NotFoundException('Chat not found'); - if (foundChat.profile1Id !== userId && foundChat.profile2Id !== userId) { + if (foundChat.profile1Id !== profileId && foundChat.profile2Id !== profileId) { throw new ForbiddenException('Not a chat participant'); } @@ -98,26 +96,29 @@ export class ChatService { .where(eq(chat.id, chatId)); await this.drizzleService.db - .update(user) + .update(profile) .set({ activeChatId: null } as any) - .where(or(eq(user.id, foundChat.profile1Id), eq(user.id, foundChat.profile2Id))); + .where(or(eq(profile.id, foundChat.profile1Id), eq(profile.id, foundChat.profile2Id))); return { message: 'Chat closed' }; } - async getMyChats(userId: string) { + async getChatsForProfile(userId: string, profileId: string) { + await this.assertProfileOwnership(userId, profileId); return this.drizzleService.db .select() .from(chat) .where( and( - or(eq(chat.profile1Id, userId), eq(chat.profile2Id, userId)), + or(eq(chat.profile1Id, profileId), eq(chat.profile2Id, profileId)), eq(chat.status, 'active'), ), ); } - async getChatMessages(userId: string, chatId: string, page = 1, limit = 50) { + async getChatMessages(userId: string, profileId: string, chatId: string, page = 1, limit = 50) { + await this.assertProfileOwnership(userId, profileId); + const [foundChat] = await this.drizzleService.db .select() .from(chat) @@ -125,7 +126,7 @@ export class ChatService { .limit(1); if (!foundChat) throw new NotFoundException('Chat not found'); - if (foundChat.profile1Id !== userId && foundChat.profile2Id !== userId) { + if (foundChat.profile1Id !== profileId && foundChat.profile2Id !== profileId) { throw new ForbiddenException('Not a chat participant'); } @@ -139,7 +140,9 @@ export class ChatService { .offset(offset); } - async sendMessage(userId: string, chatId: string, dto: SendMessageDto) { + async sendMessage(userId: string, profileId: string, chatId: string, dto: SendMessageDto) { + await this.assertProfileOwnership(userId, profileId); + const [foundChat] = await this.drizzleService.db .select() .from(chat) @@ -147,7 +150,7 @@ export class ChatService { .limit(1); if (!foundChat) throw new NotFoundException('Active chat not found'); - if (foundChat.profile1Id !== userId && foundChat.profile2Id !== userId) { + if (foundChat.profile1Id !== profileId && foundChat.profile2Id !== profileId) { throw new ForbiddenException('Not a chat participant'); } @@ -159,31 +162,50 @@ export class ChatService { .insert(message) .values({ chatId, - userId, + profileId, text: dto.text || null, mediaUrl: dto.mediaUrl || null, mediaType: dto.mediaType || null, } as any) .returning(); - const recipientId = - foundChat.profile1Id === userId ? foundChat.profile2Id : foundChat.profile1Id; + const recipientProfileId = + foundChat.profile1Id === profileId ? foundChat.profile2Id : foundChat.profile1Id; - const [recipient] = await this.drizzleService.db - .select({ fcmToken: user.fcmToken }) - .from(user) - .where(eq(user.id, recipientId)) + const [recipientProfile] = await this.drizzleService.db + .select({ userId: profile.userId }) + .from(profile) + .where(eq(profile.id, recipientProfileId)) .limit(1); - if (recipient?.fcmToken) { - await this.notificationsService.sendPushNotification( - recipient.fcmToken, - 'New message', - dto.text?.substring(0, 100) || 'Media message', - { chatId, messageId: newMessage.id, type: 'message' }, - ); + if (recipientProfile) { + const [recipientUser] = await this.drizzleService.db + .select({ fcmToken: user.fcmToken }) + .from(user) + .where(eq(user.id, recipientProfile.userId)) + .limit(1); + + if (recipientUser?.fcmToken) { + await this.notificationsService.sendPushNotification( + recipientUser.fcmToken, + 'New message', + dto.text?.substring(0, 100) || 'Media message', + { chatId, messageId: newMessage.id, type: 'message' }, + ); + } } return newMessage; } + + private async assertProfileOwnership(userId: string, profileId: string) { + const [found] = await this.drizzleService.db + .select({ userId: profile.userId }) + .from(profile) + .where(eq(profile.id, profileId)) + .limit(1); + + if (!found) throw new NotFoundException('Profile not found'); + if (found.userId !== userId) throw new ForbiddenException('Profile does not belong to you'); + } } diff --git a/src/modules/chat/dto/create-chat.dto.ts b/src/modules/chat/dto/create-chat.dto.ts index bdc7201..1929190 100644 --- a/src/modules/chat/dto/create-chat.dto.ts +++ b/src/modules/chat/dto/create-chat.dto.ts @@ -2,6 +2,10 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsUUID } from 'class-validator'; export class CreateChatDto { + @ApiProperty({ description: 'Your profile ID' }) + @IsUUID() + profileId: string; + @ApiProperty({ description: 'Match ID to open chat for' }) @IsUUID() matchId: string; diff --git a/src/modules/dates/dates.controller.ts b/src/modules/dates/dates.controller.ts index 13aaaa2..7e07815 100644 --- a/src/modules/dates/dates.controller.ts +++ b/src/modules/dates/dates.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; @@ -14,28 +14,29 @@ export class DatesController { constructor(private readonly datesService: DatesService) {} @Post() - @ApiOperation({ summary: 'Propose a date/meetup' }) - create( - @CurrentUser('id') userId: string, - @Body() dto: CreateDateDto, - ) { + @ApiOperation({ summary: 'Propose a meetup' }) + create(@CurrentUser('id') userId: string, @Body() dto: CreateDateDto) { return this.datesService.create(userId, dto); } @Get() - @ApiOperation({ summary: 'Get my dates' }) - getMyDates(@CurrentUser('id') userId: string) { - return this.datesService.getMyDates(userId); + @ApiOperation({ summary: 'Get dates for a profile' }) + getDates( + @CurrentUser('id') userId: string, + @Query('profileId') profileId: string, + ) { + return this.datesService.getForProfile(userId, profileId); } @Patch(':id/status') @ApiOperation({ summary: 'Update date status' }) updateStatus( @CurrentUser('id') userId: string, + @Query('profileId') profileId: string, @Param('id') id: string, @Body() dto: UpdateDateStatusDto, ) { - return this.datesService.updateStatus(userId, id, dto); + return this.datesService.updateStatus(userId, profileId, id, dto); } @Get('statuses') diff --git a/src/modules/dates/dates.service.ts b/src/modules/dates/dates.service.ts index 5e3fb42..32cabab 100644 --- a/src/modules/dates/dates.service.ts +++ b/src/modules/dates/dates.service.ts @@ -1,7 +1,7 @@ import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { and, eq, or } from 'drizzle-orm'; import { DrizzleService } from '../../database/drizzle.service'; -import { date, dateStatus } from '../../database/schema'; +import { date, dateStatus, profile } from '../../database/schema'; import { CreateDateDto } from './dto/create-date.dto'; import { UpdateDateStatusDto } from './dto/update-date-status.dto'; @@ -10,8 +10,9 @@ export class DatesService { constructor(private readonly drizzleService: DrizzleService) {} async create(userId: string, dto: CreateDateDto) { - let statusId = dto.statusId; + await this.assertProfileOwnership(userId, dto.profileId); + let statusId = dto.statusId; if (!statusId) { const [pending] = await this.drizzleService.db .select({ id: dateStatus.id }) @@ -24,8 +25,8 @@ export class DatesService { const [newDate] = await this.drizzleService.db .insert(date) .values({ - user1Id: userId, - user2Id: dto.partnerId, + profile1Id: dto.profileId, + profile2Id: dto.partnerProfileId, lat: dto.lat.toString(), lng: dto.lng.toString(), time: new Date(dto.time), @@ -36,16 +37,19 @@ export class DatesService { return newDate; } - async getMyDates(userId: string) { + async getForProfile(userId: string, profileId: string) { + await this.assertProfileOwnership(userId, profileId); return this.drizzleService.db .select() .from(date) .leftJoin(dateStatus, eq(dateStatus.id, date.statusId)) - .where(or(eq(date.user1Id, userId), eq(date.user2Id, userId))) + .where(or(eq(date.profile1Id, profileId), eq(date.profile2Id, profileId))) .orderBy(date.time); } - async updateStatus(userId: string, dateId: string, dto: UpdateDateStatusDto) { + async updateStatus(userId: string, profileId: string, dateId: string, dto: UpdateDateStatusDto) { + await this.assertProfileOwnership(userId, profileId); + const [found] = await this.drizzleService.db .select() .from(date) @@ -53,7 +57,7 @@ export class DatesService { .limit(1); if (!found) throw new NotFoundException('Date not found'); - if (found.user1Id !== userId && found.user2Id !== userId) { + if (found.profile1Id !== profileId && found.profile2Id !== profileId) { throw new ForbiddenException('Not a participant'); } @@ -69,4 +73,15 @@ export class DatesService { async getStatuses() { return this.drizzleService.db.select().from(dateStatus); } + + private async assertProfileOwnership(userId: string, profileId: string) { + const [found] = await this.drizzleService.db + .select({ userId: profile.userId }) + .from(profile) + .where(eq(profile.id, profileId)) + .limit(1); + + if (!found) throw new NotFoundException('Profile not found'); + if (found.userId !== userId) throw new ForbiddenException('Profile does not belong to you'); + } } diff --git a/src/modules/dates/dto/create-date.dto.ts b/src/modules/dates/dto/create-date.dto.ts index 86e3c8e..87cf1fd 100644 --- a/src/modules/dates/dto/create-date.dto.ts +++ b/src/modules/dates/dto/create-date.dto.ts @@ -2,9 +2,13 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsDateString, IsNumber, IsOptional, IsUUID } from 'class-validator'; export class CreateDateDto { - @ApiProperty() + @ApiProperty({ description: 'Your profile ID' }) @IsUUID() - partnerId: string; + profileId: string; + + @ApiProperty({ description: 'Partner profile ID' }) + @IsUUID() + partnerProfileId: string; @ApiProperty() @IsNumber() diff --git a/src/modules/feed/dto/feed-filter.dto.ts b/src/modules/feed/dto/feed-filter.dto.ts index c7db4bf..58ff8c3 100644 --- a/src/modules/feed/dto/feed-filter.dto.ts +++ b/src/modules/feed/dto/feed-filter.dto.ts @@ -1,8 +1,12 @@ -import { ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsArray, IsNumber, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator'; export class FeedFilterDto { + @ApiProperty({ description: 'Your profile ID' }) + @IsUUID() + profileId: string; + @ApiPropertyOptional({ default: 1 }) @IsOptional() @Type(() => Number) @@ -18,24 +22,16 @@ export class FeedFilterDto { @Max(50) limit?: number = 20; - @ApiPropertyOptional({ description: 'City UUID filter' }) + @ApiPropertyOptional() @IsOptional() @IsUUID() cityId?: string; - @ApiPropertyOptional({ description: 'District UUID filter' }) + @ApiPropertyOptional() @IsOptional() @IsUUID() districtId?: string; - @ApiPropertyOptional({ description: 'Search radius in km' }) - @IsOptional() - @Type(() => Number) - @IsNumber() - @Min(1) - @Max(500) - radiusKm?: number; - @ApiPropertyOptional() @IsOptional() @Type(() => Number) @@ -50,12 +46,12 @@ export class FeedFilterDto { @Max(100) ageMax?: number; - @ApiPropertyOptional({ description: 'Search keyword in description/name' }) + @ApiPropertyOptional() @IsOptional() @IsString() keyword?: string; - @ApiPropertyOptional({ type: [String], description: 'Tag IDs to filter' }) + @ApiPropertyOptional({ type: [String] }) @IsOptional() @IsArray() @IsUUID(undefined, { each: true }) diff --git a/src/modules/feed/feed.controller.ts b/src/modules/feed/feed.controller.ts index d13d6e6..fe74345 100644 --- a/src/modules/feed/feed.controller.ts +++ b/src/modules/feed/feed.controller.ts @@ -13,11 +13,8 @@ export class FeedController { constructor(private readonly feedService: FeedService) {} @Get() - @ApiOperation({ summary: 'Get filtered feed of profiles' }) - getFeed( - @CurrentUser('id') userId: string, - @Query() filter: FeedFilterDto, - ) { + @ApiOperation({ summary: 'Get filtered feed (requires profileId)' }) + getFeed(@CurrentUser('id') userId: string, @Query() filter: FeedFilterDto) { return this.feedService.getFeed(userId, filter); } } diff --git a/src/modules/feed/feed.service.ts b/src/modules/feed/feed.service.ts index ef6cb38..b69ab29 100644 --- a/src/modules/feed/feed.service.ts +++ b/src/modules/feed/feed.service.ts @@ -1,31 +1,33 @@ -import { Injectable } from '@nestjs/common'; +import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { and, eq, gte, ilike, inArray, lte, ne, notInArray, or, sql } from 'drizzle-orm'; import { DrizzleService } from '../../database/drizzle.service'; -import { like, match, profile, profileTag, tag, user } from '../../database/schema'; +import { like, profile, profileMedia, profileTag, tag, user } from '../../database/schema'; import { FeedFilterDto } from './dto/feed-filter.dto'; @Injectable() export class FeedService { constructor(private readonly drizzleService: DrizzleService) {} - async getFeed(currentUserId: string, filter: FeedFilterDto) { - const { page = 1, limit = 20, cityId, districtId, ageMin, ageMax, keyword, tagIds } = filter; + async getFeed(userId: string, filter: FeedFilterDto) { + const { profileId, page = 1, limit = 20, cityId, districtId, ageMin, ageMax, keyword, tagIds } = filter; + await this.assertProfileOwnership(userId, profileId); + const offset = (page - 1) * limit; const alreadyInteracted = await this.drizzleService.db - .select({ targetUser: like.targetUser }) + .select({ targetProfileId: like.targetProfileId }) .from(like) - .where(eq(like.sourceUser, currentUserId)); + .where(eq(like.sourceProfileId, profileId)); - const interactedIds = alreadyInteracted.map((r) => r.targetUser); + const interactedIds = alreadyInteracted.map((r) => r.targetProfileId); const conditions: any[] = [ - ne(profile.userId, currentUserId), + ne(profile.id, profileId), ne(user.status, 'banned'), ]; if (interactedIds.length > 0) { - conditions.push(notInArray(profile.userId, interactedIds)); + conditions.push(notInArray(profile.id, interactedIds)); } if (cityId) conditions.push(eq(profile.cityId, cityId)); @@ -52,19 +54,15 @@ export class FeedService { ); } - let profileIds: string[] | null = null; - if (tagIds?.length) { const tagMatches = await this.drizzleService.db .select({ profileId: profileTag.profileId }) .from(profileTag) .where(inArray(profileTag.tagId, tagIds)); - profileIds = tagMatches.map((r) => r.profileId); - if (profileIds.length > 0) { - conditions.push(inArray(profile.id, profileIds)); - } else { - return { data: [], total: 0, page, limit }; - } + + const matchedIds = tagMatches.map((r) => r.profileId); + if (matchedIds.length === 0) return { data: [], page, limit }; + conditions.push(inArray(profile.id, matchedIds)); } const rows = await this.drizzleService.db @@ -73,6 +71,7 @@ export class FeedService { userId: profile.userId, name: profile.name, birthDate: profile.birthDate, + gender: profile.gender, cityId: profile.cityId, districtId: profile.districtId, description: profile.description, @@ -89,14 +88,19 @@ export class FeedService { const enriched = await Promise.all( rows.map(async (p) => { - const tags = await this.drizzleService.db - .select({ id: tag.id, value: tag.value }) - .from(profileTag) - .innerJoin(tag, eq(tag.id, profileTag.tagId)) - .where(eq(profileTag.profileId, p.id)); - - const age = this.calculateAge(p.birthDate); - return { ...p, age, tags }; + const [tags, media] = await Promise.all([ + this.drizzleService.db + .select({ id: tag.id, value: tag.value }) + .from(profileTag) + .innerJoin(tag, eq(tag.id, profileTag.tagId)) + .where(eq(profileTag.profileId, p.id)), + this.drizzleService.db + .select() + .from(profileMedia) + .where(eq(profileMedia.profileId, p.id)) + .orderBy(profileMedia.sortOrder), + ]); + return { ...p, age: this.calculateAge(p.birthDate), tags, media }; }), ); @@ -111,4 +115,15 @@ export class FeedService { if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--; return age; } + + private async assertProfileOwnership(userId: string, profileId: string) { + const [found] = await this.drizzleService.db + .select({ userId: profile.userId }) + .from(profile) + .where(eq(profile.id, profileId)) + .limit(1); + + if (!found) throw new NotFoundException('Profile not found'); + if (found.userId !== userId) throw new ForbiddenException('Profile does not belong to you'); + } } diff --git a/src/modules/likes/dto/create-like.dto.ts b/src/modules/likes/dto/create-like.dto.ts index 50bc284..1f41c27 100644 --- a/src/modules/likes/dto/create-like.dto.ts +++ b/src/modules/likes/dto/create-like.dto.ts @@ -2,9 +2,13 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsUUID } from 'class-validator'; export class CreateLikeDto { - @ApiProperty() + @ApiProperty({ description: 'Your profile ID' }) @IsUUID() - targetUserId: string; + sourceProfileId: string; + + @ApiProperty({ description: 'Target profile ID' }) + @IsUUID() + targetProfileId: string; @ApiProperty({ enum: ['like', 'dislike'] }) @IsEnum(['like', 'dislike']) diff --git a/src/modules/likes/likes.controller.ts b/src/modules/likes/likes.controller.ts index 9d30b1e..9474238 100644 --- a/src/modules/likes/likes.controller.ts +++ b/src/modules/likes/likes.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; @@ -13,17 +13,17 @@ export class LikesController { constructor(private readonly likesService: LikesService) {} @Post() - @ApiOperation({ summary: 'Like or dislike a user' }) - createLike( - @CurrentUser('id') userId: string, - @Body() dto: CreateLikeDto, - ) { + @ApiOperation({ summary: 'Like or dislike a profile' }) + createLike(@CurrentUser('id') userId: string, @Body() dto: CreateLikeDto) { return this.likesService.createLike(userId, dto); } @Get('matches') - @ApiOperation({ summary: 'Get my matches' }) - getMyMatches(@CurrentUser('id') userId: string) { - return this.likesService.getMyMatches(userId); + @ApiOperation({ summary: 'Get matches for a profile' }) + getMyMatches( + @CurrentUser('id') userId: string, + @Query('profileId') profileId: string, + ) { + return this.likesService.getMyMatches(userId, profileId); } } diff --git a/src/modules/likes/likes.service.ts b/src/modules/likes/likes.service.ts index c564b5d..5cfe15e 100644 --- a/src/modules/likes/likes.service.ts +++ b/src/modules/likes/likes.service.ts @@ -1,13 +1,10 @@ -import { - BadRequestException, - Injectable, -} from '@nestjs/common'; +import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { and, eq, or } from 'drizzle-orm'; +import { ConfigService } from '@nestjs/config'; import { DrizzleService } from '../../database/drizzle.service'; -import { like, match, user } from '../../database/schema'; +import { like, match, profile, user } from '../../database/schema'; import { NotificationsService } from '../../notifications/notifications.service'; import { RedisService } from '../../redis/redis.service'; -import { ConfigService } from '@nestjs/config'; import { CreateLikeDto } from './dto/create-like.dto'; @Injectable() @@ -19,16 +16,18 @@ export class LikesService { private readonly configService: ConfigService, ) {} - async createLike(sourceUserId: string, dto: CreateLikeDto) { - if (sourceUserId === dto.targetUserId) { + async createLike(userId: string, dto: CreateLikeDto) { + await this.assertProfileOwnership(userId, dto.sourceProfileId); + + if (dto.sourceProfileId === dto.targetProfileId) { throw new BadRequestException('Cannot like yourself'); } const maxMatches = this.configService.get('app.maxMatchesBeforePause'); - const activeMatchesCount = await this.getActiveMatchesCount(sourceUserId); + const activeMatchesCount = await this.getMatchesCount(dto.sourceProfileId); if (activeMatchesCount >= maxMatches) { throw new BadRequestException( - `You have ${activeMatchesCount} matches. Resolve them before searching for new ones.`, + `Profile has ${activeMatchesCount} matches. Resolve them before searching for new ones.`, ); } @@ -37,92 +36,99 @@ export class LikesService { .from(like) .where( and( - eq(like.sourceUser, sourceUserId), - eq(like.targetUser, dto.targetUserId), + eq(like.sourceProfileId, dto.sourceProfileId), + eq(like.targetProfileId, dto.targetProfileId), ), ) .limit(1); - if (existing.length > 0) { - throw new BadRequestException('Already reacted to this user'); - } + if (existing.length > 0) throw new BadRequestException('Already reacted to this profile'); const [newLike] = await this.drizzleService.db .insert(like) .values({ - sourceUser: sourceUserId, - targetUser: dto.targetUserId, + sourceProfileId: dto.sourceProfileId, + targetProfileId: dto.targetProfileId, type: dto.type, }) .returning(); if (dto.type === 'like') { - return this.checkAndCreateMatch(sourceUserId, dto.targetUserId, newLike); + return this.checkAndCreateMatch(dto.sourceProfileId, dto.targetProfileId, newLike); } return { like: newLike, match: null }; } - private async checkAndCreateMatch(userId1: string, userId2: string, newLike: any) { + async getMyMatches(userId: string, profileId: string) { + await this.assertProfileOwnership(userId, profileId); + return this.drizzleService.db + .select() + .from(match) + .where(or(eq(match.profile1Id, profileId), eq(match.profile2Id, profileId))) + .orderBy(match.createdAt); + } + + private async checkAndCreateMatch(profileId1: string, profileId2: string, newLike: any) { const reverseLike = await this.drizzleService.db .select() .from(like) .where( and( - eq(like.sourceUser, userId2), - eq(like.targetUser, userId1), + eq(like.sourceProfileId, profileId2), + eq(like.targetProfileId, profileId1), eq(like.type, 'like'), ), ) .limit(1); - if (reverseLike.length === 0) { - return { like: newLike, match: null }; - } + if (reverseLike.length === 0) return { like: newLike, match: null }; const existingMatch = await this.drizzleService.db .select() .from(match) .where( or( - and(eq(match.user1Id, userId1), eq(match.user2Id, userId2)), - and(eq(match.user1Id, userId2), eq(match.user2Id, userId1)), + and(eq(match.profile1Id, profileId1), eq(match.profile2Id, profileId2)), + and(eq(match.profile1Id, profileId2), eq(match.profile2Id, profileId1)), ), ) .limit(1); - if (existingMatch.length > 0) { - return { like: newLike, match: existingMatch[0] }; - } + if (existingMatch.length > 0) return { like: newLike, match: existingMatch[0] }; const [newMatch] = await this.drizzleService.db .insert(match) - .values({ user1Id: userId1, user2Id: userId2 }) + .values({ profile1Id: profileId1, profile2Id: profileId2 }) .returning(); - await this.notifyMatch(userId1, userId2, newMatch.id); + await this.notifyMatch(profileId1, profileId2, newMatch.id); return { like: newLike, match: newMatch }; } - private async getActiveMatchesCount(userId: string): Promise { + private async getMatchesCount(profileId: string): Promise { const matches = await this.drizzleService.db .select({ id: match.id }) .from(match) - .where( - or(eq(match.user1Id, userId), eq(match.user2Id, userId)), - ); + .where(or(eq(match.profile1Id, profileId), eq(match.profile2Id, profileId))); return matches.length; } - private async notifyMatch(userId1: string, userId2: string, matchId: string) { - const users = await this.drizzleService.db - .select({ id: user.id, fcmToken: user.fcmToken }) - .from(user) - .where(or(eq(user.id, userId1), eq(user.id, userId2))); + private async notifyMatch(profileId1: string, profileId2: string, matchId: string) { + const profiles = await this.drizzleService.db + .select({ userId: profile.userId }) + .from(profile) + .where(or(eq(profile.id, profileId1), eq(profile.id, profileId2))); - for (const u of users) { - if (u.fcmToken) { + for (const p of profiles) { + const [u] = await this.drizzleService.db + .select({ fcmToken: user.fcmToken }) + .from(user) + .where(eq(user.id, p.userId)) + .limit(1); + + if (u?.fcmToken) { await this.notificationsService.sendPushNotification( u.fcmToken, 'New Match!', @@ -132,16 +138,20 @@ export class LikesService { } } - await this.redisService.publish('match:created', JSON.stringify({ matchId, userId1, userId2 })); + await this.redisService.publish( + 'match:created', + JSON.stringify({ matchId, profileId1, profileId2 }), + ); } - async getMyMatches(userId: string) { - return this.drizzleService.db - .select() - .from(match) - .where( - or(eq(match.user1Id, userId), eq(match.user2Id, userId)), - ) - .orderBy(match.createdAt); + private async assertProfileOwnership(userId: string, profileId: string) { + const [found] = await this.drizzleService.db + .select({ userId: profile.userId }) + .from(profile) + .where(eq(profile.id, profileId)) + .limit(1); + + if (!found) throw new NotFoundException('Profile not found'); + if (found.userId !== userId) throw new ForbiddenException('Profile does not belong to you'); } } diff --git a/src/modules/media/media.controller.ts b/src/modules/media/media.controller.ts index 6ea681e..a5552c0 100644 --- a/src/modules/media/media.controller.ts +++ b/src/modules/media/media.controller.ts @@ -1,13 +1,4 @@ -import { - Controller, - Delete, - Get, - Param, - Post, - Query, - Req, - UseGuards, -} from '@nestjs/common'; +import { Controller, Delete, Get, Param, Post, Query, Req, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; import { FastifyRequest } from 'fastify'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; @@ -17,39 +8,42 @@ import { MediaService } from './media.service'; @ApiTags('media') @ApiBearerAuth() @UseGuards(JwtAuthGuard) -@Controller('media') +@Controller('profiles/:profileId/media') export class MediaController { constructor(private readonly mediaService: MediaService) {} @Post('upload') - @ApiOperation({ summary: 'Upload photo or video' }) + @ApiOperation({ summary: 'Upload photo / video / audio to profile' }) @ApiConsumes('multipart/form-data') async upload( @CurrentUser('id') userId: string, + @Param('profileId') profileId: string, @Req() req: FastifyRequest, - @Query('type') type: 'photo' | 'video' = 'photo', + @Query('type') type: 'photo' | 'video' | 'audio' = 'photo', ) { const data = await (req as any).file(); - if (!data) { - throw new Error('No file provided'); - } + if (!data) throw new Error('No file provided'); const buffer = await data.toBuffer(); - return this.mediaService.uploadMedia( + return this.mediaService.upload( userId, + profileId, { buffer, originalname: data.filename, mimetype: data.mimetype }, type, ); } @Get() - @ApiOperation({ summary: 'Get my media' }) - getMyMedia(@CurrentUser('id') userId: string) { - return this.mediaService.getByUserId(userId); + @ApiOperation({ summary: 'Get all media for a profile' }) + getMedia(@Param('profileId') profileId: string) { + return this.mediaService.getByProfileId(profileId); } - @Delete(':id') - @ApiOperation({ summary: 'Delete media' }) - deleteMedia(@CurrentUser('id') userId: string, @Param('id') id: string) { - return this.mediaService.deleteMedia(userId, id); + @Delete(':mediaId') + @ApiOperation({ summary: 'Delete media item' }) + deleteMedia( + @CurrentUser('id') userId: string, + @Param('mediaId') mediaId: string, + ) { + return this.mediaService.delete(userId, mediaId); } } diff --git a/src/modules/media/media.service.ts b/src/modules/media/media.service.ts index 44f82ca..0d16ea9 100644 --- a/src/modules/media/media.service.ts +++ b/src/modules/media/media.service.ts @@ -1,7 +1,7 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { eq } from 'drizzle-orm'; import { DrizzleService } from '../../database/drizzle.service'; -import { media } from '../../database/schema'; +import { profile, profileMedia } from '../../database/schema'; import { StorageService } from '../../storage/storage.service'; @Injectable() @@ -11,50 +11,75 @@ export class MediaService { private readonly storageService: StorageService, ) {} - async uploadMedia( + async upload( userId: string, + profileId: string, file: { buffer: Buffer; originalname: string; mimetype: string }, - type: 'photo' | 'video', + type: 'photo' | 'video' | 'audio', ) { - const folder = type === 'photo' ? 'photos' : 'videos'; + await this.assertOwnership(userId, profileId); + + const folder = type; const objectName = await this.storageService.uploadFile( file.buffer, file.originalname, file.mimetype, folder, ); - const publicUrl = this.storageService.getPublicUrl(objectName); + const path = this.storageService.getPublicUrl(objectName); + + const existing = await this.drizzleService.db + .select({ sortOrder: profileMedia.sortOrder }) + .from(profileMedia) + .where(eq(profileMedia.profileId, profileId)) + .orderBy(profileMedia.sortOrder); + + const nextOrder = existing.length > 0 + ? (existing[existing.length - 1].sortOrder ?? 0) + 1 + : 0; const [newMedia] = await this.drizzleService.db - .insert(media) - .values({ userId, path: publicUrl, type }) + .insert(profileMedia) + .values({ profileId, path, type, sortOrder: nextOrder } as any) .returning(); return newMedia; } - async getByUserId(userId: string) { + async getByProfileId(profileId: string) { return this.drizzleService.db .select() - .from(media) - .where(eq(media.userId, userId)); + .from(profileMedia) + .where(eq(profileMedia.profileId, profileId)) + .orderBy(profileMedia.sortOrder); } - async deleteMedia(userId: string, mediaId: string) { + async delete(userId: string, mediaId: string) { const [found] = await this.drizzleService.db .select() - .from(media) - .where(eq(media.id, mediaId)) + .from(profileMedia) + .where(eq(profileMedia.id, mediaId)) .limit(1); - if (!found || found.userId !== userId) { - throw new NotFoundException('Media not found'); - } + if (!found) throw new NotFoundException('Media not found'); + + await this.assertOwnership(userId, found.profileId); const objectName = found.path.split('/').slice(-2).join('/'); await this.storageService.deleteFile(objectName).catch(() => {}); + await this.drizzleService.db.delete(profileMedia).where(eq(profileMedia.id, mediaId)); - await this.drizzleService.db.delete(media).where(eq(media.id, mediaId)); return { message: 'Media deleted' }; } + + private async assertOwnership(userId: string, profileId: string) { + const [found] = await this.drizzleService.db + .select({ userId: profile.userId }) + .from(profile) + .where(eq(profile.id, profileId)) + .limit(1); + + if (!found) throw new NotFoundException('Profile not found'); + if (found.userId !== userId) throw new ForbiddenException('Profile does not belong to you'); + } } diff --git a/src/modules/profiles/profiles.controller.ts b/src/modules/profiles/profiles.controller.ts index d70a6b9..1e3f0a6 100644 --- a/src/modules/profiles/profiles.controller.ts +++ b/src/modules/profiles/profiles.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post, Put, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; @@ -14,32 +14,36 @@ export class ProfilesController { constructor(private readonly profilesService: ProfilesService) {} @Post() - @ApiOperation({ summary: 'Create my profile' }) - create( - @CurrentUser('id') userId: string, - @Body() dto: CreateProfileDto, - ) { + @ApiOperation({ summary: 'Create a new profile (one user can have many)' }) + create(@CurrentUser('id') userId: string, @Body() dto: CreateProfileDto) { return this.profilesService.create(userId, dto); } - @Put() - @ApiOperation({ summary: 'Update my profile' }) + @Get('my') + @ApiOperation({ summary: 'Get all my profiles' }) + getMyProfiles(@CurrentUser('id') userId: string) { + return this.profilesService.findAllByUserId(userId); + } + + @Put(':profileId') + @ApiOperation({ summary: 'Update profile by ID (must be owner)' }) update( @CurrentUser('id') userId: string, + @Param('profileId') profileId: string, @Body() dto: UpdateProfileDto, ) { - return this.profilesService.update(userId, dto); + return this.profilesService.update(userId, profileId, dto); } - @Get('me') - @ApiOperation({ summary: 'Get my profile' }) - getMyProfile(@CurrentUser('id') userId: string) { - return this.profilesService.findByUserId(userId); - } - - @Get(':id') + @Get(':profileId') @ApiOperation({ summary: 'Get profile by ID' }) - findOne(@Param('id') id: string) { - return this.profilesService.findByProfileId(id); + findOne(@Param('profileId') profileId: string) { + return this.profilesService.findByProfileId(profileId); + } + + @Delete(':profileId') + @ApiOperation({ summary: 'Delete profile (must be owner)' }) + delete(@CurrentUser('id') userId: string, @Param('profileId') profileId: string) { + return this.profilesService.delete(userId, profileId); } } diff --git a/src/modules/profiles/profiles.service.ts b/src/modules/profiles/profiles.service.ts index 048b2d7..43f365e 100644 --- a/src/modules/profiles/profiles.service.ts +++ b/src/modules/profiles/profiles.service.ts @@ -1,12 +1,7 @@ -import { - BadRequestException, - ConflictException, - Injectable, - NotFoundException, -} from '@nestjs/common'; +import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { eq, inArray } from 'drizzle-orm'; import { DrizzleService } from '../../database/drizzle.service'; -import { profile, profileTag, tag, media, city, cityDistrict } from '../../database/schema'; +import { profile, profileMedia, profileTag, tag, city, cityDistrict } from '../../database/schema'; import { CreateProfileDto } from './dto/create-profile.dto'; import { UpdateProfileDto } from './dto/update-profile.dto'; @@ -15,14 +10,6 @@ export class ProfilesService { constructor(private readonly drizzleService: DrizzleService) {} async create(userId: string, dto: CreateProfileDto) { - const existing = await this.drizzleService.db - .select({ id: profile.id }) - .from(profile) - .where(eq(profile.userId, userId)) - .limit(1); - - if (existing.length > 0) throw new ConflictException('Profile already exists'); - const [newProfile] = await this.drizzleService.db .insert(profile) .values({ @@ -46,40 +33,36 @@ export class ProfilesService { return this.findByProfileId(newProfile.id); } - async update(userId: string, dto: UpdateProfileDto) { - const [found] = await this.drizzleService.db - .select({ id: profile.id }) - .from(profile) - .where(eq(profile.userId, userId)) - .limit(1); - - if (!found) throw new NotFoundException('Profile not found'); + async update(userId: string, profileId: string, dto: UpdateProfileDto) { + await this.assertOwnership(userId, profileId); const { tagIds, ...fields } = dto; + const updateFields: any = {}; + for (const [k, v] of Object.entries(fields)) { + if (v !== undefined) updateFields[k] = v; + } - if (Object.keys(fields).length > 0) { + if (Object.keys(updateFields).length > 0) { await this.drizzleService.db .update(profile) - .set(fields) - .where(eq(profile.id, found.id)); + .set(updateFields as any) + .where(eq(profile.id, profileId)); } if (tagIds !== undefined) { - await this.setTags(found.id, tagIds); + await this.setTags(profileId, tagIds); } - return this.findByProfileId(found.id); + return this.findByProfileId(profileId); } - async findByUserId(userId: string) { - const [found] = await this.drizzleService.db - .select({ id: profile.id }) + async findAllByUserId(userId: string) { + const profiles = await this.drizzleService.db + .select() .from(profile) - .where(eq(profile.userId, userId)) - .limit(1); + .where(eq(profile.userId, userId)); - if (!found) throw new NotFoundException('Profile not found'); - return this.findByProfileId(found.id); + return Promise.all(profiles.map((p) => this.findByProfileId(p.id))); } async findByProfileId(profileId: string) { @@ -93,18 +76,43 @@ export class ProfilesService { if (!found) throw new NotFoundException('Profile not found'); - const tags = await this.drizzleService.db - .select({ id: tag.id, value: tag.value }) - .from(profileTag) - .innerJoin(tag, eq(tag.id, profileTag.tagId)) - .where(eq(profileTag.profileId, profileId)); + const [tags, media] = await Promise.all([ + this.drizzleService.db + .select({ id: tag.id, value: tag.value }) + .from(profileTag) + .innerJoin(tag, eq(tag.id, profileTag.tagId)) + .where(eq(profileTag.profileId, profileId)), + this.drizzleService.db + .select() + .from(profileMedia) + .where(eq(profileMedia.profileId, profileId)) + .orderBy(profileMedia.sortOrder), + ]); - const medias = await this.drizzleService.db - .select() - .from(media) - .where(eq(media.userId, found.profile.userId)); + return { + ...found.profile, + city: found.city, + district: found.city_district, + tags, + media, + }; + } - return { ...found.profile, city: found.city, district: found.city_district, tags, media: medias }; + async delete(userId: string, profileId: string) { + await this.assertOwnership(userId, profileId); + await this.drizzleService.db.delete(profile).where(eq(profile.id, profileId)); + return { message: 'Profile deleted' }; + } + + private async assertOwnership(userId: string, profileId: string) { + const [found] = await this.drizzleService.db + .select({ userId: profile.userId }) + .from(profile) + .where(eq(profile.id, profileId)) + .limit(1); + + if (!found) throw new NotFoundException('Profile not found'); + if (found.userId !== userId) throw new ForbiddenException('Profile does not belong to you'); } private async setTags(profileId: string, tagIds: string[]) { @@ -113,9 +121,9 @@ export class ProfilesService { .where(eq(profileTag.profileId, profileId)); if (tagIds.length > 0) { - await this.drizzleService.db.insert(profileTag).values( - tagIds.map((tagId) => ({ profileId, tagId })), - ); + await this.drizzleService.db + .insert(profileTag) + .values(tagIds.map((tagId) => ({ profileId, tagId }))); } } } diff --git a/src/modules/reports/dto/create-report.dto.ts b/src/modules/reports/dto/create-report.dto.ts index 8ae98ad..21668b4 100644 --- a/src/modules/reports/dto/create-report.dto.ts +++ b/src/modules/reports/dto/create-report.dto.ts @@ -2,6 +2,10 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; export class CreateReportDto { + @ApiProperty({ description: 'Your profile ID' }) + @IsUUID() + sourceProfileId: string; + @ApiProperty() @IsUUID() entityId: string; diff --git a/src/modules/reports/reports.controller.ts b/src/modules/reports/reports.controller.ts index 15199a7..bd5b102 100644 --- a/src/modules/reports/reports.controller.ts +++ b/src/modules/reports/reports.controller.ts @@ -16,10 +16,7 @@ export class ReportsController { @Post() @ApiOperation({ summary: 'Submit a report' }) - create( - @CurrentUser('id') userId: string, - @Body() dto: CreateReportDto, - ) { + create(@CurrentUser('id') userId: string, @Body() dto: CreateReportDto) { return this.reportsService.create(userId, dto); } diff --git a/src/modules/reports/reports.service.ts b/src/modules/reports/reports.service.ts index 844ecd0..4c6840f 100644 --- a/src/modules/reports/reports.service.ts +++ b/src/modules/reports/reports.service.ts @@ -1,23 +1,26 @@ -import { Injectable } from '@nestjs/common'; -import { DrizzleService } from '../../database/drizzle.service'; -import { report } from '../../database/schema'; -import { CreateReportDto } from './dto/create-report.dto'; +import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { eq } from 'drizzle-orm'; +import { DrizzleService } from '../../database/drizzle.service'; +import { profile, report } from '../../database/schema'; +import { CreateReportDto } from './dto/create-report.dto'; @Injectable() export class ReportsService { constructor(private readonly drizzleService: DrizzleService) {} async create(userId: string, dto: CreateReportDto) { + await this.assertProfileOwnership(userId, dto.sourceProfileId); + const [newReport] = await this.drizzleService.db .insert(report) .values({ - sourceUser: userId, + sourceProfileId: dto.sourceProfileId, entityId: dto.entityId, entityType: dto.entityType, description: dto.description || null, } as any) .returning(); + return newReport; } @@ -25,10 +28,14 @@ export class ReportsService { return this.drizzleService.db.select().from(report).orderBy(report.id); } - async getByUser(userId: string) { - return this.drizzleService.db - .select() - .from(report) - .where(eq(report.sourceUser, userId)); + private async assertProfileOwnership(userId: string, profileId: string) { + const [found] = await this.drizzleService.db + .select({ userId: profile.userId }) + .from(profile) + .where(eq(profile.id, profileId)) + .limit(1); + + if (!found) throw new NotFoundException('Profile not found'); + if (found.userId !== userId) throw new ForbiddenException('Profile does not belong to you'); } } diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index 65b7ed8..263d49b 100644 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -14,9 +14,9 @@ export class UsersController { constructor(private readonly usersService: UsersService) {} @Get('me') - @ApiOperation({ summary: 'Get current user profile' }) + @ApiOperation({ summary: 'Get current user with profile list' }) getMe(@CurrentUser('id') userId: string) { - return this.usersService.getMyProfile(userId); + return this.usersService.getMe(userId); } @Get(':id') @@ -28,7 +28,7 @@ export class UsersController { @Patch(':id/ban') @Roles('admin', 'moderator') @UseGuards(RolesGuard) - @ApiOperation({ summary: 'Ban user (admin/moderator only)' }) + @ApiOperation({ summary: 'Ban user' }) ban(@Param('id') id: string) { return this.usersService.banUser(id); } @@ -36,7 +36,7 @@ export class UsersController { @Patch(':id/activate') @Roles('admin', 'moderator') @UseGuards(RolesGuard) - @ApiOperation({ summary: 'Activate user (admin/moderator only)' }) + @ApiOperation({ summary: 'Activate user' }) activate(@Param('id') id: string) { return this.usersService.activateUser(id); } diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index b27c7a1..a79a0f2 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -1,7 +1,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { eq } from 'drizzle-orm'; import { DrizzleService } from '../../database/drizzle.service'; -import { user, profile, media, role } from '../../database/schema'; +import { user, profile, role } from '../../database/schema'; @Injectable() export class UsersService { @@ -19,38 +19,23 @@ export class UsersService { return rest; } - async findByIdWithProfile(id: string) { + async getMe(userId: string) { const [found] = await this.drizzleService.db .select() .from(user) - .leftJoin(profile, eq(profile.userId, user.id)) - .where(eq(user.id, id)) - .limit(1); - - if (!found) throw new NotFoundException('User not found'); - return found; - } - - async getMyProfile(userId: string) { - const result = await this.drizzleService.db - .select() - .from(user) - .leftJoin(profile, eq(profile.userId, user.id)) .leftJoin(role, eq(role.id, user.roleId)) .where(eq(user.id, userId)) .limit(1); - if (!result.length) throw new NotFoundException('User not found'); - const row = result[0]; - const { password, ...userFields } = row.user; - return { ...userFields, profile: row.profile, role: row.role }; - } + if (!found) throw new NotFoundException('User not found'); + const { password, ...userFields } = found.user; - async getMediaByUserId(userId: string) { - return this.drizzleService.db - .select() - .from(media) - .where(eq(media.userId, userId)); + const profiles = await this.drizzleService.db + .select({ id: profile.id, name: profile.name, gender: profile.gender }) + .from(profile) + .where(eq(profile.userId, userId)); + + return { ...userFields, role: found.role, profiles }; } async banUser(userId: string) {