feat: integrate Prisma for database management

This commit is contained in:
2026-02-26 13:14:09 -03:00
parent 2f29430b28
commit e60752743f
12 changed files with 1390 additions and 14 deletions

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
DATABASE_URL="postgres://user:secret123@postgres:5432/db"
PORT=3000

2
.gitignore vendored
View File

@@ -54,3 +54,5 @@ pids
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
/generated/prisma

944
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,8 @@
"@nestjs/core": "^11.0.1",
"@nestjs/mapped-types": "*",
"@nestjs/platform-express": "^11.0.1",
"@prisma/client": "^7.4.1",
"dotenv": "^17.3.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
@@ -43,6 +45,7 @@
"globals": "^16.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"prisma": "^7.4.1",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",

14
prisma.config.ts Normal file
View File

@@ -0,0 +1,14 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});

View File

@@ -0,0 +1,229 @@
-- CreateTable
CREATE TABLE "categories" (
"id" BIGSERIAL NOT NULL,
"name" VARCHAR(100) NOT NULL,
"description" TEXT,
CONSTRAINT "categories_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "roles" (
"id" BIGSERIAL NOT NULL,
"name" VARCHAR(50) NOT NULL,
"description" TEXT,
CONSTRAINT "roles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "users" (
"id" BIGSERIAL NOT NULL,
"external_uid" VARCHAR(255) NOT NULL,
"role_id" BIGINT NOT NULL,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tickets" (
"id" BIGSERIAL NOT NULL,
"title" VARCHAR(255) NOT NULL,
"description" TEXT,
"status" VARCHAR(30) NOT NULL,
"priority" VARCHAR(30),
"category_id" BIGINT,
"created_by_user_id" BIGINT NOT NULL,
"assigned_to_user_id" BIGINT,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"closed_at" TIMESTAMPTZ,
"ticket_number" VARCHAR(30) NOT NULL,
CONSTRAINT "tickets_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ticket_messages" (
"id" BIGSERIAL NOT NULL,
"ticket_id" BIGINT NOT NULL,
"author_id" BIGINT NOT NULL,
"message" TEXT NOT NULL,
"is_internal" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ticket_messages_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ticket_attachments" (
"id" BIGSERIAL NOT NULL,
"ticket_id" BIGINT NOT NULL,
"message_id" BIGINT,
"uploaded_by" BIGINT NOT NULL,
"file_name" VARCHAR(255),
"file_url" TEXT NOT NULL,
"file_size" BIGINT,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ticket_attachments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ticket_invitations" (
"id" BIGSERIAL NOT NULL,
"ticket_id" BIGINT NOT NULL,
"inviter_id" BIGINT NOT NULL,
"invitee_id" BIGINT NOT NULL,
"status" VARCHAR(20) NOT NULL DEFAULT 'pending',
"message" TEXT,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"responded_at" TIMESTAMPTZ,
CONSTRAINT "ticket_invitations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ticket_participants" (
"id" BIGSERIAL NOT NULL,
"ticket_id" BIGINT NOT NULL,
"user_id" BIGINT NOT NULL,
"added_by" BIGINT NOT NULL,
"added_via" VARCHAR(20) NOT NULL,
"can_edit" BOOLEAN NOT NULL DEFAULT false,
"can_comment" BOOLEAN NOT NULL DEFAULT true,
"joined_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ticket_participants_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ticket_status_history" (
"id" BIGSERIAL NOT NULL,
"ticket_id" BIGINT NOT NULL,
"old_status" VARCHAR(30),
"new_status" VARCHAR(30) NOT NULL,
"changed_by" BIGINT NOT NULL,
"changed_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ticket_status_history_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "categories_name_key" ON "categories"("name");
-- CreateIndex
CREATE UNIQUE INDEX "roles_name_key" ON "roles"("name");
-- CreateIndex
CREATE UNIQUE INDEX "users_external_uid_key" ON "users"("external_uid");
-- CreateIndex
CREATE INDEX "idx_users_external_uid" ON "users"("external_uid");
-- CreateIndex
CREATE UNIQUE INDEX "tickets_ticket_number_key" ON "tickets"("ticket_number");
-- CreateIndex
CREATE INDEX "idx_tickets_assigned_to" ON "tickets"("assigned_to_user_id");
-- CreateIndex
CREATE INDEX "idx_tickets_created_at" ON "tickets"("created_at");
-- CreateIndex
CREATE INDEX "idx_tickets_created_by" ON "tickets"("created_by_user_id");
-- CreateIndex
CREATE INDEX "idx_tickets_status" ON "tickets"("status");
-- CreateIndex
CREATE INDEX "idx_tickets_ticket_number" ON "tickets"("ticket_number");
-- CreateIndex
CREATE INDEX "idx_messages_ticket_id" ON "ticket_messages"("ticket_id");
-- CreateIndex
CREATE INDEX "idx_invitations_created_at" ON "ticket_invitations"("created_at");
-- CreateIndex
CREATE INDEX "idx_invitations_invitee_id" ON "ticket_invitations"("invitee_id");
-- CreateIndex
CREATE INDEX "idx_invitations_inviter_id" ON "ticket_invitations"("inviter_id");
-- CreateIndex
CREATE INDEX "idx_invitations_status" ON "ticket_invitations"("status");
-- CreateIndex
CREATE INDEX "idx_invitations_ticket_id" ON "ticket_invitations"("ticket_id");
-- CreateIndex
CREATE UNIQUE INDEX "ticket_invitations_ticket_id_invitee_id_status_key" ON "ticket_invitations"("ticket_id", "invitee_id", "status");
-- CreateIndex
CREATE INDEX "idx_participants_added_by" ON "ticket_participants"("added_by");
-- CreateIndex
CREATE INDEX "idx_participants_ticket_id" ON "ticket_participants"("ticket_id");
-- CreateIndex
CREATE INDEX "idx_participants_user_id" ON "ticket_participants"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "ticket_participants_ticket_id_user_id_key" ON "ticket_participants"("ticket_id", "user_id");
-- CreateIndex
CREATE INDEX "idx_history_ticket_id" ON "ticket_status_history"("ticket_id");
-- AddForeignKey
ALTER TABLE "users" ADD CONSTRAINT "users_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_assigned_to_user_id_fkey" FOREIGN KEY ("assigned_to_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ticket_messages" ADD CONSTRAINT "ticket_messages_ticket_id_fkey" FOREIGN KEY ("ticket_id") REFERENCES "tickets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ticket_messages" ADD CONSTRAINT "ticket_messages_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ticket_attachments" ADD CONSTRAINT "ticket_attachments_ticket_id_fkey" FOREIGN KEY ("ticket_id") REFERENCES "tickets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ticket_attachments" ADD CONSTRAINT "ticket_attachments_message_id_fkey" FOREIGN KEY ("message_id") REFERENCES "ticket_messages"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ticket_attachments" ADD CONSTRAINT "ticket_attachments_uploaded_by_fkey" FOREIGN KEY ("uploaded_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ticket_invitations" ADD CONSTRAINT "ticket_invitations_ticket_id_fkey" FOREIGN KEY ("ticket_id") REFERENCES "tickets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ticket_invitations" ADD CONSTRAINT "ticket_invitations_inviter_id_fkey" FOREIGN KEY ("inviter_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ticket_invitations" ADD CONSTRAINT "ticket_invitations_invitee_id_fkey" FOREIGN KEY ("invitee_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ticket_participants" ADD CONSTRAINT "ticket_participants_ticket_id_fkey" FOREIGN KEY ("ticket_id") REFERENCES "tickets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ticket_participants" ADD CONSTRAINT "ticket_participants_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ticket_participants" ADD CONSTRAINT "ticket_participants_added_by_fkey" FOREIGN KEY ("added_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ticket_status_history" ADD CONSTRAINT "ticket_status_history_ticket_id_fkey" FOREIGN KEY ("ticket_id") REFERENCES "tickets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ticket_status_history" ADD CONSTRAINT "ticket_status_history_changed_by_fkey" FOREIGN KEY ("changed_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

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

184
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,184 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client"
output = "../generated/prisma"
}
datasource db {
provider = "postgresql"
}
model Category {
id BigInt @id @default(autoincrement())
name String @unique @db.VarChar(100)
description String?
tickets Ticket[]
@@map("categories")
}
model Role {
id BigInt @id @default(autoincrement())
name String @unique @db.VarChar(50)
description String?
users User[]
@@map("roles")
}
model User {
id BigInt @id @default(autoincrement())
external_uid String @unique @db.VarChar(255)
role_id BigInt
is_active Boolean @default(true)
created_at DateTime @default(now()) @db.Timestamptz
role Role @relation(fields: [role_id], references: [id])
tickets_created Ticket[] @relation("CreatedBy")
tickets_assigned Ticket[] @relation("AssignedTo")
messages TicketMessage[]
attachments_uploaded TicketAttachment[]
invitations_sent TicketInvitation[] @relation("Inviter")
invitations_received TicketInvitation[] @relation("Invitee")
participants TicketParticipant[] @relation("Participant")
participants_added TicketParticipant[] @relation("AddedBy")
status_history_changed TicketStatusHistory[]
@@index([external_uid], name: "idx_users_external_uid")
@@map("users")
}
model Ticket {
id BigInt @id @default(autoincrement())
title String @db.VarChar(255)
description String?
status String @db.VarChar(30)
priority String? @db.VarChar(30)
category_id BigInt?
created_by_user_id BigInt
assigned_to_user_id BigInt?
created_at DateTime @default(now()) @db.Timestamptz
updated_at DateTime @default(now()) @db.Timestamptz
closed_at DateTime? @db.Timestamptz
/// Número de ticket legible para usuarios y APIs públicas (formato: TCK-YYYY-NNNNN). Este identificador se usa en lugar del ID interno en todas las comunicaciones externas.
ticket_number String @unique @db.VarChar(30)
category Category? @relation(fields: [category_id], references: [id])
created_by User @relation("CreatedBy", fields: [created_by_user_id], references: [id])
assigned_to User? @relation("AssignedTo", fields: [assigned_to_user_id], references: [id])
messages TicketMessage[]
attachments TicketAttachment[]
invitations TicketInvitation[]
participants TicketParticipant[]
status_history TicketStatusHistory[]
@@index([assigned_to_user_id], name: "idx_tickets_assigned_to")
@@index([created_at], name: "idx_tickets_created_at")
@@index([created_by_user_id], name: "idx_tickets_created_by")
@@index([status], name: "idx_tickets_status")
@@index([ticket_number], name: "idx_tickets_ticket_number")
@@map("tickets")
}
model TicketMessage {
id BigInt @id @default(autoincrement())
ticket_id BigInt
author_id BigInt
message String
is_internal Boolean @default(false)
created_at DateTime @default(now()) @db.Timestamptz
ticket Ticket @relation(fields: [ticket_id], references: [id], onDelete: Cascade)
author User @relation(fields: [author_id], references: [id])
attachments TicketAttachment[]
@@index([ticket_id], name: "idx_messages_ticket_id")
@@map("ticket_messages")
}
model TicketAttachment {
id BigInt @id @default(autoincrement())
ticket_id BigInt
message_id BigInt?
uploaded_by BigInt
file_name String? @db.VarChar(255)
file_url String
file_size BigInt?
created_at DateTime @default(now()) @db.Timestamptz
ticket Ticket @relation(fields: [ticket_id], references: [id], onDelete: Cascade)
message TicketMessage? @relation(fields: [message_id], references: [id])
uploader User @relation(fields: [uploaded_by], references: [id])
@@map("ticket_attachments")
}
/// Invitaciones pendientes, aceptadas o rechazadas para unirse a un ticket
model TicketInvitation {
id BigInt @id @default(autoincrement())
ticket_id BigInt
inviter_id BigInt
invitee_id BigInt
/// Estado de la invitación: pending, accepted, rejected, cancelled
status String @default("pending") @db.VarChar(20)
message String?
created_at DateTime @default(now()) @db.Timestamptz
responded_at DateTime? @db.Timestamptz
ticket Ticket @relation(fields: [ticket_id], references: [id], onDelete: Cascade)
inviter User @relation("Inviter", fields: [inviter_id], references: [id])
invitee User @relation("Invitee", fields: [invitee_id], references: [id])
@@unique([ticket_id, invitee_id, status], name: "unique_pending_invitation")
@@index([created_at], name: "idx_invitations_created_at")
@@index([invitee_id], name: "idx_invitations_invitee_id")
@@index([inviter_id], name: "idx_invitations_inviter_id")
@@index([status], name: "idx_invitations_status")
@@index([ticket_id], name: "idx_invitations_ticket_id")
@@map("ticket_invitations")
}
/// Usuarios que tienen acceso a un ticket específico
model TicketParticipant {
id BigInt @id @default(autoincrement())
ticket_id BigInt
user_id BigInt
added_by BigInt
/// Método de adición: creator, invitation, direct_add, assignment, staff_access
added_via String @db.VarChar(20)
can_edit Boolean @default(false)
can_comment Boolean @default(true)
joined_at DateTime @default(now()) @db.Timestamptz
ticket Ticket @relation(fields: [ticket_id], references: [id], onDelete: Cascade)
user User @relation("Participant", fields: [user_id], references: [id])
added_by_user User @relation("AddedBy", fields: [added_by], references: [id])
@@unique([ticket_id, user_id])
@@index([added_by], name: "idx_participants_added_by")
@@index([ticket_id], name: "idx_participants_ticket_id")
@@index([user_id], name: "idx_participants_user_id")
@@map("ticket_participants")
}
model TicketStatusHistory {
id BigInt @id @default(autoincrement())
ticket_id BigInt
old_status String? @db.VarChar(30)
new_status String @db.VarChar(30)
changed_by BigInt
changed_at DateTime @default(now()) @db.Timestamptz
ticket Ticket @relation(fields: [ticket_id], references: [id], onDelete: Cascade)
changed_by_user User @relation(fields: [changed_by], references: [id])
@@index([ticket_id], name: "idx_history_ticket_id")
@@map("ticket_status_history")
}

View File

@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { CategoriesModule } from './categories/categories.module';
import { PrismaModule } from './prisma/prisma.module';
@Module({
imports: [CategoriesModule],
imports: [PrismaModule, CategoriesModule],
controllers: [AppController],
providers: [AppService],
})

View File

@@ -3,6 +3,6 @@ import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(process.env.PORT ?? 3020);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

View File

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@@ -0,0 +1,9 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from 'generated/prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
}