This commit is contained in:
Oscar
2026-06-02 16:22:53 +03:00
parent dc44cdd639
commit bc3e48bcad
37 changed files with 973 additions and 1894 deletions

View File

@@ -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` ### 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` | | 2 | Поиск «неактивен» при превышении лимита матчей | `BadRequestException` при лайке | Проще контракт с клиентом: ошибка явная, не нужно отдельного флага `searchActive` |
| 3 | Тарифный план один, но оплата предусмотрена | Таблицы `tariff` и `payment` созданы, логика оплаты не реализована | ТЗ: «регистрация открыта для всех, тарифный план один» — бизнес-логики оплаты нет | | 3 | Тарифный план один, но оплата предусмотрена | Таблицы `tariff` и `payment` созданы, логика оплаты не реализована | ТЗ: «регистрация открыта для всех, тарифный план один» — бизнес-логики оплаты нет |
| 4 | `radiusKm` в фильтре ленты | Параметр принимается, но фильтрация по радиусу не применяется | Требует PostGIS или формулы Haversine; добавить в следующей итерации | | 4 | `radiusKm` в фильтре ленты | Параметр принимается, но фильтрация по радиусу не применяется | Требует PostGIS или формулы Haversine; добавить в следующей итерации |

View File

@@ -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;
},
);

View File

@@ -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<boolean> {
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;
}
}

View File

@@ -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"."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"."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 TYPE "public"."report_entity_type" AS ENUM('profile', 'message');--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "permission" ( CREATE TABLE IF NOT EXISTS "permission" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "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 "name" varchar(200) NOT NULL
); );
--> statement-breakpoint --> 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" ( CREATE TABLE IF NOT EXISTS "payment" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL, "user_id" uuid NOT NULL,
@@ -72,30 +52,31 @@ CREATE TABLE IF NOT EXISTS "user" (
"role_id" uuid, "role_id" uuid,
"tariff_id" uuid, "tariff_id" uuid,
"payment_id" uuid, "payment_id" uuid,
"active_chat_id" uuid,
"fcm_token" text, "fcm_token" text,
CONSTRAINT "user_phone_unique" UNIQUE("phone") CONSTRAINT "user_phone_unique" UNIQUE("phone")
); );
--> statement-breakpoint --> 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" ( CREATE TABLE IF NOT EXISTS "profile" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL, "user_id" uuid NOT NULL,
"name" varchar(100) NOT NULL, "name" varchar(100) NOT NULL,
"birth_date" date NOT NULL, "birth_date" date NOT NULL,
"gender" "gender" NOT NULL,
"city_id" uuid, "city_id" uuid,
"district_id" uuid, "district_id" uuid,
"description" text, "description" text,
"nation" varchar(100), "nation" varchar(100),
"height" double precision, "height" double precision,
"weight" 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 --> statement-breakpoint
CREATE TABLE IF NOT EXISTS "profile_tag" ( CREATE TABLE IF NOT EXISTS "profile_tag" (
@@ -111,23 +92,45 @@ CREATE TABLE IF NOT EXISTS "tag" (
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE IF NOT EXISTS "like" ( CREATE TABLE IF NOT EXISTS "like" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"source_user" uuid NOT NULL, "source_profile_id" uuid NOT NULL,
"target_user" uuid NOT NULL, "target_profile_id" uuid NOT NULL,
"type" "like_type" NOT NULL, "type" "like_type" NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL "created_at" timestamp with time zone DEFAULT now() NOT NULL
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE IF NOT EXISTS "match" ( CREATE TABLE IF NOT EXISTS "match" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user1_id" uuid NOT NULL, "profile1_id" uuid NOT NULL,
"user2_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 "created_at" timestamp with time zone DEFAULT now() NOT NULL
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE IF NOT EXISTS "date" ( CREATE TABLE IF NOT EXISTS "date" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user1_id" uuid NOT NULL, "profile1_id" uuid NOT NULL,
"user2_id" uuid NOT NULL, "profile2_id" uuid NOT NULL,
"lat" numeric(10, 7) NOT NULL, "lat" numeric(10, 7) NOT NULL,
"lng" numeric(10, 7) NOT NULL, "lng" numeric(10, 7) NOT NULL,
"time" timestamp with time zone NOT NULL, "time" timestamp with time zone NOT NULL,
@@ -141,7 +144,7 @@ CREATE TABLE IF NOT EXISTS "date_status" (
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE IF NOT EXISTS "report" ( CREATE TABLE IF NOT EXISTS "report" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "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_id" uuid NOT NULL,
"entity_type" "report_entity_type" NOT NULL, "entity_type" "report_entity_type" NOT NULL,
"description" text "description" text
@@ -159,12 +162,6 @@ EXCEPTION
WHEN duplicate_object THEN null; WHEN duplicate_object THEN null;
END $$; END $$;
--> statement-breakpoint --> 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 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; 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 EXCEPTION
@@ -183,12 +180,6 @@ EXCEPTION
WHEN duplicate_object THEN null; WHEN duplicate_object THEN null;
END $$; END $$;
--> statement-breakpoint --> 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 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; 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 EXCEPTION
@@ -207,6 +198,12 @@ EXCEPTION
WHEN duplicate_object THEN null; WHEN duplicate_object THEN null;
END $$; END $$;
--> statement-breakpoint --> 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 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; 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 EXCEPTION
@@ -220,37 +217,61 @@ EXCEPTION
END $$; END $$;
--> statement-breakpoint --> statement-breakpoint
DO $$ BEGIN 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 EXCEPTION
WHEN duplicate_object THEN null; WHEN duplicate_object THEN null;
END $$; END $$;
--> statement-breakpoint --> statement-breakpoint
DO $$ BEGIN 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 EXCEPTION
WHEN duplicate_object THEN null; WHEN duplicate_object THEN null;
END $$; END $$;
--> statement-breakpoint --> statement-breakpoint
DO $$ BEGIN 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 EXCEPTION
WHEN duplicate_object THEN null; WHEN duplicate_object THEN null;
END $$; END $$;
--> statement-breakpoint --> statement-breakpoint
DO $$ BEGIN 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 EXCEPTION
WHEN duplicate_object THEN null; WHEN duplicate_object THEN null;
END $$; END $$;
--> statement-breakpoint --> statement-breakpoint
DO $$ BEGIN 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 EXCEPTION
WHEN duplicate_object THEN null; WHEN duplicate_object THEN null;
END $$; END $$;
--> statement-breakpoint --> statement-breakpoint
DO $$ BEGIN 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 EXCEPTION
WHEN duplicate_object THEN null; WHEN duplicate_object THEN null;
END $$; END $$;
@@ -262,7 +283,7 @@ EXCEPTION
END $$; END $$;
--> statement-breakpoint --> statement-breakpoint
DO $$ BEGIN 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 EXCEPTION
WHEN duplicate_object THEN null; WHEN duplicate_object THEN null;
END $$; END $$;

View File

@@ -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;

View File

@@ -1,5 +1,5 @@
{ {
"id": "3d66c7d2-fc68-4c66-ad86-4f558d519225", "id": "7caebd65-9149-400c-92fa-1981f0e4ea72",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "00000000-0000-0000-0000-000000000000",
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
@@ -206,144 +206,6 @@
"checkConstraints": {}, "checkConstraints": {},
"isRLSEnabled": false "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": { "public.payment": {
"name": "payment", "name": "payment",
"schema": "", "schema": "",
@@ -445,12 +307,6 @@
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false
}, },
"active_chat_id": {
"name": "active_chat_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"fcm_token": { "fcm_token": {
"name": "fcm_token", "name": "fcm_token",
"type": "text", "type": "text",
@@ -501,58 +357,6 @@
"checkConstraints": {}, "checkConstraints": {},
"isRLSEnabled": false "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": { "public.profile": {
"name": "profile", "name": "profile",
"schema": "", "schema": "",
@@ -582,6 +386,13 @@
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
}, },
"gender": {
"name": "gender",
"type": "gender",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"city_id": { "city_id": {
"name": "city_id", "name": "city_id",
"type": "uuid", "type": "uuid",
@@ -617,6 +428,12 @@
"type": "double precision", "type": "double precision",
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false
},
"active_chat_id": {
"name": "active_chat_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
} }
}, },
"indexes": {}, "indexes": {},
@@ -662,15 +479,67 @@
} }
}, },
"compositePrimaryKeys": {}, "compositePrimaryKeys": {},
"uniqueConstraints": { "uniqueConstraints": {},
"profile_user_id_unique": { "policies": {},
"name": "profile_user_id_unique", "checkConstraints": {},
"nullsNotDistinct": false, "isRLSEnabled": false
"columns": [ },
"user_id" "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": {}, "policies": {},
"checkConstraints": {}, "checkConstraints": {},
"isRLSEnabled": false "isRLSEnabled": false
@@ -772,14 +641,14 @@
"notNull": true, "notNull": true,
"default": "gen_random_uuid()" "default": "gen_random_uuid()"
}, },
"source_user": { "source_profile_id": {
"name": "source_user", "name": "source_profile_id",
"type": "uuid", "type": "uuid",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
}, },
"target_user": { "target_profile_id": {
"name": "target_user", "name": "target_profile_id",
"type": "uuid", "type": "uuid",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
@@ -801,12 +670,12 @@
}, },
"indexes": {}, "indexes": {},
"foreignKeys": { "foreignKeys": {
"like_source_user_user_id_fk": { "like_source_profile_id_profile_id_fk": {
"name": "like_source_user_user_id_fk", "name": "like_source_profile_id_profile_id_fk",
"tableFrom": "like", "tableFrom": "like",
"tableTo": "user", "tableTo": "profile",
"columnsFrom": [ "columnsFrom": [
"source_user" "source_profile_id"
], ],
"columnsTo": [ "columnsTo": [
"id" "id"
@@ -814,12 +683,12 @@
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
"like_target_user_user_id_fk": { "like_target_profile_id_profile_id_fk": {
"name": "like_target_user_user_id_fk", "name": "like_target_profile_id_profile_id_fk",
"tableFrom": "like", "tableFrom": "like",
"tableTo": "user", "tableTo": "profile",
"columnsFrom": [ "columnsFrom": [
"target_user" "target_profile_id"
], ],
"columnsTo": [ "columnsTo": [
"id" "id"
@@ -845,14 +714,14 @@
"notNull": true, "notNull": true,
"default": "gen_random_uuid()" "default": "gen_random_uuid()"
}, },
"user1_id": { "profile1_id": {
"name": "user1_id", "name": "profile1_id",
"type": "uuid", "type": "uuid",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
}, },
"user2_id": { "profile2_id": {
"name": "user2_id", "name": "profile2_id",
"type": "uuid", "type": "uuid",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
@@ -867,12 +736,12 @@
}, },
"indexes": {}, "indexes": {},
"foreignKeys": { "foreignKeys": {
"match_user1_id_user_id_fk": { "match_profile1_id_profile_id_fk": {
"name": "match_user1_id_user_id_fk", "name": "match_profile1_id_profile_id_fk",
"tableFrom": "match", "tableFrom": "match",
"tableTo": "user", "tableTo": "profile",
"columnsFrom": [ "columnsFrom": [
"user1_id" "profile1_id"
], ],
"columnsTo": [ "columnsTo": [
"id" "id"
@@ -880,12 +749,190 @@
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
"match_user2_id_user_id_fk": { "match_profile2_id_profile_id_fk": {
"name": "match_user2_id_user_id_fk", "name": "match_profile2_id_profile_id_fk",
"tableFrom": "match", "tableFrom": "match",
"tableTo": "user", "tableTo": "profile",
"columnsFrom": [ "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": [ "columnsTo": [
"id" "id"
@@ -911,14 +958,14 @@
"notNull": true, "notNull": true,
"default": "gen_random_uuid()" "default": "gen_random_uuid()"
}, },
"user1_id": { "profile1_id": {
"name": "user1_id", "name": "profile1_id",
"type": "uuid", "type": "uuid",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
}, },
"user2_id": { "profile2_id": {
"name": "user2_id", "name": "profile2_id",
"type": "uuid", "type": "uuid",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
@@ -950,12 +997,12 @@
}, },
"indexes": {}, "indexes": {},
"foreignKeys": { "foreignKeys": {
"date_user1_id_user_id_fk": { "date_profile1_id_profile_id_fk": {
"name": "date_user1_id_user_id_fk", "name": "date_profile1_id_profile_id_fk",
"tableFrom": "date", "tableFrom": "date",
"tableTo": "user", "tableTo": "profile",
"columnsFrom": [ "columnsFrom": [
"user1_id" "profile1_id"
], ],
"columnsTo": [ "columnsTo": [
"id" "id"
@@ -963,12 +1010,12 @@
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
"date_user2_id_user_id_fk": { "date_profile2_id_profile_id_fk": {
"name": "date_user2_id_user_id_fk", "name": "date_profile2_id_profile_id_fk",
"tableFrom": "date", "tableFrom": "date",
"tableTo": "user", "tableTo": "profile",
"columnsFrom": [ "columnsFrom": [
"user2_id" "profile2_id"
], ],
"columnsTo": [ "columnsTo": [
"id" "id"
@@ -1033,8 +1080,8 @@
"notNull": true, "notNull": true,
"default": "gen_random_uuid()" "default": "gen_random_uuid()"
}, },
"source_user": { "source_profile_id": {
"name": "source_user", "name": "source_profile_id",
"type": "uuid", "type": "uuid",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
@@ -1061,12 +1108,12 @@
}, },
"indexes": {}, "indexes": {},
"foreignKeys": { "foreignKeys": {
"report_source_user_user_id_fk": { "report_source_profile_id_profile_id_fk": {
"name": "report_source_user_user_id_fk", "name": "report_source_profile_id_profile_id_fk",
"tableFrom": "report", "tableFrom": "report",
"tableTo": "user", "tableTo": "profile",
"columnsFrom": [ "columnsFrom": [
"source_user" "source_profile_id"
], ],
"columnsTo": [ "columnsTo": [
"id" "id"
@@ -1083,23 +1130,6 @@
} }
}, },
"enums": { "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": { "public.user_status": {
"name": "user_status", "name": "user_status",
"schema": "public", "schema": "public",
@@ -1109,6 +1139,23 @@
"pending" "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": { "public.like_type": {
"name": "like_type", "name": "like_type",
"schema": "public", "schema": "public",
@@ -1117,6 +1164,23 @@
"dislike" "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": { "public.report_entity_type": {
"name": "report_entity_type", "name": "report_entity_type",
"schema": "public", "schema": "public",

File diff suppressed because it is too large Load Diff

View File

@@ -5,15 +5,8 @@
{ {
"idx": 0, "idx": 0,
"version": "7", "version": "7",
"when": 1780401435523, "when": 1780405352119,
"tag": "0000_romantic_morg", "tag": "0000_quick_silver_samurai",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1780403477744,
"tag": "0001_brown_marrow",
"breakpoints": true "breakpoints": true
} }
] ]

View File

@@ -1,12 +1,17 @@
import { pgEnum, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'; 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 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', { export const chat = pgTable('chat', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
profile1Id: uuid('profile1_id').notNull(), profile1Id: uuid('profile1_id')
profile2Id: uuid('profile2_id').notNull(), .notNull()
.references(() => profile.id, { onDelete: 'cascade' }),
profile2Id: uuid('profile2_id')
.notNull()
.references(() => profile.id, { onDelete: 'cascade' }),
status: chatStatusEnum('status').notNull().default('active'), status: chatStatusEnum('status').notNull().default('active'),
}); });
@@ -15,10 +20,12 @@ export const message = pgTable('message', {
chatId: uuid('chat_id') chatId: uuid('chat_id')
.notNull() .notNull()
.references(() => chat.id, { onDelete: 'cascade' }), .references(() => chat.id, { onDelete: 'cascade' }),
userId: uuid('user_id').notNull(), profileId: uuid('profile_id')
.notNull()
.references(() => profile.id, { onDelete: 'cascade' }),
text: text('text'), text: text('text'),
mediaUrl: text('media_url'), mediaUrl: text('media_url'),
mediaType: mediaTypeEnum('media_type'), mediaType: messageMediaTypeEnum('media_type'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}); });

View File

@@ -1,5 +1,5 @@
import { decimal, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'; 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', { export const dateStatus = pgTable('date_status', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
@@ -8,12 +8,12 @@ export const dateStatus = pgTable('date_status', {
export const date = pgTable('date', { export const date = pgTable('date', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
user1Id: uuid('user1_id') profile1Id: uuid('profile1_id')
.notNull() .notNull()
.references(() => user.id, { onDelete: 'cascade' }), .references(() => profile.id, { onDelete: 'cascade' }),
user2Id: uuid('user2_id') profile2Id: uuid('profile2_id')
.notNull() .notNull()
.references(() => user.id, { onDelete: 'cascade' }), .references(() => profile.id, { onDelete: 'cascade' }),
lat: decimal('lat', { precision: 10, scale: 7 }).notNull(), lat: decimal('lat', { precision: 10, scale: 7 }).notNull(),
lng: decimal('lng', { precision: 10, scale: 7 }).notNull(), lng: decimal('lng', { precision: 10, scale: 7 }).notNull(),
time: timestamp('time', { withTimezone: true }).notNull(), time: timestamp('time', { withTimezone: true }).notNull(),

View File

@@ -1,9 +1,9 @@
export * from './role.schema'; export * from './role.schema';
export * from './tariff.schema'; export * from './tariff.schema';
export * from './city.schema'; export * from './city.schema';
export * from './chat.schema';
export * from './user.schema'; export * from './user.schema';
export * from './profile.schema'; export * from './profile.schema';
export * from './social.schema'; export * from './social.schema';
export * from './chat.schema';
export * from './date.schema'; export * from './date.schema';
export * from './report.schema'; export * from './report.schema';

View File

@@ -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 { user } from './user.schema';
import { city, cityDistrict } from './city.schema'; import { city, cityDistrict } from './city.schema';
export const genderEnum = pgEnum('gender', ['male', 'female']); export const genderEnum = pgEnum('gender', ['male', 'female']);
export const profileMediaTypeEnum = pgEnum('profile_media_type', ['photo', 'video', 'audio']);
export const profile = pgTable('profile', { export const profile = pgTable('profile', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id') userId: uuid('user_id')
.notNull() .notNull()
.unique()
.references(() => user.id, { onDelete: 'cascade' }), .references(() => user.id, { onDelete: 'cascade' }),
name: varchar('name', { length: 100 }).notNull(), name: varchar('name', { length: 100 }).notNull(),
birthDate: date('birth_date').notNull(), birthDate: date('birth_date').notNull(),
@@ -19,6 +19,18 @@ export const profile = pgTable('profile', {
nation: varchar('nation', { length: 100 }), nation: varchar('nation', { length: 100 }),
height: doublePrecision('height'), height: doublePrecision('height'),
weight: doublePrecision('weight'), 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', { export const tag = pgTable('tag', {
@@ -34,12 +46,3 @@ export const profileTag = pgTable('profile_tag', {
.notNull() .notNull()
.references(() => tag.id, { onDelete: 'cascade' }), .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(),
});

View File

@@ -1,13 +1,13 @@
import { pgEnum, pgTable, text, uuid } from 'drizzle-orm/pg-core'; 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 reportEntityTypeEnum = pgEnum('report_entity_type', ['profile', 'message']);
export const report = pgTable('report', { export const report = pgTable('report', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
sourceUser: uuid('source_user') sourceProfileId: uuid('source_profile_id')
.notNull() .notNull()
.references(() => user.id, { onDelete: 'cascade' }), .references(() => profile.id, { onDelete: 'cascade' }),
entityId: uuid('entity_id').notNull(), entityId: uuid('entity_id').notNull(),
entityType: reportEntityTypeEnum('entity_type').notNull(), entityType: reportEntityTypeEnum('entity_type').notNull(),
description: text('description'), description: text('description'),

View File

@@ -1,27 +1,27 @@
import { pgEnum, pgTable, timestamp, uuid } from 'drizzle-orm/pg-core'; 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 likeTypeEnum = pgEnum('like_type', ['like', 'dislike']);
export const like = pgTable('like', { export const like = pgTable('like', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
sourceUser: uuid('source_user') sourceProfileId: uuid('source_profile_id')
.notNull() .notNull()
.references(() => user.id, { onDelete: 'cascade' }), .references(() => profile.id, { onDelete: 'cascade' }),
targetUser: uuid('target_user') targetProfileId: uuid('target_profile_id')
.notNull() .notNull()
.references(() => user.id, { onDelete: 'cascade' }), .references(() => profile.id, { onDelete: 'cascade' }),
type: likeTypeEnum('type').notNull(), type: likeTypeEnum('type').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}); });
export const match = pgTable('match', { export const match = pgTable('match', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
user1Id: uuid('user1_id') profile1Id: uuid('profile1_id')
.notNull() .notNull()
.references(() => user.id, { onDelete: 'cascade' }), .references(() => profile.id, { onDelete: 'cascade' }),
user2Id: uuid('user2_id') profile2Id: uuid('profile2_id')
.notNull() .notNull()
.references(() => user.id, { onDelete: 'cascade' }), .references(() => profile.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}); });

View File

@@ -12,7 +12,6 @@ export const user = pgTable('user', {
roleId: uuid('role_id').references(() => role.id, { onDelete: 'set null' }), roleId: uuid('role_id').references(() => role.id, { onDelete: 'set null' }),
tariffId: uuid('tariff_id').references(() => tariff.id, { onDelete: 'set null' }), tariffId: uuid('tariff_id').references(() => tariff.id, { onDelete: 'set null' }),
paymentId: uuid('payment_id'), paymentId: uuid('payment_id'),
activeChatId: uuid('active_chat_id'),
fcmToken: text('fcm_token'), fcmToken: text('fcm_token'),
}); });

View File

@@ -16,10 +16,7 @@ import { ChatService } from '../modules/chat/chat.service';
import { SendMessageDto } from '../modules/chat/dto/send-message.dto'; import { SendMessageDto } from '../modules/chat/dto/send-message.dto';
@WebSocketGateway({ @WebSocketGateway({
cors: { cors: { origin: '*', credentials: true },
origin: '*',
credentials: true,
},
namespace: 'chat', namespace: 'chat',
}) })
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@@ -27,7 +24,8 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
server: Server; server: Server;
private readonly logger = new Logger(ChatGateway.name); private readonly logger = new Logger(ChatGateway.name);
private connectedUsers = new Map<string, string>(); // profileId → socketId
private connectedProfiles = new Map<string, string>();
constructor( constructor(
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
@@ -41,28 +39,30 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
client.handshake.auth?.token || client.handshake.auth?.token ||
client.handshake.headers?.authorization?.replace('Bearer ', ''); client.handshake.headers?.authorization?.replace('Bearer ', '');
if (!token) { if (!token) { client.disconnect(); return; }
client.disconnect();
return;
}
const payload = this.jwtService.verify(token, { const payload = this.jwtService.verify(token, {
secret: this.configService.get<string>('jwt.secret'), secret: this.configService.get<string>('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; client.data.userId = payload.sub;
this.connectedUsers.set(payload.sub, client.id); client.data.profileId = profileId;
this.logger.log(`User ${payload.sub} connected via WebSocket`); this.connectedProfiles.set(profileId, client.id);
this.logger.log(`Profile ${profileId} (user ${payload.sub}) connected`);
} catch { } catch {
client.disconnect(); client.disconnect();
} }
} }
handleDisconnect(client: Socket) { handleDisconnect(client: Socket) {
const userId = client.data.userId; const profileId = client.data.profileId;
if (userId) { if (profileId) {
this.connectedUsers.delete(userId); this.connectedProfiles.delete(profileId);
this.logger.log(`User ${userId} disconnected`); this.logger.log(`Profile ${profileId} disconnected`);
} }
} }
@@ -71,8 +71,7 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@ConnectedSocket() client: Socket, @ConnectedSocket() client: Socket,
@MessageBody() data: { chatId: string }, @MessageBody() data: { chatId: string },
) { ) {
const userId = client.data.userId; if (!client.data.profileId) throw new WsException('Unauthorized');
if (!userId) throw new WsException('Unauthorized');
await client.join(`chat:${data.chatId}`); await client.join(`chat:${data.chatId}`);
return { event: 'joined_chat', chatId: data.chatId }; return { event: 'joined_chat', chatId: data.chatId };
} }
@@ -91,12 +90,11 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@ConnectedSocket() client: Socket, @ConnectedSocket() client: Socket,
@MessageBody() data: { chatId: string } & SendMessageDto, @MessageBody() data: { chatId: string } & SendMessageDto,
) { ) {
const userId = client.data.userId; const { userId, profileId } = client.data;
if (!userId) throw new WsException('Unauthorized'); if (!userId || !profileId) throw new WsException('Unauthorized');
const { chatId, ...msgDto } = data; 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); this.server.to(`chat:${chatId}`).emit('new_message', newMessage);
return newMessage; return newMessage;
} }
@@ -106,17 +104,12 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@ConnectedSocket() client: Socket, @ConnectedSocket() client: Socket,
@MessageBody() data: { chatId: string; isTyping: boolean }, @MessageBody() data: { chatId: string; isTyping: boolean },
) { ) {
const userId = client.data.userId; const { profileId } = client.data;
client.to(`chat:${data.chatId}`).emit('user_typing', { client.to(`chat:${data.chatId}`).emit('user_typing', { profileId, isTyping: data.isTyping });
userId,
isTyping: data.isTyping,
});
} }
emitToUser(userId: string, event: string, data: any) { emitToProfile(profileId: string, event: string, data: any) {
const socketId = this.connectedUsers.get(userId); const socketId = this.connectedProfiles.get(profileId);
if (socketId) { if (socketId) this.server.to(socketId).emit(event, data);
this.server.to(socketId).emit(event, data);
}
} }
} }

View File

@@ -1,13 +1,4 @@
import { import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
Body,
Controller,
Delete,
Get,
Param,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
@@ -24,46 +15,49 @@ export class ChatController {
@Post() @Post()
@ApiOperation({ summary: 'Open a chat for a match' }) @ApiOperation({ summary: 'Open a chat for a match' })
createChat( createChat(@CurrentUser('id') userId: string, @Body() dto: CreateChatDto) {
@CurrentUser('id') userId: string,
@Body() dto: CreateChatDto,
) {
return this.chatService.createChat(userId, dto); return this.chatService.createChat(userId, dto);
} }
@Get() @Get()
@ApiOperation({ summary: 'Get my active chats' }) @ApiOperation({ summary: 'Get active chats for a profile' })
getMyChats(@CurrentUser('id') userId: string) { getChats(
return this.chatService.getMyChats(userId); @CurrentUser('id') userId: string,
@Query('profileId') profileId: string,
) {
return this.chatService.getChatsForProfile(userId, profileId);
} }
@Get(':chatId/messages') @Get(':chatId/messages')
@ApiOperation({ summary: 'Get chat messages' }) @ApiOperation({ summary: 'Get chat messages' })
getMessages( getMessages(
@CurrentUser('id') userId: string, @CurrentUser('id') userId: string,
@Query('profileId') profileId: string,
@Param('chatId') chatId: string, @Param('chatId') chatId: string,
@Query('page') page = 1, @Query('page') page = 1,
@Query('limit') limit = 50, @Query('limit') limit = 50,
) { ) {
return this.chatService.getChatMessages(userId, chatId, +page, +limit); return this.chatService.getChatMessages(userId, profileId, chatId, +page, +limit);
} }
@Post(':chatId/messages') @Post(':chatId/messages')
@ApiOperation({ summary: 'Send a message' }) @ApiOperation({ summary: 'Send a message' })
sendMessage( sendMessage(
@CurrentUser('id') userId: string, @CurrentUser('id') userId: string,
@Query('profileId') profileId: string,
@Param('chatId') chatId: string, @Param('chatId') chatId: string,
@Body() dto: SendMessageDto, @Body() dto: SendMessageDto,
) { ) {
return this.chatService.sendMessage(userId, chatId, dto); return this.chatService.sendMessage(userId, profileId, chatId, dto);
} }
@Delete(':chatId') @Delete(':chatId')
@ApiOperation({ summary: 'Close a chat' }) @ApiOperation({ summary: 'Close a chat' })
closeChat( closeChat(
@CurrentUser('id') userId: string, @CurrentUser('id') userId: string,
@Query('profileId') profileId: string,
@Param('chatId') chatId: string, @Param('chatId') chatId: string,
) { ) {
return this.chatService.closeChat(userId, chatId); return this.chatService.closeChat(userId, profileId, chatId);
} }
} }

View File

@@ -19,13 +19,15 @@ export class ChatService {
) {} ) {}
async createChat(userId: string, dto: CreateChatDto) { async createChat(userId: string, dto: CreateChatDto) {
await this.assertProfileOwnership(userId, dto.profileId);
const [foundMatch] = await this.drizzleService.db const [foundMatch] = await this.drizzleService.db
.select() .select()
.from(match) .from(match)
.where( .where(
and( and(
eq(match.id, dto.matchId), 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); .limit(1);
@@ -37,50 +39,46 @@ export class ChatService {
.from(chat) .from(chat)
.where( .where(
or( or(
and( and(eq(chat.profile1Id, foundMatch.profile1Id), eq(chat.profile2Id, foundMatch.profile2Id)),
eq(chat.profile1Id, foundMatch.user1Id), and(eq(chat.profile1Id, foundMatch.profile2Id), eq(chat.profile2Id, foundMatch.profile1Id)),
eq(chat.profile2Id, foundMatch.user2Id),
),
and(
eq(chat.profile1Id, foundMatch.user2Id),
eq(chat.profile2Id, foundMatch.user1Id),
),
), ),
) )
.limit(1); .limit(1);
if (existingChat.length > 0) return existingChat[0]; if (existingChat.length > 0) return existingChat[0];
const currentUser = await this.drizzleService.db const [currentProfile] = await this.drizzleService.db
.select({ activeChatId: user.activeChatId }) .select({ activeChatId: profile.activeChatId })
.from(user) .from(profile)
.where(eq(user.id, userId)) .where(eq(profile.id, dto.profileId))
.limit(1); .limit(1);
if (currentUser[0]?.activeChatId) { if (currentProfile?.activeChatId) {
throw new BadRequestException( 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 const [newChat] = await this.drizzleService.db
.insert(chat) .insert(chat)
.values({ .values({
profile1Id: foundMatch.user1Id, profile1Id: foundMatch.profile1Id,
profile2Id: foundMatch.user2Id, profile2Id: foundMatch.profile2Id,
status: 'active', status: 'active',
} as any) } as any)
.returning(); .returning();
await this.drizzleService.db await this.drizzleService.db
.update(user) .update(profile)
.set({ activeChatId: newChat.id } as any) .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; 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 const [foundChat] = await this.drizzleService.db
.select() .select()
.from(chat) .from(chat)
@@ -88,7 +86,7 @@ export class ChatService {
.limit(1); .limit(1);
if (!foundChat) throw new NotFoundException('Chat not found'); 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'); throw new ForbiddenException('Not a chat participant');
} }
@@ -98,26 +96,29 @@ export class ChatService {
.where(eq(chat.id, chatId)); .where(eq(chat.id, chatId));
await this.drizzleService.db await this.drizzleService.db
.update(user) .update(profile)
.set({ activeChatId: null } as any) .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' }; return { message: 'Chat closed' };
} }
async getMyChats(userId: string) { async getChatsForProfile(userId: string, profileId: string) {
await this.assertProfileOwnership(userId, profileId);
return this.drizzleService.db return this.drizzleService.db
.select() .select()
.from(chat) .from(chat)
.where( .where(
and( and(
or(eq(chat.profile1Id, userId), eq(chat.profile2Id, userId)), or(eq(chat.profile1Id, profileId), eq(chat.profile2Id, profileId)),
eq(chat.status, 'active'), 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 const [foundChat] = await this.drizzleService.db
.select() .select()
.from(chat) .from(chat)
@@ -125,7 +126,7 @@ export class ChatService {
.limit(1); .limit(1);
if (!foundChat) throw new NotFoundException('Chat not found'); 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'); throw new ForbiddenException('Not a chat participant');
} }
@@ -139,7 +140,9 @@ export class ChatService {
.offset(offset); .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 const [foundChat] = await this.drizzleService.db
.select() .select()
.from(chat) .from(chat)
@@ -147,7 +150,7 @@ export class ChatService {
.limit(1); .limit(1);
if (!foundChat) throw new NotFoundException('Active chat not found'); 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'); throw new ForbiddenException('Not a chat participant');
} }
@@ -159,31 +162,50 @@ export class ChatService {
.insert(message) .insert(message)
.values({ .values({
chatId, chatId,
userId, profileId,
text: dto.text || null, text: dto.text || null,
mediaUrl: dto.mediaUrl || null, mediaUrl: dto.mediaUrl || null,
mediaType: dto.mediaType || null, mediaType: dto.mediaType || null,
} as any) } as any)
.returning(); .returning();
const recipientId = const recipientProfileId =
foundChat.profile1Id === userId ? foundChat.profile2Id : foundChat.profile1Id; foundChat.profile1Id === profileId ? foundChat.profile2Id : foundChat.profile1Id;
const [recipient] = await this.drizzleService.db const [recipientProfile] = await this.drizzleService.db
.select({ fcmToken: user.fcmToken }) .select({ userId: profile.userId })
.from(user) .from(profile)
.where(eq(user.id, recipientId)) .where(eq(profile.id, recipientProfileId))
.limit(1); .limit(1);
if (recipient?.fcmToken) { if (recipientProfile) {
await this.notificationsService.sendPushNotification( const [recipientUser] = await this.drizzleService.db
recipient.fcmToken, .select({ fcmToken: user.fcmToken })
'New message', .from(user)
dto.text?.substring(0, 100) || 'Media message', .where(eq(user.id, recipientProfile.userId))
{ chatId, messageId: newMessage.id, type: 'message' }, .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; 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');
}
} }

View File

@@ -2,6 +2,10 @@ import { ApiProperty } from '@nestjs/swagger';
import { IsUUID } from 'class-validator'; import { IsUUID } from 'class-validator';
export class CreateChatDto { export class CreateChatDto {
@ApiProperty({ description: 'Your profile ID' })
@IsUUID()
profileId: string;
@ApiProperty({ description: 'Match ID to open chat for' }) @ApiProperty({ description: 'Match ID to open chat for' })
@IsUUID() @IsUUID()
matchId: string; matchId: string;

View File

@@ -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 { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
@@ -14,28 +14,29 @@ export class DatesController {
constructor(private readonly datesService: DatesService) {} constructor(private readonly datesService: DatesService) {}
@Post() @Post()
@ApiOperation({ summary: 'Propose a date/meetup' }) @ApiOperation({ summary: 'Propose a meetup' })
create( create(@CurrentUser('id') userId: string, @Body() dto: CreateDateDto) {
@CurrentUser('id') userId: string,
@Body() dto: CreateDateDto,
) {
return this.datesService.create(userId, dto); return this.datesService.create(userId, dto);
} }
@Get() @Get()
@ApiOperation({ summary: 'Get my dates' }) @ApiOperation({ summary: 'Get dates for a profile' })
getMyDates(@CurrentUser('id') userId: string) { getDates(
return this.datesService.getMyDates(userId); @CurrentUser('id') userId: string,
@Query('profileId') profileId: string,
) {
return this.datesService.getForProfile(userId, profileId);
} }
@Patch(':id/status') @Patch(':id/status')
@ApiOperation({ summary: 'Update date status' }) @ApiOperation({ summary: 'Update date status' })
updateStatus( updateStatus(
@CurrentUser('id') userId: string, @CurrentUser('id') userId: string,
@Query('profileId') profileId: string,
@Param('id') id: string, @Param('id') id: string,
@Body() dto: UpdateDateStatusDto, @Body() dto: UpdateDateStatusDto,
) { ) {
return this.datesService.updateStatus(userId, id, dto); return this.datesService.updateStatus(userId, profileId, id, dto);
} }
@Get('statuses') @Get('statuses')

View File

@@ -1,7 +1,7 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { and, eq, or } from 'drizzle-orm'; import { and, eq, or } from 'drizzle-orm';
import { DrizzleService } from '../../database/drizzle.service'; 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 { CreateDateDto } from './dto/create-date.dto';
import { UpdateDateStatusDto } from './dto/update-date-status.dto'; import { UpdateDateStatusDto } from './dto/update-date-status.dto';
@@ -10,8 +10,9 @@ export class DatesService {
constructor(private readonly drizzleService: DrizzleService) {} constructor(private readonly drizzleService: DrizzleService) {}
async create(userId: string, dto: CreateDateDto) { async create(userId: string, dto: CreateDateDto) {
let statusId = dto.statusId; await this.assertProfileOwnership(userId, dto.profileId);
let statusId = dto.statusId;
if (!statusId) { if (!statusId) {
const [pending] = await this.drizzleService.db const [pending] = await this.drizzleService.db
.select({ id: dateStatus.id }) .select({ id: dateStatus.id })
@@ -24,8 +25,8 @@ export class DatesService {
const [newDate] = await this.drizzleService.db const [newDate] = await this.drizzleService.db
.insert(date) .insert(date)
.values({ .values({
user1Id: userId, profile1Id: dto.profileId,
user2Id: dto.partnerId, profile2Id: dto.partnerProfileId,
lat: dto.lat.toString(), lat: dto.lat.toString(),
lng: dto.lng.toString(), lng: dto.lng.toString(),
time: new Date(dto.time), time: new Date(dto.time),
@@ -36,16 +37,19 @@ export class DatesService {
return newDate; return newDate;
} }
async getMyDates(userId: string) { async getForProfile(userId: string, profileId: string) {
await this.assertProfileOwnership(userId, profileId);
return this.drizzleService.db return this.drizzleService.db
.select() .select()
.from(date) .from(date)
.leftJoin(dateStatus, eq(dateStatus.id, date.statusId)) .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); .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 const [found] = await this.drizzleService.db
.select() .select()
.from(date) .from(date)
@@ -53,7 +57,7 @@ export class DatesService {
.limit(1); .limit(1);
if (!found) throw new NotFoundException('Date not found'); 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'); throw new ForbiddenException('Not a participant');
} }
@@ -69,4 +73,15 @@ export class DatesService {
async getStatuses() { async getStatuses() {
return this.drizzleService.db.select().from(dateStatus); 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');
}
} }

View File

@@ -2,9 +2,13 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsNumber, IsOptional, IsUUID } from 'class-validator'; import { IsDateString, IsNumber, IsOptional, IsUUID } from 'class-validator';
export class CreateDateDto { export class CreateDateDto {
@ApiProperty() @ApiProperty({ description: 'Your profile ID' })
@IsUUID() @IsUUID()
partnerId: string; profileId: string;
@ApiProperty({ description: 'Partner profile ID' })
@IsUUID()
partnerProfileId: string;
@ApiProperty() @ApiProperty()
@IsNumber() @IsNumber()

View File

@@ -1,8 +1,12 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsArray, IsNumber, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator'; import { IsArray, IsNumber, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
export class FeedFilterDto { export class FeedFilterDto {
@ApiProperty({ description: 'Your profile ID' })
@IsUUID()
profileId: string;
@ApiPropertyOptional({ default: 1 }) @ApiPropertyOptional({ default: 1 })
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@@ -18,24 +22,16 @@ export class FeedFilterDto {
@Max(50) @Max(50)
limit?: number = 20; limit?: number = 20;
@ApiPropertyOptional({ description: 'City UUID filter' }) @ApiPropertyOptional()
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
cityId?: string; cityId?: string;
@ApiPropertyOptional({ description: 'District UUID filter' }) @ApiPropertyOptional()
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
districtId?: string; districtId?: string;
@ApiPropertyOptional({ description: 'Search radius in km' })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
@Max(500)
radiusKm?: number;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@@ -50,12 +46,12 @@ export class FeedFilterDto {
@Max(100) @Max(100)
ageMax?: number; ageMax?: number;
@ApiPropertyOptional({ description: 'Search keyword in description/name' }) @ApiPropertyOptional()
@IsOptional() @IsOptional()
@IsString() @IsString()
keyword?: string; keyword?: string;
@ApiPropertyOptional({ type: [String], description: 'Tag IDs to filter' }) @ApiPropertyOptional({ type: [String] })
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@IsUUID(undefined, { each: true }) @IsUUID(undefined, { each: true })

View File

@@ -13,11 +13,8 @@ export class FeedController {
constructor(private readonly feedService: FeedService) {} constructor(private readonly feedService: FeedService) {}
@Get() @Get()
@ApiOperation({ summary: 'Get filtered feed of profiles' }) @ApiOperation({ summary: 'Get filtered feed (requires profileId)' })
getFeed( getFeed(@CurrentUser('id') userId: string, @Query() filter: FeedFilterDto) {
@CurrentUser('id') userId: string,
@Query() filter: FeedFilterDto,
) {
return this.feedService.getFeed(userId, filter); return this.feedService.getFeed(userId, filter);
} }
} }

View File

@@ -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 { and, eq, gte, ilike, inArray, lte, ne, notInArray, or, sql } from 'drizzle-orm';
import { DrizzleService } from '../../database/drizzle.service'; 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'; import { FeedFilterDto } from './dto/feed-filter.dto';
@Injectable() @Injectable()
export class FeedService { export class FeedService {
constructor(private readonly drizzleService: DrizzleService) {} constructor(private readonly drizzleService: DrizzleService) {}
async getFeed(currentUserId: string, filter: FeedFilterDto) { async getFeed(userId: string, filter: FeedFilterDto) {
const { page = 1, limit = 20, cityId, districtId, ageMin, ageMax, keyword, tagIds } = filter; const { profileId, page = 1, limit = 20, cityId, districtId, ageMin, ageMax, keyword, tagIds } = filter;
await this.assertProfileOwnership(userId, profileId);
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const alreadyInteracted = await this.drizzleService.db const alreadyInteracted = await this.drizzleService.db
.select({ targetUser: like.targetUser }) .select({ targetProfileId: like.targetProfileId })
.from(like) .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[] = [ const conditions: any[] = [
ne(profile.userId, currentUserId), ne(profile.id, profileId),
ne(user.status, 'banned'), ne(user.status, 'banned'),
]; ];
if (interactedIds.length > 0) { if (interactedIds.length > 0) {
conditions.push(notInArray(profile.userId, interactedIds)); conditions.push(notInArray(profile.id, interactedIds));
} }
if (cityId) conditions.push(eq(profile.cityId, cityId)); if (cityId) conditions.push(eq(profile.cityId, cityId));
@@ -52,19 +54,15 @@ export class FeedService {
); );
} }
let profileIds: string[] | null = null;
if (tagIds?.length) { if (tagIds?.length) {
const tagMatches = await this.drizzleService.db const tagMatches = await this.drizzleService.db
.select({ profileId: profileTag.profileId }) .select({ profileId: profileTag.profileId })
.from(profileTag) .from(profileTag)
.where(inArray(profileTag.tagId, tagIds)); .where(inArray(profileTag.tagId, tagIds));
profileIds = tagMatches.map((r) => r.profileId);
if (profileIds.length > 0) { const matchedIds = tagMatches.map((r) => r.profileId);
conditions.push(inArray(profile.id, profileIds)); if (matchedIds.length === 0) return { data: [], page, limit };
} else { conditions.push(inArray(profile.id, matchedIds));
return { data: [], total: 0, page, limit };
}
} }
const rows = await this.drizzleService.db const rows = await this.drizzleService.db
@@ -73,6 +71,7 @@ export class FeedService {
userId: profile.userId, userId: profile.userId,
name: profile.name, name: profile.name,
birthDate: profile.birthDate, birthDate: profile.birthDate,
gender: profile.gender,
cityId: profile.cityId, cityId: profile.cityId,
districtId: profile.districtId, districtId: profile.districtId,
description: profile.description, description: profile.description,
@@ -89,14 +88,19 @@ export class FeedService {
const enriched = await Promise.all( const enriched = await Promise.all(
rows.map(async (p) => { rows.map(async (p) => {
const tags = await this.drizzleService.db const [tags, media] = await Promise.all([
.select({ id: tag.id, value: tag.value }) this.drizzleService.db
.from(profileTag) .select({ id: tag.id, value: tag.value })
.innerJoin(tag, eq(tag.id, profileTag.tagId)) .from(profileTag)
.where(eq(profileTag.profileId, p.id)); .innerJoin(tag, eq(tag.id, profileTag.tagId))
.where(eq(profileTag.profileId, p.id)),
const age = this.calculateAge(p.birthDate); this.drizzleService.db
return { ...p, age, tags }; .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--; if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--;
return 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');
}
} }

View File

@@ -2,9 +2,13 @@ import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsUUID } from 'class-validator'; import { IsEnum, IsUUID } from 'class-validator';
export class CreateLikeDto { export class CreateLikeDto {
@ApiProperty() @ApiProperty({ description: 'Your profile ID' })
@IsUUID() @IsUUID()
targetUserId: string; sourceProfileId: string;
@ApiProperty({ description: 'Target profile ID' })
@IsUUID()
targetProfileId: string;
@ApiProperty({ enum: ['like', 'dislike'] }) @ApiProperty({ enum: ['like', 'dislike'] })
@IsEnum(['like', 'dislike']) @IsEnum(['like', 'dislike'])

View File

@@ -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 { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
@@ -13,17 +13,17 @@ export class LikesController {
constructor(private readonly likesService: LikesService) {} constructor(private readonly likesService: LikesService) {}
@Post() @Post()
@ApiOperation({ summary: 'Like or dislike a user' }) @ApiOperation({ summary: 'Like or dislike a profile' })
createLike( createLike(@CurrentUser('id') userId: string, @Body() dto: CreateLikeDto) {
@CurrentUser('id') userId: string,
@Body() dto: CreateLikeDto,
) {
return this.likesService.createLike(userId, dto); return this.likesService.createLike(userId, dto);
} }
@Get('matches') @Get('matches')
@ApiOperation({ summary: 'Get my matches' }) @ApiOperation({ summary: 'Get matches for a profile' })
getMyMatches(@CurrentUser('id') userId: string) { getMyMatches(
return this.likesService.getMyMatches(userId); @CurrentUser('id') userId: string,
@Query('profileId') profileId: string,
) {
return this.likesService.getMyMatches(userId, profileId);
} }
} }

View File

@@ -1,13 +1,10 @@
import { import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
BadRequestException,
Injectable,
} from '@nestjs/common';
import { and, eq, or } from 'drizzle-orm'; import { and, eq, or } from 'drizzle-orm';
import { ConfigService } from '@nestjs/config';
import { DrizzleService } from '../../database/drizzle.service'; 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 { NotificationsService } from '../../notifications/notifications.service';
import { RedisService } from '../../redis/redis.service'; import { RedisService } from '../../redis/redis.service';
import { ConfigService } from '@nestjs/config';
import { CreateLikeDto } from './dto/create-like.dto'; import { CreateLikeDto } from './dto/create-like.dto';
@Injectable() @Injectable()
@@ -19,16 +16,18 @@ export class LikesService {
private readonly configService: ConfigService, private readonly configService: ConfigService,
) {} ) {}
async createLike(sourceUserId: string, dto: CreateLikeDto) { async createLike(userId: string, dto: CreateLikeDto) {
if (sourceUserId === dto.targetUserId) { await this.assertProfileOwnership(userId, dto.sourceProfileId);
if (dto.sourceProfileId === dto.targetProfileId) {
throw new BadRequestException('Cannot like yourself'); throw new BadRequestException('Cannot like yourself');
} }
const maxMatches = this.configService.get<number>('app.maxMatchesBeforePause'); const maxMatches = this.configService.get<number>('app.maxMatchesBeforePause');
const activeMatchesCount = await this.getActiveMatchesCount(sourceUserId); const activeMatchesCount = await this.getMatchesCount(dto.sourceProfileId);
if (activeMatchesCount >= maxMatches) { if (activeMatchesCount >= maxMatches) {
throw new BadRequestException( 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) .from(like)
.where( .where(
and( and(
eq(like.sourceUser, sourceUserId), eq(like.sourceProfileId, dto.sourceProfileId),
eq(like.targetUser, dto.targetUserId), eq(like.targetProfileId, dto.targetProfileId),
), ),
) )
.limit(1); .limit(1);
if (existing.length > 0) { if (existing.length > 0) throw new BadRequestException('Already reacted to this profile');
throw new BadRequestException('Already reacted to this user');
}
const [newLike] = await this.drizzleService.db const [newLike] = await this.drizzleService.db
.insert(like) .insert(like)
.values({ .values({
sourceUser: sourceUserId, sourceProfileId: dto.sourceProfileId,
targetUser: dto.targetUserId, targetProfileId: dto.targetProfileId,
type: dto.type, type: dto.type,
}) })
.returning(); .returning();
if (dto.type === 'like') { if (dto.type === 'like') {
return this.checkAndCreateMatch(sourceUserId, dto.targetUserId, newLike); return this.checkAndCreateMatch(dto.sourceProfileId, dto.targetProfileId, newLike);
} }
return { like: newLike, match: null }; 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 const reverseLike = await this.drizzleService.db
.select() .select()
.from(like) .from(like)
.where( .where(
and( and(
eq(like.sourceUser, userId2), eq(like.sourceProfileId, profileId2),
eq(like.targetUser, userId1), eq(like.targetProfileId, profileId1),
eq(like.type, 'like'), eq(like.type, 'like'),
), ),
) )
.limit(1); .limit(1);
if (reverseLike.length === 0) { if (reverseLike.length === 0) return { like: newLike, match: null };
return { like: newLike, match: null };
}
const existingMatch = await this.drizzleService.db const existingMatch = await this.drizzleService.db
.select() .select()
.from(match) .from(match)
.where( .where(
or( or(
and(eq(match.user1Id, userId1), eq(match.user2Id, userId2)), and(eq(match.profile1Id, profileId1), eq(match.profile2Id, profileId2)),
and(eq(match.user1Id, userId2), eq(match.user2Id, userId1)), and(eq(match.profile1Id, profileId2), eq(match.profile2Id, profileId1)),
), ),
) )
.limit(1); .limit(1);
if (existingMatch.length > 0) { if (existingMatch.length > 0) return { like: newLike, match: existingMatch[0] };
return { like: newLike, match: existingMatch[0] };
}
const [newMatch] = await this.drizzleService.db const [newMatch] = await this.drizzleService.db
.insert(match) .insert(match)
.values({ user1Id: userId1, user2Id: userId2 }) .values({ profile1Id: profileId1, profile2Id: profileId2 })
.returning(); .returning();
await this.notifyMatch(userId1, userId2, newMatch.id); await this.notifyMatch(profileId1, profileId2, newMatch.id);
return { like: newLike, match: newMatch }; return { like: newLike, match: newMatch };
} }
private async getActiveMatchesCount(userId: string): Promise<number> { private async getMatchesCount(profileId: string): Promise<number> {
const matches = await this.drizzleService.db const matches = await this.drizzleService.db
.select({ id: match.id }) .select({ id: match.id })
.from(match) .from(match)
.where( .where(or(eq(match.profile1Id, profileId), eq(match.profile2Id, profileId)));
or(eq(match.user1Id, userId), eq(match.user2Id, userId)),
);
return matches.length; return matches.length;
} }
private async notifyMatch(userId1: string, userId2: string, matchId: string) { private async notifyMatch(profileId1: string, profileId2: string, matchId: string) {
const users = await this.drizzleService.db const profiles = await this.drizzleService.db
.select({ id: user.id, fcmToken: user.fcmToken }) .select({ userId: profile.userId })
.from(user) .from(profile)
.where(or(eq(user.id, userId1), eq(user.id, userId2))); .where(or(eq(profile.id, profileId1), eq(profile.id, profileId2)));
for (const u of users) { for (const p of profiles) {
if (u.fcmToken) { 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( await this.notificationsService.sendPushNotification(
u.fcmToken, u.fcmToken,
'New Match!', '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) { private async assertProfileOwnership(userId: string, profileId: string) {
return this.drizzleService.db const [found] = await this.drizzleService.db
.select() .select({ userId: profile.userId })
.from(match) .from(profile)
.where( .where(eq(profile.id, profileId))
or(eq(match.user1Id, userId), eq(match.user2Id, userId)), .limit(1);
)
.orderBy(match.createdAt); if (!found) throw new NotFoundException('Profile not found');
if (found.userId !== userId) throw new ForbiddenException('Profile does not belong to you');
} }
} }

View File

@@ -1,13 +1,4 @@
import { import { Controller, Delete, Get, Param, Post, Query, Req, UseGuards } from '@nestjs/common';
Controller,
Delete,
Get,
Param,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FastifyRequest } from 'fastify'; import { FastifyRequest } from 'fastify';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
@@ -17,39 +8,42 @@ import { MediaService } from './media.service';
@ApiTags('media') @ApiTags('media')
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('media') @Controller('profiles/:profileId/media')
export class MediaController { export class MediaController {
constructor(private readonly mediaService: MediaService) {} constructor(private readonly mediaService: MediaService) {}
@Post('upload') @Post('upload')
@ApiOperation({ summary: 'Upload photo or video' }) @ApiOperation({ summary: 'Upload photo / video / audio to profile' })
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
async upload( async upload(
@CurrentUser('id') userId: string, @CurrentUser('id') userId: string,
@Param('profileId') profileId: string,
@Req() req: FastifyRequest, @Req() req: FastifyRequest,
@Query('type') type: 'photo' | 'video' = 'photo', @Query('type') type: 'photo' | 'video' | 'audio' = 'photo',
) { ) {
const data = await (req as any).file(); const data = await (req as any).file();
if (!data) { if (!data) throw new Error('No file provided');
throw new Error('No file provided');
}
const buffer = await data.toBuffer(); const buffer = await data.toBuffer();
return this.mediaService.uploadMedia( return this.mediaService.upload(
userId, userId,
profileId,
{ buffer, originalname: data.filename, mimetype: data.mimetype }, { buffer, originalname: data.filename, mimetype: data.mimetype },
type, type,
); );
} }
@Get() @Get()
@ApiOperation({ summary: 'Get my media' }) @ApiOperation({ summary: 'Get all media for a profile' })
getMyMedia(@CurrentUser('id') userId: string) { getMedia(@Param('profileId') profileId: string) {
return this.mediaService.getByUserId(userId); return this.mediaService.getByProfileId(profileId);
} }
@Delete(':id') @Delete(':mediaId')
@ApiOperation({ summary: 'Delete media' }) @ApiOperation({ summary: 'Delete media item' })
deleteMedia(@CurrentUser('id') userId: string, @Param('id') id: string) { deleteMedia(
return this.mediaService.deleteMedia(userId, id); @CurrentUser('id') userId: string,
@Param('mediaId') mediaId: string,
) {
return this.mediaService.delete(userId, mediaId);
} }
} }

View File

@@ -1,7 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { DrizzleService } from '../../database/drizzle.service'; import { DrizzleService } from '../../database/drizzle.service';
import { media } from '../../database/schema'; import { profile, profileMedia } from '../../database/schema';
import { StorageService } from '../../storage/storage.service'; import { StorageService } from '../../storage/storage.service';
@Injectable() @Injectable()
@@ -11,50 +11,75 @@ export class MediaService {
private readonly storageService: StorageService, private readonly storageService: StorageService,
) {} ) {}
async uploadMedia( async upload(
userId: string, userId: string,
profileId: string,
file: { buffer: Buffer; originalname: string; mimetype: 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( const objectName = await this.storageService.uploadFile(
file.buffer, file.buffer,
file.originalname, file.originalname,
file.mimetype, file.mimetype,
folder, 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 const [newMedia] = await this.drizzleService.db
.insert(media) .insert(profileMedia)
.values({ userId, path: publicUrl, type }) .values({ profileId, path, type, sortOrder: nextOrder } as any)
.returning(); .returning();
return newMedia; return newMedia;
} }
async getByUserId(userId: string) { async getByProfileId(profileId: string) {
return this.drizzleService.db return this.drizzleService.db
.select() .select()
.from(media) .from(profileMedia)
.where(eq(media.userId, userId)); .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 const [found] = await this.drizzleService.db
.select() .select()
.from(media) .from(profileMedia)
.where(eq(media.id, mediaId)) .where(eq(profileMedia.id, mediaId))
.limit(1); .limit(1);
if (!found || found.userId !== userId) { if (!found) throw new NotFoundException('Media not found');
throw new NotFoundException('Media not found');
} await this.assertOwnership(userId, found.profileId);
const objectName = found.path.split('/').slice(-2).join('/'); const objectName = found.path.split('/').slice(-2).join('/');
await this.storageService.deleteFile(objectName).catch(() => {}); 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' }; 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');
}
} }

View File

@@ -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 { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
@@ -14,32 +14,36 @@ export class ProfilesController {
constructor(private readonly profilesService: ProfilesService) {} constructor(private readonly profilesService: ProfilesService) {}
@Post() @Post()
@ApiOperation({ summary: 'Create my profile' }) @ApiOperation({ summary: 'Create a new profile (one user can have many)' })
create( create(@CurrentUser('id') userId: string, @Body() dto: CreateProfileDto) {
@CurrentUser('id') userId: string,
@Body() dto: CreateProfileDto,
) {
return this.profilesService.create(userId, dto); return this.profilesService.create(userId, dto);
} }
@Put() @Get('my')
@ApiOperation({ summary: 'Update my profile' }) @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( update(
@CurrentUser('id') userId: string, @CurrentUser('id') userId: string,
@Param('profileId') profileId: string,
@Body() dto: UpdateProfileDto, @Body() dto: UpdateProfileDto,
) { ) {
return this.profilesService.update(userId, dto); return this.profilesService.update(userId, profileId, dto);
} }
@Get('me') @Get(':profileId')
@ApiOperation({ summary: 'Get my profile' })
getMyProfile(@CurrentUser('id') userId: string) {
return this.profilesService.findByUserId(userId);
}
@Get(':id')
@ApiOperation({ summary: 'Get profile by ID' }) @ApiOperation({ summary: 'Get profile by ID' })
findOne(@Param('id') id: string) { findOne(@Param('profileId') profileId: string) {
return this.profilesService.findByProfileId(id); 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);
} }
} }

View File

@@ -1,12 +1,7 @@
import { import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { eq, inArray } from 'drizzle-orm'; import { eq, inArray } from 'drizzle-orm';
import { DrizzleService } from '../../database/drizzle.service'; 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 { CreateProfileDto } from './dto/create-profile.dto';
import { UpdateProfileDto } from './dto/update-profile.dto'; import { UpdateProfileDto } from './dto/update-profile.dto';
@@ -15,14 +10,6 @@ export class ProfilesService {
constructor(private readonly drizzleService: DrizzleService) {} constructor(private readonly drizzleService: DrizzleService) {}
async create(userId: string, dto: CreateProfileDto) { 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 const [newProfile] = await this.drizzleService.db
.insert(profile) .insert(profile)
.values({ .values({
@@ -46,40 +33,36 @@ export class ProfilesService {
return this.findByProfileId(newProfile.id); return this.findByProfileId(newProfile.id);
} }
async update(userId: string, dto: UpdateProfileDto) { async update(userId: string, profileId: string, dto: UpdateProfileDto) {
const [found] = await this.drizzleService.db await this.assertOwnership(userId, profileId);
.select({ id: profile.id })
.from(profile)
.where(eq(profile.userId, userId))
.limit(1);
if (!found) throw new NotFoundException('Profile not found');
const { tagIds, ...fields } = dto; 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 await this.drizzleService.db
.update(profile) .update(profile)
.set(fields) .set(updateFields as any)
.where(eq(profile.id, found.id)); .where(eq(profile.id, profileId));
} }
if (tagIds !== undefined) { 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) { async findAllByUserId(userId: string) {
const [found] = await this.drizzleService.db const profiles = await this.drizzleService.db
.select({ id: profile.id }) .select()
.from(profile) .from(profile)
.where(eq(profile.userId, userId)) .where(eq(profile.userId, userId));
.limit(1);
if (!found) throw new NotFoundException('Profile not found'); return Promise.all(profiles.map((p) => this.findByProfileId(p.id)));
return this.findByProfileId(found.id);
} }
async findByProfileId(profileId: string) { async findByProfileId(profileId: string) {
@@ -93,18 +76,43 @@ export class ProfilesService {
if (!found) throw new NotFoundException('Profile not found'); if (!found) throw new NotFoundException('Profile not found');
const tags = await this.drizzleService.db const [tags, media] = await Promise.all([
.select({ id: tag.id, value: tag.value }) this.drizzleService.db
.from(profileTag) .select({ id: tag.id, value: tag.value })
.innerJoin(tag, eq(tag.id, profileTag.tagId)) .from(profileTag)
.where(eq(profileTag.profileId, profileId)); .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 return {
.select() ...found.profile,
.from(media) city: found.city,
.where(eq(media.userId, found.profile.userId)); 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[]) { private async setTags(profileId: string, tagIds: string[]) {
@@ -113,9 +121,9 @@ export class ProfilesService {
.where(eq(profileTag.profileId, profileId)); .where(eq(profileTag.profileId, profileId));
if (tagIds.length > 0) { if (tagIds.length > 0) {
await this.drizzleService.db.insert(profileTag).values( await this.drizzleService.db
tagIds.map((tagId) => ({ profileId, tagId })), .insert(profileTag)
); .values(tagIds.map((tagId) => ({ profileId, tagId })));
} }
} }
} }

View File

@@ -2,6 +2,10 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
export class CreateReportDto { export class CreateReportDto {
@ApiProperty({ description: 'Your profile ID' })
@IsUUID()
sourceProfileId: string;
@ApiProperty() @ApiProperty()
@IsUUID() @IsUUID()
entityId: string; entityId: string;

View File

@@ -16,10 +16,7 @@ export class ReportsController {
@Post() @Post()
@ApiOperation({ summary: 'Submit a report' }) @ApiOperation({ summary: 'Submit a report' })
create( create(@CurrentUser('id') userId: string, @Body() dto: CreateReportDto) {
@CurrentUser('id') userId: string,
@Body() dto: CreateReportDto,
) {
return this.reportsService.create(userId, dto); return this.reportsService.create(userId, dto);
} }

View File

@@ -1,23 +1,26 @@
import { Injectable } from '@nestjs/common'; import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { DrizzleService } from '../../database/drizzle.service';
import { report } from '../../database/schema';
import { CreateReportDto } from './dto/create-report.dto';
import { eq } from 'drizzle-orm'; 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() @Injectable()
export class ReportsService { export class ReportsService {
constructor(private readonly drizzleService: DrizzleService) {} constructor(private readonly drizzleService: DrizzleService) {}
async create(userId: string, dto: CreateReportDto) { async create(userId: string, dto: CreateReportDto) {
await this.assertProfileOwnership(userId, dto.sourceProfileId);
const [newReport] = await this.drizzleService.db const [newReport] = await this.drizzleService.db
.insert(report) .insert(report)
.values({ .values({
sourceUser: userId, sourceProfileId: dto.sourceProfileId,
entityId: dto.entityId, entityId: dto.entityId,
entityType: dto.entityType, entityType: dto.entityType,
description: dto.description || null, description: dto.description || null,
} as any) } as any)
.returning(); .returning();
return newReport; return newReport;
} }
@@ -25,10 +28,14 @@ export class ReportsService {
return this.drizzleService.db.select().from(report).orderBy(report.id); return this.drizzleService.db.select().from(report).orderBy(report.id);
} }
async getByUser(userId: string) { private async assertProfileOwnership(userId: string, profileId: string) {
return this.drizzleService.db const [found] = await this.drizzleService.db
.select() .select({ userId: profile.userId })
.from(report) .from(profile)
.where(eq(report.sourceUser, userId)); .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');
} }
} }

View File

@@ -14,9 +14,9 @@ export class UsersController {
constructor(private readonly usersService: UsersService) {} constructor(private readonly usersService: UsersService) {}
@Get('me') @Get('me')
@ApiOperation({ summary: 'Get current user profile' }) @ApiOperation({ summary: 'Get current user with profile list' })
getMe(@CurrentUser('id') userId: string) { getMe(@CurrentUser('id') userId: string) {
return this.usersService.getMyProfile(userId); return this.usersService.getMe(userId);
} }
@Get(':id') @Get(':id')
@@ -28,7 +28,7 @@ export class UsersController {
@Patch(':id/ban') @Patch(':id/ban')
@Roles('admin', 'moderator') @Roles('admin', 'moderator')
@UseGuards(RolesGuard) @UseGuards(RolesGuard)
@ApiOperation({ summary: 'Ban user (admin/moderator only)' }) @ApiOperation({ summary: 'Ban user' })
ban(@Param('id') id: string) { ban(@Param('id') id: string) {
return this.usersService.banUser(id); return this.usersService.banUser(id);
} }
@@ -36,7 +36,7 @@ export class UsersController {
@Patch(':id/activate') @Patch(':id/activate')
@Roles('admin', 'moderator') @Roles('admin', 'moderator')
@UseGuards(RolesGuard) @UseGuards(RolesGuard)
@ApiOperation({ summary: 'Activate user (admin/moderator only)' }) @ApiOperation({ summary: 'Activate user' })
activate(@Param('id') id: string) { activate(@Param('id') id: string) {
return this.usersService.activateUser(id); return this.usersService.activateUser(id);
} }

View File

@@ -1,7 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { DrizzleService } from '../../database/drizzle.service'; import { DrizzleService } from '../../database/drizzle.service';
import { user, profile, media, role } from '../../database/schema'; import { user, profile, role } from '../../database/schema';
@Injectable() @Injectable()
export class UsersService { export class UsersService {
@@ -19,38 +19,23 @@ export class UsersService {
return rest; return rest;
} }
async findByIdWithProfile(id: string) { async getMe(userId: string) {
const [found] = await this.drizzleService.db const [found] = await this.drizzleService.db
.select() .select()
.from(user) .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)) .leftJoin(role, eq(role.id, user.roleId))
.where(eq(user.id, userId)) .where(eq(user.id, userId))
.limit(1); .limit(1);
if (!result.length) throw new NotFoundException('User not found'); if (!found) throw new NotFoundException('User not found');
const row = result[0]; const { password, ...userFields } = found.user;
const { password, ...userFields } = row.user;
return { ...userFields, profile: row.profile, role: row.role };
}
async getMediaByUserId(userId: string) { const profiles = await this.drizzleService.db
return this.drizzleService.db .select({ id: profile.id, name: profile.name, gender: profile.gender })
.select() .from(profile)
.from(media) .where(eq(profile.userId, userId));
.where(eq(media.userId, userId));
return { ...userFields, role: found.role, profiles };
} }
async banUser(userId: string) { async banUser(userId: string) {