full project: admin panel, backend modules, docs
This commit is contained in:
@@ -1,8 +1,5 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
require("dotenv/config");
|
||||
const config_1 = require("prisma/config");
|
||||
exports.default = (0, config_1.defineConfig)({
|
||||
const { defineConfig } = require("prisma/config");
|
||||
module.exports = defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
@@ -10,5 +7,4 @@ exports.default = (0, config_1.defineConfig)({
|
||||
datasource: {
|
||||
url: process.env["DATABASE_URL"],
|
||||
},
|
||||
});
|
||||
//# sourceMappingURL=prisma.config.js.map
|
||||
});
|
||||
@@ -0,0 +1,363 @@
|
||||
Loaded Prisma config from prisma.config.js.
|
||||
|
||||
-- CreateSchema
|
||||
CREATE SCHEMA IF NOT EXISTS "public";
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "service_location" AS ENUM ('office', 'delivery');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "service_status" AS ENUM ('pending', 'accepted', 'denied', 'active', 'cancelled', 'completed', 'self_booked');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "chats" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"user_id" UUID NOT NULL,
|
||||
"professional_id" UUID NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "chats_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "cities" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"region_id" UUID NOT NULL,
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
"latitude" DECIMAL(10,7),
|
||||
"longitude" DECIMAL(10,7),
|
||||
|
||||
CONSTRAINT "cities_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "comments" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"author_id" UUID NOT NULL,
|
||||
"destination_id" UUID NOT NULL,
|
||||
"service_id" UUID,
|
||||
"content" TEXT,
|
||||
"score" SMALLINT NOT NULL,
|
||||
"is_from_user" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "comments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "countries" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
|
||||
CONSTRAINT "countries_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "messages" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"chat_id" UUID NOT NULL,
|
||||
"sender_id" UUID NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "messages_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "payment_methods" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"professional_id" UUID NOT NULL,
|
||||
"nequi" BOOLEAN DEFAULT false,
|
||||
"datafono" BOOLEAN DEFAULT false,
|
||||
"transferencia" BOOLEAN DEFAULT false,
|
||||
|
||||
CONSTRAINT "payment_methods_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "professionals" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"user_id" UUID NOT NULL,
|
||||
"identification" VARCHAR(50),
|
||||
"address" TEXT,
|
||||
"additional_address" TEXT,
|
||||
"profession" VARCHAR(255),
|
||||
"rate" DECIMAL(10,2),
|
||||
"rate_preferences" TEXT,
|
||||
"location_preferences" VARCHAR(20) DEFAULT 'office',
|
||||
"banner_picture" TEXT,
|
||||
"identification_picture" TEXT,
|
||||
"certificate_picture" TEXT,
|
||||
"latitude" DECIMAL(10,7),
|
||||
"longitude" DECIMAL(10,7),
|
||||
"average_score" DECIMAL(3,2) DEFAULT 0,
|
||||
"is_active" BOOLEAN DEFAULT true,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "professionals_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "professions" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
|
||||
CONSTRAINT "professions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "regions" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"country_id" UUID NOT NULL,
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
|
||||
CONSTRAINT "regions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "reputations" (
|
||||
"user_id" UUID NOT NULL,
|
||||
"total" INTEGER NOT NULL DEFAULT 0,
|
||||
"average" DECIMAL(3,2) NOT NULL DEFAULT 0,
|
||||
"total_pro" INTEGER NOT NULL DEFAULT 0,
|
||||
"average_pro" DECIMAL(3,2) NOT NULL DEFAULT 0,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "reputations_pkey" PRIMARY KEY ("user_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "schedules" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"professional_id" UUID NOT NULL,
|
||||
"day_of_week" SMALLINT NOT NULL,
|
||||
"enabled" BOOLEAN DEFAULT false,
|
||||
"continuous_day" BOOLEAN DEFAULT false,
|
||||
"range1_hour1" TIME(6),
|
||||
"range1_hour2" TIME(6),
|
||||
"range2_hour1" TIME(6),
|
||||
"range2_hour2" TIME(6),
|
||||
|
||||
CONSTRAINT "schedules_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "services" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"professional_id" UUID NOT NULL,
|
||||
"user_id" UUID NOT NULL,
|
||||
"address" TEXT,
|
||||
"additional_address" TEXT,
|
||||
"latitude" DECIMAL(10,7),
|
||||
"longitude" DECIMAL(10,7),
|
||||
"day" DATE NOT NULL,
|
||||
"description" TEXT,
|
||||
"rate" DECIMAL(10,2),
|
||||
"range1_hour1" TIME(6),
|
||||
"range1_hour2" TIME(6),
|
||||
"status" "service_status" NOT NULL DEFAULT 'pending',
|
||||
"location_preference" "service_location" DEFAULT 'office',
|
||||
"professional_scored" BOOLEAN DEFAULT false,
|
||||
"user_scored" BOOLEAN DEFAULT false,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "services_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "settings" (
|
||||
"key" VARCHAR(100) NOT NULL,
|
||||
"value" JSONB NOT NULL,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "settings_pkey" PRIMARY KEY ("key")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "specializations" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"professional_id" UUID NOT NULL,
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
"picture" TEXT,
|
||||
|
||||
CONSTRAINT "specializations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"email" VARCHAR(255),
|
||||
"phone" VARCHAR(20),
|
||||
"password_hash" VARCHAR(255),
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
"nickname" VARCHAR(100),
|
||||
"city" VARCHAR(100),
|
||||
"picture" TEXT,
|
||||
"birthday" DATE,
|
||||
"gender" VARCHAR(20),
|
||||
"pro_state" SMALLINT NOT NULL DEFAULT 0,
|
||||
"fcm_token" TEXT,
|
||||
"is_phone_verified" BOOLEAN DEFAULT false,
|
||||
"is_email_verified" BOOLEAN DEFAULT false,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_chats_professional_id" ON "chats"("professional_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_chats_user_id" ON "chats"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "chats_user_id_professional_id_key" ON "chats"("user_id", "professional_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_cities_region_id" ON "cities"("region_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_comments_author_id" ON "comments"("author_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_comments_destination_id" ON "comments"("destination_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_comments_service_id" ON "comments"("service_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "countries_name_key" ON "countries"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_messages_chat_id" ON "messages"("chat_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_messages_created_at" ON "messages"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "payment_methods_professional_id_key" ON "payment_methods"("professional_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "professionals_user_id_key" ON "professionals"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_professionals_user_id" ON "professionals"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "professions_name_key" ON "professions"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_regions_country_id" ON "regions"("country_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_schedules_professional_id" ON "schedules"("professional_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "schedules_professional_id_day_of_week_key" ON "schedules"("professional_id", "day_of_week");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_services_day" ON "services"("day");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_services_professional_id" ON "services"("professional_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_services_status" ON "services"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_services_user_id" ON "services"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_specializations_professional_id" ON "specializations"("professional_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_phone_key" ON "users"("phone");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "chats" ADD CONSTRAINT "chats_professional_id_fkey" FOREIGN KEY ("professional_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "chats" ADD CONSTRAINT "chats_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "cities" ADD CONSTRAINT "cities_region_id_fkey" FOREIGN KEY ("region_id") REFERENCES "regions"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_destination_id_fkey" FOREIGN KEY ("destination_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_service_id_fkey" FOREIGN KEY ("service_id") REFERENCES "services"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "messages" ADD CONSTRAINT "messages_chat_id_fkey" FOREIGN KEY ("chat_id") REFERENCES "chats"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "messages" ADD CONSTRAINT "messages_sender_id_fkey" FOREIGN KEY ("sender_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "payment_methods" ADD CONSTRAINT "payment_methods_professional_id_fkey" FOREIGN KEY ("professional_id") REFERENCES "professionals"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "professionals" ADD CONSTRAINT "professionals_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "regions" ADD CONSTRAINT "regions_country_id_fkey" FOREIGN KEY ("country_id") REFERENCES "countries"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "reputations" ADD CONSTRAINT "reputations_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "schedules" ADD CONSTRAINT "schedules_professional_id_fkey" FOREIGN KEY ("professional_id") REFERENCES "professionals"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "services" ADD CONSTRAINT "services_professional_id_fkey" FOREIGN KEY ("professional_id") REFERENCES "professionals"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "services" ADD CONSTRAINT "services_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "specializations" ADD CONSTRAINT "specializations_professional_id_fkey" FOREIGN KEY ("professional_id") REFERENCES "professionals"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- CreateTrigger: update reputation on comment insert/update
|
||||
CREATE OR REPLACE FUNCTION update_reputation()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
UPDATE reputations
|
||||
SET
|
||||
total = (SELECT COUNT(*) FROM comments WHERE destination_id = NEW.destination_id AND is_from_user = false),
|
||||
average = (SELECT COALESCE(AVG(score), 0) FROM comments WHERE destination_id = NEW.destination_id AND is_from_user = false),
|
||||
total_pro = (SELECT COUNT(*) FROM comments WHERE destination_id = NEW.destination_id AND is_from_user = true),
|
||||
average_pro = (SELECT COALESCE(AVG(score), 0) FROM comments WHERE destination_id = NEW.destination_id AND is_from_user = true),
|
||||
updated_at = NOW()
|
||||
WHERE user_id = NEW.destination_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
INSERT INTO reputations (user_id, total, average, total_pro, average_pro, updated_at)
|
||||
VALUES (
|
||||
NEW.destination_id,
|
||||
(SELECT COUNT(*) FROM comments WHERE destination_id = NEW.destination_id AND is_from_user = false),
|
||||
(SELECT COALESCE(AVG(score), 0) FROM comments WHERE destination_id = NEW.destination_id AND is_from_user = false),
|
||||
(SELECT COUNT(*) FROM comments WHERE destination_id = NEW.destination_id AND is_from_user = true),
|
||||
(SELECT COALESCE(AVG(score), 0) FROM comments WHERE destination_id = NEW.destination_id AND is_from_user = true),
|
||||
NOW()
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE TRIGGER trg_update_reputation
|
||||
AFTER INSERT OR UPDATE ON comments
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_reputation();
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -11,6 +11,7 @@ import { LocationsModule } from './locations/locations.module';
|
||||
import { SettingsModule } from './settings/settings.module';
|
||||
import { ProfessionsModule } from './professions/professions.module';
|
||||
import { StorageModule } from './storage/storage.module';
|
||||
import { NotificationsModule } from './notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -26,6 +27,7 @@ import { StorageModule } from './storage/storage.module';
|
||||
SettingsModule,
|
||||
ProfessionsModule,
|
||||
StorageModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Controller, Post, Body, UseGuards, Get, Req } from '@nestjs/common';
|
||||
import { Controller, Post, Body, UseGuards, Get, Req, Patch } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { RegisterDto, LoginDto, PhoneDto, UpdateUserDto, FcmTokenDto } from './dto/auth.dto';
|
||||
|
||||
@ApiTags('Auth')
|
||||
@Controller('auth')
|
||||
@@ -9,20 +10,34 @@ export class AuthController {
|
||||
constructor(private auth: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
register(@Body() dto: { email: string; password: string; name: string }) {
|
||||
register(@Body() dto: RegisterDto) {
|
||||
return this.auth.register(dto.email, dto.password, dto.name);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
login(@Body() dto: { email: string; password: string }) {
|
||||
login(@Body() dto: LoginDto) {
|
||||
return this.auth.login(dto.email, dto.password);
|
||||
}
|
||||
|
||||
@Post('phone')
|
||||
phone(@Body() dto: { phone: string; name?: string }) {
|
||||
phone(@Body() dto: PhoneDto) {
|
||||
return this.auth.loginOrCreateByPhone(dto.phone, dto.name);
|
||||
}
|
||||
|
||||
@Post('verify-phone')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
verifyPhone(@Req() req, @Body() dto: { phone: string }) {
|
||||
return this.auth.verifyOtpAndLinkPhone(req.user.sub, dto.phone);
|
||||
}
|
||||
|
||||
@Post('link-email')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
linkEmail(@Req() req, @Body() dto: { email: string; password: string }) {
|
||||
return this.auth.linkEmail(req.user.sub, dto.email, dto.password);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
@@ -8,9 +9,13 @@ import { JwtStrategy } from './jwt.strategy';
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET || 'prosapp-secret-dev',
|
||||
signOptions: { expiresIn: '30d' },
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>('JWT_SECRET'),
|
||||
signOptions: { expiresIn: '7d' },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
|
||||
@@ -16,7 +16,7 @@ export class AuthService {
|
||||
|
||||
const password_hash = await bcrypt.hash(password, 10);
|
||||
const user = await this.prisma.users.create({
|
||||
data: { email, password_hash, name, is_email_verified: true },
|
||||
data: { email, password_hash, name },
|
||||
});
|
||||
|
||||
return this.generateToken(user);
|
||||
@@ -36,14 +36,36 @@ export class AuthService {
|
||||
let user = await this.prisma.users.findUnique({ where: { phone } });
|
||||
if (!user) {
|
||||
user = await this.prisma.users.create({
|
||||
data: { phone, name: name || phone, is_phone_verified: true },
|
||||
data: { phone, name: name || phone },
|
||||
});
|
||||
}
|
||||
return this.generateToken(user);
|
||||
}
|
||||
|
||||
async verifyOtpAndLinkPhone(userId: string, phone: string) {
|
||||
const existing = await this.prisma.users.findUnique({ where: { phone } });
|
||||
if (existing && existing.id !== userId) {
|
||||
throw new ConflictException('Teléfono ya registrado por otro usuario');
|
||||
}
|
||||
return this.prisma.users.update({
|
||||
where: { id: userId },
|
||||
data: { phone, is_phone_verified: true },
|
||||
});
|
||||
}
|
||||
|
||||
async linkEmail(userId: string, email: string, password: string) {
|
||||
const existing = await this.prisma.users.findUnique({ where: { email } });
|
||||
if (existing) throw new ConflictException('Email ya registrado');
|
||||
|
||||
const password_hash = await bcrypt.hash(password, 10);
|
||||
return this.prisma.users.update({
|
||||
where: { id: userId },
|
||||
data: { email, password_hash },
|
||||
});
|
||||
}
|
||||
|
||||
async me(userId: string) {
|
||||
return this.prisma.users.findUnique({
|
||||
const user = await this.prisma.users.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
professionals: {
|
||||
@@ -52,6 +74,13 @@ export class AuthService {
|
||||
reputations: true,
|
||||
},
|
||||
});
|
||||
if (!user) throw new UnauthorizedException('Usuario no encontrado');
|
||||
return user;
|
||||
}
|
||||
|
||||
async getProfessionalId(userId: string): Promise<string | null> {
|
||||
const prof = await this.prisma.professionals.findUnique({ where: { user_id: userId } });
|
||||
return prof?.id || null;
|
||||
}
|
||||
|
||||
private generateToken(user: any) {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { IsEmail, IsString, MinLength, IsOptional, IsPhoneNumber, Matches } from 'class-validator';
|
||||
|
||||
export class RegisterDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
password: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
name: string;
|
||||
}
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class PhoneDto {
|
||||
@IsString()
|
||||
phone: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export class UpdateUserDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nickname?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
city?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
picture?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
gender?: string;
|
||||
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'birthday must be YYYY-MM-DD' })
|
||||
@IsOptional()
|
||||
birthday?: string;
|
||||
}
|
||||
|
||||
export class FcmTokenDto {
|
||||
@IsString()
|
||||
token: string;
|
||||
}
|
||||
@@ -1,21 +1,25 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(private prisma: PrismaService) {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private prisma: PrismaService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: process.env.JWT_SECRET || 'prosapp-secret-dev',
|
||||
secretOrKey: config.get<string>('JWT_SECRET')!,
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: { sub: string }) {
|
||||
const user = await this.prisma.users.findUnique({ where: { id: payload.sub } });
|
||||
if (!user) return null;
|
||||
return { sub: user.id, email: user.email, phone: user.phone };
|
||||
if (!user) throw new UnauthorizedException('Token inválido');
|
||||
return { sub: user.id, email: user.email, phone: user.phone, role: user.pro_state >= 2 ? 'professional' : 'user' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,47 @@
|
||||
import { Controller, Get, Post, Param, Body, UseGuards, Req } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Controller, Get, Post, Param, Body, UseGuards, Req, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { ChatService } from './chat.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { SendMessageDto } from './dto/chat.dto';
|
||||
|
||||
@ApiTags('Chat')
|
||||
@Controller('chat')
|
||||
export class ChatController {
|
||||
constructor(private chat: ChatService) {}
|
||||
|
||||
@Post('start/:professionalId')
|
||||
@Post('start/:professionalUserId')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
start(@Req() req, @Param('professionalId') professionalId: string) {
|
||||
return this.chat.getOrCreateChat(req.user.sub, professionalId);
|
||||
start(@Req() req, @Param('professionalUserId') professionalUserId: string) {
|
||||
return this.chat.getOrCreateChat(req.user.sub, professionalUserId);
|
||||
}
|
||||
|
||||
@Post(':chatId/message')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
sendMessage(@Req() req, @Param('chatId') chatId: string, @Body() dto: { content: string }) {
|
||||
sendMessage(@Req() req, @Param('chatId') chatId: string, @Body() dto: SendMessageDto) {
|
||||
return this.chat.sendMessage(chatId, req.user.sub, dto.content);
|
||||
}
|
||||
|
||||
@Get('my')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'limit', required: false })
|
||||
myChats(@Req() req) {
|
||||
return this.chat.getUserChats(req.user.sub);
|
||||
const page = +(req.query.page || 1);
|
||||
const limit = +(req.query.limit || 20);
|
||||
return this.chat.getUserChats(req.user.sub, page, limit);
|
||||
}
|
||||
|
||||
@Get(':chatId/messages')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
messages(@Param('chatId') chatId: string) {
|
||||
return this.chat.getChatMessages(chatId);
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'limit', required: false })
|
||||
messages(@Req() req, @Param('chatId') chatId: string) {
|
||||
const page = +(req.query.page || 1);
|
||||
const limit = +(req.query.limit || 50);
|
||||
return this.chat.getChatMessages(chatId, req.user.sub, page, limit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { ChatService } from './chat.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
|
||||
@WebSocketGateway({ cors: { origin: '*' } })
|
||||
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@@ -18,39 +19,54 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
|
||||
private userSockets = new Map<string, string>();
|
||||
|
||||
constructor(private chat: ChatService, private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private chat: ChatService,
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
handleConnection(client: Socket) {
|
||||
const userId = client.handshake.query.userId as string;
|
||||
if (userId) {
|
||||
this.userSockets.set(userId, client.id);
|
||||
client.join(`user:${userId}`);
|
||||
async handleConnection(client: Socket) {
|
||||
const token = client.handshake.auth?.token || client.handshake.query?.token as string;
|
||||
if (!token) {
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret) { client.disconnect(); return; }
|
||||
const payload = jwt.verify(token, secret) as { sub: string };
|
||||
const user = await this.prisma.users.findUnique({ where: { id: payload.sub } });
|
||||
if (!user) {
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
(client as any).userId = payload.sub;
|
||||
this.userSockets.set(payload.sub, client.id);
|
||||
client.join(`user:${payload.sub}`);
|
||||
} catch {
|
||||
client.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
handleDisconnect(client: Socket) {
|
||||
for (const [userId, socketId] of this.userSockets) {
|
||||
if (socketId === client.id) {
|
||||
this.userSockets.delete(userId);
|
||||
break;
|
||||
}
|
||||
const userId = (client as any).userId;
|
||||
if (userId) {
|
||||
this.userSockets.delete(userId);
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage('sendMessage')
|
||||
async handleMessage(@ConnectedSocket() client: Socket, @MessageBody() data: { chatId: string; content: string }) {
|
||||
const userId = client.handshake.query.userId as string;
|
||||
const message = await this.chat.sendMessage(data.chatId, userId, data.content);
|
||||
const userId = (client as any).userId;
|
||||
if (!userId) return;
|
||||
|
||||
const chat = await this.prisma.chats.findUnique({
|
||||
where: { id: data.chatId },
|
||||
});
|
||||
const message = await this.chat.sendMessage(data.chatId, userId, data.content);
|
||||
const chat = await this.prisma.chats.findUnique({ where: { id: data.chatId } });
|
||||
|
||||
if (chat) {
|
||||
this.server.to(`user:${chat.user_id}`).emit('newMessage', message);
|
||||
this.server.to(`user:${chat.professional_id}`).emit('newMessage', message);
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,11 @@ import { Module } from '@nestjs/common';
|
||||
import { ChatService } from './chat.service';
|
||||
import { ChatController } from './chat.controller';
|
||||
import { ChatGateway } from './chat.gateway';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, PrismaModule],
|
||||
providers: [ChatService, ChatGateway],
|
||||
controllers: [ChatController],
|
||||
exports: [ChatService],
|
||||
|
||||
@@ -1,53 +1,84 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class ChatService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async getOrCreateChat(userId: string, professionalUserId: string) {
|
||||
const prof = await this.prisma.professionals.findUnique({ where: { user_id: professionalUserId } });
|
||||
if (!prof) throw new NotFoundException('Profesional no encontrado');
|
||||
|
||||
async getOrCreateChat(userId: string, professionalId: string) {
|
||||
const existing = await this.prisma.chats.findUnique({
|
||||
where: { user_id_professional_id: { user_id: userId, professional_id: professionalId } },
|
||||
include: { messages: { orderBy: { created_at: 'asc' } } },
|
||||
where: { user_id_professional_id: { user_id: userId, professional_id: professionalUserId } },
|
||||
include: { messages: { orderBy: { created_at: 'asc' }, take: 50 } },
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
return this.prisma.chats.create({
|
||||
data: { user_id: userId, professional_id: professionalId },
|
||||
data: { user_id: userId, professional_id: professionalUserId },
|
||||
include: { messages: true },
|
||||
});
|
||||
}
|
||||
|
||||
async sendMessage(chatId: string, senderId: string, content: string) {
|
||||
const message = await this.prisma.messages.create({
|
||||
const chat = await this.prisma.chats.findUnique({ where: { id: chatId } });
|
||||
if (!chat) throw new NotFoundException('Chat no encontrado');
|
||||
|
||||
if (chat.user_id !== senderId && chat.professional_id !== senderId) {
|
||||
throw new ForbiddenException('No eres participante de este chat');
|
||||
}
|
||||
|
||||
return this.prisma.messages.create({
|
||||
data: { chat_id: chatId, sender_id: senderId, content },
|
||||
});
|
||||
|
||||
await this.prisma.chats.update({
|
||||
where: { id: chatId },
|
||||
data: {},
|
||||
});
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
getUserChats(userId: string) {
|
||||
return this.prisma.chats.findMany({
|
||||
where: {
|
||||
OR: [{ user_id: userId }, { professional_id: userId }],
|
||||
},
|
||||
include: {
|
||||
users_chats_user_idTousers: { select: { id: true, name: true, picture: true } },
|
||||
users_chats_professional_idTousers: { select: { id: true, name: true, picture: true } },
|
||||
messages: { orderBy: { created_at: 'desc' }, take: 1 },
|
||||
},
|
||||
});
|
||||
async getUserChats(userId: string, page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
const where = {
|
||||
OR: [
|
||||
{ user_id: userId },
|
||||
{ professional_id: userId },
|
||||
],
|
||||
};
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.chats.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
include: {
|
||||
users_chats_user_idTousers: { select: { id: true, name: true, picture: true } },
|
||||
users_chats_professional_idTousers: { select: { id: true, name: true, picture: true } },
|
||||
messages: { orderBy: { created_at: 'desc' }, take: 1 },
|
||||
},
|
||||
}),
|
||||
this.prisma.chats.count({ where }),
|
||||
]);
|
||||
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
||||
}
|
||||
|
||||
getChatMessages(chatId: string) {
|
||||
return this.prisma.messages.findMany({
|
||||
where: { chat_id: chatId },
|
||||
orderBy: { created_at: 'asc' },
|
||||
});
|
||||
async getChatMessages(chatId: string, userId: string, page = 1, limit = 50) {
|
||||
const chat = await this.prisma.chats.findUnique({ where: { id: chatId } });
|
||||
if (!chat) throw new NotFoundException('Chat no encontrado');
|
||||
|
||||
if (chat.user_id !== userId && chat.professional_id !== userId) {
|
||||
throw new ForbiddenException('No tienes acceso a este chat');
|
||||
}
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
const where = { chat_id: chatId };
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.messages.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { created_at: 'asc' },
|
||||
}),
|
||||
this.prisma.messages.count({ where }),
|
||||
]);
|
||||
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class SendMessageDto {
|
||||
@IsString()
|
||||
content: string;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Controller, Get, Post, Param, Body, UseGuards, Req } from '@nestjs/comm
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { CommentsService } from './comments.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CreateCommentDto } from './dto/comment.dto';
|
||||
|
||||
@ApiTags('Comments')
|
||||
@Controller('comments')
|
||||
@@ -11,18 +12,22 @@ export class CommentsController {
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
create(@Req() req, @Body() data: any) {
|
||||
return this.comments.create({ ...data, author_id: req.user.sub });
|
||||
create(@Req() req, @Body() dto: CreateCommentDto) {
|
||||
return this.comments.create({ ...dto, author_id: req.user.sub });
|
||||
}
|
||||
|
||||
@Get('user/:userId')
|
||||
getScoresForUser(@Param('userId') id: string) {
|
||||
return this.comments.getScoresForUser(id);
|
||||
getScoresForUser(@Param('userId') id: string, @Req() req) {
|
||||
const page = +(req.query.page || 1);
|
||||
const limit = +(req.query.limit || 20);
|
||||
return this.comments.getScoresForUser(id, page, limit);
|
||||
}
|
||||
|
||||
@Get('professional/:userId')
|
||||
getScoresForProfessional(@Param('userId') id: string) {
|
||||
return this.comments.getScoresForProfessional(id);
|
||||
getScoresForProfessional(@Param('userId') id: string, @Req() req) {
|
||||
const page = +(req.query.page || 1);
|
||||
const limit = +(req.query.limit || 20);
|
||||
return this.comments.getScoresForProfessional(id, page, limit);
|
||||
}
|
||||
|
||||
@Get('reputation/:userId')
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class CommentsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
create(data: {
|
||||
async create(data: {
|
||||
author_id: string;
|
||||
destination_id: string;
|
||||
service_id?: string;
|
||||
@@ -13,26 +13,100 @@ export class CommentsService {
|
||||
score: number;
|
||||
is_from_user: boolean;
|
||||
}) {
|
||||
return this.prisma.comments.create({ data });
|
||||
}
|
||||
if (data.service_id) {
|
||||
const service = await this.prisma.services.findUnique({ where: { id: data.service_id } });
|
||||
if (!service) throw new NotFoundException('Servicio no encontrado');
|
||||
if (service.status !== 'completed') throw new BadRequestException('Solo puedes calificar servicios completados');
|
||||
|
||||
getScoresForUser(userId: string) {
|
||||
return this.prisma.comments.findMany({
|
||||
where: { destination_id: userId, is_from_user: false },
|
||||
include: { users_comments_author_idTousers: { select: { name: true, picture: true } } },
|
||||
orderBy: { created_at: 'desc' },
|
||||
const alreadyScored = await this.prisma.comments.findFirst({
|
||||
where: { author_id: data.author_id, service_id: data.service_id },
|
||||
});
|
||||
if (alreadyScored) throw new BadRequestException('Ya calificaste este servicio');
|
||||
|
||||
const field = data.is_from_user ? 'user_scored' : 'professional_scored';
|
||||
await this.prisma.services.update({
|
||||
where: { id: data.service_id },
|
||||
data: { [field]: true },
|
||||
});
|
||||
|
||||
const service2 = await this.prisma.services.findUnique({ where: { id: data.service_id } });
|
||||
if (service2?.user_scored && service2?.professional_scored) {
|
||||
await this.prisma.services.update({
|
||||
where: { id: data.service_id },
|
||||
data: { status: 'completed' as any },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const comment = await this.prisma.comments.create({ data });
|
||||
|
||||
const stats = await this.prisma.comments.aggregate({
|
||||
where: { destination_id: data.destination_id, is_from_user: data.is_from_user },
|
||||
_count: true,
|
||||
_avg: { score: true },
|
||||
});
|
||||
}
|
||||
|
||||
getScoresForProfessional(userId: string) {
|
||||
return this.prisma.comments.findMany({
|
||||
where: { destination_id: userId, is_from_user: true },
|
||||
include: { users_comments_author_idTousers: { select: { name: true, picture: true } } },
|
||||
orderBy: { created_at: 'desc' },
|
||||
const oppositeStats = await this.prisma.comments.aggregate({
|
||||
where: { destination_id: data.destination_id, is_from_user: !data.is_from_user },
|
||||
_count: true,
|
||||
_avg: { score: true },
|
||||
});
|
||||
|
||||
await this.prisma.reputations.upsert({
|
||||
where: { user_id: data.destination_id },
|
||||
create: {
|
||||
user_id: data.destination_id,
|
||||
total: data.is_from_user ? 0 : stats._count,
|
||||
average: data.is_from_user ? 0 : (stats._avg.score || 0),
|
||||
total_pro: data.is_from_user ? stats._count : 0,
|
||||
average_pro: data.is_from_user ? (stats._avg.score || 0) : 0,
|
||||
},
|
||||
update: {
|
||||
total: data.is_from_user ? undefined : stats._count,
|
||||
average: data.is_from_user ? undefined : (stats._avg.score || 0),
|
||||
total_pro: data.is_from_user ? stats._count : oppositeStats._count,
|
||||
average_pro: data.is_from_user ? (stats._avg.score || 0) : (oppositeStats._avg.score || 0),
|
||||
},
|
||||
});
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
getReputation(userId: string) {
|
||||
return this.prisma.reputations.findUnique({ where: { user_id: userId } });
|
||||
async getScoresForUser(userId: string, page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
const where = { destination_id: userId, is_from_user: false };
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.comments.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
include: { users_comments_author_idTousers: { select: { name: true, picture: true } } },
|
||||
orderBy: { created_at: 'desc' },
|
||||
}),
|
||||
this.prisma.comments.count({ where }),
|
||||
]);
|
||||
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
||||
}
|
||||
|
||||
async getScoresForProfessional(userId: string, page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
const where = { destination_id: userId, is_from_user: true };
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.comments.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
include: { users_comments_author_idTousers: { select: { name: true, picture: true } } },
|
||||
orderBy: { created_at: 'desc' },
|
||||
}),
|
||||
this.prisma.comments.count({ where }),
|
||||
]);
|
||||
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
||||
}
|
||||
|
||||
async getReputation(userId: string) {
|
||||
const rep = await this.prisma.reputations.findUnique({ where: { user_id: userId } });
|
||||
if (!rep) return { total: 0, average: 0, total_pro: 0, average_pro: 0 };
|
||||
return rep;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { IsString, IsNumber, IsBoolean, IsOptional, Min, Max } from 'class-validator';
|
||||
|
||||
export class CreateCommentDto {
|
||||
@IsString()
|
||||
destination_id: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
service_id?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
content?: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
score: number;
|
||||
|
||||
@IsBoolean()
|
||||
is_from_user: boolean;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { IsOptional, IsInt, Min, Max } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class PaginationDto {
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@ApiPropertyOptional({ default: 20 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number = 20;
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
data: T[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function paginate<T>(
|
||||
data: T[],
|
||||
total: number,
|
||||
page: number,
|
||||
limit: number,
|
||||
): PaginatedResult<T> {
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException, SetMetadata } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export const OWNERSHIP_KEY = 'ownership';
|
||||
export const Ownership = (param: string, model: string, field: string) =>
|
||||
SetMetadata(OWNERSHIP_KEY, { param, model, field });
|
||||
|
||||
@Injectable()
|
||||
export class OwnershipGuard implements CanActivate {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const metadata = this.reflector.get(OWNERSHIP_KEY, context.getHandler());
|
||||
if (!metadata) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const resourceId = request.params[metadata.param];
|
||||
const userId = request.user.sub;
|
||||
|
||||
const record = await (this.prisma as any)[metadata.model].findUnique({
|
||||
where: { id: resourceId },
|
||||
});
|
||||
|
||||
if (!record || record[metadata.field] !== userId) {
|
||||
throw new ForbiddenException('No tienes permiso para modificar este recurso');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ROLES_KEY } from './roles.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!requiredRoles) return true;
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
return requiredRoles.some((role) => user?.role === role);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Controller, Get, Param } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Controller, Get, Post, Param, Body, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { LocationsService } from './locations.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
|
||||
@ApiTags('Locations')
|
||||
@Controller('locations')
|
||||
@@ -21,4 +22,11 @@ export class LocationsController {
|
||||
getCities(@Param('regionId') id: string) {
|
||||
return this.locations.getCities(id);
|
||||
}
|
||||
|
||||
@Post('cities')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
createCity(@Body() body: { region_id: string; name: string; latitude?: number; longitude?: number }) {
|
||||
return this.locations.createCity(body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
@@ -16,4 +16,18 @@ export class LocationsService {
|
||||
getCities(regionId: string) {
|
||||
return this.prisma.cities.findMany({ where: { region_id: regionId } });
|
||||
}
|
||||
|
||||
async createCity(data: { region_id: string; name: string; latitude?: number; longitude?: number }) {
|
||||
const region = await this.prisma.regions.findUnique({ where: { id: data.region_id } });
|
||||
if (!region) throw new NotFoundException('Región no encontrada');
|
||||
|
||||
const existing = await this.prisma.cities.findFirst({
|
||||
where: { region_id: data.region_id, name: data.name },
|
||||
});
|
||||
if (existing) throw new ConflictException('Ya existe esta ciudad en la región');
|
||||
|
||||
return this.prisma.cities.create({
|
||||
data: { region_id: data.region_id, name: data.name, latitude: data.latitude, longitude: data.longitude },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -1,10 +1,12 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe, Logger } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { join } from 'path';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
const logger = new Logger('Bootstrap');
|
||||
|
||||
app.enableCors({
|
||||
@@ -16,6 +18,8 @@ async function bootstrap() {
|
||||
app.setGlobalPrefix('api/v1');
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||
|
||||
app.useStaticAssets(join(__dirname, '..', 'uploads'), { prefix: '/uploads' });
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('ProsApp API')
|
||||
.setDescription('API de ProsApp - Migración Firebase a PostgreSQL')
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Controller, Post, Body } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { IsString, IsOptional, IsObject } from 'class-validator';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
class SendNotificationDto {
|
||||
@IsString()
|
||||
to: string;
|
||||
|
||||
@IsString()
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
body: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
data?: Record<string, any>;
|
||||
}
|
||||
|
||||
@ApiTags('Notifications')
|
||||
@Controller('notifications')
|
||||
export class NotificationsController {
|
||||
constructor(private notifications: NotificationsService) {}
|
||||
|
||||
@Post('send')
|
||||
send(@Body() dto: SendNotificationDto) {
|
||||
return this.notifications.send(dto.to, dto.title, dto.body, dto.data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { NotificationsController } from './notifications.controller';
|
||||
|
||||
@Module({
|
||||
providers: [NotificationsService],
|
||||
controllers: [NotificationsController],
|
||||
exports: [NotificationsService],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
private readonly logger = new Logger(NotificationsService.name);
|
||||
private readonly fcmServerKey: string;
|
||||
|
||||
constructor(private config: ConfigService) {
|
||||
this.fcmServerKey = this.config.getOrThrow<string>('FCM_SERVER_KEY');
|
||||
}
|
||||
|
||||
async send(to: string, title: string, body: string, data?: Record<string, any>) {
|
||||
const message = {
|
||||
notification: { title, body },
|
||||
priority: 'high' as const,
|
||||
data: data ?? { click_action: 'FLUTTER_NOTIFICATION_CLICK', id: '1', status: 'done' },
|
||||
to,
|
||||
};
|
||||
|
||||
const response = await fetch('https://fcm.googleapis.com/fcm/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=UTF-8',
|
||||
Authorization: `key=${this.fcmServerKey}`,
|
||||
},
|
||||
body: JSON.stringify(message),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
this.logger.error(`FCM error ${response.status}: ${text}`);
|
||||
throw new Error(`FCM request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
this.logger.log(`FCM success: ${JSON.stringify(result)}`);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { IsString, IsOptional, IsArray, IsNumber, IsBoolean, MinLength, Matches } from 'class-validator';
|
||||
|
||||
export class CreateProfessionalDto {
|
||||
@IsString()
|
||||
@MinLength(5)
|
||||
identification: string;
|
||||
|
||||
@IsString()
|
||||
profession: string;
|
||||
|
||||
@IsString()
|
||||
address: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
additional_address?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
identification_picture?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
certificate_picture?: string;
|
||||
}
|
||||
|
||||
export class UpdateProfessionalDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
identification?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
address?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
additional_address?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
profession?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
rate?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
banner_picture?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
latitude?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
longitude?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
location_preferences?: string;
|
||||
}
|
||||
|
||||
export class ScheduleDto {
|
||||
@IsNumber()
|
||||
day_of_week: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
continuous_day?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Matches(/^\d{2}:\d{2}$/, { message: 'time must be HH:MM' })
|
||||
range1_hour1?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Matches(/^\d{2}:\d{2}$/)
|
||||
range1_hour2?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Matches(/^\d{2}:\d{2}$/)
|
||||
range2_hour1?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Matches(/^\d{2}:\d{2}$/)
|
||||
range2_hour2?: string;
|
||||
}
|
||||
|
||||
export class UpdateSchedulesDto {
|
||||
@IsArray()
|
||||
schedules: ScheduleDto[];
|
||||
}
|
||||
@@ -1,22 +1,54 @@
|
||||
import { Controller, Get, Post, Patch, Param, Body, UseGuards, Req, Query } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Patch, Param, Body, UseGuards, Req, HttpCode, HttpStatus, NotFoundException } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { ProfessionalsService } from './professionals.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CreateProfessionalDto, UpdateProfessionalDto, UpdateSchedulesDto } from './dto/professional.dto';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
|
||||
@ApiTags('Professionals')
|
||||
@Controller('professionals')
|
||||
export class ProfessionalsController {
|
||||
constructor(private pros: ProfessionalsService) {}
|
||||
constructor(
|
||||
private pros: ProfessionalsService,
|
||||
private auth: AuthService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
findAllActive() {
|
||||
return this.pros.findAllActive();
|
||||
findAllActive(@Req() req) {
|
||||
const page = +(req.query.page || 1);
|
||||
const limit = +(req.query.limit || 20);
|
||||
return this.pros.findAllActive(page, limit);
|
||||
}
|
||||
|
||||
@Get('pending')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
findPending(@Req() req) {
|
||||
const page = +(req.query.page || 1);
|
||||
const limit = +(req.query.limit || 20);
|
||||
return this.pros.findPendingApprovals(page, limit);
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
approve(@Param('id') id: string) {
|
||||
return this.pros.approve(id);
|
||||
}
|
||||
|
||||
@Post(':id/deny')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
deny(@Param('id') id: string) {
|
||||
return this.pros.deny(id);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
findByMe(@Req() req) {
|
||||
async findByMe(@Req() req) {
|
||||
const profId = await this.auth.getProfessionalId(req.user.sub);
|
||||
if (!profId) throw new NotFoundException('No eres un profesional');
|
||||
return this.pros.findByUserId(req.user.sub);
|
||||
}
|
||||
|
||||
@@ -28,21 +60,22 @@ export class ProfessionalsController {
|
||||
@Post('request')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
request(@Req() req, @Body() data: any) {
|
||||
return this.pros.requestProfessional(req.user.sub, data);
|
||||
request(@Req() req, @Body() dto: CreateProfessionalDto) {
|
||||
return this.pros.requestProfessional(req.user.sub, dto);
|
||||
}
|
||||
|
||||
@Patch('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
update(@Req() req, @Body() data: any) {
|
||||
return this.pros.upsert(req.user.sub, data);
|
||||
async update(@Req() req, @Body() dto: UpdateProfessionalDto) {
|
||||
return this.pros.upsert(req.user.sub, dto);
|
||||
}
|
||||
|
||||
@Patch('me/schedules')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
updateSchedules(@Req() req, @Body() data: { schedules: any[] }) {
|
||||
return this.pros.updateSchedules(req.user.sub, data.schedules);
|
||||
async updateSchedules(@Req() req, @Body() dto: UpdateSchedulesDto) {
|
||||
const prof = await this.pros.findByUserId(req.user.sub);
|
||||
return this.pros.updateSchedules(prof.id, dto.schedules);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ProfessionalsService } from './professionals.service';
|
||||
import { ProfessionalsController } from './professionals.controller';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
providers: [ProfessionalsService],
|
||||
controllers: [ProfessionalsController],
|
||||
exports: [ProfessionalsService],
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class ProfessionalsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
findAllActive() {
|
||||
return this.prisma.professionals.findMany({
|
||||
where: { is_active: true },
|
||||
include: {
|
||||
users: { select: { id: true, name: true, picture: true, city: true } },
|
||||
schedules: true,
|
||||
specializations: true,
|
||||
payment_methods: true,
|
||||
},
|
||||
});
|
||||
async findAllActive(page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.professionals.findMany({
|
||||
where: { is_active: true },
|
||||
skip,
|
||||
take: limit,
|
||||
include: {
|
||||
users: { select: { id: true, name: true, picture: true, city: true } },
|
||||
schedules: true,
|
||||
specializations: true,
|
||||
payment_methods: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.professionals.count({ where: { is_active: true } }),
|
||||
]);
|
||||
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
||||
}
|
||||
|
||||
findById(id: string) {
|
||||
return this.prisma.professionals.findUnique({
|
||||
async findById(id: string) {
|
||||
const prof = await this.prisma.professionals.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
users: { select: { id: true, name: true, picture: true, city: true } },
|
||||
@@ -27,43 +34,106 @@ export class ProfessionalsService {
|
||||
payment_methods: true,
|
||||
},
|
||||
});
|
||||
if (!prof) throw new NotFoundException('Profesional no encontrado');
|
||||
return prof;
|
||||
}
|
||||
|
||||
findByUserId(userId: string) {
|
||||
return this.prisma.professionals.findUnique({
|
||||
async findByUserId(userId: string) {
|
||||
const prof = await this.prisma.professionals.findUnique({
|
||||
where: { user_id: userId },
|
||||
include: { schedules: true, specializations: true, payment_methods: true },
|
||||
});
|
||||
if (!prof) throw new NotFoundException('No eres un profesional registrado');
|
||||
return prof;
|
||||
}
|
||||
|
||||
async upsert(userId: string, data: any) {
|
||||
const existing = await this.prisma.professionals.findUnique({ where: { user_id: userId } });
|
||||
const prof = await this.prisma.professionals.findUnique({ where: { user_id: userId } });
|
||||
if (!prof) throw new NotFoundException('Debes solicitar ser profesional primero');
|
||||
|
||||
if (existing) {
|
||||
return this.prisma.professionals.update({ where: { user_id: userId }, data });
|
||||
}
|
||||
return this.prisma.professionals.create({ data: { ...data, user_id: userId } });
|
||||
return this.prisma.professionals.update({ where: { user_id: userId }, data });
|
||||
}
|
||||
|
||||
async updateSchedules(professionalId: string, schedules: any[]) {
|
||||
await this.prisma.schedules.deleteMany({ where: { professional_id: professionalId } });
|
||||
return this.prisma.schedules.createMany({
|
||||
data: schedules.map((s: any) => ({
|
||||
professional_id: professionalId,
|
||||
day_of_week: s.day_of_week,
|
||||
enabled: s.enabled,
|
||||
continuous_day: s.continuous_day,
|
||||
range1_hour1: s.range1_hour1,
|
||||
range1_hour2: s.range1_hour2,
|
||||
range2_hour1: s.range2_hour1,
|
||||
range2_hour2: s.range2_hour2,
|
||||
})),
|
||||
const prof = await this.prisma.professionals.findUnique({ where: { id: professionalId } });
|
||||
if (!prof) throw new NotFoundException('Profesional no encontrado');
|
||||
|
||||
for (const s of schedules) {
|
||||
if (s.range1_hour1 && s.range1_hour2 && s.range1_hour1 >= s.range1_hour2) {
|
||||
throw new BadRequestException(`Día ${s.day_of_week}: range1_hour1 debe ser menor que range1_hour2`);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.prisma.$transaction(async (tx: any) => {
|
||||
await tx.schedules.deleteMany({ where: { professional_id: professionalId } });
|
||||
if (schedules.length > 0) {
|
||||
await tx.schedules.createMany({
|
||||
data: schedules.map((s: any) => ({
|
||||
professional_id: professionalId,
|
||||
day_of_week: s.day_of_week,
|
||||
enabled: s.enabled ?? false,
|
||||
continuous_day: s.continuous_day ?? false,
|
||||
range1_hour1: s.range1_hour1 ? new Date(`1970-01-01T${s.range1_hour1}:00`) : null,
|
||||
range1_hour2: s.range1_hour2 ? new Date(`1970-01-01T${s.range1_hour2}:00`) : null,
|
||||
range2_hour1: s.range2_hour1 ? new Date(`1970-01-01T${s.range2_hour1}:00`) : null,
|
||||
range2_hour2: s.range2_hour2 ? new Date(`1970-01-01T${s.range2_hour2}:00`) : null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return tx.schedules.findMany({ where: { professional_id: professionalId } });
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async findPendingApprovals(page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.professionals.findMany({
|
||||
where: { is_active: false },
|
||||
skip,
|
||||
take: limit,
|
||||
include: { users: { select: { id: true, name: true, email: true, phone: true, created_at: true } } },
|
||||
}),
|
||||
this.prisma.professionals.count({ where: { is_active: false } }),
|
||||
]);
|
||||
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
||||
}
|
||||
|
||||
async approve(professionalId: string) {
|
||||
const prof = await this.prisma.professionals.findUnique({ where: { id: professionalId } });
|
||||
if (!prof) throw new NotFoundException('Profesional no encontrado');
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.professionals.update({
|
||||
where: { id: professionalId },
|
||||
data: { is_active: true },
|
||||
}),
|
||||
this.prisma.users.update({
|
||||
where: { id: prof.user_id },
|
||||
data: { pro_state: 2 },
|
||||
}),
|
||||
]);
|
||||
return { message: 'Profesional aprobado' };
|
||||
}
|
||||
|
||||
async deny(professionalId: string) {
|
||||
const prof = await this.prisma.professionals.findUnique({ where: { id: professionalId } });
|
||||
if (!prof) throw new NotFoundException('Profesional no encontrado');
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.professionals.delete({ where: { id: professionalId } }),
|
||||
this.prisma.users.update({
|
||||
where: { id: prof.user_id },
|
||||
data: { pro_state: 3 },
|
||||
}),
|
||||
]);
|
||||
return { message: 'Solicitud rechazada' };
|
||||
}
|
||||
|
||||
async requestProfessional(userId: string, data: any) {
|
||||
const existing = await this.prisma.professionals.findUnique({ where: { user_id: userId } });
|
||||
if (existing) throw new Error('Ya tienes una solicitud de profesional');
|
||||
if (existing) throw new BadRequestException('Ya tienes una solicitud de profesional');
|
||||
|
||||
await this.prisma.users.update({ where: { id: userId }, data: { pro_state: 1 } });
|
||||
return this.prisma.professionals.create({
|
||||
@@ -72,6 +142,7 @@ export class ProfessionalsService {
|
||||
identification: data.identification,
|
||||
profession: data.profession,
|
||||
address: data.address,
|
||||
additional_address: data.additional_address,
|
||||
identification_picture: data.identification_picture,
|
||||
certificate_picture: data.certificate_picture,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Controller, Get, Post, Delete, Param, Body, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { ProfessionsService } from './professions.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
|
||||
@ApiTags('Professions')
|
||||
@Controller('professions')
|
||||
@@ -11,4 +12,18 @@ export class ProfessionsController {
|
||||
findAll() {
|
||||
return this.professions.findAll();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
create(@Body() body: { name: string }) {
|
||||
return this.professions.create(body.name);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
remove(@Param('id') id: string) {
|
||||
return this.professions.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
@@ -8,4 +8,17 @@ export class ProfessionsService {
|
||||
findAll() {
|
||||
return this.prisma.professions.findMany({ orderBy: { name: 'asc' } });
|
||||
}
|
||||
|
||||
async create(name: string) {
|
||||
const existing = await this.prisma.professions.findUnique({ where: { name } });
|
||||
if (existing) throw new ConflictException('Ya existe esta profesión');
|
||||
return this.prisma.professions.create({ data: { name } });
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
const prof = await this.prisma.professions.findUnique({ where: { id } });
|
||||
if (!prof) throw new NotFoundException('Profesión no encontrada');
|
||||
await this.prisma.professions.delete({ where: { id } });
|
||||
return { message: 'Profesión eliminada' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { IsString, IsOptional, IsNumber, IsDateString, IsEnum, Matches } from 'class-validator';
|
||||
|
||||
export enum ServiceStatus {
|
||||
PENDING = 'pending',
|
||||
ACCEPTED = 'accepted',
|
||||
DENIED = 'denied',
|
||||
ACTIVE = 'active',
|
||||
CANCELLED = 'cancelled',
|
||||
COMPLETED = 'completed',
|
||||
SELF_BOOKED = 'self_booked',
|
||||
}
|
||||
|
||||
export const VALID_TRANSITIONS: Record<string, string[]> = {
|
||||
[ServiceStatus.PENDING]: [ServiceStatus.ACCEPTED, ServiceStatus.DENIED, ServiceStatus.CANCELLED],
|
||||
[ServiceStatus.ACCEPTED]: [ServiceStatus.ACTIVE, ServiceStatus.CANCELLED],
|
||||
[ServiceStatus.ACTIVE]: [ServiceStatus.COMPLETED, ServiceStatus.CANCELLED],
|
||||
[ServiceStatus.DENIED]: [],
|
||||
[ServiceStatus.CANCELLED]: [],
|
||||
[ServiceStatus.COMPLETED]: [],
|
||||
[ServiceStatus.SELF_BOOKED]: [ServiceStatus.CANCELLED],
|
||||
};
|
||||
|
||||
export class CreateServiceDto {
|
||||
@IsString()
|
||||
professional_id: string;
|
||||
|
||||
@IsDateString()
|
||||
day: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
rate?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Matches(/^\d{2}:\d{2}$/)
|
||||
range1_hour1?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Matches(/^\d{2}:\d{2}$/)
|
||||
range1_hour2?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
address?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
latitude?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
longitude?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['office', 'delivery'])
|
||||
location_preference?: 'office' | 'delivery';
|
||||
}
|
||||
|
||||
export class UpdateServiceStatusDto {
|
||||
@IsEnum(ServiceStatus)
|
||||
status: ServiceStatus;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Controller, Get, Post, Patch, Param, Body, UseGuards, Req } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Controller, Get, Post, Patch, Param, Body, UseGuards, Req, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { ServicesService } from './services.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CreateServiceDto, UpdateServiceStatusDto } from './dto/service.dto';
|
||||
|
||||
@ApiTags('Services')
|
||||
@Controller('services')
|
||||
@@ -11,43 +12,74 @@ export class ServicesController {
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
create(@Req() req, @Body() data: any) {
|
||||
return this.services.create({ ...data, user_id: req.user.sub });
|
||||
create(@Req() req, @Body() dto: CreateServiceDto) {
|
||||
return this.services.create({ ...dto, user_id: req.user.sub });
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'limit', required: false })
|
||||
findAll(@Req() req) {
|
||||
const page = +(req.query.page || 1);
|
||||
const limit = +(req.query.limit || 20);
|
||||
return this.services.findAll(page, limit);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'limit', required: false })
|
||||
findByMe(@Req() req) {
|
||||
return this.services.findByUser(req.user.sub);
|
||||
const page = +(req.query.page || 1);
|
||||
const limit = +(req.query.limit || 20);
|
||||
return this.services.findByUser(req.user.sub, page, limit);
|
||||
}
|
||||
|
||||
@Get('professional')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'limit', required: false })
|
||||
findByProfessional(@Req() req) {
|
||||
return this.services.findByProfessional(req.user.sub);
|
||||
const page = +(req.query.page || 1);
|
||||
const limit = +(req.query.limit || 20);
|
||||
return this.services.findByProfessional(req.user.sub, page, limit);
|
||||
}
|
||||
|
||||
@Get('professional/requests')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'limit', required: false })
|
||||
requestsByProfessional(@Req() req) {
|
||||
return this.services.findRequestsByProfessional(req.user.sub);
|
||||
const page = +(req.query.page || 1);
|
||||
const limit = +(req.query.limit || 20);
|
||||
return this.services.findRequestsByProfessional(req.user.sub, page, limit);
|
||||
}
|
||||
|
||||
@Get('professional/history')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'limit', required: false })
|
||||
historyByProfessional(@Req() req) {
|
||||
return this.services.getHistoryByProfessional(req.user.sub);
|
||||
const page = +(req.query.page || 1);
|
||||
const limit = +(req.query.limit || 20);
|
||||
return this.services.getHistoryByProfessional(req.user.sub, page, limit);
|
||||
}
|
||||
|
||||
@Get('me/history')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'limit', required: false })
|
||||
historyByUser(@Req() req) {
|
||||
return this.services.getHistoryByUser(req.user.sub);
|
||||
const page = +(req.query.page || 1);
|
||||
const limit = +(req.query.limit || 20);
|
||||
return this.services.getHistoryByUser(req.user.sub, page, limit);
|
||||
}
|
||||
|
||||
@Get('professional/calendar')
|
||||
@@ -72,7 +104,7 @@ export class ServicesController {
|
||||
@Patch(':id/status')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
updateStatus(@Param('id') id: string, @Body() dto: { status: string }) {
|
||||
return this.services.updateStatus(id, dto.status);
|
||||
updateStatus(@Req() req, @Param('id') id: string, @Body() dto: UpdateServiceStatusDto) {
|
||||
return this.services.updateStatus(id, dto.status, req.user.sub);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ServicesService } from './services.service';
|
||||
import { ServicesController } from './services.controller';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
providers: [ServicesService],
|
||||
controllers: [ServicesController],
|
||||
exports: [ServicesService],
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { ServiceStatus, VALID_TRANSITIONS } from './dto/service.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ServicesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
create(data: {
|
||||
async create(data: {
|
||||
professional_id: string;
|
||||
user_id: string;
|
||||
day: string;
|
||||
@@ -18,71 +21,174 @@ export class ServicesService {
|
||||
longitude?: number;
|
||||
location_preference?: 'office' | 'delivery';
|
||||
}) {
|
||||
return this.prisma.services.create({ data: { ...data, day: new Date(data.day) } as any });
|
||||
}
|
||||
const prof = await this.prisma.professionals.findUnique({ where: { id: data.professional_id } });
|
||||
if (!prof || !prof.is_active) throw new BadRequestException('Profesional no disponible');
|
||||
|
||||
findByUser(userId: string) {
|
||||
return this.prisma.services.findMany({
|
||||
where: { user_id: userId },
|
||||
include: { professionals: { include: { users: true } } },
|
||||
orderBy: { created_at: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
findByProfessional(professionalId: string) {
|
||||
return this.prisma.services.findMany({
|
||||
where: { professional_id: professionalId },
|
||||
include: { users: { select: { id: true, name: true, picture: true, phone: true } } },
|
||||
orderBy: { created_at: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
findRequestsByProfessional(professionalId: string) {
|
||||
return this.prisma.services.findMany({
|
||||
where: { professional_id: professionalId, status: 'pending' },
|
||||
include: { users: { select: { id: true, name: true, picture: true, phone: true } } },
|
||||
orderBy: { created_at: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
updateStatus(id: string, status: string) {
|
||||
return this.prisma.services.update({ where: { id }, data: { status: status as any } });
|
||||
}
|
||||
|
||||
findById(id: string) {
|
||||
return this.prisma.services.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
users: { select: { id: true, name: true, picture: true, phone: true } },
|
||||
professionals: { include: { users: true } },
|
||||
return this.prisma.services.create({
|
||||
data: {
|
||||
professional_id: data.professional_id,
|
||||
user_id: data.user_id,
|
||||
day: new Date(data.day),
|
||||
description: data.description,
|
||||
rate: data.rate ?? prof.rate,
|
||||
range1_hour1: data.range1_hour1 ? new Date(`1970-01-01T${data.range1_hour1}:00`) : null,
|
||||
range1_hour2: data.range1_hour2 ? new Date(`1970-01-01T${data.range1_hour2}:00`) : null,
|
||||
address: data.address,
|
||||
latitude: data.latitude,
|
||||
longitude: data.longitude,
|
||||
location_preference: data.location_preference as any,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getHistoryByUser(userId: string) {
|
||||
return this.prisma.services.findMany({
|
||||
where: { user_id: userId, status: { in: ['completed', 'cancelled'] } },
|
||||
include: { professionals: { include: { users: true } } },
|
||||
orderBy: { day: 'desc' },
|
||||
async updateStatus(serviceId: string, newStatus: ServiceStatus, userId: string) {
|
||||
const service = await this.prisma.services.findUnique({ where: { id: serviceId } });
|
||||
if (!service) throw new NotFoundException('Servicio no encontrado');
|
||||
|
||||
const prof = await this.prisma.professionals.findUnique({ where: { user_id: userId } });
|
||||
|
||||
const allowedTransitions = VALID_TRANSITIONS[service.status];
|
||||
if (!allowedTransitions || !allowedTransitions.includes(newStatus)) {
|
||||
throw new BadRequestException(
|
||||
`Transición inválida: ${service.status} → ${newStatus}. Permitidas: ${allowedTransitions?.join(', ') || 'ninguna'}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (newStatus === ServiceStatus.ACCEPTED || newStatus === ServiceStatus.DENIED) {
|
||||
if (!prof || prof.id !== service.professional_id) {
|
||||
throw new ForbiddenException('Solo el profesional puede aceptar/rechazar');
|
||||
}
|
||||
}
|
||||
|
||||
if (newStatus === ServiceStatus.CANCELLED) {
|
||||
if (service.user_id !== userId && (!prof || prof.id !== service.professional_id)) {
|
||||
throw new ForbiddenException('Solo el usuario o el profesional pueden cancelar');
|
||||
}
|
||||
}
|
||||
|
||||
if (newStatus === ServiceStatus.COMPLETED) {
|
||||
if (!prof || prof.id !== service.professional_id) {
|
||||
throw new ForbiddenException('Solo el profesional puede marcar como completado');
|
||||
}
|
||||
}
|
||||
|
||||
return this.prisma.services.update({
|
||||
where: { id: serviceId },
|
||||
data: { status: newStatus as any },
|
||||
});
|
||||
}
|
||||
|
||||
getHistoryByProfessional(professionalId: string) {
|
||||
return this.prisma.services.findMany({
|
||||
where: { professional_id: professionalId, status: { in: ['completed', 'cancelled'] } },
|
||||
include: { users: { select: { id: true, name: true, picture: true } } },
|
||||
orderBy: { day: 'desc' },
|
||||
});
|
||||
async findByUser(userId: string, page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
const where = { user_id: userId, status: { notIn: ['completed' as const, 'cancelled' as const, 'denied' as const] } };
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.services.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
include: { professionals: { include: { users: true } } },
|
||||
orderBy: { created_at: 'desc' },
|
||||
}),
|
||||
this.prisma.services.count({ where }),
|
||||
]);
|
||||
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
||||
}
|
||||
|
||||
getCalendarByProfessional(professionalId: string) {
|
||||
async findByProfessional(userId: string, page = 1, limit = 20) {
|
||||
const prof = await this.prisma.professionals.findUnique({ where: { user_id: userId } });
|
||||
if (!prof) throw new NotFoundException('No eres un profesional');
|
||||
const where = { professional_id: prof.id };
|
||||
const skip = (page - 1) * limit;
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.services.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
include: { users: { select: { id: true, name: true, picture: true, phone: true } } },
|
||||
orderBy: { created_at: 'desc' },
|
||||
}),
|
||||
this.prisma.services.count({ where }),
|
||||
]);
|
||||
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
||||
}
|
||||
|
||||
async findRequestsByProfessional(userId: string, page = 1, limit = 20) {
|
||||
const prof = await this.prisma.professionals.findUnique({ where: { user_id: userId } });
|
||||
if (!prof) throw new NotFoundException('No eres un profesional');
|
||||
const where = { professional_id: prof.id, status: 'pending' as const };
|
||||
const skip = (page - 1) * limit;
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.services.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
include: { users: { select: { id: true, name: true, picture: true, phone: true } } },
|
||||
orderBy: { created_at: 'desc' },
|
||||
}),
|
||||
this.prisma.services.count({ where }),
|
||||
]);
|
||||
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
||||
}
|
||||
|
||||
async findById(id: string) {
|
||||
const service = await this.prisma.services.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
users: { select: { id: true, name: true, picture: true, phone: true } },
|
||||
professionals: { include: { users: { select: { name: true, picture: true } } } },
|
||||
},
|
||||
});
|
||||
if (!service) throw new NotFoundException('Servicio no encontrado');
|
||||
return service;
|
||||
}
|
||||
|
||||
async getHistoryByUser(userId: string, page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
const where = { user_id: userId, status: { in: ['completed' as const, 'cancelled' as const] } };
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.services.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
include: { professionals: { include: { users: { select: { name: true, picture: true } } } } },
|
||||
orderBy: { day: 'desc' },
|
||||
}),
|
||||
this.prisma.services.count({ where }),
|
||||
]);
|
||||
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
||||
}
|
||||
|
||||
async getHistoryByProfessional(userId: string, page = 1, limit = 20) {
|
||||
const prof = await this.prisma.professionals.findUnique({ where: { user_id: userId } });
|
||||
if (!prof) throw new NotFoundException('No eres un profesional');
|
||||
const where = { professional_id: prof.id, status: { in: ['completed' as const, 'cancelled' as const] } };
|
||||
const skip = (page - 1) * limit;
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.services.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
include: { users: { select: { id: true, name: true, picture: true } } },
|
||||
orderBy: { day: 'desc' },
|
||||
}),
|
||||
this.prisma.services.count({ where }),
|
||||
]);
|
||||
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
||||
}
|
||||
|
||||
async getCalendarByProfessional(userId: string) {
|
||||
const prof = await this.prisma.professionals.findUnique({ where: { user_id: userId } });
|
||||
if (!prof) throw new NotFoundException('No eres un profesional');
|
||||
return this.prisma.services.findMany({
|
||||
where: { professional_id: professionalId, status: { notIn: ['denied', 'cancelled'] } },
|
||||
where: { professional_id: prof.id, status: { notIn: ['denied', 'cancelled'] } },
|
||||
orderBy: { day: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getPublicCalendar(professionalId: string) {
|
||||
const prof = await this.prisma.professionals.findUnique({ where: { id: professionalId } });
|
||||
if (!prof) throw new NotFoundException('Profesional no encontrado');
|
||||
|
||||
const schedules = await this.prisma.schedules.findMany({
|
||||
where: { professional_id: professionalId, enabled: true },
|
||||
});
|
||||
@@ -91,4 +197,21 @@ export class ServicesService {
|
||||
});
|
||||
return { schedules, services };
|
||||
}
|
||||
|
||||
async findAll(page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.services.findMany({
|
||||
skip,
|
||||
take: limit,
|
||||
include: {
|
||||
users: { select: { id: true, name: true } },
|
||||
professionals: { include: { users: { select: { name: true } } } },
|
||||
},
|
||||
orderBy: { created_at: 'desc' },
|
||||
}),
|
||||
this.prisma.services.count(),
|
||||
]);
|
||||
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Controller, Get, Patch, Body, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { SettingsService } from './settings.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
|
||||
@ApiTags('Settings')
|
||||
@Controller('settings')
|
||||
@@ -11,4 +12,11 @@ export class SettingsController {
|
||||
getGlobal() {
|
||||
return this.settings.getGlobal();
|
||||
}
|
||||
|
||||
@Patch()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
updateGlobal(@Body() body: Record<string, any>) {
|
||||
return this.settings.updateGlobal(body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,4 +9,12 @@ export class SettingsService {
|
||||
const setting = await this.prisma.settings.findUnique({ where: { key: 'global' } });
|
||||
return setting?.value;
|
||||
}
|
||||
|
||||
async updateGlobal(value: Record<string, any>) {
|
||||
return this.prisma.settings.upsert({
|
||||
where: { key: 'global' },
|
||||
create: { key: 'global', value },
|
||||
update: { value },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Controller, Post, UseGuards, Req, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||
import { Controller, Post, UseGuards, UploadedFile, UseInterceptors, BadRequestException } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiBearerAuth, ApiBody, ApiConsumes } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { StorageService } from './storage.service';
|
||||
import { StorageService, StoredFile } from './storage.service';
|
||||
|
||||
@ApiTags('Storage')
|
||||
@Controller('storage')
|
||||
@@ -12,8 +12,12 @@ export class StorageController {
|
||||
@Post('upload')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({ schema: { type: 'object', properties: { file: { type: 'string', format: 'binary' } } } })
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
upload(@UploadedFile() file: any) {
|
||||
return { url: this.storage.getUploadUrl(file?.originalname) };
|
||||
async upload(@UploadedFile() file: StoredFile) {
|
||||
if (!file) throw new BadRequestException('Archivo requerido');
|
||||
const url = await this.storage.save(file);
|
||||
return { url };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,45 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { writeFile, mkdir, unlink } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export interface StoredFile {
|
||||
originalname: string;
|
||||
buffer: Buffer;
|
||||
mimetype: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class StorageService {
|
||||
private uploadDir: string;
|
||||
private baseUrl: string;
|
||||
|
||||
constructor() {
|
||||
this.baseUrl = process.env.STORAGE_URL || 'http://localhost:9000';
|
||||
this.uploadDir = process.env.UPLOAD_DIR || join(process.cwd(), 'uploads');
|
||||
this.baseUrl = process.env.STORAGE_URL || `http://localhost:3000/uploads`;
|
||||
}
|
||||
|
||||
getUploadUrl(fileName: string) {
|
||||
return `${this.baseUrl}/uploads/${fileName}`;
|
||||
async save(file: StoredFile, subfolder = 'general'): Promise<string> {
|
||||
const dir = join(this.uploadDir, subfolder);
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
const ext = file.originalname.split('.').pop() || 'bin';
|
||||
const filename = `${randomUUID()}.${ext}`;
|
||||
const filepath = join(dir, filename);
|
||||
|
||||
await writeFile(filepath, file.buffer);
|
||||
|
||||
return `${this.baseUrl}/${subfolder}/${filename}`;
|
||||
}
|
||||
|
||||
async delete(url: string): Promise<void> {
|
||||
const relativePath = url.replace(this.baseUrl, '');
|
||||
const filepath = join(this.uploadDir, relativePath);
|
||||
await unlink(filepath).catch(() => {});
|
||||
}
|
||||
|
||||
getUploadUrl(fileName: string): string {
|
||||
return `${this.baseUrl}/${fileName}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Controller, Get, Patch, Param, Body, UseGuards, Req } from '@nestjs/com
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { UsersService } from './users.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { UpdateUserDto, FcmTokenDto } from '../auth/dto/auth.dto';
|
||||
|
||||
@ApiTags('Users')
|
||||
@Controller('users')
|
||||
@@ -9,30 +10,35 @@ export class UsersController {
|
||||
constructor(private users: UsersService) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
findAll() {
|
||||
return this.users.findAll();
|
||||
findAll(@Req() req) {
|
||||
const page = +(req.query.page || 1);
|
||||
const limit = +(req.query.limit || 20);
|
||||
return this.users.findAll(page, limit);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
findById(@Param('id') id: string) {
|
||||
return this.users.findById(id);
|
||||
findMe(@Req() req) {
|
||||
return this.users.findById(req.user.sub);
|
||||
}
|
||||
|
||||
@Patch('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
update(@Req() req, @Body() data: any) {
|
||||
return this.users.update(req.user.sub, data);
|
||||
update(@Req() req, @Body() dto: UpdateUserDto) {
|
||||
return this.users.update(req.user.sub, dto);
|
||||
}
|
||||
|
||||
@Patch('fcm-token')
|
||||
@Patch('me/fcm-token')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
updateFcmToken(@Req() req, @Body() dto: { token: string }) {
|
||||
updateFcmToken(@Req() req, @Body() dto: FcmTokenDto) {
|
||||
return this.users.updateFcmToken(req.user.sub, dto.token);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findById(@Param('id') id: string) {
|
||||
return this.users.findById(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,13 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
export class UsersService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
findAll() {
|
||||
return this.prisma.users.findMany({ orderBy: { created_at: 'desc' } });
|
||||
async findAll(page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.users.findMany({ skip, take: limit, orderBy: { created_at: 'desc' } }),
|
||||
this.prisma.users.count(),
|
||||
]);
|
||||
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
||||
}
|
||||
|
||||
findById(id: string) {
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"include": ["src/**/*"],
|
||||
"strictPropertyInitialization": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "prisma", "prisma.config.ts"]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user