Commit 1909772c by Augusto

Player-Features and Agents

parent 78e29712
ALTER TABLE "players"
ADD COLUMN IF NOT EXISTS "eu_password" boolean DEFAULT false;
CREATE TYPE "public"."audit_log_action" AS ENUM('create', 'update', 'delete', 'soft_delete', 'restore');--> statement-breakpoint
CREATE TYPE "public"."position_category" AS ENUM('Forward', 'Goalkeeper', 'Defender', 'Midfield');--> statement-breakpoint
CREATE TABLE "agents" (
"id" serial PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"description" text,
"type" text NOT NULL,
"email" text NOT NULL,
"phone" text NOT NULL,
"status" text NOT NULL,
"address" text NOT NULL,
"country" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "audit_logs" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer,
"action" "audit_log_action" NOT NULL,
"entity_type" text NOT NULL,
"entity_id" integer,
"entity_wy_id" integer,
"old_values" json,
"new_values" json,
"changes" json,
"ip_address" varchar(45),
"user_agent" text,
"metadata" json,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "coach_agents" (
"id" serial PRIMARY KEY NOT NULL,
"coach_id" integer NOT NULL,
"agent_id" integer NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "player_agents" (
"id" serial PRIMARY KEY NOT NULL,
"player_id" integer NOT NULL,
"agent_id" integer NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "positions" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar(100) NOT NULL,
"code2" varchar(10),
"code3" varchar(10),
"order" integer DEFAULT 0,
"location_x" integer,
"location_y" integer,
"bg_color" varchar(7),
"text_color" varchar(7),
"category" "position_category" NOT NULL,
"is_active" boolean DEFAULT true,
"created_at" timestamp with time zone DEFAULT now(),
"updated_at" timestamp with time zone DEFAULT now(),
"deleted_at" timestamp with time zone
);
--> statement-breakpoint
DROP INDEX "idx_players_position";--> statement-breakpoint
ALTER TABLE "players" ALTER COLUMN "wy_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "current_national_team_name" varchar(255);--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "current_national_team_official_name" varchar(255);--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "position_id" integer;--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "other_position_ids" integer[];--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "second_birth_area_wy_id" integer;--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "second_passport_area_wy_id" integer;--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "agent_id" integer;--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "contract_ends_at" date;--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "eu_password" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "coach_agents" ADD CONSTRAINT "coach_agents_coach_id_coaches_id_fk" FOREIGN KEY ("coach_id") REFERENCES "public"."coaches"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "coach_agents" ADD CONSTRAINT "coach_agents_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "player_agents" ADD CONSTRAINT "player_agents_player_id_players_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "player_agents" ADD CONSTRAINT "player_agents_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_audit_logs_user_id" ON "audit_logs" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_audit_logs_action" ON "audit_logs" USING btree ("action");--> statement-breakpoint
CREATE INDEX "idx_audit_logs_entity_type" ON "audit_logs" USING btree ("entity_type");--> statement-breakpoint
CREATE INDEX "idx_audit_logs_entity_id" ON "audit_logs" USING btree ("entity_id");--> statement-breakpoint
CREATE INDEX "idx_audit_logs_entity_wy_id" ON "audit_logs" USING btree ("entity_wy_id");--> statement-breakpoint
CREATE INDEX "idx_audit_logs_created_at" ON "audit_logs" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "idx_audit_logs_entity_type_id" ON "audit_logs" USING btree ("entity_type","entity_id");--> statement-breakpoint
CREATE INDEX "idx_audit_logs_entity_type_wy_id" ON "audit_logs" USING btree ("entity_type","entity_wy_id");--> statement-breakpoint
CREATE INDEX "idx_audit_logs_user_created_at" ON "audit_logs" USING btree ("user_id","created_at");--> statement-breakpoint
CREATE UNIQUE INDEX "coach_agents_coach_id_agent_id_idx" ON "coach_agents" USING btree ("coach_id","agent_id");--> statement-breakpoint
CREATE UNIQUE INDEX "player_agents_player_id_agent_id_idx" ON "player_agents" USING btree ("player_id","agent_id");--> statement-breakpoint
CREATE UNIQUE INDEX "positions_code2_unique" ON "positions" USING btree ("code2");--> statement-breakpoint
CREATE UNIQUE INDEX "positions_code3_unique" ON "positions" USING btree ("code3");--> statement-breakpoint
CREATE INDEX "positions_name_idx" ON "positions" USING btree ("name");--> statement-breakpoint
CREATE INDEX "positions_category_idx" ON "positions" USING btree ("category");--> statement-breakpoint
CREATE INDEX "positions_is_active_idx" ON "positions" USING btree ("is_active");--> statement-breakpoint
CREATE INDEX "positions_category_active_idx" ON "positions" USING btree ("category","is_active");--> statement-breakpoint
CREATE INDEX "positions_deleted_at_idx" ON "positions" USING btree ("deleted_at");--> statement-breakpoint
ALTER TABLE "players" ADD CONSTRAINT "players_position_id_positions_id_fk" FOREIGN KEY ("position_id") REFERENCES "public"."positions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "players" ADD CONSTRAINT "players_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_players_position_id" ON "players" USING btree ("position_id");--> statement-breakpoint
ALTER TABLE "players" DROP COLUMN "position";--> statement-breakpoint
ALTER TABLE "players" DROP COLUMN "agent";
\ No newline at end of file
ALTER TABLE "players"
ALTER COLUMN "wy_id" DROP NOT NULL;
CREATE TABLE "player_feature_categories" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar(100) NOT NULL,
"description" text,
"order" integer DEFAULT 0,
"is_active" boolean DEFAULT true,
"created_at" timestamp with time zone DEFAULT now(),
"updated_at" timestamp with time zone DEFAULT now(),
"deleted_at" timestamp with time zone,
CONSTRAINT "player_feature_categories_name_unique" UNIQUE("name")
);
--> statement-breakpoint
CREATE TABLE "player_feature_ratings" (
"id" serial PRIMARY KEY NOT NULL,
"player_id" integer NOT NULL,
"feature_type_id" integer NOT NULL,
"user_id" integer NOT NULL,
"rating" integer NOT NULL,
"notes" text,
"created_at" timestamp with time zone DEFAULT now(),
"updated_at" timestamp with time zone DEFAULT now(),
"deleted_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "player_feature_types" (
"id" serial PRIMARY KEY NOT NULL,
"category_id" integer NOT NULL,
"name" varchar(100) NOT NULL,
"description" text,
"order" integer DEFAULT 0,
"is_active" boolean DEFAULT true,
"created_at" timestamp with time zone DEFAULT now(),
"updated_at" timestamp with time zone DEFAULT now(),
"deleted_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "player_feature_ratings" ADD CONSTRAINT "player_feature_ratings_player_id_players_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "player_feature_ratings" ADD CONSTRAINT "player_feature_ratings_feature_type_id_player_feature_types_id_fk" FOREIGN KEY ("feature_type_id") REFERENCES "public"."player_feature_types"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "player_feature_ratings" ADD CONSTRAINT "player_feature_ratings_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "player_feature_types" ADD CONSTRAINT "player_feature_types_category_id_player_feature_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."player_feature_categories"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "player_feature_categories_name_idx" ON "player_feature_categories" USING btree ("name");--> statement-breakpoint
CREATE INDEX "player_feature_categories_order_idx" ON "player_feature_categories" USING btree ("order");--> statement-breakpoint
CREATE INDEX "player_feature_categories_is_active_idx" ON "player_feature_categories" USING btree ("is_active");--> statement-breakpoint
CREATE INDEX "player_feature_categories_deleted_at_idx" ON "player_feature_categories" USING btree ("deleted_at");--> statement-breakpoint
CREATE INDEX "player_feature_ratings_player_id_idx" ON "player_feature_ratings" USING btree ("player_id");--> statement-breakpoint
CREATE INDEX "player_feature_ratings_feature_type_id_idx" ON "player_feature_ratings" USING btree ("feature_type_id");--> statement-breakpoint
CREATE INDEX "player_feature_ratings_user_id_idx" ON "player_feature_ratings" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "player_feature_ratings_rating_idx" ON "player_feature_ratings" USING btree ("rating");--> statement-breakpoint
CREATE INDEX "player_feature_ratings_deleted_at_idx" ON "player_feature_ratings" USING btree ("deleted_at");--> statement-breakpoint
CREATE UNIQUE INDEX "player_feature_ratings_player_feature_user_idx" ON "player_feature_ratings" USING btree ("player_id","feature_type_id","user_id");--> statement-breakpoint
CREATE INDEX "player_feature_ratings_player_feature_idx" ON "player_feature_ratings" USING btree ("player_id","feature_type_id");--> statement-breakpoint
CREATE INDEX "player_feature_ratings_player_user_idx" ON "player_feature_ratings" USING btree ("player_id","user_id");--> statement-breakpoint
CREATE INDEX "player_feature_types_category_id_idx" ON "player_feature_types" USING btree ("category_id");--> statement-breakpoint
CREATE INDEX "player_feature_types_name_idx" ON "player_feature_types" USING btree ("name");--> statement-breakpoint
CREATE INDEX "player_feature_types_order_idx" ON "player_feature_types" USING btree ("order");--> statement-breakpoint
CREATE INDEX "player_feature_types_is_active_idx" ON "player_feature_types" USING btree ("is_active");--> statement-breakpoint
CREATE INDEX "player_feature_types_deleted_at_idx" ON "player_feature_types" USING btree ("deleted_at");--> statement-breakpoint
CREATE INDEX "player_feature_types_category_order_idx" ON "player_feature_types" USING btree ("category_id","order");
\ No newline at end of file
ALTER TABLE "player_feature_ratings" ALTER COLUMN "rating" DROP NOT NULL;
\ No newline at end of file
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -71,6 +71,27 @@ ...@@ -71,6 +71,27 @@
"when": 1763057316693, "when": 1763057316693,
"tag": "0009_tough_greymalkin", "tag": "0009_tough_greymalkin",
"breakpoints": true "breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1763553196783,
"tag": "0010_marvelous_loners",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1763556954199,
"tag": "0011_right_tempest",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1763557856440,
"tag": "0012_freezing_jean_grey",
"breakpoints": true
} }
] ]
} }
\ No newline at end of file
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AppService } from './app.service'; import { AppService } from './app.service';
import { DatabaseModule } from './database/database.module'; import { DatabaseModule } from './database/database.module';
...@@ -18,6 +19,8 @@ import { AreasModule } from './modules/areas/areas.module'; ...@@ -18,6 +19,8 @@ import { AreasModule } from './modules/areas/areas.module';
import { ListsModule } from './modules/lists/lists.module'; import { ListsModule } from './modules/lists/lists.module';
import { PositionsModule } from './modules/positions/positions.module'; import { PositionsModule } from './modules/positions/positions.module';
import { AuditLogsModule } from './modules/audit-logs/audit-logs.module'; import { AuditLogsModule } from './modules/audit-logs/audit-logs.module';
import { AgentsModule } from './modules/agents/agents.module';
import { PlayerFeaturesModule } from './modules/player-features/player-features.module';
@Module({ @Module({
imports: [ imports: [
...@@ -26,6 +29,16 @@ import { AuditLogsModule } from './modules/audit-logs/audit-logs.module'; ...@@ -26,6 +29,16 @@ import { AuditLogsModule } from './modules/audit-logs/audit-logs.module';
isGlobal: true, isGlobal: true,
envFilePath: '.env', envFilePath: '.env',
}), }),
// Register JwtModule globally to avoid multiple registrations
JwtModule.registerAsync({
global: true,
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET') || 'your-secret-key',
signOptions: { expiresIn: '24h' },
}),
inject: [ConfigService],
}),
DatabaseModule, DatabaseModule,
FeatureFlagModule, FeatureFlagModule,
UsersModule, UsersModule,
...@@ -42,6 +55,8 @@ import { AuditLogsModule } from './modules/audit-logs/audit-logs.module'; ...@@ -42,6 +55,8 @@ import { AuditLogsModule } from './modules/audit-logs/audit-logs.module';
ListsModule, ListsModule,
PositionsModule, PositionsModule,
AuditLogsModule, AuditLogsModule,
AgentsModule,
PlayerFeaturesModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],
......
...@@ -17,20 +17,13 @@ export class LoggingInterceptor implements NestInterceptor { ...@@ -17,20 +17,13 @@ export class LoggingInterceptor implements NestInterceptor {
const { method, url, user } = request; const { method, url, user } = request;
const now = Date.now(); const now = Date.now();
this.logger.log(`→ ${method} ${url} | User: ${user?.email || 'Anonymous'}`);
return next.handle().pipe( return next.handle().pipe(
tap({ tap({
next: () => { // Only log when there is an error; successful requests stay silent
const response = context.switchToHttp().getResponse();
const { statusCode } = response;
const elapsed = Date.now() - now;
this.logger.log(` ${method} ${url} ${statusCode} | ${elapsed}ms`);
},
error: (error) => { error: (error) => {
const elapsed = Date.now() - now; const elapsed = Date.now() - now;
this.logger.error( this.logger.error(
` ${method} ${url} ERROR | ${elapsed}ms`, `← ${method} ${url} ERROR | ${elapsed}ms | User: ${user?.email || 'Anonymous'}`,
error.stack, error.stack,
); );
}, },
......
...@@ -152,7 +152,7 @@ export const players = pgTable( ...@@ -152,7 +152,7 @@ export const players = pgTable(
'players', 'players',
{ {
id: serial('id').primaryKey(), id: serial('id').primaryKey(),
wyId: integer('wy_id').unique().notNull(), // Wyscout external reference - REQUIRED wyId: integer('wy_id').unique(), // Wyscout external reference - OPTIONAL for non-Wyscout players
// CHANGED: Now using wy_id for foreign key // CHANGED: Now using wy_id for foreign key
teamWyId: integer('team_wy_id'), teamWyId: integer('team_wy_id'),
...@@ -169,7 +169,16 @@ export const players = pgTable( ...@@ -169,7 +169,16 @@ export const players = pgTable(
currentTeamName: varchar('current_team_name', { length: 255 }), // Current team name from API currentTeamName: varchar('current_team_name', { length: 255 }), // Current team name from API
currentTeamOfficialName: varchar('current_team_official_name', { currentTeamOfficialName: varchar('current_team_official_name', {
length: 255, length: 255,
}), // Current team official name from API }),
currentNationalTeamName: varchar('current_national_team_name', {
length: 255,
}),
currentNationalTeamOfficialName: varchar(
'current_national_team_official_name',
{
length: 255,
},
),
// Physical attributes // Physical attributes
dateOfBirth: date('date_of_birth'), dateOfBirth: date('date_of_birth'),
...@@ -204,7 +213,9 @@ export const players = pgTable( ...@@ -204,7 +213,9 @@ export const players = pgTable(
// Transfer and financial information // Transfer and financial information
onLoan: boolean('on_loan').default(false), onLoan: boolean('on_loan').default(false),
agent: varchar('agent', { length: 255 }), agentId: integer('agent_id').references(() => agents.id, {
onDelete: 'set null',
}),
ranking: varchar('ranking', { length: 255 }), ranking: varchar('ranking', { length: 255 }),
roi: varchar('roi', { length: 255 }), // Return on Investment roi: varchar('roi', { length: 255 }), // Return on Investment
marketValue: decimal('market_value', { precision: 15, scale: 2 }), // Monetary value marketValue: decimal('market_value', { precision: 15, scale: 2 }), // Monetary value
...@@ -223,6 +234,7 @@ export const players = pgTable( ...@@ -223,6 +234,7 @@ export const players = pgTable(
// Standard fields // Standard fields
isActive: boolean('is_active').default(true), isActive: boolean('is_active').default(true),
euPassword: boolean('eu_password').default(false),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
deletedAt: timestamp('deleted_at', { withTimezone: true }), deletedAt: timestamp('deleted_at', { withTimezone: true }),
...@@ -404,6 +416,66 @@ export const matches = pgTable( ...@@ -404,6 +416,66 @@ export const matches = pgTable(
); );
// ============================================================================ // ============================================================================
// Agents
// ============================================================================
export const agents = pgTable('agents', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
description: text('description'),
type: text('type').notNull(),
email: text('email').notNull(),
phone: text('phone').notNull(),
status: text('status').notNull(),
address: text('address').notNull(),
country: text('country').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
});
export const playerAgents = pgTable(
'player_agents',
{
id: serial('id').primaryKey(),
playerId: integer('player_id')
.notNull()
.references(() => players.id, { onDelete: 'cascade' }),
agentId: integer('agent_id')
.notNull()
.references(() => agents.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
playerAgentIdx: uniqueIndex('player_agents_player_id_agent_id_idx').on(
table.playerId,
table.agentId,
),
}),
);
export const coachAgents = pgTable(
'coach_agents',
{
id: serial('id').primaryKey(),
coachId: integer('coach_id')
.notNull()
.references(() => coaches.id, { onDelete: 'cascade' }),
agentId: integer('agent_id')
.notNull()
.references(() => agents.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
coachAgentIdx: uniqueIndex('coach_agents_coach_id_agent_id_idx').on(
table.coachId,
table.agentId,
),
}),
);
// ============================================================================
// Reports // Reports
// ============================================================================ // ============================================================================
...@@ -779,6 +851,113 @@ export const clientModules = pgTable( ...@@ -779,6 +851,113 @@ export const clientModules = pgTable(
); );
// ============================================================================ // ============================================================================
// PLAYER FEATURES (Características del Jugador)
// ============================================================================
export const playerFeatureCategories = pgTable(
'player_feature_categories',
{
id: serial('id').primaryKey(),
name: varchar('name', { length: 100 }).notNull().unique(), // e.g., "Físicas", "Técnicas", "Tácticas", "Mentales"
description: text('description'),
order: integer('order').default(0),
isActive: boolean('is_active').default(true),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
},
(table) => ({
nameIdx: uniqueIndex('player_feature_categories_name_idx').on(table.name),
orderIdx: index('player_feature_categories_order_idx').on(table.order),
isActiveIdx: index('player_feature_categories_is_active_idx').on(
table.isActive,
),
deletedAtIdx: index('player_feature_categories_deleted_at_idx').on(
table.deletedAt,
),
}),
);
export const playerFeatureTypes = pgTable(
'player_feature_types',
{
id: serial('id').primaryKey(),
categoryId: integer('category_id')
.notNull()
.references(() => playerFeatureCategories.id, { onDelete: 'cascade' }),
name: varchar('name', { length: 100 }).notNull(), // e.g., "Agilidad", "Agresividad", "Cambio de Ritmo"
description: text('description'),
order: integer('order').default(0),
isActive: boolean('is_active').default(true),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
},
(table) => ({
categoryIdIdx: index('player_feature_types_category_id_idx').on(
table.categoryId,
),
nameIdx: index('player_feature_types_name_idx').on(table.name),
orderIdx: index('player_feature_types_order_idx').on(table.order),
isActiveIdx: index('player_feature_types_is_active_idx').on(table.isActive),
deletedAtIdx: index('player_feature_types_deleted_at_idx').on(
table.deletedAt,
),
categoryOrderIdx: index('player_feature_types_category_order_idx').on(
table.categoryId,
table.order,
),
}),
);
export const playerFeatureRatings = pgTable(
'player_feature_ratings',
{
id: serial('id').primaryKey(),
playerId: integer('player_id')
.notNull()
.references(() => players.id, { onDelete: 'cascade' }),
featureTypeId: integer('feature_type_id')
.notNull()
.references(() => playerFeatureTypes.id, { onDelete: 'cascade' }),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
rating: integer('rating').$type<1 | 2 | 3 | 4 | 5 | null>(), // Rating scale 1-5, optional
notes: text('notes'), // Optional scout notes
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
},
(table) => ({
playerIdIdx: index('player_feature_ratings_player_id_idx').on(
table.playerId,
),
featureTypeIdIdx: index('player_feature_ratings_feature_type_id_idx').on(
table.featureTypeId,
),
userIdIdx: index('player_feature_ratings_user_id_idx').on(table.userId),
ratingIdx: index('player_feature_ratings_rating_idx').on(table.rating),
deletedAtIdx: index('player_feature_ratings_deleted_at_idx').on(
table.deletedAt,
),
// Unique constraint: one rating per scout per feature per player
playerFeatureUserIdx: uniqueIndex(
'player_feature_ratings_player_feature_user_idx',
).on(table.playerId, table.featureTypeId, table.userId),
// Composite indexes for common queries
playerFeatureIdx: index('player_feature_ratings_player_feature_idx').on(
table.playerId,
table.featureTypeId,
),
playerUserIdx: index('player_feature_ratings_player_user_idx').on(
table.playerId,
table.userId,
),
}),
);
// ============================================================================
// AUDIT LOGS // AUDIT LOGS
// ============================================================================ // ============================================================================
...@@ -894,6 +1073,22 @@ export const selectAreaSchema = createSelectSchema(areas); ...@@ -894,6 +1073,22 @@ export const selectAreaSchema = createSelectSchema(areas);
export const insertPositionSchema = createInsertSchema(positions); export const insertPositionSchema = createInsertSchema(positions);
export const selectPositionSchema = createSelectSchema(positions); export const selectPositionSchema = createSelectSchema(positions);
// Player Features
export const insertPlayerFeatureCategorySchema = createInsertSchema(
playerFeatureCategories,
);
export const selectPlayerFeatureCategorySchema = createSelectSchema(
playerFeatureCategories,
);
export const insertPlayerFeatureTypeSchema =
createInsertSchema(playerFeatureTypes);
export const selectPlayerFeatureTypeSchema =
createSelectSchema(playerFeatureTypes);
export const insertPlayerFeatureRatingSchema =
createInsertSchema(playerFeatureRatings);
export const selectPlayerFeatureRatingSchema =
createSelectSchema(playerFeatureRatings);
// Audit Logs // Audit Logs
export const insertAuditLogSchema = createInsertSchema(auditLogs); export const insertAuditLogSchema = createInsertSchema(auditLogs);
export const selectAuditLogSchema = createSelectSchema(auditLogs); export const selectAuditLogSchema = createSelectSchema(auditLogs);
...@@ -915,6 +1110,12 @@ export type Coach = typeof coaches.$inferSelect; ...@@ -915,6 +1110,12 @@ export type Coach = typeof coaches.$inferSelect;
export type NewCoach = typeof coaches.$inferInsert; export type NewCoach = typeof coaches.$inferInsert;
export type Match = typeof matches.$inferSelect; export type Match = typeof matches.$inferSelect;
export type NewMatch = typeof matches.$inferInsert; export type NewMatch = typeof matches.$inferInsert;
export type Agent = typeof agents.$inferSelect;
export type NewAgent = typeof agents.$inferInsert;
export type PlayerAgent = typeof playerAgents.$inferSelect;
export type NewPlayerAgent = typeof playerAgents.$inferInsert;
export type CoachAgent = typeof coachAgents.$inferSelect;
export type NewCoachAgent = typeof coachAgents.$inferInsert;
// Categories // Categories
export type Category = typeof categories.$inferSelect; export type Category = typeof categories.$inferSelect;
...@@ -961,6 +1162,15 @@ export type NewArea = typeof areas.$inferInsert; ...@@ -961,6 +1162,15 @@ export type NewArea = typeof areas.$inferInsert;
export type Position = typeof positions.$inferSelect; export type Position = typeof positions.$inferSelect;
export type NewPosition = typeof positions.$inferInsert; export type NewPosition = typeof positions.$inferInsert;
// Player Features
export type PlayerFeatureCategory = typeof playerFeatureCategories.$inferSelect;
export type NewPlayerFeatureCategory =
typeof playerFeatureCategories.$inferInsert;
export type PlayerFeatureType = typeof playerFeatureTypes.$inferSelect;
export type NewPlayerFeatureType = typeof playerFeatureTypes.$inferInsert;
export type PlayerFeatureRating = typeof playerFeatureRatings.$inferSelect;
export type NewPlayerFeatureRating = typeof playerFeatureRatings.$inferInsert;
// Audit Logs // Audit Logs
export type AuditLog = typeof auditLogs.$inferSelect; export type AuditLog = typeof auditLogs.$inferSelect;
export type NewAuditLog = typeof auditLogs.$inferInsert; export type NewAuditLog = typeof auditLogs.$inferInsert;
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { Logger, ValidationPipe } from '@nestjs/common'; import { Logger, ValidationPipe } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { json, urlencoded } from 'express'; import { json, urlencoded, static as expressStatic } from 'express';
import { join } from 'path';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { GlobalExceptionFilter } from './common/filters/global-exception.filter'; import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
import { LoggingInterceptor } from './common/interceptors/logging.interceptor'; import { LoggingInterceptor } from './common/interceptors/logging.interceptor';
...@@ -40,6 +41,9 @@ async function bootstrap() { ...@@ -40,6 +41,9 @@ async function bootstrap() {
app.use(json({ limit: '50mb' })); app.use(json({ limit: '50mb' }));
app.use(urlencoded({ limit: '50mb', extended: true })); app.use(urlencoded({ limit: '50mb', extended: true }));
// Serve uploaded files statically
app.use('/uploads', expressStatic(join(process.cwd(), 'uploads')));
// Set global prefix for all routes // Set global prefix for all routes
app.setGlobalPrefix('api'); app.setGlobalPrefix('api');
......
import {
Body,
Controller,
Delete,
Get,
NotFoundException,
Param,
ParseIntPipe,
Patch,
Post,
} from '@nestjs/common';
import {
ApiBody,
ApiNoContentResponse,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiTags,
} from '@nestjs/swagger';
import { AgentsService } from './agents.service';
import { type Agent } from '../../database/schema';
import { CreateAgentDto } from './dto/create-agent.dto';
import { UpdateAgentDto } from './dto/update-agent.dto';
@ApiTags('Agents')
@Controller('agents')
export class AgentsController {
constructor(private readonly agentsService: AgentsService) {}
@Post()
@ApiOperation({ summary: 'Create agent' })
@ApiBody({ type: CreateAgentDto })
@ApiOkResponse({ description: 'Created agent' })
async create(@Body() body: CreateAgentDto): Promise<Agent> {
return this.agentsService.create(body as any);
}
@Patch(':id')
@ApiOperation({ summary: 'Update agent by ID' })
@ApiParam({ name: 'id', type: Number })
@ApiBody({ type: UpdateAgentDto })
@ApiOkResponse({ description: 'Updated agent' })
@ApiNotFoundResponse({ description: 'Agent not found' })
async update(
@Param('id', ParseIntPipe) id: number,
@Body() body: UpdateAgentDto,
): Promise<Agent> {
return this.agentsService.update(id, body as any);
}
@Get(':id')
@ApiOperation({ summary: 'Get agent by ID' })
@ApiParam({ name: 'id', type: Number })
@ApiOkResponse({ description: 'Agent if found' })
async getById(@Param('id', ParseIntPipe) id: number): Promise<Agent | null> {
return this.agentsService.findById(id);
}
@Get()
@ApiOperation({ summary: 'List all agents' })
@ApiOkResponse({ description: 'List of agents' })
async list(): Promise<Agent[]> {
return this.agentsService.findAll();
}
@Delete(':id')
@ApiOperation({ summary: 'Delete agent by ID' })
@ApiParam({ name: 'id', type: Number })
@ApiNoContentResponse({ description: 'Agent deleted successfully' })
@ApiNotFoundResponse({ description: 'Agent not found' })
async delete(@Param('id', ParseIntPipe) id: number): Promise<void> {
const result = await this.agentsService.delete(id);
if (!result) {
throw new NotFoundException(`Agent with ID ${id} not found`);
}
}
@Post(':agentId/players/:playerId')
@ApiOperation({ summary: 'Associate agent with player' })
@ApiParam({ name: 'agentId', type: Number })
@ApiParam({ name: 'playerId', type: Number })
@ApiOkResponse({ description: 'Association created or already exists' })
async associateWithPlayer(
@Param('agentId', ParseIntPipe) agentId: number,
@Param('playerId', ParseIntPipe) playerId: number,
) {
return this.agentsService.associateWithPlayer(agentId, playerId);
}
@Post(':agentId/coaches/:coachId')
@ApiOperation({ summary: 'Associate agent with coach' })
@ApiParam({ name: 'agentId', type: Number })
@ApiParam({ name: 'coachId', type: Number })
@ApiOkResponse({ description: 'Association created or already exists' })
async associateWithCoach(
@Param('agentId', ParseIntPipe) agentId: number,
@Param('coachId', ParseIntPipe) coachId: number,
) {
return this.agentsService.associateWithCoach(agentId, coachId);
}
@Delete(':agentId/players/:playerId')
@ApiOperation({ summary: 'Remove agent from player' })
@ApiParam({ name: 'agentId', type: Number })
@ApiParam({ name: 'playerId', type: Number })
@ApiNoContentResponse({ description: 'Association removed if it existed' })
async removeFromPlayer(
@Param('agentId', ParseIntPipe) agentId: number,
@Param('playerId', ParseIntPipe) playerId: number,
): Promise<void> {
await this.agentsService.removeFromPlayer(agentId, playerId);
}
@Delete(':agentId/coaches/:coachId')
@ApiOperation({ summary: 'Remove agent from coach' })
@ApiParam({ name: 'agentId', type: Number })
@ApiParam({ name: 'coachId', type: Number })
@ApiNoContentResponse({ description: 'Association removed if it existed' })
async removeFromCoach(
@Param('agentId', ParseIntPipe) agentId: number,
@Param('coachId', ParseIntPipe) coachId: number,
): Promise<void> {
await this.agentsService.removeFromCoach(agentId, coachId);
}
@Get('by-player/:playerId')
@ApiOperation({ summary: 'Get agents associated with a player' })
@ApiParam({ name: 'playerId', type: Number })
@ApiOkResponse({ description: 'List of agents for the player' })
async getByPlayer(
@Param('playerId', ParseIntPipe) playerId: number,
): Promise<Agent[]> {
return this.agentsService.getAgentsByPlayer(playerId);
}
@Get('by-coach/:coachId')
@ApiOperation({ summary: 'Get agents associated with a coach' })
@ApiParam({ name: 'coachId', type: Number })
@ApiOkResponse({ description: 'List of agents for the coach' })
async getByCoach(
@Param('coachId', ParseIntPipe) coachId: number,
): Promise<Agent[]> {
return this.agentsService.getAgentsByCoach(coachId);
}
}
import { Module } from '@nestjs/common';
import { DatabaseModule } from '../../database/database.module';
import { AgentsController } from './agents.controller';
import { AgentsService } from './agents.service';
@Module({
imports: [DatabaseModule],
controllers: [AgentsController],
providers: [AgentsService],
exports: [AgentsService],
})
export class AgentsModule {}
import { Injectable, NotFoundException } from '@nestjs/common';
import { eq, and } from 'drizzle-orm';
import { DatabaseService } from '../../database/database.service';
import {
agents,
playerAgents,
coachAgents,
players,
coaches,
type Agent,
type NewAgent,
} from '../../database/schema';
@Injectable()
export class AgentsService {
constructor(private readonly databaseService: DatabaseService) {}
async create(data: Partial<NewAgent>): Promise<Agent> {
const db = this.databaseService.getDatabase();
const now = new Date();
const [row] = await db
.insert(agents)
.values({
name: data.name!,
type: data.type!,
email: data.email!,
phone: data.phone!,
status: data.status!,
address: data.address!,
country: data.country!,
description: data.description ?? null,
createdAt: now as any,
updatedAt: now as any,
} as NewAgent)
.returning();
return row as Agent;
}
async update(id: number, data: Partial<NewAgent>): Promise<Agent> {
const db = this.databaseService.getDatabase();
const [existing] = await db.select().from(agents).where(eq(agents.id, id));
if (!existing) {
throw new NotFoundException(`Agent with ID ${id} not found`);
}
const [row] = await db
.update(agents)
.set({
...data,
updatedAt: new Date() as any,
})
.where(eq(agents.id, id))
.returning();
return row as Agent;
}
async findById(id: number): Promise<Agent | null> {
const db = this.databaseService.getDatabase();
const [row] = await db.select().from(agents).where(eq(agents.id, id));
return (row as Agent) || null;
}
async findAll(): Promise<Agent[]> {
const db = this.databaseService.getDatabase();
const rows = await db.select().from(agents);
return rows as Agent[];
}
async delete(id: number): Promise<boolean> {
const db = this.databaseService.getDatabase();
const [existing] = await db.select().from(agents).where(eq(agents.id, id));
if (!existing) {
return false;
}
await db.delete(agents).where(eq(agents.id, id));
return true;
}
async associateWithPlayer(agentId: number, playerId: number) {
const db = this.databaseService.getDatabase();
const [[agent], [player]] = await Promise.all([
db.select().from(agents).where(eq(agents.id, agentId)).limit(1),
db.select().from(players).where(eq(players.id, playerId)).limit(1),
]);
if (!agent) {
throw new NotFoundException(`Agent with ID ${agentId} not found`);
}
if (!player) {
throw new NotFoundException(`Player with ID ${playerId} not found`);
}
const now = new Date();
const [row] = await db
.insert(playerAgents)
.values({
playerId,
agentId,
createdAt: now as any,
updatedAt: now as any,
})
.onConflictDoNothing()
.returning();
return row ?? null;
}
async associateWithCoach(agentId: number, coachId: number) {
const db = this.databaseService.getDatabase();
const [[agent], [coach]] = await Promise.all([
db.select().from(agents).where(eq(agents.id, agentId)).limit(1),
db.select().from(coaches).where(eq(coaches.id, coachId)).limit(1),
]);
if (!agent) {
throw new NotFoundException(`Agent with ID ${agentId} not found`);
}
if (!coach) {
throw new NotFoundException(`Coach with ID ${coachId} not found`);
}
const now = new Date();
const [row] = await db
.insert(coachAgents)
.values({
coachId,
agentId,
createdAt: now as any,
updatedAt: now as any,
})
.onConflictDoNothing()
.returning();
return row ?? null;
}
async removeFromPlayer(agentId: number, playerId: number): Promise<boolean> {
const db = this.databaseService.getDatabase();
await db
.delete(playerAgents)
.where(
and(
eq(playerAgents.agentId, agentId),
eq(playerAgents.playerId, playerId),
),
);
return true;
}
async removeFromCoach(agentId: number, coachId: number): Promise<boolean> {
const db = this.databaseService.getDatabase();
await db
.delete(coachAgents)
.where(
and(eq(coachAgents.agentId, agentId), eq(coachAgents.coachId, coachId)),
);
return true;
}
async getAgentsByPlayer(playerId: number): Promise<Agent[]> {
const db = this.databaseService.getDatabase();
const rows = await db
.select({ agent: agents })
.from(playerAgents)
.innerJoin(agents, eq(playerAgents.agentId, agents.id))
.where(eq(playerAgents.playerId, playerId));
return rows.map((r) => r.agent as Agent);
}
async getAgentsByCoach(coachId: number): Promise<Agent[]> {
const db = this.databaseService.getDatabase();
const rows = await db
.select({ agent: agents })
.from(coachAgents)
.innerJoin(agents, eq(coachAgents.agentId, agents.id))
.where(eq(coachAgents.coachId, coachId));
return rows.map((r) => r.agent as Agent);
}
}
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class CreateAgentDto {
@ApiProperty({ description: 'Agent name' })
@IsString()
@IsNotEmpty()
name: string;
@ApiProperty({ description: 'Agent type (e.g., individual, agency)' })
@IsString()
@IsNotEmpty()
type: string;
@ApiProperty({ description: 'Agent email' })
@IsEmail()
email: string;
@ApiProperty({ description: 'Agent phone' })
@IsString()
@IsNotEmpty()
phone: string;
@ApiProperty({ description: 'Status' })
@IsString()
@IsNotEmpty()
status: string;
@ApiProperty({ description: 'Address' })
@IsString()
@IsNotEmpty()
address: string;
@ApiProperty({ description: 'Country' })
@IsString()
@IsNotEmpty()
country: string;
@ApiPropertyOptional({ description: 'Description' })
@IsOptional()
@IsString()
description?: string;
}
export * from './create-agent.dto';
export * from './update-agent.dto';
import { PartialType } from '@nestjs/swagger';
import { CreateAgentDto } from './create-agent.dto';
export class UpdateAgentDto extends PartialType(CreateAgentDto) {}
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '../users/users.module';
...@@ -10,14 +8,7 @@ import { DatabaseModule } from '../../database/database.module'; ...@@ -10,14 +8,7 @@ import { DatabaseModule } from '../../database/database.module';
imports: [ imports: [
DatabaseModule, DatabaseModule,
UsersModule, UsersModule,
JwtModule.registerAsync({ // JwtModule is now global, no need to import here
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET') || 'your-secret-key',
signOptions: { expiresIn: '24h' },
}),
inject: [ConfigService],
}),
], ],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService], providers: [AuthService],
......
...@@ -11,11 +11,14 @@ import { ...@@ -11,11 +11,14 @@ import {
Patch, Patch,
Post, Post,
Query, Query,
UploadedFile,
UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { FilesService } from './files.service'; import { FilesService } from './files.service';
import { type File } from '../../database/schema'; import { type File } from '../../database/schema';
import { import {
ApiBody, ApiBody,
ApiConsumes,
ApiNoContentResponse, ApiNoContentResponse,
ApiNotFoundResponse, ApiNotFoundResponse,
ApiOkResponse, ApiOkResponse,
...@@ -24,6 +27,7 @@ import { ...@@ -24,6 +27,7 @@ import {
ApiQuery, ApiQuery,
ApiTags, ApiTags,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { FileInterceptor } from '@nestjs/platform-express';
import { CreateFileDto, UpdateFileDto, QueryFilesDto } from './dto'; import { CreateFileDto, UpdateFileDto, QueryFilesDto } from './dto';
@ApiTags('Files') @ApiTags('Files')
...@@ -43,6 +47,88 @@ export class FilesController { ...@@ -43,6 +47,88 @@ export class FilesController {
return this.filesService.create(body); return this.filesService.create(body);
} }
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
@ApiOperation({ summary: 'Upload a file and create a file record' })
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
file: {
type: 'string',
format: 'binary',
},
entityType: {
type: 'string',
enum: ['player', 'coach', 'match', 'report', 'user'],
},
entityId: {
type: 'integer',
nullable: true,
},
entityWyId: {
type: 'integer',
nullable: true,
},
category: {
type: 'string',
nullable: true,
},
description: {
type: 'string',
nullable: true,
},
},
required: ['file', 'entityType'],
},
})
@ApiOkResponse({ description: 'Uploaded file record' })
async upload(
@UploadedFile()
file: {
originalname: string;
filename?: string;
path?: string;
mimetype: string;
size: number;
[key: string]: any;
},
@Body()
body: {
entityType: string;
entityId?: number | string;
entityWyId?: number | string;
category?: string;
description?: string;
},
): Promise<File> {
const data = {
originalFileName: file.originalname,
fileName: file.filename ?? file.originalname,
filePath: file.path ?? file.originalname,
mimeType: file.mimetype,
fileSize: file.size,
entityType: body.entityType,
entityId:
body.entityId !== undefined &&
body.entityId !== null &&
body.entityId !== ''
? Number(body.entityId)
: undefined,
entityWyId:
body.entityWyId !== undefined &&
body.entityWyId !== null &&
body.entityWyId !== ''
? Number(body.entityWyId)
: undefined,
category: body.category,
description: body.description,
};
return this.filesService.create(data);
}
@Get('by-entity/:entityType') @Get('by-entity/:entityType')
@ApiOperation({ summary: 'Get files by entity type and ID' }) @ApiOperation({ summary: 'Get files by entity type and ID' })
@ApiParam({ @ApiParam({
...@@ -73,7 +159,11 @@ export class FilesController { ...@@ -73,7 +159,11 @@ export class FilesController {
): Promise<File[]> { ): Promise<File[]> {
const entityIdNum = entityId ? parseInt(entityId, 10) : undefined; const entityIdNum = entityId ? parseInt(entityId, 10) : undefined;
const entityWyIdNum = entityWyId ? parseInt(entityWyId, 10) : undefined; const entityWyIdNum = entityWyId ? parseInt(entityWyId, 10) : undefined;
return this.filesService.findByEntity(entityType, entityIdNum, entityWyIdNum); return this.filesService.findByEntity(
entityType,
entityIdNum,
entityWyIdNum,
);
} }
@Get('by-category/:category') @Get('by-category/:category')
...@@ -85,9 +175,7 @@ export class FilesController { ...@@ -85,9 +175,7 @@ export class FilesController {
example: 'profile_image', example: 'profile_image',
}) })
@ApiOkResponse({ description: 'List of files in the category' }) @ApiOkResponse({ description: 'List of files in the category' })
async getByCategory( async getByCategory(@Param('category') category: string): Promise<File[]> {
@Param('category') category: string,
): Promise<File[]> {
return this.filesService.findByCategory(category); return this.filesService.findByCategory(category);
} }
...@@ -117,7 +205,15 @@ export class FilesController { ...@@ -117,7 +205,15 @@ export class FilesController {
@ApiQuery({ @ApiQuery({
name: 'sortBy', name: 'sortBy',
required: false, required: false,
enum: ['fileName', 'originalFileName', 'mimeType', 'fileSize', 'category', 'createdAt', 'updatedAt'], enum: [
'fileName',
'originalFileName',
'mimeType',
'fileSize',
'category',
'createdAt',
'updatedAt',
],
description: 'Field to sort by (default: createdAt)', description: 'Field to sort by (default: createdAt)',
}) })
@ApiQuery({ @ApiQuery({
...@@ -174,4 +270,3 @@ export class FilesController { ...@@ -174,4 +270,3 @@ export class FilesController {
} }
} }
} }
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MulterModule } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { join } from 'path';
import { FilesController } from './files.controller'; import { FilesController } from './files.controller';
import { FilesService } from './files.service'; import { FilesService } from './files.service';
@Module({ @Module({
imports: [
MulterModule.register({
storage: diskStorage({
destination: (_req, _file, cb) => {
const uploadPath = join(process.cwd(), 'uploads');
cb(null, uploadPath);
},
filename: (_req, file, cb) => {
const timestamp = Date.now();
const sanitizedOriginalName = file.originalname.replace(/\s+/g, '_');
cb(null, `${timestamp}-${sanitizedOriginalName}`);
},
}),
}),
],
controllers: [FilesController], controllers: [FilesController],
providers: [FilesService], providers: [FilesService],
exports: [FilesService], exports: [FilesService],
}) })
export class FilesModule {} export class FilesModule {}
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ListsController } from './lists.controller'; import { ListsController } from './lists.controller';
import { ListsService } from './lists.service'; import { ListsService } from './lists.service';
import { DatabaseModule } from '../../database/database.module'; import { DatabaseModule } from '../../database/database.module';
...@@ -9,14 +7,7 @@ import { JwtSimpleGuard } from '../../common/guards/jwt-simple.guard'; ...@@ -9,14 +7,7 @@ import { JwtSimpleGuard } from '../../common/guards/jwt-simple.guard';
@Module({ @Module({
imports: [ imports: [
DatabaseModule, DatabaseModule,
JwtModule.registerAsync({ // JwtModule is now global, no need to import here
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET') || 'your-secret-key',
signOptions: { expiresIn: '24h' },
}),
inject: [ConfigService],
}),
], ],
controllers: [ListsController], controllers: [ListsController],
providers: [ListsService, JwtSimpleGuard], providers: [ListsService, JwtSimpleGuard],
......
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsOptional, IsInt, IsBoolean } from 'class-validator';
export class CreateFeatureCategoryDto {
@ApiProperty({
description:
'Category name (e.g., "Físicas", "Técnicas", "Tácticas", "Mentales")',
example: 'Físicas',
type: String,
})
@IsString()
name: string;
@ApiPropertyOptional({
description: 'Category description',
example: 'Physical characteristics of the player',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
description?: string | null;
@ApiPropertyOptional({
description: 'Display order',
example: 0,
type: Number,
default: 0,
})
@IsOptional()
@IsInt()
order?: number;
@ApiPropertyOptional({
description: 'Whether the category is active',
example: true,
type: Boolean,
default: true,
})
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsOptional, IsString, Min, Max } from 'class-validator';
export class CreateFeatureRatingDto {
@ApiProperty({
description: 'Player ID being rated',
example: 1,
type: Number,
})
@IsInt()
playerId: number;
@ApiProperty({
description: 'Feature type ID being rated',
example: 1,
type: Number,
})
@IsInt()
featureTypeId: number;
@ApiProperty({
description: 'Scout/User ID providing the rating',
example: 1,
type: Number,
})
@IsInt()
userId: number;
@ApiPropertyOptional({
description:
'Rating value (1-5 scale). Optional - clients can select features without rating',
example: 4,
type: Number,
minimum: 1,
maximum: 5,
nullable: true,
})
@IsOptional()
@IsInt()
@Min(1)
@Max(5)
rating?: number | null;
@ApiPropertyOptional({
description: 'Optional scout notes about the rating',
example: 'Player showed excellent agility during the match',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
notes?: string | null;
}
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsOptional, IsInt, IsBoolean } from 'class-validator';
export class CreateFeatureTypeDto {
@ApiProperty({
description: 'Category ID this feature type belongs to',
example: 1,
type: Number,
})
@IsInt()
categoryId: number;
@ApiProperty({
description:
'Feature type name (e.g., "Agilidad", "Agresividad", "Cambio de Ritmo")',
example: 'Agilidad',
type: String,
})
@IsString()
name: string;
@ApiPropertyOptional({
description: 'Feature type description',
example: 'Player agility and speed of movement',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
description?: string | null;
@ApiPropertyOptional({
description: 'Display order within category',
example: 0,
type: Number,
default: 0,
})
@IsOptional()
@IsInt()
order?: number;
@ApiPropertyOptional({
description: 'Whether the feature type is active',
example: true,
type: Boolean,
default: true,
})
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsOptional, IsInt, IsBoolean } from 'class-validator';
export class UpdateFeatureCategoryDto {
@ApiPropertyOptional({
description: 'Category name',
example: 'Físicas',
type: String,
})
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional({
description: 'Category description',
example: 'Physical characteristics of the player',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
description?: string | null;
@ApiPropertyOptional({
description: 'Display order',
example: 0,
type: Number,
})
@IsOptional()
@IsInt()
order?: number;
@ApiPropertyOptional({
description: 'Whether the category is active',
example: true,
type: Boolean,
})
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsOptional, IsString, Min, Max } from 'class-validator';
export class UpdateFeatureRatingDto {
@ApiPropertyOptional({
description: 'Rating value (1-5 scale)',
example: 4,
type: Number,
minimum: 1,
maximum: 5,
})
@IsOptional()
@IsInt()
@Min(1)
@Max(5)
rating?: number;
@ApiPropertyOptional({
description: 'Optional scout notes about the rating',
example: 'Player showed excellent agility during the match',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
notes?: string | null;
}
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsOptional, IsInt, IsBoolean } from 'class-validator';
export class UpdateFeatureTypeDto {
@ApiPropertyOptional({
description: 'Category ID this feature type belongs to',
example: 1,
type: Number,
})
@IsOptional()
@IsInt()
categoryId?: number;
@ApiPropertyOptional({
description: 'Feature type name',
example: 'Agilidad',
type: String,
})
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional({
description: 'Feature type description',
example: 'Player agility and speed of movement',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
description?: string | null;
@ApiPropertyOptional({
description: 'Display order within category',
example: 0,
type: Number,
})
@IsOptional()
@IsInt()
order?: number;
@ApiPropertyOptional({
description: 'Whether the feature type is active',
example: true,
type: Boolean,
})
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
import {
Body,
Controller,
Delete,
Get,
NotFoundException,
Param,
ParseIntPipe,
Patch,
Post,
} from '@nestjs/common';
import {
ApiBody,
ApiNoContentResponse,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiTags,
} from '@nestjs/swagger';
import { PlayerFeaturesService } from './player-features.service';
import {
type PlayerFeatureCategory,
type PlayerFeatureType,
type PlayerFeatureRating,
} from '../../database/schema';
import { CreateFeatureCategoryDto } from './dto/create-feature-category.dto';
import { UpdateFeatureCategoryDto } from './dto/update-feature-category.dto';
import { CreateFeatureTypeDto } from './dto/create-feature-type.dto';
import { UpdateFeatureTypeDto } from './dto/update-feature-type.dto';
import { CreateFeatureRatingDto } from './dto/create-feature-rating.dto';
import { UpdateFeatureRatingDto } from './dto/update-feature-rating.dto';
@ApiTags('Player Features')
@Controller('player-features')
export class PlayerFeaturesController {
constructor(private readonly playerFeaturesService: PlayerFeaturesService) {}
// ============================================================================
// CATEGORIES
// ============================================================================
@Post('categories')
@ApiOperation({ summary: 'Create feature category' })
@ApiBody({ type: CreateFeatureCategoryDto })
@ApiOkResponse({ description: 'Created feature category' })
async createCategory(
@Body() body: CreateFeatureCategoryDto,
): Promise<PlayerFeatureCategory> {
return this.playerFeaturesService.createCategory(body as any);
}
@Patch('categories/:id')
@ApiOperation({ summary: 'Update feature category by ID' })
@ApiParam({ name: 'id', type: Number })
@ApiBody({ type: UpdateFeatureCategoryDto })
@ApiOkResponse({ description: 'Updated feature category' })
@ApiNotFoundResponse({ description: 'Category not found' })
async updateCategory(
@Param('id', ParseIntPipe) id: number,
@Body() body: UpdateFeatureCategoryDto,
): Promise<PlayerFeatureCategory> {
return this.playerFeaturesService.updateCategory(id, body as any);
}
@Get('categories/:id')
@ApiOperation({ summary: 'Get feature category by ID' })
@ApiParam({ name: 'id', type: Number })
@ApiOkResponse({ description: 'Feature category if found' })
async getCategoryById(
@Param('id', ParseIntPipe) id: number,
): Promise<PlayerFeatureCategory | null> {
return this.playerFeaturesService.getCategoryById(id);
}
@Get('categories')
@ApiOperation({ summary: 'List all feature categories' })
@ApiOkResponse({ description: 'List of feature categories' })
async listCategories(): Promise<PlayerFeatureCategory[]> {
return this.playerFeaturesService.getAllCategories();
}
@Delete('categories/:id')
@ApiOperation({ summary: 'Delete feature category by ID' })
@ApiParam({ name: 'id', type: Number })
@ApiNoContentResponse({ description: 'Category deleted successfully' })
@ApiNotFoundResponse({ description: 'Category not found' })
async deleteCategory(@Param('id', ParseIntPipe) id: number): Promise<void> {
const result = await this.playerFeaturesService.deleteCategory(id);
if (!result) {
throw new NotFoundException(`Category with ID ${id} not found`);
}
}
// ============================================================================
// FEATURE TYPES
// ============================================================================
@Post('types')
@ApiOperation({ summary: 'Create feature type' })
@ApiBody({ type: CreateFeatureTypeDto })
@ApiOkResponse({ description: 'Created feature type' })
async createFeatureType(
@Body() body: CreateFeatureTypeDto,
): Promise<PlayerFeatureType> {
return this.playerFeaturesService.createFeatureType(body as any);
}
@Patch('types/:id')
@ApiOperation({ summary: 'Update feature type by ID' })
@ApiParam({ name: 'id', type: Number })
@ApiBody({ type: UpdateFeatureTypeDto })
@ApiOkResponse({ description: 'Updated feature type' })
@ApiNotFoundResponse({ description: 'Feature type not found' })
async updateFeatureType(
@Param('id', ParseIntPipe) id: number,
@Body() body: UpdateFeatureTypeDto,
): Promise<PlayerFeatureType> {
return this.playerFeaturesService.updateFeatureType(id, body as any);
}
@Get('types/:id')
@ApiOperation({ summary: 'Get feature type by ID' })
@ApiParam({ name: 'id', type: Number })
@ApiOkResponse({ description: 'Feature type if found' })
async getFeatureTypeById(
@Param('id', ParseIntPipe) id: number,
): Promise<PlayerFeatureType | null> {
return this.playerFeaturesService.getFeatureTypeById(id);
}
@Get('types/category/:categoryId')
@ApiOperation({ summary: 'Get feature types by category' })
@ApiParam({ name: 'categoryId', type: Number })
@ApiOkResponse({ description: 'List of feature types for category' })
async getFeatureTypesByCategory(
@Param('categoryId', ParseIntPipe) categoryId: number,
): Promise<PlayerFeatureType[]> {
return this.playerFeaturesService.getFeatureTypesByCategory(categoryId);
}
@Get('types')
@ApiOperation({ summary: 'List all feature types' })
@ApiOkResponse({ description: 'List of feature types' })
async listFeatureTypes(): Promise<PlayerFeatureType[]> {
return this.playerFeaturesService.getAllFeatureTypes();
}
@Delete('types/:id')
@ApiOperation({ summary: 'Delete feature type by ID' })
@ApiParam({ name: 'id', type: Number })
@ApiNoContentResponse({ description: 'Feature type deleted successfully' })
@ApiNotFoundResponse({ description: 'Feature type not found' })
async deleteFeatureType(
@Param('id', ParseIntPipe) id: number,
): Promise<void> {
const result = await this.playerFeaturesService.deleteFeatureType(id);
if (!result) {
throw new NotFoundException(`Feature type with ID ${id} not found`);
}
}
// ============================================================================
// RATINGS
// ============================================================================
@Post('ratings')
@ApiOperation({ summary: 'Create or update feature rating' })
@ApiBody({ type: CreateFeatureRatingDto })
@ApiOkResponse({ description: 'Created or updated feature rating' })
async createRating(
@Body() body: CreateFeatureRatingDto,
): Promise<PlayerFeatureRating> {
return this.playerFeaturesService.createRating(body as any);
}
@Patch('ratings/:id')
@ApiOperation({ summary: 'Update feature rating by ID' })
@ApiParam({ name: 'id', type: Number })
@ApiBody({ type: UpdateFeatureRatingDto })
@ApiOkResponse({ description: 'Updated feature rating' })
@ApiNotFoundResponse({ description: 'Rating not found' })
async updateRating(
@Param('id', ParseIntPipe) id: number,
@Body() body: UpdateFeatureRatingDto,
): Promise<PlayerFeatureRating> {
return this.playerFeaturesService.updateRating(id, body as any);
}
@Get('ratings/:id')
@ApiOperation({ summary: 'Get feature rating by ID' })
@ApiParam({ name: 'id', type: Number })
@ApiOkResponse({ description: 'Feature rating if found' })
async getRatingById(
@Param('id', ParseIntPipe) id: number,
): Promise<PlayerFeatureRating | null> {
return this.playerFeaturesService.getRatingById(id);
}
@Get('ratings/player/:playerId')
@ApiOperation({ summary: 'Get all ratings for a player' })
@ApiParam({ name: 'playerId', type: Number })
@ApiOkResponse({ description: 'List of ratings for player' })
async getRatingsByPlayer(
@Param('playerId', ParseIntPipe) playerId: number,
): Promise<PlayerFeatureRating[]> {
return this.playerFeaturesService.getRatingsByPlayer(playerId);
}
@Get('ratings/player/:playerId/scout/:userId')
@ApiOperation({ summary: 'Get ratings for a player by a specific scout' })
@ApiParam({ name: 'playerId', type: Number })
@ApiParam({ name: 'userId', type: Number })
@ApiOkResponse({ description: 'List of ratings from scout for player' })
async getRatingsByPlayerAndScout(
@Param('playerId', ParseIntPipe) playerId: number,
@Param('userId', ParseIntPipe) userId: number,
): Promise<PlayerFeatureRating[]> {
return this.playerFeaturesService.getRatingsByPlayerAndScout(
playerId,
userId,
);
}
@Get('ratings/feature/:featureTypeId')
@ApiOperation({ summary: 'Get all ratings for a feature type' })
@ApiParam({ name: 'featureTypeId', type: Number })
@ApiOkResponse({ description: 'List of ratings for feature type' })
async getRatingsByFeature(
@Param('featureTypeId', ParseIntPipe) featureTypeId: number,
): Promise<PlayerFeatureRating[]> {
return this.playerFeaturesService.getRatingsByFeature(featureTypeId);
}
@Delete('ratings/:id')
@ApiOperation({ summary: 'Delete feature rating by ID' })
@ApiParam({ name: 'id', type: Number })
@ApiNoContentResponse({ description: 'Rating deleted successfully' })
@ApiNotFoundResponse({ description: 'Rating not found' })
async deleteRating(@Param('id', ParseIntPipe) id: number): Promise<void> {
const result = await this.playerFeaturesService.deleteRating(id);
if (!result) {
throw new NotFoundException(`Rating with ID ${id} not found`);
}
}
// ============================================================================
// AGGREGATED DATA
// ============================================================================
@Get('player/:playerId/all')
@ApiOperation({
summary: 'Get all features and ratings for a player grouped by category',
})
@ApiParam({ name: 'playerId', type: Number })
@ApiOkResponse({
description: 'Player features with ratings grouped by category',
})
async getPlayerFeaturesWithRatings(
@Param('playerId', ParseIntPipe) playerId: number,
) {
return this.playerFeaturesService.getPlayerFeaturesWithRatings(playerId);
}
}
import { Module } from '@nestjs/common';
import { DatabaseModule } from '../../database/database.module';
import { PlayerFeaturesController } from './player-features.controller';
import { PlayerFeaturesService } from './player-features.service';
@Module({
imports: [DatabaseModule],
controllers: [PlayerFeaturesController],
providers: [PlayerFeaturesService],
exports: [PlayerFeaturesService],
})
export class PlayerFeaturesModule {}
import {
Injectable,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { eq, and, isNull } from 'drizzle-orm';
import { DatabaseService } from '../../database/database.service';
import {
playerFeatureCategories,
playerFeatureTypes,
playerFeatureRatings,
type PlayerFeatureCategory,
type NewPlayerFeatureCategory,
type PlayerFeatureType,
type NewPlayerFeatureType,
type PlayerFeatureRating,
type NewPlayerFeatureRating,
} from '../../database/schema';
@Injectable()
export class PlayerFeaturesService {
constructor(private readonly databaseService: DatabaseService) {}
// ============================================================================
// CATEGORIES
// ============================================================================
async createCategory(
data: Partial<NewPlayerFeatureCategory>,
): Promise<PlayerFeatureCategory> {
const db = this.databaseService.getDatabase();
if (!data.name) {
throw new BadRequestException('Category name is required');
}
const [row] = await db
.insert(playerFeatureCategories)
.values({
name: data.name,
description: data.description ?? null,
order: data.order ?? 0,
isActive: data.isActive ?? true,
} as NewPlayerFeatureCategory)
.returning();
return row as PlayerFeatureCategory;
}
async updateCategory(
id: number,
data: Partial<NewPlayerFeatureCategory>,
): Promise<PlayerFeatureCategory> {
const db = this.databaseService.getDatabase();
const [existing] = await db
.select()
.from(playerFeatureCategories)
.where(eq(playerFeatureCategories.id, id));
if (!existing) {
throw new NotFoundException(`Category with ID ${id} not found`);
}
const [row] = await db
.update(playerFeatureCategories)
.set({
...data,
updatedAt: new Date(),
})
.where(eq(playerFeatureCategories.id, id))
.returning();
return row as PlayerFeatureCategory;
}
async getCategoryById(id: number): Promise<PlayerFeatureCategory | null> {
const db = this.databaseService.getDatabase();
const [row] = await db
.select()
.from(playerFeatureCategories)
.where(eq(playerFeatureCategories.id, id));
return (row as PlayerFeatureCategory) || null;
}
async getAllCategories(): Promise<PlayerFeatureCategory[]> {
const db = this.databaseService.getDatabase();
const rows = await db
.select()
.from(playerFeatureCategories)
.where(isNull(playerFeatureCategories.deletedAt))
.orderBy(playerFeatureCategories.order);
return rows as PlayerFeatureCategory[];
}
async deleteCategory(id: number): Promise<boolean> {
const db = this.databaseService.getDatabase();
const [existing] = await db
.select()
.from(playerFeatureCategories)
.where(eq(playerFeatureCategories.id, id));
if (!existing) {
return false;
}
// Soft delete
await db
.update(playerFeatureCategories)
.set({ deletedAt: new Date() })
.where(eq(playerFeatureCategories.id, id));
return true;
}
// ============================================================================
// FEATURE TYPES
// ============================================================================
async createFeatureType(
data: Partial<NewPlayerFeatureType>,
): Promise<PlayerFeatureType> {
const db = this.databaseService.getDatabase();
if (!data.categoryId) {
throw new BadRequestException('Category ID is required');
}
if (!data.name) {
throw new BadRequestException('Feature type name is required');
}
// Verify category exists
const [category] = await db
.select()
.from(playerFeatureCategories)
.where(eq(playerFeatureCategories.id, data.categoryId));
if (!category) {
throw new NotFoundException(
`Category with ID ${data.categoryId} not found`,
);
}
const [row] = await db
.insert(playerFeatureTypes)
.values({
categoryId: data.categoryId,
name: data.name,
description: data.description ?? null,
order: data.order ?? 0,
isActive: data.isActive ?? true,
} as NewPlayerFeatureType)
.returning();
return row as PlayerFeatureType;
}
async updateFeatureType(
id: number,
data: Partial<NewPlayerFeatureType>,
): Promise<PlayerFeatureType> {
const db = this.databaseService.getDatabase();
const [existing] = await db
.select()
.from(playerFeatureTypes)
.where(eq(playerFeatureTypes.id, id));
if (!existing) {
throw new NotFoundException(`Feature type with ID ${id} not found`);
}
const [row] = await db
.update(playerFeatureTypes)
.set({
...data,
updatedAt: new Date(),
})
.where(eq(playerFeatureTypes.id, id))
.returning();
return row as PlayerFeatureType;
}
async getFeatureTypeById(id: number): Promise<PlayerFeatureType | null> {
const db = this.databaseService.getDatabase();
const [row] = await db
.select()
.from(playerFeatureTypes)
.where(eq(playerFeatureTypes.id, id));
return (row as PlayerFeatureType) || null;
}
async getFeatureTypesByCategory(
categoryId: number,
): Promise<PlayerFeatureType[]> {
const db = this.databaseService.getDatabase();
const rows = await db
.select()
.from(playerFeatureTypes)
.where(
and(
eq(playerFeatureTypes.categoryId, categoryId),
isNull(playerFeatureTypes.deletedAt),
),
)
.orderBy(playerFeatureTypes.order);
return rows as PlayerFeatureType[];
}
async getAllFeatureTypes(): Promise<PlayerFeatureType[]> {
const db = this.databaseService.getDatabase();
const rows = await db
.select()
.from(playerFeatureTypes)
.where(isNull(playerFeatureTypes.deletedAt))
.orderBy(playerFeatureTypes.order);
return rows as PlayerFeatureType[];
}
async deleteFeatureType(id: number): Promise<boolean> {
const db = this.databaseService.getDatabase();
const [existing] = await db
.select()
.from(playerFeatureTypes)
.where(eq(playerFeatureTypes.id, id));
if (!existing) {
return false;
}
// Soft delete
await db
.update(playerFeatureTypes)
.set({ deletedAt: new Date() })
.where(eq(playerFeatureTypes.id, id));
return true;
}
// ============================================================================
// RATINGS
// ============================================================================
async createRating(
data: Partial<NewPlayerFeatureRating>,
): Promise<PlayerFeatureRating> {
const db = this.databaseService.getDatabase();
if (!data.playerId) {
throw new BadRequestException('Player ID is required');
}
if (!data.featureTypeId) {
throw new BadRequestException('Feature type ID is required');
}
if (!data.userId) {
throw new BadRequestException('User ID is required');
}
// Rating is optional - clients can just select features without rating
if (data.rating && (data.rating < 1 || data.rating > 5)) {
throw new BadRequestException('Rating must be between 1 and 5');
}
const [row] = await db
.insert(playerFeatureRatings)
.values({
playerId: data.playerId,
featureTypeId: data.featureTypeId,
userId: data.userId,
rating: data.rating ?? null,
notes: data.notes ?? null,
} as NewPlayerFeatureRating)
.onConflictDoUpdate({
target: [
playerFeatureRatings.playerId,
playerFeatureRatings.featureTypeId,
playerFeatureRatings.userId,
],
set: {
rating: data.rating ?? null,
notes: data.notes ?? null,
updatedAt: new Date(),
},
})
.returning();
return row as PlayerFeatureRating;
}
async updateRating(
id: number,
data: Partial<NewPlayerFeatureRating>,
): Promise<PlayerFeatureRating> {
const db = this.databaseService.getDatabase();
const [existing] = await db
.select()
.from(playerFeatureRatings)
.where(eq(playerFeatureRatings.id, id));
if (!existing) {
throw new NotFoundException(`Rating with ID ${id} not found`);
}
if (data.rating && (data.rating < 1 || data.rating > 5)) {
throw new BadRequestException('Rating must be between 1 and 5');
}
const [row] = await db
.update(playerFeatureRatings)
.set({
...data,
updatedAt: new Date(),
})
.where(eq(playerFeatureRatings.id, id))
.returning();
return row as PlayerFeatureRating;
}
async getRatingById(id: number): Promise<PlayerFeatureRating | null> {
const db = this.databaseService.getDatabase();
const [row] = await db
.select()
.from(playerFeatureRatings)
.where(eq(playerFeatureRatings.id, id));
return (row as PlayerFeatureRating) || null;
}
async getRatingsByPlayer(playerId: number): Promise<PlayerFeatureRating[]> {
const db = this.databaseService.getDatabase();
const rows = await db
.select()
.from(playerFeatureRatings)
.where(
and(
eq(playerFeatureRatings.playerId, playerId),
isNull(playerFeatureRatings.deletedAt),
),
);
return rows as PlayerFeatureRating[];
}
async getRatingsByPlayerAndScout(
playerId: number,
userId: number,
): Promise<PlayerFeatureRating[]> {
const db = this.databaseService.getDatabase();
const rows = await db
.select()
.from(playerFeatureRatings)
.where(
and(
eq(playerFeatureRatings.playerId, playerId),
eq(playerFeatureRatings.userId, userId),
isNull(playerFeatureRatings.deletedAt),
),
);
return rows as PlayerFeatureRating[];
}
async getRatingsByFeature(
featureTypeId: number,
): Promise<PlayerFeatureRating[]> {
const db = this.databaseService.getDatabase();
const rows = await db
.select()
.from(playerFeatureRatings)
.where(
and(
eq(playerFeatureRatings.featureTypeId, featureTypeId),
isNull(playerFeatureRatings.deletedAt),
),
);
return rows as PlayerFeatureRating[];
}
async deleteRating(id: number): Promise<boolean> {
const db = this.databaseService.getDatabase();
const [existing] = await db
.select()
.from(playerFeatureRatings)
.where(eq(playerFeatureRatings.id, id));
if (!existing) {
return false;
}
// Soft delete
await db
.update(playerFeatureRatings)
.set({ deletedAt: new Date() })
.where(eq(playerFeatureRatings.id, id));
return true;
}
// ============================================================================
// AGGREGATED DATA
// ============================================================================
async getPlayerFeaturesWithRatings(playerId: number) {
const db = this.databaseService.getDatabase();
// Get all categories with their feature types and ratings for this player
const categories = await db
.select()
.from(playerFeatureCategories)
.where(isNull(playerFeatureCategories.deletedAt))
.orderBy(playerFeatureCategories.order);
const result = await Promise.all(
categories.map(async (category) => {
const types = await db
.select()
.from(playerFeatureTypes)
.where(
and(
eq(playerFeatureTypes.categoryId, category.id),
isNull(playerFeatureTypes.deletedAt),
),
)
.orderBy(playerFeatureTypes.order);
const typesWithRatings = await Promise.all(
types.map(async (type) => {
const ratings = await db
.select()
.from(playerFeatureRatings)
.where(
and(
eq(playerFeatureRatings.playerId, playerId),
eq(playerFeatureRatings.featureTypeId, type.id),
isNull(playerFeatureRatings.deletedAt),
),
);
// Calculate average rating only from ratings that have a value
const ratedValues = ratings.filter((r) => r.rating !== null);
const averageRating =
ratedValues.length > 0
? ratedValues.reduce((sum, r) => sum + (r.rating ?? 0), 0) /
ratedValues.length
: null;
return {
...type,
ratings,
averageRating,
};
}),
);
return {
...category,
featureTypes: typesWithRatings,
};
}),
);
return result;
}
}
...@@ -12,14 +12,16 @@ import { ...@@ -12,14 +12,16 @@ import {
IsArray, IsArray,
} from 'class-validator'; } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
export class CreatePlayerDto { export class CreatePlayerDto {
@ApiProperty({ @ApiPropertyOptional({
description: 'Wyscout ID of the player', description: 'Wyscout ID of the player',
example: 123456, example: 123456,
}) })
@IsOptional()
@IsInt() @IsInt()
wyId: number; wyId?: number;
@ApiProperty({ @ApiProperty({
description: 'First name of the player', description: 'First name of the player',
...@@ -76,6 +78,38 @@ export class CreatePlayerDto { ...@@ -76,6 +78,38 @@ export class CreatePlayerDto {
currentTeamId?: number; currentTeamId?: number;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Current team name from API or manual input',
example: 'Lecce',
})
@IsOptional()
@IsString()
currentTeamName?: string;
@ApiPropertyOptional({
description: 'Current team official name from API or manual input',
example: 'U.S. Lecce',
})
@IsOptional()
@IsString()
currentTeamOfficialName?: string;
@ApiPropertyOptional({
description: 'Current national team name from API or manual input',
example: 'Italy',
})
@IsOptional()
@IsString()
currentNationalTeamName?: string;
@ApiPropertyOptional({
description: 'Current national team official name from API or manual input',
example: 'Italy National Team',
})
@IsOptional()
@IsString()
currentNationalTeamOfficialName?: string;
@ApiPropertyOptional({
description: 'Current team object from external API', description: 'Current team object from external API',
example: { id: 49, wyId: 3168, name: 'Lecce' }, example: { id: 49, wyId: 3168, name: 'Lecce' },
}) })
...@@ -185,7 +219,7 @@ export class CreatePlayerDto { ...@@ -185,7 +219,7 @@ export class CreatePlayerDto {
gender?: 'male' | 'female' | 'other'; gender?: 'male' | 'female' | 'other';
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Position', description: 'Position code or name (alternative to positionId)',
example: 'FW', example: 'FW',
}) })
@IsOptional() @IsOptional()
...@@ -193,15 +227,62 @@ export class CreatePlayerDto { ...@@ -193,15 +227,62 @@ export class CreatePlayerDto {
position?: string; position?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Other positions the player can play', description: 'Position ID (alternative to position string)',
example: ['MF', 'DF'], example: 1,
type: [String], type: Number,
nullable: true,
})
@IsOptional()
@Transform(({ value }) => {
if (value === null || value === undefined) return null;
if (typeof value === 'string') {
const num = parseInt(value, 10);
return isNaN(num) ? value : num;
}
return value;
})
@IsInt()
positionId?: number | null;
@ApiPropertyOptional({
description:
'Other positions the player can play (codes, names, or IDs). Can be array of strings like ["MF", "DF"] or array of numbers like [23, 24]',
example: [23, 24],
type: [Number],
nullable: true,
})
@IsOptional()
@Transform(({ value }) => {
if (value === null || value === undefined) return null;
if (Array.isArray(value)) {
// Convert all values to strings for codes/names, or keep as numbers if they're IDs
return value.map((v) => (typeof v === 'number' ? v : String(v)));
}
return value;
})
@IsArray()
otherPositions?: (string | number)[] | null;
@ApiPropertyOptional({
description: 'Other position IDs (alternative to otherPositions array)',
example: [2, 3],
type: [Number],
nullable: true, nullable: true,
}) })
@IsOptional() @IsOptional()
@Transform(({ value }) => {
if (value === null || value === undefined) return null;
if (Array.isArray(value)) {
return value.map((v) => {
const num = typeof v === 'string' ? parseInt(v, 10) : Number(v);
return isNaN(num) ? v : num;
});
}
return value;
})
@IsArray() @IsArray()
@IsString({ each: true }) @IsInt({ each: true })
otherPositions?: string[] | null; otherPositionIds?: number[] | null;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Role code (2 characters)', description: 'Role code (2 characters)',
...@@ -326,7 +407,8 @@ export class CreatePlayerDto { ...@@ -326,7 +407,8 @@ export class CreatePlayerDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Image data URL - from external API', description: 'Image data URL - from external API',
example: 'https://cdn5.wyscout.com/photos/players/public/ndplayer_100x130.png', example:
'https://cdn5.wyscout.com/photos/players/public/ndplayer_100x130.png',
}) })
@IsOptional() @IsOptional()
@IsString() @IsString()
...@@ -431,7 +513,7 @@ export class CreatePlayerDto { ...@@ -431,7 +513,7 @@ export class CreatePlayerDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Market value of the player', description: 'Market value of the player',
example: 5000000.00, example: 5000000.0,
type: Number, type: Number,
nullable: true, nullable: true,
}) })
...@@ -451,7 +533,7 @@ export class CreatePlayerDto { ...@@ -451,7 +533,7 @@ export class CreatePlayerDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Transfer value of the player', description: 'Transfer value of the player',
example: 3000000.00, example: 3000000.0,
type: Number, type: Number,
nullable: true, nullable: true,
}) })
...@@ -461,7 +543,7 @@ export class CreatePlayerDto { ...@@ -461,7 +543,7 @@ export class CreatePlayerDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Salary of the player', description: 'Salary of the player',
example: 100000.00, example: 100000.0,
type: Number, type: Number,
nullable: true, nullable: true,
}) })
...@@ -500,4 +582,3 @@ export class CreatePlayerDto { ...@@ -500,4 +582,3 @@ export class CreatePlayerDto {
@IsString() @IsString()
morphology?: string | null; morphology?: string | null;
} }
...@@ -64,6 +64,22 @@ export class QueryPlayersDto extends BaseQueryDto { ...@@ -64,6 +64,22 @@ export class QueryPlayersDto extends BaseQueryDto {
nationalTeamId?: number; nationalTeamId?: number;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Filter by current national team name (ILIKE)',
example: 'Italy',
})
@IsOptional()
@IsString()
currentNationalTeamName?: string;
@ApiPropertyOptional({
description: 'Filter by current national team official name (ILIKE)',
example: 'Italy National Team',
})
@IsOptional()
@IsString()
currentNationalTeamOfficialName?: string;
@ApiPropertyOptional({
description: 'Filter by passport area WyID (for EU passport filtering)', description: 'Filter by passport area WyID (for EU passport filtering)',
type: Number, type: Number,
example: 1002, example: 1002,
......
...@@ -11,6 +11,7 @@ import { ...@@ -11,6 +11,7 @@ import {
IsArray, IsArray,
} from 'class-validator'; } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
export class UpdatePlayerDto { export class UpdatePlayerDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
...@@ -78,6 +79,22 @@ export class UpdatePlayerDto { ...@@ -78,6 +79,22 @@ export class UpdatePlayerDto {
currentTeamId?: number; currentTeamId?: number;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Current team name from API or manual input',
example: 'Lecce',
})
@IsOptional()
@IsString()
currentTeamName?: string;
@ApiPropertyOptional({
description: 'Current team official name from API or manual input',
example: 'U.S. Lecce',
})
@IsOptional()
@IsString()
currentTeamOfficialName?: string;
@ApiPropertyOptional({
description: 'National team ID', description: 'National team ID',
example: 44, example: 44,
}) })
...@@ -86,6 +103,22 @@ export class UpdatePlayerDto { ...@@ -86,6 +103,22 @@ export class UpdatePlayerDto {
currentNationalTeamId?: number; currentNationalTeamId?: number;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Current national team name from API or manual input',
example: 'Italy',
})
@IsOptional()
@IsString()
currentNationalTeamName?: string;
@ApiPropertyOptional({
description: 'Current national team official name from API or manual input',
example: 'Italy National Team',
})
@IsOptional()
@IsString()
currentNationalTeamOfficialName?: string;
@ApiPropertyOptional({
description: 'Date of birth (YYYY-MM-DD)', description: 'Date of birth (YYYY-MM-DD)',
example: '1998-06-01', example: '1998-06-01',
}) })
...@@ -128,7 +161,7 @@ export class UpdatePlayerDto { ...@@ -128,7 +161,7 @@ export class UpdatePlayerDto {
gender?: 'male' | 'female' | 'other'; gender?: 'male' | 'female' | 'other';
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Position', description: 'Position code or name (alternative to positionId)',
example: 'FW', example: 'FW',
}) })
@IsOptional() @IsOptional()
...@@ -136,15 +169,62 @@ export class UpdatePlayerDto { ...@@ -136,15 +169,62 @@ export class UpdatePlayerDto {
position?: string; position?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Other positions the player can play', description: 'Position ID (alternative to position string)',
example: ['MF', 'DF'], example: 1,
type: [String], type: Number,
nullable: true, nullable: true,
}) })
@IsOptional() @IsOptional()
@Transform(({ value }) => {
if (value === null || value === undefined) return null;
if (typeof value === 'string') {
const num = parseInt(value, 10);
return isNaN(num) ? value : num;
}
return value;
})
@IsInt()
positionId?: number | null;
@ApiPropertyOptional({
description:
'Other positions the player can play (codes, names, or IDs). Can be array of strings like ["MF", "DF"] or array of numbers like [23, 24]',
example: [23, 24],
type: [Number],
nullable: true,
})
@IsOptional()
@Transform(({ value }) => {
if (value === null || value === undefined) return null;
if (Array.isArray(value)) {
// Convert all values to strings for codes/names, or keep as numbers if they're IDs
return value.map((v) => (typeof v === 'number' ? v : String(v)));
}
return value;
})
@IsArray()
otherPositions?: (string | number)[] | null;
@ApiPropertyOptional({
description: 'Other position IDs (alternative to otherPositions array)',
example: [2, 3],
type: [Number],
nullable: true,
})
@IsOptional()
@Transform(({ value }) => {
if (value === null || value === undefined) return null;
if (Array.isArray(value)) {
return value.map((v) => {
const num = typeof v === 'string' ? parseInt(v, 10) : Number(v);
return isNaN(num) ? v : num;
});
}
return value;
})
@IsArray() @IsArray()
@IsString({ each: true }) @IsInt({ each: true })
otherPositions?: string[] | null; otherPositionIds?: number[] | null;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Role code (2 characters)', description: 'Role code (2 characters)',
...@@ -222,6 +302,15 @@ export class UpdatePlayerDto { ...@@ -222,6 +302,15 @@ export class UpdatePlayerDto {
imageDataUrl?: string; imageDataUrl?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Image data URL - alias used by external API and some clients',
example:
'https://cdn5.wyscout.com/photos/players/public/ndplayer_100x130.png',
})
@IsOptional()
@IsString()
imageDataURL?: string;
@ApiPropertyOptional({
description: 'Jersey number', description: 'Jersey number',
example: 9, example: 9,
}) })
......
...@@ -39,6 +39,20 @@ export interface PlayerPosition { ...@@ -39,6 +39,20 @@ export interface PlayerPosition {
category: PositionCategory; category: PositionCategory;
} }
export interface Agent {
id: number;
name: string;
description: string | null;
type: string;
email: string;
phone: string;
status: string;
address: string;
country: string;
createdAt: string;
updatedAt: string;
}
export interface StructuredPlayer { export interface StructuredPlayer {
id: number; id: number;
wyId: number; wyId: number;
...@@ -60,6 +74,10 @@ export interface StructuredPlayer { ...@@ -60,6 +74,10 @@ export interface StructuredPlayer {
foot: string | null; foot: string | null;
currentTeamId: number | null; currentTeamId: number | null;
currentNationalTeamId: number | null; currentNationalTeamId: number | null;
currentTeamName: string | null;
currentTeamOfficialName: string | null;
currentNationalTeamName: string | null;
currentNationalTeamOfficialName: string | null;
currentTeam: any | null; currentTeam: any | null;
gender: string; gender: string;
status: string; status: string;
...@@ -73,7 +91,8 @@ export interface StructuredPlayer { ...@@ -73,7 +91,8 @@ export interface StructuredPlayer {
email: string | null; email: string | null;
phone: string | null; phone: string | null;
onLoan: boolean; onLoan: boolean;
agent: string | null; agentId: number | null;
agent: Agent | null;
ranking: string | null; ranking: string | null;
roi: string | null; roi: string | null;
marketValue: number | null; marketValue: number | null;
......
...@@ -44,6 +44,25 @@ export class PlayersController { ...@@ -44,6 +44,25 @@ export class PlayersController {
return this.playersService.upsertByWyId(body as any); return this.playersService.upsertByWyId(body as any);
} }
@Get('by-id/:id')
@ApiOperation({ summary: 'Get player by database ID' })
@ApiParam({
name: 'id',
type: Number,
description: 'Database ID of the player',
})
@ApiOkResponse({ description: 'Player if found', type: Object })
@ApiNotFoundResponse({ description: 'Player not found' })
async getById(
@Param('id', ParseIntPipe) id: number,
): Promise<StructuredPlayer | undefined> {
const player = await this.playersService.findById(id);
if (!player) {
throw new NotFoundException(`Player with ID ${id} not found`);
}
return player;
}
@Get(':wyId') @Get(':wyId')
@ApiOperation({ summary: 'Get player by wyId' }) @ApiOperation({ summary: 'Get player by wyId' })
@ApiOkResponse({ description: 'Player if found', type: Object }) @ApiOkResponse({ description: 'Player if found', type: Object })
......
...@@ -5,6 +5,7 @@ import { ...@@ -5,6 +5,7 @@ import {
reports, reports,
areas, areas,
positions, positions,
agents,
type NewPlayer, type NewPlayer,
type Player, type Player,
type Position, type Position,
...@@ -53,6 +54,7 @@ export class PlayersService { ...@@ -53,6 +54,7 @@ export class PlayersService {
/** /**
* Resolves position ID from either a position ID (number) or position code/name (string) * Resolves position ID from either a position ID (number) or position code/name (string)
* If positionId is explicitly null, returns null (to clear the position)
*/ */
private async resolvePositionId( private async resolvePositionId(
positionCodeOrName?: string | null, positionCodeOrName?: string | null,
...@@ -60,8 +62,8 @@ export class PlayersService { ...@@ -60,8 +62,8 @@ export class PlayersService {
): Promise<number | null> { ): Promise<number | null> {
const db = this.databaseService.getDatabase(); const db = this.databaseService.getDatabase();
// If positionId is provided, use it directly // If positionId is explicitly provided (including null), use it directly
if (positionId !== null && positionId !== undefined) { if (positionId !== undefined) {
return positionId; return positionId;
} }
...@@ -92,29 +94,48 @@ export class PlayersService { ...@@ -92,29 +94,48 @@ export class PlayersService {
/** /**
* Resolves position IDs from either position IDs (number[]) or position codes/names (string[]) * Resolves position IDs from either position IDs (number[]) or position codes/names (string[])
* Also handles mixed arrays where otherPositions contains numbers (IDs) or strings (codes/names)
* If positionIds is explicitly null or empty array, returns null (to clear other positions)
*/ */
private async resolvePositionIds( private async resolvePositionIds(
positionCodesOrNames?: string[] | null, positionCodesOrNames?: (string | number)[] | null,
positionIds?: number[] | null, positionIds?: number[] | null,
): Promise<number[] | null> { ): Promise<number[] | null> {
const db = this.databaseService.getDatabase(); const db = this.databaseService.getDatabase();
// If positionIds are provided, use them directly // If positionIds is explicitly provided (including null or empty array), use it
if (positionIds && positionIds.length > 0) { if (positionIds !== undefined) {
return positionIds; // Return null for null or empty array, otherwise return the array
return positionIds && positionIds.length > 0 ? positionIds : null;
} }
// If position codes/names are provided, look them up // If position codes/names are provided, check if they're numbers (IDs) or strings (codes/names)
if (positionCodesOrNames && positionCodesOrNames.length > 0) { if (positionCodesOrNames && positionCodesOrNames.length > 0) {
// Check if all values are numbers (IDs)
const allNumbers = positionCodesOrNames.every(
(v) => typeof v === 'number',
);
if (allNumbers) {
// All values are numbers, treat them as IDs
return positionCodesOrNames as number[];
}
// Check if all values are strings (codes/names)
const allStrings = positionCodesOrNames.every(
(v) => typeof v === 'string',
);
if (allStrings) {
// All values are strings, look them up as codes/names
const stringCodesOrNames = positionCodesOrNames as string[];
const foundPositions = await db const foundPositions = await db
.select({ id: positions.id }) .select({ id: positions.id })
.from(positions) .from(positions)
.where( .where(
and( and(
or( or(
inArray(positions.code2, positionCodesOrNames), inArray(positions.code2, stringCodesOrNames),
inArray(positions.code3, positionCodesOrNames), inArray(positions.code3, stringCodesOrNames),
inArray(positions.name, positionCodesOrNames), inArray(positions.name, stringCodesOrNames),
), ),
eq(positions.isActive, true), eq(positions.isActive, true),
), ),
...@@ -123,6 +144,41 @@ export class PlayersService { ...@@ -123,6 +144,41 @@ export class PlayersService {
if (foundPositions.length > 0) { if (foundPositions.length > 0) {
return foundPositions.map((p) => p.id); return foundPositions.map((p) => p.id);
} }
} else {
// Mixed array - extract numbers and strings separately
const numberIds = positionCodesOrNames
.filter((v) => typeof v === 'number')
.map((v) => v as number);
const stringCodes = positionCodesOrNames
.filter((v) => typeof v === 'string')
.map((v) => v as string);
const allIds: number[] = [...numberIds];
if (stringCodes.length > 0) {
const foundPositions = await db
.select({ id: positions.id })
.from(positions)
.where(
and(
or(
inArray(positions.code2, stringCodes),
inArray(positions.code3, stringCodes),
inArray(positions.name, stringCodes),
),
eq(positions.isActive, true),
),
);
foundPositions.forEach((p) => {
if (!allIds.includes(p.id)) {
allIds.push(p.id);
}
});
}
return allIds.length > 0 ? allIds : null;
}
} }
return null; return null;
...@@ -241,6 +297,20 @@ export class PlayersService { ...@@ -241,6 +297,20 @@ export class PlayersService {
rawPlayer.currentNationalTeamId ?? rawPlayer.currentNationalTeamId ??
rawPlayer.current_national_team_id ?? rawPlayer.current_national_team_id ??
null, null,
currentTeamName:
rawPlayer.currentTeamName ?? rawPlayer.current_team_name ?? null,
currentTeamOfficialName:
rawPlayer.currentTeamOfficialName ??
rawPlayer.current_team_official_name ??
null,
currentNationalTeamName:
rawPlayer.currentNationalTeamName ??
rawPlayer.current_national_team_name ??
null,
currentNationalTeamOfficialName:
rawPlayer.currentNationalTeamOfficialName ??
rawPlayer.current_national_team_official_name ??
null,
currentTeam: (() => { currentTeam: (() => {
// Get stored team name and official name // Get stored team name and official name
const storedTeamName = const storedTeamName =
...@@ -323,6 +393,7 @@ export class PlayersService { ...@@ -323,6 +393,7 @@ export class PlayersService {
email: rawPlayer.email ?? null, email: rawPlayer.email ?? null,
phone: rawPlayer.phone ?? null, phone: rawPlayer.phone ?? null,
onLoan: rawPlayer.onLoan ?? rawPlayer.on_loan ?? false, onLoan: rawPlayer.onLoan ?? rawPlayer.on_loan ?? false,
agentId: rawPlayer.agentId ?? null,
agent: rawPlayer.agent ?? null, agent: rawPlayer.agent ?? null,
ranking: rawPlayer.ranking ? String(rawPlayer.ranking) : null, ranking: rawPlayer.ranking ? String(rawPlayer.ranking) : null,
roi: rawPlayer.roi ?? null, roi: rawPlayer.roi ?? null,
...@@ -440,6 +511,18 @@ export class PlayersService { ...@@ -440,6 +511,18 @@ export class PlayersService {
conditions.push(eq(players.currentNationalTeamId, query.nationalTeamId)); conditions.push(eq(players.currentNationalTeamId, query.nationalTeamId));
} }
// Current national team name filter (case-insensitive partial match)
if (query?.currentNationalTeamName) {
const pattern = `%${query.currentNationalTeamName.trim()}%`;
conditions.push(ilike(players.currentNationalTeamName, pattern));
}
// Current national team official name filter (case-insensitive partial match)
if (query?.currentNationalTeamOfficialName) {
const pattern = `%${query.currentNationalTeamOfficialName.trim()}%`;
conditions.push(ilike(players.currentNationalTeamOfficialName, pattern));
}
// Passport area filter // Passport area filter
if (query?.passportAreaWyId) { if (query?.passportAreaWyId) {
conditions.push(eq(players.passportAreaWyId, query.passportAreaWyId)); conditions.push(eq(players.passportAreaWyId, query.passportAreaWyId));
...@@ -718,7 +801,20 @@ export class PlayersService { ...@@ -718,7 +801,20 @@ export class PlayersService {
email: players.email, email: players.email,
phone: players.phone, phone: players.phone,
onLoan: players.onLoan, onLoan: players.onLoan,
agent: players.agent, agentId: players.agentId,
agent: {
id: agents.id,
name: agents.name,
description: agents.description,
type: agents.type,
email: agents.email,
phone: agents.phone,
status: agents.status,
address: agents.address,
country: agents.country,
createdAt: agents.createdAt,
updatedAt: agents.updatedAt,
},
ranking: players.ranking, ranking: players.ranking,
roi: players.roi, roi: players.roi,
marketValue: players.marketValue, marketValue: players.marketValue,
...@@ -755,7 +851,8 @@ export class PlayersService { ...@@ -755,7 +851,8 @@ export class PlayersService {
}) })
.from(players) .from(players)
.leftJoin(areas, eq(players.birthAreaWyId, areas.wyId)) .leftJoin(areas, eq(players.birthAreaWyId, areas.wyId))
.leftJoin(positions, eq(players.positionId, positions.id)); .leftJoin(positions, eq(players.positionId, positions.id))
.leftJoin(agents, eq(players.agentId, agents.id));
// Add passport area join using a subquery approach // Add passport area join using a subquery approach
// We'll need to do a second query or use a different approach // We'll need to do a second query or use a different approach
...@@ -1131,9 +1228,6 @@ export class PlayersService { ...@@ -1131,9 +1228,6 @@ export class PlayersService {
async upsertByWyId(data: NewPlayer | any): Promise<Player> { async upsertByWyId(data: NewPlayer | any): Promise<Player> {
const db = this.databaseService.getDatabase(); const db = this.databaseService.getDatabase();
if (data.wyId == null) {
throw new PlayerWyIdRequiredError();
}
// Normalize date/timestamp fields that may arrive as strings // Normalize date/timestamp fields that may arrive as strings
const toDate = (value: unknown): Date | undefined => { const toDate = (value: unknown): Date | undefined => {
...@@ -1239,6 +1333,10 @@ export class PlayersService { ...@@ -1239,6 +1333,10 @@ export class PlayersService {
currentTeamOfficialName: normalizeToNull( currentTeamOfficialName: normalizeToNull(
currentTeam?.officialName ?? data.currentTeamOfficialName, currentTeam?.officialName ?? data.currentTeamOfficialName,
), ),
currentNationalTeamName: normalizeToNull(data.currentNationalTeamName),
currentNationalTeamOfficialName: normalizeToNull(
data.currentNationalTeamOfficialName,
),
// Status and metadata - handle both imageDataURL (external) and imageDataUrl (DB) // Status and metadata - handle both imageDataURL (external) and imageDataUrl (DB)
status: data.status ?? 'active', status: data.status ?? 'active',
...@@ -1259,7 +1357,7 @@ export class PlayersService { ...@@ -1259,7 +1357,7 @@ export class PlayersService {
email: normalizeToNull(data.email), email: normalizeToNull(data.email),
phone: normalizeToNull(data.phone), phone: normalizeToNull(data.phone),
onLoan: data.onLoan ?? false, onLoan: data.onLoan ?? false,
agent: normalizeToNull(data.agent), agentId: data.agentId ?? null,
ranking: normalizeToNull(data.ranking), ranking: normalizeToNull(data.ranking),
roi: normalizeToNull(data.roi), roi: normalizeToNull(data.roi),
marketValue: normalizeToNull(data.marketValue), marketValue: normalizeToNull(data.marketValue),
...@@ -1276,11 +1374,14 @@ export class PlayersService { ...@@ -1276,11 +1374,14 @@ export class PlayersService {
: {}), : {}),
}; };
const [result] = await db const query = db.insert(players).values(transformed);
.insert(players)
.values(transformed) const [result] =
transformed.wyId != null
? await query
.onConflictDoUpdate({ target: players.wyId, set: transformed }) .onConflictDoUpdate({ target: players.wyId, set: transformed })
.returning(); .returning()
: await query.returning();
return result as Player; return result as Player;
} }
...@@ -1343,6 +1444,9 @@ export class PlayersService { ...@@ -1343,6 +1444,9 @@ export class PlayersService {
currentNationalTeamId: players.currentNationalTeamId, currentNationalTeamId: players.currentNationalTeamId,
currentTeamName: players.currentTeamName, currentTeamName: players.currentTeamName,
currentTeamOfficialName: players.currentTeamOfficialName, currentTeamOfficialName: players.currentTeamOfficialName,
currentNationalTeamName: players.currentNationalTeamName,
currentNationalTeamOfficialName:
players.currentNationalTeamOfficialName,
gender: players.gender, gender: players.gender,
status: players.status, status: players.status,
jerseyNumber: players.jerseyNumber, jerseyNumber: players.jerseyNumber,
...@@ -1356,7 +1460,7 @@ export class PlayersService { ...@@ -1356,7 +1460,7 @@ export class PlayersService {
email: players.email, email: players.email,
phone: players.phone, phone: players.phone,
onLoan: players.onLoan, onLoan: players.onLoan,
agent: players.agent, agentId: players.agentId,
ranking: players.ranking, ranking: players.ranking,
roi: players.roi, roi: players.roi,
marketValue: players.marketValue, marketValue: players.marketValue,
...@@ -1568,6 +1672,286 @@ export class PlayersService { ...@@ -1568,6 +1672,286 @@ export class PlayersService {
return structuredPlayer; return structuredPlayer;
} }
async findById(id: number): Promise<StructuredPlayer | undefined> {
const db = this.databaseService.getDatabase();
// Try to fetch with relations using query API
let rawPlayer: any;
try {
const queryApi = (db as any).query;
if (queryApi?.players?.findFirst) {
rawPlayer = await queryApi.players.findFirst({
where: (players: any, { eq }: any) => eq(players.id, id),
with: {
birthArea: true,
passportArea: true,
currentTeam: {
with: {
area: true,
coach: true,
},
},
},
});
} else {
throw new QueryApiNotAvailableError();
}
} catch (error) {
// Fallback to simple select with manual joins if relations are not configured
const playerRow = await db
.select({
// Player fields
id: players.id,
wyId: players.wyId,
gsmId: players.gsmId,
shortName: players.shortName,
firstName: players.firstName,
middleName: players.middleName,
lastName: players.lastName,
heightCm: players.heightCm,
weightKg: players.weightKg,
dateOfBirth: players.dateOfBirth,
roleName: players.roleName,
roleCode2: players.roleCode2,
roleCode3: players.roleCode3,
positionId: players.positionId,
otherPositionIds: players.otherPositionIds,
foot: players.foot,
// Position fields
positionName: positions.name,
positionCode2: positions.code2,
positionCode3: positions.code3,
positionOrder: positions.order,
positionLocationX: positions.locationX,
positionLocationY: positions.locationY,
positionBgColor: positions.bgColor,
positionTextColor: positions.textColor,
positionCategory: positions.category,
currentTeamId: players.currentTeamId,
currentNationalTeamId: players.currentNationalTeamId,
currentTeamName: players.currentTeamName,
currentTeamOfficialName: players.currentTeamOfficialName,
currentNationalTeamName: players.currentNationalTeamName,
currentNationalTeamOfficialName:
players.currentNationalTeamOfficialName,
gender: players.gender,
status: players.status,
jerseyNumber: players.jerseyNumber,
imageDataUrl: players.imageDataUrl,
apiLastSyncedAt: players.apiLastSyncedAt,
apiSyncStatus: players.apiSyncStatus,
createdAt: players.createdAt,
updatedAt: players.updatedAt,
deletedAt: players.deletedAt,
isActive: players.isActive,
email: players.email,
phone: players.phone,
onLoan: players.onLoan,
agentId: players.agentId,
ranking: players.ranking,
roi: players.roi,
marketValue: players.marketValue,
valueRange: players.valueRange,
transferValue: players.transferValue,
salary: players.salary,
contractEndsAt: players.contractEndsAt,
feasible: players.feasible,
morphology: players.morphology,
secondBirthAreaWyId: players.secondBirthAreaWyId,
passportAreaWyId: players.passportAreaWyId,
secondPassportAreaWyId: players.secondPassportAreaWyId,
// Birth area fields
birthAreaId: areas.id,
birthAreaWyId: areas.wyId,
birthAreaName: areas.name,
birthAreaAlpha2code: areas.alpha2code,
birthAreaAlpha3code: areas.alpha3code,
birthAreaCreatedAt: areas.createdAt,
birthAreaUpdatedAt: areas.updatedAt,
birthAreaDeletedAt: areas.deletedAt,
})
.from(players)
.leftJoin(areas, eq(players.birthAreaWyId, areas.wyId))
.leftJoin(positions, eq(players.positionId, positions.id))
.where(eq(players.id, id))
.limit(1);
if (!playerRow || playerRow.length === 0) {
return undefined;
}
rawPlayer = playerRow[0];
// Fetch passport areas and second birth area separately and merge
const passportAreaRow = await db
.select({
areaId: areas.id,
areaWyId: areas.wyId,
areaName: areas.name,
areaAlpha2code: areas.alpha2code,
areaAlpha3code: areas.alpha3code,
areaCreatedAt: areas.createdAt,
areaUpdatedAt: areas.updatedAt,
areaDeletedAt: areas.deletedAt,
})
.from(areas)
.where(eq(areas.wyId, rawPlayer.passportAreaWyId))
.limit(1);
const secondBirthAreaRow = await db
.select({
areaId: areas.id,
areaWyId: areas.wyId,
areaName: areas.name,
areaAlpha2code: areas.alpha2code,
areaAlpha3code: areas.alpha3code,
areaCreatedAt: areas.createdAt,
areaUpdatedAt: areas.updatedAt,
areaDeletedAt: areas.deletedAt,
})
.from(areas)
.where(eq(areas.wyId, rawPlayer.secondBirthAreaWyId))
.limit(1);
const secondPassportAreaRow = await db
.select({
areaId: areas.id,
areaWyId: areas.wyId,
areaName: areas.name,
areaAlpha2code: areas.alpha2code,
areaAlpha3code: areas.alpha3code,
areaCreatedAt: areas.createdAt,
areaUpdatedAt: areas.updatedAt,
areaDeletedAt: areas.deletedAt,
})
.from(areas)
.where(eq(areas.wyId, rawPlayer.secondPassportAreaWyId))
.limit(1);
const passportAreaData = passportAreaRow[0];
const secondBirthAreaData = secondBirthAreaRow[0];
const secondPassportAreaData = secondPassportAreaRow[0];
const birthArea = rawPlayer.birthAreaId
? {
id: rawPlayer.birthAreaId,
wyId: rawPlayer.birthAreaWyId,
name: rawPlayer.birthAreaName,
alpha2code: rawPlayer.birthAreaAlpha2code,
alpha3code: rawPlayer.birthAreaAlpha3code,
createdAt: rawPlayer.birthAreaCreatedAt,
updatedAt: rawPlayer.birthAreaUpdatedAt,
deletedAt: rawPlayer.birthAreaDeletedAt,
}
: null;
const secondBirthArea = secondBirthAreaData?.areaId
? {
id: secondBirthAreaData.areaId,
wyId: secondBirthAreaData.areaWyId,
name: secondBirthAreaData.areaName,
alpha2code: secondBirthAreaData.areaAlpha2code,
alpha3code: secondBirthAreaData.areaAlpha3code,
createdAt: secondBirthAreaData.areaCreatedAt,
updatedAt: secondBirthAreaData.areaUpdatedAt,
deletedAt: secondBirthAreaData.areaDeletedAt,
}
: null;
const passportArea = passportAreaData?.areaId
? {
id: passportAreaData.areaId,
wyId: passportAreaData.areaWyId,
name: passportAreaData.areaName,
alpha2code: passportAreaData.areaAlpha2code,
alpha3code: passportAreaData.areaAlpha3code,
createdAt: passportAreaData.areaCreatedAt,
updatedAt: passportAreaData.areaUpdatedAt,
deletedAt: passportAreaData.areaDeletedAt,
}
: null;
const secondPassportArea = secondPassportAreaData?.areaId
? {
id: secondPassportAreaData.areaId,
wyId: secondPassportAreaData.areaWyId,
name: secondPassportAreaData.areaName,
alpha2code: secondPassportAreaData.areaAlpha2code,
alpha3code: secondPassportAreaData.areaAlpha3code,
createdAt: secondPassportAreaData.areaCreatedAt,
updatedAt: secondPassportAreaData.areaUpdatedAt,
deletedAt: secondPassportAreaData.areaDeletedAt,
}
: null;
rawPlayer = {
...rawPlayer,
birthArea,
secondBirthArea,
passportArea,
secondPassportArea,
};
}
if (!rawPlayer) {
return undefined;
}
const structuredPlayer = this.transformToStructuredPlayer(rawPlayer);
// Fetch other positions if otherPositionIds exist
if (
rawPlayer.otherPositionIds &&
Array.isArray(rawPlayer.otherPositionIds) &&
rawPlayer.otherPositionIds.length > 0
) {
const otherPositions = await db
.select()
.from(positions)
.where(
and(
inArray(positions.id, rawPlayer.otherPositionIds),
eq(positions.isActive, true),
),
);
structuredPlayer.otherPositions = otherPositions.map((pos) => ({
id: pos.id,
name: pos.name,
code2: pos.code2,
code3: pos.code3,
order: pos.order ?? 0,
location:
pos.locationX !== null && pos.locationY !== null
? { x: pos.locationX, y: pos.locationY }
: null,
bgColor: pos.bgColor,
textColor: pos.textColor,
category: pos.category,
}));
}
// Fetch reports for this player
const playerReports = await db
.select({
id: reports.id,
playerWyId: reports.playerWyId,
grade: reports.grade,
rating: reports.rating,
})
.from(reports)
.where(eq(reports.playerWyId, rawPlayer.wyId));
// Attach reports to player
structuredPlayer.reports = playerReports.map((report) => ({
id: report.id,
grade: report.grade,
rating: report.rating ? parseFloat(report.rating as string) : null,
}));
return structuredPlayer;
}
async list(limit = 50, offset = 0): Promise<Player[]> { async list(limit = 50, offset = 0): Promise<Player[]> {
const db = this.databaseService.getDatabase(); const db = this.databaseService.getDatabase();
return db.select().from(players).limit(limit).offset(offset); return db.select().from(players).limit(limit).offset(offset);
...@@ -1652,6 +2036,14 @@ export class PlayersService { ...@@ -1652,6 +2036,14 @@ export class PlayersService {
updateData.currentTeamOfficialName = normalizeToNull( updateData.currentTeamOfficialName = normalizeToNull(
data.currentTeamOfficialName, data.currentTeamOfficialName,
); );
if (data.currentNationalTeamName !== undefined)
updateData.currentNationalTeamName = normalizeToNull(
data.currentNationalTeamName,
);
if (data.currentNationalTeamOfficialName !== undefined)
updateData.currentNationalTeamOfficialName = normalizeToNull(
data.currentNationalTeamOfficialName,
);
if (data.dateOfBirth !== undefined) if (data.dateOfBirth !== undefined)
updateData.dateOfBirth = toDateString(data.dateOfBirth) as any; updateData.dateOfBirth = toDateString(data.dateOfBirth) as any;
if (data.heightCm !== undefined) if (data.heightCm !== undefined)
...@@ -1661,19 +2053,19 @@ export class PlayersService { ...@@ -1661,19 +2053,19 @@ export class PlayersService {
if (data.foot !== undefined) updateData.foot = normalizeToNull(data.foot); if (data.foot !== undefined) updateData.foot = normalizeToNull(data.foot);
if (data.gender !== undefined) if (data.gender !== undefined)
updateData.gender = normalizeToNull(data.gender); updateData.gender = normalizeToNull(data.gender);
if (data.position !== undefined || (data as any).positionId !== undefined) { if (data.position !== undefined || data.positionId !== undefined) {
updateData.positionId = await this.resolvePositionId( updateData.positionId = await this.resolvePositionId(
data.position, data.position,
(data as any).positionId, data.positionId,
); );
} }
if ( if (
data.otherPositions !== undefined || data.otherPositions !== undefined ||
(data as any).otherPositionIds !== undefined data.otherPositionIds !== undefined
) { ) {
updateData.otherPositionIds = await this.resolvePositionIds( updateData.otherPositionIds = await this.resolvePositionIds(
data.otherPositions, data.otherPositions,
(data as any).otherPositionIds, data.otherPositionIds,
); );
} }
if (data.roleCode2 !== undefined) if (data.roleCode2 !== undefined)
...@@ -1695,8 +2087,17 @@ export class PlayersService { ...@@ -1695,8 +2087,17 @@ export class PlayersService {
data.secondPassportAreaWyId, data.secondPassportAreaWyId,
); );
if (data.status !== undefined) updateData.status = data.status; if (data.status !== undefined) updateData.status = data.status;
if (data.imageDataUrl !== undefined) // Support both imageDataUrl (internal) and imageDataURL (external alias)
updateData.imageDataUrl = normalizeToNull(data.imageDataUrl); if (
data.imageDataUrl !== undefined ||
(data as any).imageDataURL !== undefined
) {
const imageValue =
data.imageDataUrl !== undefined
? data.imageDataUrl
: (data as any).imageDataURL;
updateData.imageDataUrl = normalizeToNull(imageValue);
}
if (data.jerseyNumber !== undefined) if (data.jerseyNumber !== undefined)
updateData.jerseyNumber = normalizeToNull(data.jerseyNumber); updateData.jerseyNumber = normalizeToNull(data.jerseyNumber);
if (data.apiLastSyncedAt !== undefined) if (data.apiLastSyncedAt !== undefined)
...@@ -1711,8 +2112,7 @@ export class PlayersService { ...@@ -1711,8 +2112,7 @@ export class PlayersService {
if (data.phone !== undefined) if (data.phone !== undefined)
updateData.phone = normalizeToNull(data.phone); updateData.phone = normalizeToNull(data.phone);
if (data.onLoan !== undefined) updateData.onLoan = data.onLoan; if (data.onLoan !== undefined) updateData.onLoan = data.onLoan;
if (data.agent !== undefined) if (data.agentId !== undefined) updateData.agentId = data.agentId;
updateData.agent = normalizeToNull(data.agent);
if (data.ranking !== undefined) if (data.ranking !== undefined)
updateData.ranking = normalizeToNull(data.ranking); updateData.ranking = normalizeToNull(data.ranking);
if (data.roi !== undefined) updateData.roi = normalizeToNull(data.roi); if (data.roi !== undefined) updateData.roi = normalizeToNull(data.roi);
......
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ReportsController } from './reports.controller'; import { ReportsController } from './reports.controller';
import { ReportsService } from './reports.service'; import { ReportsService } from './reports.service';
import { DatabaseModule } from '../../database/database.module'; import { DatabaseModule } from '../../database/database.module';
...@@ -9,14 +7,7 @@ import { JwtSimpleGuard } from '../../common/guards/jwt-simple.guard'; ...@@ -9,14 +7,7 @@ import { JwtSimpleGuard } from '../../common/guards/jwt-simple.guard';
@Module({ @Module({
imports: [ imports: [
DatabaseModule, DatabaseModule,
JwtModule.registerAsync({ // JwtModule is now global, no need to import here
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET') || 'your-secret-key',
signOptions: { expiresIn: '24h' },
}),
inject: [ConfigService],
}),
], ],
controllers: [ReportsController], controllers: [ReportsController],
providers: [ReportsService, JwtSimpleGuard], providers: [ReportsService, JwtSimpleGuard],
......
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { UsersService } from './users.service'; import { UsersService } from './users.service';
import { UsersController } from './users.controller'; import { UsersController } from './users.controller';
import { DatabaseModule } from '../../database/database.module'; import { DatabaseModule } from '../../database/database.module';
...@@ -9,14 +7,7 @@ import { JwtSimpleGuard } from '../../common/guards/jwt-simple.guard'; ...@@ -9,14 +7,7 @@ import { JwtSimpleGuard } from '../../common/guards/jwt-simple.guard';
@Module({ @Module({
imports: [ imports: [
DatabaseModule, DatabaseModule,
JwtModule.registerAsync({ // JwtModule is now global, no need to import here
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET') || 'your-secret-key',
signOptions: { expiresIn: '24h' },
}),
inject: [ConfigService],
}),
], ],
controllers: [UsersController], controllers: [UsersController],
providers: [UsersService, JwtSimpleGuard], providers: [UsersService, JwtSimpleGuard],
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment