Commit 7730f929 by Augusto

documentation, player-features and profile

parent 1909772c
CREATE TABLE "profile_descriptions" (
"id" serial PRIMARY KEY NOT NULL,
"player_id" integer,
"coach_id" integer,
"description" text NOT NULL,
"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 "profile_links" (
"id" serial PRIMARY KEY NOT NULL,
"player_id" integer,
"coach_id" integer,
"title" varchar(255) NOT NULL,
"url" text NOT NULL,
"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 "profile_descriptions" ADD CONSTRAINT "profile_descriptions_player_id_players_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "profile_descriptions" ADD CONSTRAINT "profile_descriptions_coach_id_coaches_id_fk" FOREIGN KEY ("coach_id") REFERENCES "public"."coaches"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "profile_links" ADD CONSTRAINT "profile_links_player_id_players_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "profile_links" ADD CONSTRAINT "profile_links_coach_id_coaches_id_fk" FOREIGN KEY ("coach_id") REFERENCES "public"."coaches"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "profile_descriptions_player_id_idx" ON "profile_descriptions" USING btree ("player_id");--> statement-breakpoint
CREATE INDEX "profile_descriptions_coach_id_idx" ON "profile_descriptions" USING btree ("coach_id");--> statement-breakpoint
CREATE INDEX "profile_descriptions_deleted_at_idx" ON "profile_descriptions" USING btree ("deleted_at");--> statement-breakpoint
CREATE INDEX "profile_links_player_id_idx" ON "profile_links" USING btree ("player_id");--> statement-breakpoint
CREATE INDEX "profile_links_coach_id_idx" ON "profile_links" USING btree ("coach_id");--> statement-breakpoint
CREATE INDEX "profile_links_is_active_idx" ON "profile_links" USING btree ("is_active");--> statement-breakpoint
CREATE INDEX "profile_links_deleted_at_idx" ON "profile_links" USING btree ("deleted_at");
-- Create player_feature_selections table
-- This table tracks which features a user has selected for a player,
-- independent of ratings. Allows users to select features without rating them.
CREATE TABLE IF NOT EXISTS "player_feature_selections" (
"id" serial PRIMARY KEY NOT NULL,
"player_id" integer NOT NULL,
"feature_type_id" integer NOT NULL,
"user_id" integer NOT NULL,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone
);
-- Create indexes for player_feature_selections
CREATE INDEX "player_feature_selections_player_id_idx" ON "player_feature_selections" ("player_id");
CREATE INDEX "player_feature_selections_feature_type_id_idx" ON "player_feature_selections" ("feature_type_id");
CREATE INDEX "player_feature_selections_user_id_idx" ON "player_feature_selections" ("user_id");
CREATE INDEX "player_feature_selections_deleted_at_idx" ON "player_feature_selections" ("deleted_at");
-- Unique constraint: one selection per scout per feature per player
CREATE UNIQUE INDEX "player_feature_selections_player_feature_user_idx" ON "player_feature_selections" ("player_id","feature_type_id","user_id");
-- Composite indexes for common queries
CREATE INDEX "player_feature_selections_player_feature_idx" ON "player_feature_selections" ("player_id","feature_type_id");
CREATE INDEX "player_feature_selections_player_user_idx" ON "player_feature_selections" ("player_id","user_id");
-- Add foreign key constraints
ALTER TABLE "player_feature_selections" ADD CONSTRAINT "player_feature_selections_player_id_fk" FOREIGN KEY ("player_id") REFERENCES "players"("id") ON DELETE cascade;
ALTER TABLE "player_feature_selections" ADD CONSTRAINT "player_feature_selections_feature_type_id_fk" FOREIGN KEY ("feature_type_id") REFERENCES "player_feature_types"("id") ON DELETE cascade;
ALTER TABLE "player_feature_selections" ADD CONSTRAINT "player_feature_selections_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade;
......@@ -21,6 +21,7 @@ import { PositionsModule } from './modules/positions/positions.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';
import { ProfilesModule } from './modules/profiles/profiles.module';
@Module({
imports: [
......@@ -57,6 +58,7 @@ import { PlayerFeaturesModule } from './modules/player-features/player-features.
AuditLogsModule,
AgentsModule,
PlayerFeaturesModule,
ProfilesModule,
],
controllers: [AppController],
providers: [AppService],
......
......@@ -851,7 +851,7 @@ export const clientModules = pgTable(
);
// ============================================================================
// PLAYER FEATURES (Características del Jugador)
// PLAYER FEATURES
// ============================================================================
export const playerFeatureCategories = pgTable(
......@@ -957,6 +957,105 @@ export const playerFeatureRatings = pgTable(
}),
);
export const playerFeatureSelections = pgTable(
'player_feature_selections',
{
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' }),
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_selections_player_id_idx').on(
table.playerId,
),
featureTypeIdIdx: index('player_feature_selections_feature_type_id_idx').on(
table.featureTypeId,
),
userIdIdx: index('player_feature_selections_user_id_idx').on(table.userId),
deletedAtIdx: index('player_feature_selections_deleted_at_idx').on(
table.deletedAt,
),
// Unique constraint: one selection per scout per feature per player
playerFeatureUserIdx: uniqueIndex(
'player_feature_selections_player_feature_user_idx',
).on(table.playerId, table.featureTypeId, table.userId),
// Composite indexes for common queries
playerFeatureIdx: index('player_feature_selections_player_feature_idx').on(
table.playerId,
table.featureTypeId,
),
playerUserIdx: index('player_feature_selections_player_user_idx').on(
table.playerId,
table.userId,
),
}),
);
// ============================================================================
// PROFILE DESCRIPTIONS & LINKS
// ============================================================================
export const profileDescriptions = pgTable(
'profile_descriptions',
{
id: serial('id').primaryKey(),
playerId: integer('player_id').references(() => players.id, {
onDelete: 'cascade',
}),
coachId: integer('coach_id').references(() => coaches.id, {
onDelete: 'cascade',
}),
description: text('description').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
},
(table) => ({
playerIdIdx: index('profile_descriptions_player_id_idx').on(table.playerId),
coachIdIdx: index('profile_descriptions_coach_id_idx').on(table.coachId),
deletedAtIdx: index('profile_descriptions_deleted_at_idx').on(
table.deletedAt,
),
}),
);
export const profileLinks = pgTable(
'profile_links',
{
id: serial('id').primaryKey(),
playerId: integer('player_id').references(() => players.id, {
onDelete: 'cascade',
}),
coachId: integer('coach_id').references(() => coaches.id, {
onDelete: 'cascade',
}),
title: varchar('title', { length: 255 }).notNull(), // e.g., "YouTube", "Instagram", "Portfolio"
url: text('url').notNull(),
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) => ({
playerIdIdx: index('profile_links_player_id_idx').on(table.playerId),
coachIdIdx: index('profile_links_coach_id_idx').on(table.coachId),
isActiveIdx: index('profile_links_is_active_idx').on(table.isActive),
deletedAtIdx: index('profile_links_deleted_at_idx').on(table.deletedAt),
}),
);
// ============================================================================
// AUDIT LOGS
// ============================================================================
......@@ -1088,6 +1187,20 @@ export const insertPlayerFeatureRatingSchema =
createInsertSchema(playerFeatureRatings);
export const selectPlayerFeatureRatingSchema =
createSelectSchema(playerFeatureRatings);
export const insertPlayerFeatureSelectionSchema = createInsertSchema(
playerFeatureSelections,
);
export const selectPlayerFeatureSelectionSchema = createSelectSchema(
playerFeatureSelections,
);
// Profile Descriptions & Links
export const insertProfileDescriptionSchema =
createInsertSchema(profileDescriptions);
export const selectProfileDescriptionSchema =
createSelectSchema(profileDescriptions);
export const insertProfileLinkSchema = createInsertSchema(profileLinks);
export const selectProfileLinkSchema = createSelectSchema(profileLinks);
// Audit Logs
export const insertAuditLogSchema = createInsertSchema(auditLogs);
......@@ -1170,6 +1283,16 @@ export type PlayerFeatureType = typeof playerFeatureTypes.$inferSelect;
export type NewPlayerFeatureType = typeof playerFeatureTypes.$inferInsert;
export type PlayerFeatureRating = typeof playerFeatureRatings.$inferSelect;
export type NewPlayerFeatureRating = typeof playerFeatureRatings.$inferInsert;
export type PlayerFeatureSelection =
typeof playerFeatureSelections.$inferSelect;
export type NewPlayerFeatureSelection =
typeof playerFeatureSelections.$inferInsert;
// Profile Descriptions & Links
export type ProfileDescription = typeof profileDescriptions.$inferSelect;
export type NewProfileDescription = typeof profileDescriptions.$inferInsert;
export type ProfileLink = typeof profileLinks.$inferSelect;
export type NewProfileLink = typeof profileLinks.$inferInsert;
// Audit Logs
export type AuditLog = typeof auditLogs.$inferSelect;
......
......@@ -25,19 +25,65 @@ export class AreasController {
constructor(private readonly areasService: AreasService) {}
@Post()
@ApiOperation({ summary: 'Create or update area by wyId' })
@ApiOperation({
summary: 'Create or update area by wyId',
description:
'Creates a new area or updates an existing one identified by Wyscout ID',
})
@ApiBody({
description: 'Area payload',
type: CreateAreaDto,
examples: {
example1: {
summary: 'Create area',
value: {
wyId: 1,
name: 'Spain',
alpha2code: 'ES',
alpha3code: 'ESP',
},
},
},
})
@ApiOkResponse({
description: 'Area created or updated successfully',
schema: {
example: {
id: 1,
wyId: 1,
name: 'Spain',
alpha2code: 'ES',
alpha3code: 'ESP',
createdAt: '2025-01-15T10:30:00Z',
updatedAt: '2025-01-15T10:30:00Z',
deletedAt: null,
},
},
})
@ApiOkResponse({ description: 'Upserted area' })
async save(@Body() body: CreateAreaDto): Promise<Area> {
return this.areasService.upsertByWyId(body as any);
}
@Get(':wyId')
@ApiOperation({ summary: 'Get area by wyId' })
@ApiOkResponse({ description: 'Area if found' })
@ApiOperation({
summary: 'Get area by wyId',
description: 'Retrieves a specific area by its Wyscout ID',
})
@ApiOkResponse({
description: 'Area retrieved successfully',
schema: {
example: {
id: 1,
wyId: 1,
name: 'Spain',
alpha2code: 'ES',
alpha3code: 'ESP',
createdAt: '2025-01-15T10:30:00Z',
updatedAt: '2025-01-15T10:30:00Z',
deletedAt: null,
},
},
})
async getByWyId(
@Param('wyId', ParseIntPipe) wyId: number,
): Promise<Area | undefined> {
......@@ -54,8 +100,7 @@ export class AreasController {
name: 'search',
required: false,
type: String,
description:
'Search areas by name, alpha2code, or alpha3code. Optional.',
description: 'Search areas by name, alpha2code, or alpha3code. Optional.',
})
@ApiQuery({
name: 'limit',
......@@ -82,10 +127,11 @@ export class AreasController {
description: 'Sort order - ascending or descending (default: asc)',
})
@ApiOkResponse({ description: 'Paginated list of areas' })
async findAll(@Query() query: QueryAreasDto): Promise<SimplePaginatedResponse<any>> {
async findAll(
@Query() query: QueryAreasDto,
): Promise<SimplePaginatedResponse<any>> {
const l = query.limit ?? 50;
const o = query.offset ?? 0;
return this.areasService.findAll(l, o, query.search, query);
}
}
......@@ -41,11 +41,35 @@ export class AuthController {
description:
'Authenticates a user and returns a JWT token. Supports 2FA and single-device login.',
})
@ApiBody({ type: LoginDto })
@ApiBody({
type: LoginDto,
examples: {
example1: {
summary: 'Login with email',
value: {
email: 'user@example.com',
password: 'SecurePassword123!',
},
},
},
})
@ApiResponse({
status: 200,
description: 'Login successful',
type: AuthResponseDto,
schema: {
example: {
accessToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
user: {
id: 1,
email: 'user@example.com',
name: 'John Doe',
role: 'scout',
},
requires2FA: false,
},
},
})
@ApiResponse({
status: 401,
......@@ -146,7 +170,17 @@ export class AuthController {
summary: 'Verify and enable 2FA',
description: 'Verifies the 2FA code and enables two-factor authentication.',
})
@ApiBody({ type: Verify2FADto })
@ApiBody({
type: Verify2FADto,
examples: {
example1: {
summary: 'Verify 2FA code',
value: {
code: '123456',
},
},
},
})
@ApiResponse({
status: 204,
description: '2FA enabled successfully',
......@@ -214,7 +248,19 @@ export class AuthController {
description:
'Allows users to change their password by providing current password and new password.',
})
@ApiBody({ type: ChangePasswordDto })
@ApiBody({
type: ChangePasswordDto,
examples: {
example1: {
summary: 'Change password',
value: {
currentPassword: 'OldPassword123!',
newPassword: 'NewPassword456!',
confirmPassword: 'NewPassword456!',
},
},
},
})
@ApiResponse({
status: 204,
description: 'Password changed successfully',
......
......@@ -25,19 +25,71 @@ export class CoachesController {
constructor(private readonly coachesService: CoachesService) {}
@Post()
@ApiOperation({ summary: 'Create or update coach by wyId' })
@ApiOperation({
summary: 'Create or update coach by wyId',
description:
'Creates a new coach or updates an existing one identified by Wyscout ID',
})
@ApiBody({
description: 'Coach payload',
type: CreateCoachDto,
examples: {
example1: {
summary: 'Create coach',
value: {
wyId: 1,
firstName: 'Carlo',
lastName: 'Ancelotti',
shortName: 'C. Ancelotti',
position: 'Head Coach',
status: 'active',
},
},
},
})
@ApiOkResponse({
description: 'Coach created or updated successfully',
schema: {
example: {
id: 1,
wyId: 1,
firstName: 'Carlo',
lastName: 'Ancelotti',
shortName: 'C. Ancelotti',
position: 'Head Coach',
status: 'active',
createdAt: '2025-01-15T10:30:00Z',
updatedAt: '2025-01-15T10:30:00Z',
deletedAt: null,
},
},
})
@ApiOkResponse({ description: 'Upserted coach' })
async save(@Body() body: CreateCoachDto): Promise<Coach> {
return this.coachesService.upsertByWyId(body as any);
}
@Get(':wyId')
@ApiOperation({ summary: 'Get coach by wyId' })
@ApiOkResponse({ description: 'Coach if found' })
@ApiOperation({
summary: 'Get coach by wyId',
description: 'Retrieves a specific coach by their Wyscout ID',
})
@ApiOkResponse({
description: 'Coach retrieved successfully',
schema: {
example: {
id: 1,
wyId: 1,
firstName: 'Carlo',
lastName: 'Ancelotti',
shortName: 'C. Ancelotti',
position: 'Head Coach',
status: 'active',
createdAt: '2025-01-15T10:30:00Z',
updatedAt: '2025-01-15T10:30:00Z',
deletedAt: null,
},
},
})
async getByWyId(
@Param('wyId', ParseIntPipe) wyId: number,
): Promise<Coach | undefined> {
......@@ -72,7 +124,15 @@ export class CoachesController {
@ApiQuery({
name: 'sortBy',
required: false,
enum: ['firstName', 'lastName', 'shortName', 'position', 'status', 'createdAt', 'updatedAt'],
enum: [
'firstName',
'lastName',
'shortName',
'position',
'status',
'createdAt',
'updatedAt',
],
description: 'Field to sort by (default: lastName)',
})
@ApiQuery({
......@@ -82,7 +142,9 @@ export class CoachesController {
description: 'Sort order - ascending or descending (default: asc)',
})
@ApiOkResponse({ description: 'Paginated list of coaches' })
async findAll(@Query() query: QueryCoachesDto): Promise<SimplePaginatedResponse<any>> {
async findAll(
@Query() query: QueryCoachesDto,
): Promise<SimplePaginatedResponse<any>> {
const l = query.limit ?? 50;
const o = query.offset ?? 0;
return this.coachesService.findAll(l, o, query.name, query);
......
......@@ -25,19 +25,83 @@ export class MatchesController {
constructor(private readonly matchesService: MatchesService) {}
@Post()
@ApiOperation({ summary: 'Create or update match by wyId' })
@ApiOperation({
summary: 'Create or update match by wyId',
description:
'Creates a new match or updates an existing one identified by Wyscout ID',
})
@ApiBody({
description: 'Match payload',
type: CreateMatchDto,
examples: {
example1: {
summary: 'Create match',
value: {
wyId: 1,
matchDate: '2025-01-15T20:00:00Z',
venue: 'Santiago Bernabéu',
venueCity: 'Madrid',
venueCountry: 'Spain',
homeTeamWyId: 1,
awayTeamWyId: 2,
homeScore: 2,
awayScore: 1,
status: 'completed',
},
},
},
})
@ApiOkResponse({
description: 'Match created or updated successfully',
schema: {
example: {
id: 1,
wyId: 1,
matchDate: '2025-01-15T20:00:00Z',
venue: 'Santiago Bernabéu',
venueCity: 'Madrid',
venueCountry: 'Spain',
homeTeamWyId: 1,
awayTeamWyId: 2,
homeScore: 2,
awayScore: 1,
status: 'completed',
createdAt: '2025-01-15T10:30:00Z',
updatedAt: '2025-01-15T10:30:00Z',
deletedAt: null,
},
},
})
@ApiOkResponse({ description: 'Upserted match' })
async save(@Body() body: CreateMatchDto): Promise<Match> {
return this.matchesService.upsertByWyId(body as any);
}
@Get(':wyId')
@ApiOperation({ summary: 'Get match by wyId' })
@ApiOkResponse({ description: 'Match if found' })
@ApiOperation({
summary: 'Get match by wyId',
description: 'Retrieves a specific match by its Wyscout ID',
})
@ApiOkResponse({
description: 'Match retrieved successfully',
schema: {
example: {
id: 1,
wyId: 1,
matchDate: '2025-01-15T20:00:00Z',
venue: 'Santiago Bernabéu',
venueCity: 'Madrid',
venueCountry: 'Spain',
homeTeamWyId: 1,
awayTeamWyId: 2,
homeScore: 2,
awayScore: 1,
status: 'completed',
createdAt: '2025-01-15T10:30:00Z',
updatedAt: '2025-01-15T10:30:00Z',
deletedAt: null,
},
},
})
async getByWyId(
@Param('wyId', ParseIntPipe) wyId: number,
): Promise<Match | undefined> {
......@@ -72,7 +136,17 @@ export class MatchesController {
@ApiQuery({
name: 'sortBy',
required: false,
enum: ['matchDate', 'venue', 'venueCity', 'venueCountry', 'homeScore', 'awayScore', 'status', 'createdAt', 'updatedAt'],
enum: [
'matchDate',
'venue',
'venueCity',
'venueCountry',
'homeScore',
'awayScore',
'status',
'createdAt',
'updatedAt',
],
description: 'Field to sort by (default: matchDate)',
})
@ApiQuery({
......@@ -82,7 +156,9 @@ export class MatchesController {
description: 'Sort order - ascending or descending (default: asc)',
})
@ApiOkResponse({ description: 'Paginated list of matches' })
async findAll(@Query() query: QueryMatchesDto): Promise<MatchesPaginatedResponse<any>> {
async findAll(
@Query() query: QueryMatchesDto,
): Promise<MatchesPaginatedResponse<any>> {
const l = query.limit ?? 50;
const o = query.offset ?? 0;
return this.matchesService.findAll(l, o, query.name, query);
......
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsOptional, IsString } from 'class-validator';
export class CreateFeatureSelectionDto {
@ApiProperty({
description: 'Player ID for which feature is being selected',
example: 1,
type: Number,
})
@IsInt()
playerId: number;
@ApiProperty({
description: 'Feature type ID being selected',
example: 1,
type: Number,
})
@IsInt()
featureTypeId: number;
@ApiProperty({
description: 'Scout/User ID making the selection',
example: 1,
type: Number,
})
@IsInt()
userId: number;
@ApiPropertyOptional({
description: 'Optional scout notes about the selection',
example: 'This feature applies to the player',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
notes?: string | null;
}
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';
export class UpdateFeatureSelectionDto {
@ApiPropertyOptional({
description: 'Optional scout notes about the selection',
example: 'Updated notes about the selection',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
notes?: string | null;
}
......@@ -34,12 +34,53 @@ export class PlayersController {
constructor(private readonly playersService: PlayersService) {}
@Post()
@ApiOperation({ summary: 'Create or update player by wyId' })
@ApiOperation({
summary: 'Create or update player by wyId',
description:
'Creates a new player or updates an existing one identified by Wyscout ID',
})
@ApiBody({
description: 'Player payload',
type: CreatePlayerDto,
examples: {
example1: {
summary: 'Create player',
value: {
wyId: 1,
firstName: 'Lionel',
lastName: 'Messi',
shortName: 'L. Messi',
birthDate: '1987-06-24',
height: 170,
weight: 72,
foot: 'left',
gender: 'male',
status: 'active',
},
},
},
})
@ApiOkResponse({
description: 'Player created or updated successfully',
schema: {
example: {
id: 1,
wyId: 1,
firstName: 'Lionel',
lastName: 'Messi',
shortName: 'L. Messi',
birthDate: '1987-06-24',
height: 170,
weight: 72,
foot: 'left',
gender: 'male',
status: 'active',
createdAt: '2025-01-15T10:30:00Z',
updatedAt: '2025-01-15T10:30:00Z',
deletedAt: null,
},
},
})
@ApiOkResponse({ description: 'Upserted player' })
async save(@Body() body: CreatePlayerDto): Promise<Player> {
return this.playersService.upsertByWyId(body as any);
}
......@@ -246,7 +287,11 @@ export class PlayersController {
}
@Patch(':wyId')
@ApiOperation({ summary: 'Update player by wyId' })
@ApiOperation({
summary: 'Update player by wyId',
description:
'Updates an existing player. Only provided fields will be updated.',
})
@ApiParam({
name: 'wyId',
type: Number,
......@@ -255,8 +300,43 @@ export class PlayersController {
@ApiBody({
description: 'Player update payload. All fields are optional.',
type: UpdatePlayerDto,
examples: {
example1: {
summary: 'Update player status',
value: {
status: 'inactive',
},
},
example2: {
summary: 'Update player physical attributes',
value: {
height: 172,
weight: 75,
},
},
},
})
@ApiOkResponse({
description: 'Player updated successfully',
schema: {
example: {
id: 1,
wyId: 1,
firstName: 'Lionel',
lastName: 'Messi',
shortName: 'L. Messi',
birthDate: '1987-06-24',
height: 172,
weight: 75,
foot: 'left',
gender: 'male',
status: 'inactive',
createdAt: '2025-01-15T10:30:00Z',
updatedAt: '2025-01-15T11:00:00Z',
deletedAt: null,
},
},
})
@ApiOkResponse({ description: 'Updated player', type: Object })
@ApiNotFoundResponse({ description: 'Player not found' })
async updateByWyId(
@Param('wyId', ParseIntPipe) wyId: number,
......
......@@ -788,6 +788,9 @@ export class PlayersService {
currentNationalTeamId: players.currentNationalTeamId,
currentTeamName: players.currentTeamName,
currentTeamOfficialName: players.currentTeamOfficialName,
currentNationalTeamName: players.currentNationalTeamName,
currentNationalTeamOfficialName:
players.currentNationalTeamOfficialName,
gender: players.gender,
status: players.status,
jerseyNumber: players.jerseyNumber,
......@@ -824,9 +827,10 @@ export class PlayersService {
contractEndsAt: players.contractEndsAt,
feasible: players.feasible,
morphology: players.morphology,
birthAreaWyId: players.birthAreaWyId,
// Birth area fields
birthAreaId: areas.id,
birthAreaWyId: areas.wyId,
birthAreaWyIdFromArea: areas.wyId,
birthAreaName: areas.name,
birthAreaAlpha2code: areas.alpha2code,
birthAreaAlpha3code: areas.alpha3code,
......@@ -986,7 +990,7 @@ export class PlayersService {
const birthArea = player.birthAreaId
? {
id: player.birthAreaId,
wyId: player.birthAreaWyId,
wyId: player.birthAreaWyIdFromArea,
name: player.birthAreaName,
alpha2code: player.birthAreaAlpha2code,
alpha3code: player.birthAreaAlpha3code,
......@@ -1470,12 +1474,13 @@ export class PlayersService {
contractEndsAt: players.contractEndsAt,
feasible: players.feasible,
morphology: players.morphology,
birthAreaWyId: players.birthAreaWyId,
secondBirthAreaWyId: players.secondBirthAreaWyId,
passportAreaWyId: players.passportAreaWyId,
secondPassportAreaWyId: players.secondPassportAreaWyId,
// Birth area fields
birthAreaId: areas.id,
birthAreaWyId: areas.wyId,
birthAreaWyIdFromArea: areas.wyId,
birthAreaName: areas.name,
birthAreaAlpha2code: areas.alpha2code,
birthAreaAlpha3code: areas.alpha3code,
......@@ -1553,7 +1558,7 @@ export class PlayersService {
const birthArea = player.birthAreaId
? {
id: player.birthAreaId,
wyId: player.birthAreaWyId,
wyId: player.birthAreaWyIdFromArea,
name: player.birthAreaName,
alpha2code: player.birthAreaAlpha2code,
alpha3code: player.birthAreaAlpha3code,
......@@ -1757,12 +1762,13 @@ export class PlayersService {
contractEndsAt: players.contractEndsAt,
feasible: players.feasible,
morphology: players.morphology,
birthAreaWyId: players.birthAreaWyId,
secondBirthAreaWyId: players.secondBirthAreaWyId,
passportAreaWyId: players.passportAreaWyId,
secondPassportAreaWyId: players.secondPassportAreaWyId,
// Birth area fields
birthAreaId: areas.id,
birthAreaWyId: areas.wyId,
birthAreaWyIdFromArea: areas.wyId,
birthAreaName: areas.name,
birthAreaAlpha2code: areas.alpha2code,
birthAreaAlpha3code: areas.alpha3code,
......
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsOptional, IsInt } from 'class-validator';
export class CreateDescriptionDto {
@ApiPropertyOptional({
description: 'Player ID',
example: 1,
type: Number,
})
@IsOptional()
@IsInt()
playerId?: number;
@ApiPropertyOptional({
description: 'Coach ID',
example: 1,
type: Number,
})
@IsOptional()
@IsInt()
coachId?: number;
@ApiProperty({
description: 'Profile description text',
example: 'Talented midfielder with excellent passing skills',
type: String,
})
@IsString()
description: string;
}
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsOptional, IsInt, IsBoolean, IsUrl } from 'class-validator';
export class CreateLinkDto {
@ApiPropertyOptional({
description: 'Player ID',
example: 1,
type: Number,
})
@IsOptional()
@IsInt()
playerId?: number;
@ApiPropertyOptional({
description: 'Coach ID',
example: 1,
type: Number,
})
@IsOptional()
@IsInt()
coachId?: number;
@ApiProperty({
description: 'Link title (e.g., "YouTube", "Instagram", "Portfolio")',
example: 'YouTube',
type: String,
})
@IsString()
title: string;
@ApiProperty({
description: 'Link URL',
example: 'https://youtube.com/user/example',
type: String,
})
@IsString()
@IsUrl()
url: string;
@ApiPropertyOptional({
description: 'Display order',
example: 0,
type: Number,
default: 0,
})
@IsOptional()
@IsInt()
order?: number;
@ApiPropertyOptional({
description: 'Whether the link is active',
example: true,
type: Boolean,
default: true,
})
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsOptional } from 'class-validator';
export class UpdateDescriptionDto {
@ApiPropertyOptional({
description: 'Profile description text',
example: 'Talented midfielder with excellent passing skills',
type: String,
})
@IsOptional()
@IsString()
description?: string;
}
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsOptional, IsInt, IsBoolean, IsUrl } from 'class-validator';
export class UpdateLinkDto {
@ApiPropertyOptional({
description: 'Link title',
example: 'YouTube',
type: String,
})
@IsOptional()
@IsString()
title?: string;
@ApiPropertyOptional({
description: 'Link URL',
example: 'https://youtube.com/user/example',
type: String,
})
@IsOptional()
@IsString()
@IsUrl()
url?: string;
@ApiPropertyOptional({
description: 'Display order',
example: 0,
type: Number,
})
@IsOptional()
@IsInt()
order?: number;
@ApiPropertyOptional({
description: 'Whether the link 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 { ProfilesService } from './profiles.service';
import {
type ProfileDescription,
type ProfileLink,
} from '../../database/schema';
import { CreateDescriptionDto } from './dto/create-description.dto';
import { UpdateDescriptionDto } from './dto/update-description.dto';
import { CreateLinkDto } from './dto/create-link.dto';
import { UpdateLinkDto } from './dto/update-link.dto';
@ApiTags('Profiles')
@Controller('profiles')
export class ProfilesController {
constructor(private readonly profilesService: ProfilesService) {}
// ============================================================================
// DESCRIPTIONS
// ============================================================================
@Post('descriptions')
@ApiOperation({ summary: 'Create profile description' })
@ApiBody({ type: CreateDescriptionDto })
@ApiOkResponse({ description: 'Created profile description' })
async createDescription(
@Body() body: CreateDescriptionDto,
): Promise<ProfileDescription> {
return this.profilesService.createDescription(body as any);
}
@Patch('descriptions/:id')
@ApiOperation({ summary: 'Update profile description by ID' })
@ApiParam({ name: 'id', type: Number })
@ApiBody({ type: UpdateDescriptionDto })
@ApiOkResponse({ description: 'Updated profile description' })
@ApiNotFoundResponse({ description: 'Description not found' })
async updateDescription(
@Param('id', ParseIntPipe) id: number,
@Body() body: UpdateDescriptionDto,
): Promise<ProfileDescription> {
return this.profilesService.updateDescription(id, body as any);
}
@Get('descriptions/:id')
@ApiOperation({ summary: 'Get profile description by ID' })
@ApiParam({ name: 'id', type: Number })
@ApiOkResponse({ description: 'Profile description if found' })
async getDescriptionById(
@Param('id', ParseIntPipe) id: number,
): Promise<ProfileDescription | null> {
return this.profilesService.getDescriptionById(id);
}
@Get('descriptions/player/:playerId')
@ApiOperation({ summary: 'Get profile description for a player' })
@ApiParam({ name: 'playerId', type: Number })
@ApiOkResponse({ description: 'Player profile description if found' })
async getDescriptionByPlayerId(
@Param('playerId', ParseIntPipe) playerId: number,
): Promise<ProfileDescription | null> {
return this.profilesService.getDescriptionByPlayerId(playerId);
}
@Get('descriptions/coach/:coachId')
@ApiOperation({ summary: 'Get profile description for a coach' })
@ApiParam({ name: 'coachId', type: Number })
@ApiOkResponse({ description: 'Coach profile description if found' })
async getDescriptionByCoachId(
@Param('coachId', ParseIntPipe) coachId: number,
): Promise<ProfileDescription | null> {
return this.profilesService.getDescriptionByCoachId(coachId);
}
@Delete('descriptions/:id')
@ApiOperation({ summary: 'Delete profile description by ID' })
@ApiParam({ name: 'id', type: Number })
@ApiNoContentResponse({ description: 'Description deleted successfully' })
@ApiNotFoundResponse({ description: 'Description not found' })
async deleteDescription(
@Param('id', ParseIntPipe) id: number,
): Promise<void> {
const result = await this.profilesService.deleteDescription(id);
if (!result) {
throw new NotFoundException(`Description with ID ${id} not found`);
}
}
// ============================================================================
// LINKS
// ============================================================================
@Post('links')
@ApiOperation({ summary: 'Create profile link' })
@ApiBody({ type: CreateLinkDto })
@ApiOkResponse({ description: 'Created profile link' })
async createLink(@Body() body: CreateLinkDto): Promise<ProfileLink> {
return this.profilesService.createLink(body as any);
}
@Patch('links/:id')
@ApiOperation({ summary: 'Update profile link by ID' })
@ApiParam({ name: 'id', type: Number })
@ApiBody({ type: UpdateLinkDto })
@ApiOkResponse({ description: 'Updated profile link' })
@ApiNotFoundResponse({ description: 'Link not found' })
async updateLink(
@Param('id', ParseIntPipe) id: number,
@Body() body: UpdateLinkDto,
): Promise<ProfileLink> {
return this.profilesService.updateLink(id, body as any);
}
@Get('links/:id')
@ApiOperation({ summary: 'Get profile link by ID' })
@ApiParam({ name: 'id', type: Number })
@ApiOkResponse({ description: 'Profile link if found' })
async getLinkById(
@Param('id', ParseIntPipe) id: number,
): Promise<ProfileLink | null> {
return this.profilesService.getLinkById(id);
}
@Get('links/player/:playerId')
@ApiOperation({ summary: 'Get all profile links for a player' })
@ApiParam({ name: 'playerId', type: Number })
@ApiOkResponse({ description: 'List of player profile links' })
async getLinksByPlayerId(
@Param('playerId', ParseIntPipe) playerId: number,
): Promise<ProfileLink[]> {
return this.profilesService.getLinksByPlayerId(playerId);
}
@Get('links/coach/:coachId')
@ApiOperation({ summary: 'Get all profile links for a coach' })
@ApiParam({ name: 'coachId', type: Number })
@ApiOkResponse({ description: 'List of coach profile links' })
async getLinksByCoachId(
@Param('coachId', ParseIntPipe) coachId: number,
): Promise<ProfileLink[]> {
return this.profilesService.getLinksByCoachId(coachId);
}
@Delete('links/:id')
@ApiOperation({ summary: 'Delete profile link by ID' })
@ApiParam({ name: 'id', type: Number })
@ApiNoContentResponse({ description: 'Link deleted successfully' })
@ApiNotFoundResponse({ description: 'Link not found' })
async deleteLink(@Param('id', ParseIntPipe) id: number): Promise<void> {
const result = await this.profilesService.deleteLink(id);
if (!result) {
throw new NotFoundException(`Link with ID ${id} not found`);
}
}
}
import { Module } from '@nestjs/common';
import { DatabaseModule } from '../../database/database.module';
import { ProfilesService } from './profiles.service';
import { ProfilesController } from './profiles.controller';
@Module({
imports: [DatabaseModule],
controllers: [ProfilesController],
providers: [ProfilesService],
exports: [ProfilesService],
})
export class ProfilesModule {}
import {
Injectable,
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { DatabaseService } from '../../database/database.service';
import {
profileDescriptions,
profileLinks,
type NewProfileDescription,
type NewProfileLink,
type ProfileDescription,
type ProfileLink,
} from '../../database/schema';
import { eq, and, isNull } from 'drizzle-orm';
@Injectable()
export class ProfilesService {
constructor(private readonly databaseService: DatabaseService) {}
// ============================================================================
// PROFILE DESCRIPTIONS
// ============================================================================
async createDescription(
data: Partial<NewProfileDescription>,
): Promise<ProfileDescription> {
const db = this.databaseService.getDatabase();
// Validate that either playerId or coachId is provided
if (!data.playerId && !data.coachId) {
throw new BadRequestException(
'Either playerId or coachId must be provided',
);
}
// Validate that description is provided
if (!data.description) {
throw new BadRequestException('Description is required');
}
const [row] = await db
.insert(profileDescriptions)
.values({
playerId: data.playerId ?? null,
coachId: data.coachId ?? null,
description: data.description,
} as NewProfileDescription)
.returning();
return row as ProfileDescription;
}
async updateDescription(
id: number,
data: Partial<NewProfileDescription>,
): Promise<ProfileDescription> {
const db = this.databaseService.getDatabase();
const existing = await db
.select()
.from(profileDescriptions)
.where(eq(profileDescriptions.id, id))
.limit(1);
if (!existing || existing.length === 0) {
throw new NotFoundException(`Description with ID ${id} not found`);
}
const [row] = await db
.update(profileDescriptions)
.set({
description: data.description,
updatedAt: new Date(),
})
.where(eq(profileDescriptions.id, id))
.returning();
return row as ProfileDescription;
}
async getDescriptionById(id: number): Promise<ProfileDescription | null> {
const db = this.databaseService.getDatabase();
const rows = await db
.select()
.from(profileDescriptions)
.where(
and(
eq(profileDescriptions.id, id),
isNull(profileDescriptions.deletedAt),
),
)
.limit(1);
return rows.length > 0 ? (rows[0] as ProfileDescription) : null;
}
async getDescriptionByPlayerId(
playerId: number,
): Promise<ProfileDescription | null> {
const db = this.databaseService.getDatabase();
const rows = await db
.select()
.from(profileDescriptions)
.where(
and(
eq(profileDescriptions.playerId, playerId),
isNull(profileDescriptions.deletedAt),
),
)
.limit(1);
return rows.length > 0 ? (rows[0] as ProfileDescription) : null;
}
async getDescriptionByCoachId(
coachId: number,
): Promise<ProfileDescription | null> {
const db = this.databaseService.getDatabase();
const rows = await db
.select()
.from(profileDescriptions)
.where(
and(
eq(profileDescriptions.coachId, coachId),
isNull(profileDescriptions.deletedAt),
),
)
.limit(1);
return rows.length > 0 ? (rows[0] as ProfileDescription) : null;
}
async deleteDescription(id: number): Promise<boolean> {
const db = this.databaseService.getDatabase();
const result = await db
.update(profileDescriptions)
.set({ deletedAt: new Date() })
.where(eq(profileDescriptions.id, id))
.returning();
return result.length > 0;
}
// ============================================================================
// PROFILE LINKS
// ============================================================================
async createLink(data: Partial<NewProfileLink>): Promise<ProfileLink> {
const db = this.databaseService.getDatabase();
// Validate that either playerId or coachId is provided
if (!data.playerId && !data.coachId) {
throw new BadRequestException(
'Either playerId or coachId must be provided',
);
}
// Validate required fields
if (!data.title) {
throw new BadRequestException('Title is required');
}
if (!data.url) {
throw new BadRequestException('URL is required');
}
const [row] = await db
.insert(profileLinks)
.values({
playerId: data.playerId ?? null,
coachId: data.coachId ?? null,
title: data.title,
url: data.url,
order: data.order ?? 0,
isActive: data.isActive ?? true,
} as NewProfileLink)
.returning();
return row as ProfileLink;
}
async updateLink(
id: number,
data: Partial<NewProfileLink>,
): Promise<ProfileLink> {
const db = this.databaseService.getDatabase();
const existing = await db
.select()
.from(profileLinks)
.where(eq(profileLinks.id, id))
.limit(1);
if (!existing || existing.length === 0) {
throw new NotFoundException(`Link with ID ${id} not found`);
}
const updateData: any = { updatedAt: new Date() };
if (data.title !== undefined) updateData.title = data.title;
if (data.url !== undefined) updateData.url = data.url;
if (data.order !== undefined) updateData.order = data.order;
if (data.isActive !== undefined) updateData.isActive = data.isActive;
const [row] = await db
.update(profileLinks)
.set(updateData)
.where(eq(profileLinks.id, id))
.returning();
return row as ProfileLink;
}
async getLinkById(id: number): Promise<ProfileLink | null> {
const db = this.databaseService.getDatabase();
const rows = await db
.select()
.from(profileLinks)
.where(and(eq(profileLinks.id, id), isNull(profileLinks.deletedAt)))
.limit(1);
return rows.length > 0 ? (rows[0] as ProfileLink) : null;
}
async getLinksByPlayerId(playerId: number): Promise<ProfileLink[]> {
const db = this.databaseService.getDatabase();
return db
.select()
.from(profileLinks)
.where(
and(
eq(profileLinks.playerId, playerId),
isNull(profileLinks.deletedAt),
),
)
.orderBy(profileLinks.order);
}
async getLinksByCoachId(coachId: number): Promise<ProfileLink[]> {
const db = this.databaseService.getDatabase();
return db
.select()
.from(profileLinks)
.where(
and(eq(profileLinks.coachId, coachId), isNull(profileLinks.deletedAt)),
)
.orderBy(profileLinks.order);
}
async deleteLink(id: number): Promise<boolean> {
const db = this.databaseService.getDatabase();
const result = await db
.update(profileLinks)
.set({ deletedAt: new Date() })
.where(eq(profileLinks.id, id))
.returning();
return result.length > 0;
}
}
......@@ -39,7 +39,8 @@ export class UsersController {
@ApiBearerAuth()
@ApiOperation({
summary: 'Get all users',
description: 'Retrieves a list of all users in the system with optional filtering by role, name, email, and active status.',
description:
'Retrieves a list of all users in the system with optional filtering by role, name, email, and active status.',
})
@ApiQuery({
name: 'role',
......@@ -68,7 +69,15 @@ export class UsersController {
@ApiQuery({
name: 'sortBy',
required: false,
enum: ['name', 'email', 'role', 'isActive', 'createdAt', 'updatedAt', 'lastLoginAt'],
enum: [
'name',
'email',
'role',
'isActive',
'createdAt',
'updatedAt',
'lastLoginAt',
],
description: 'Field to sort by (default: name)',
})
@ApiQuery({
......@@ -120,18 +129,46 @@ export class UsersController {
@ApiBearerAuth()
@ApiOperation({
summary: 'Update user',
description: 'Updates user information.',
description:
'Updates user information. Only provided fields will be updated.',
})
@ApiParam({
name: 'id',
description: 'User ID',
example: 1,
})
@ApiBody({ type: UpdateUserDto })
@ApiBody({
type: UpdateUserDto,
examples: {
example1: {
summary: 'Update user role',
value: {
role: 'admin',
},
},
example2: {
summary: 'Update user email',
value: {
email: 'newemail@example.com',
},
},
},
})
@ApiResponse({
status: 200,
description: 'User updated successfully',
type: UserResponseDto,
schema: {
example: {
id: 1,
name: 'John Doe',
email: 'newemail@example.com',
role: 'admin',
isActive: true,
createdAt: '2025-01-15T10:30:00Z',
updatedAt: '2025-01-15T11:00:00Z',
},
},
})
@ApiResponse({
status: 404,
......
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