Commit 78e29712 by Augusto

params, logs and positions update

parent 960e49bc
......@@ -3,6 +3,7 @@
/node_modules
/build
# Logs
logs
*.log
......@@ -55,3 +56,7 @@ pids
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
.env
# Documentation and SQL migrations
/docs
/sql-migrations
-- Migration: Add calendar_events table for user calendar management
-- Run this on your production database
-- Step 1: Create calendar_event_type enum type if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'calendar_event_type') THEN
CREATE TYPE "public"."calendar_event_type" AS ENUM('match', 'travel', 'player_observation', 'meeting', 'training', 'other');
RAISE NOTICE 'Created calendar_event_type enum';
ELSE
RAISE NOTICE 'calendar_event_type enum already exists';
END IF;
END $$;
-- Step 2: Create the calendar_events table
CREATE TABLE IF NOT EXISTS "calendar_events" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"title" text NOT NULL,
"description" text,
"event_type" "calendar_event_type" NOT NULL,
"start_date" timestamp with time zone NOT NULL,
"end_date" timestamp with time zone,
"match_wy_id" integer,
"player_wy_id" integer,
"metadata" jsonb,
"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
);
-- Step 3: Add foreign key constraint if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_name = 'calendar_events'
AND constraint_name = 'calendar_events_user_id_users_id_fk'
AND constraint_type = 'FOREIGN KEY'
) THEN
ALTER TABLE "calendar_events"
ADD CONSTRAINT "calendar_events_user_id_users_id_fk"
FOREIGN KEY ("user_id")
REFERENCES "users"("id")
ON DELETE CASCADE;
RAISE NOTICE 'Added foreign key constraint calendar_events_user_id_users_id_fk';
ELSE
RAISE NOTICE 'Foreign key constraint calendar_events_user_id_users_id_fk already exists';
END IF;
END $$;
-- Step 4: Create indexes for better query performance
CREATE INDEX IF NOT EXISTS "idx_calendar_events_user_id" ON "calendar_events"("user_id");
CREATE INDEX IF NOT EXISTS "idx_calendar_events_event_type" ON "calendar_events"("event_type");
CREATE INDEX IF NOT EXISTS "idx_calendar_events_start_date" ON "calendar_events"("start_date");
CREATE INDEX IF NOT EXISTS "idx_calendar_events_match_wy_id" ON "calendar_events"("match_wy_id");
CREATE INDEX IF NOT EXISTS "idx_calendar_events_player_wy_id" ON "calendar_events"("player_wy_id");
CREATE INDEX IF NOT EXISTS "idx_calendar_events_is_active" ON "calendar_events"("is_active");
CREATE INDEX IF NOT EXISTS "idx_calendar_events_deleted_at" ON "calendar_events"("deleted_at");
CREATE INDEX IF NOT EXISTS "idx_calendar_events_user_start_date" ON "calendar_events"("user_id", "start_date");
-- Step 5: Verify the table was created
SELECT
column_name,
data_type,
udt_name,
is_nullable,
column_default
FROM information_schema.columns
WHERE table_name = 'calendar_events'
ORDER BY ordinal_position;
-- Step 6: Verify indexes were created
SELECT
indexname,
indexdef
FROM pg_indexes
WHERE tablename = 'calendar_events'
ORDER BY indexname;
-- Add current_team_name and current_team_official_name columns to players table
-- This script can be run directly on your development database
-- Add current_team_name column if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'players' AND column_name = 'current_team_name'
) THEN
ALTER TABLE "players" ADD COLUMN "current_team_name" varchar(255);
RAISE NOTICE 'Added current_team_name column to players table';
ELSE
RAISE NOTICE 'current_team_name column already exists in players table';
END IF;
END $$;
-- Add current_team_official_name column if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'players' AND column_name = 'current_team_official_name'
) THEN
ALTER TABLE "players" ADD COLUMN "current_team_official_name" varchar(255);
RAISE NOTICE 'Added current_team_official_name column to players table';
ELSE
RAISE NOTICE 'current_team_official_name column already exists in players table';
END IF;
END $$;
-- Migration: Add files table for file/image management
-- Production-ready SQL script with idempotent operations
-- Run this on your production database
-- Create the files table if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = 'files'
) THEN
CREATE TABLE "files" (
"id" serial PRIMARY KEY NOT NULL,
"file_name" text NOT NULL,
"original_file_name" text NOT NULL,
"file_path" text NOT NULL,
"mime_type" text NOT NULL,
"file_size" integer NOT NULL,
"entity_type" text NOT NULL,
"entity_id" integer,
"entity_wy_id" integer,
"category" text,
"description" text,
"uploaded_by" integer,
"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
);
END IF;
END $$;--> statement-breakpoint
-- Add foreign key constraint if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_name = 'files'
AND constraint_name = 'files_uploaded_by_users_id_fk'
AND constraint_type = 'FOREIGN KEY'
) THEN
ALTER TABLE "files"
ADD CONSTRAINT "files_uploaded_by_users_id_fk"
FOREIGN KEY ("uploaded_by")
REFERENCES "users"("id")
ON DELETE SET NULL;
END IF;
END $$;--> statement-breakpoint
-- Create indexes for better query performance (idempotent)
CREATE INDEX IF NOT EXISTS "idx_files_entity_type" ON "files"("entity_type");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_files_entity_id" ON "files"("entity_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_files_entity_wy_id" ON "files"("entity_wy_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_files_entity_type_id" ON "files"("entity_type","entity_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_files_uploaded_by" ON "files"("uploaded_by");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_files_category" ON "files"("category");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_files_is_active" ON "files"("is_active");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_files_deleted_at" ON "files"("deleted_at");--> statement-breakpoint
-- Verify the table was created
SELECT
column_name,
data_type,
is_nullable,
column_default
FROM information_schema.columns
WHERE table_name = 'files'
ORDER BY ordinal_position;
-- Migration: Add files table for file/image management
-- Run this on your production database
-- Create the files table
CREATE TABLE IF NOT EXISTS "files" (
"id" serial PRIMARY KEY NOT NULL,
"file_name" text NOT NULL,
"original_file_name" text NOT NULL,
"file_path" text NOT NULL,
"mime_type" text NOT NULL,
"file_size" integer NOT NULL,
"entity_type" text NOT NULL,
"entity_id" integer,
"entity_wy_id" integer,
"category" text,
"description" text,
"uploaded_by" integer,
"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 "files_uploaded_by_users_id_fk" FOREIGN KEY ("uploaded_by") REFERENCES "users"("id") ON DELETE SET NULL
);--> statement-breakpoint
-- Create indexes for better query performance
CREATE INDEX IF NOT EXISTS "idx_files_entity_type" ON "files"("entity_type");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_files_entity_id" ON "files"("entity_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_files_entity_wy_id" ON "files"("entity_wy_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_files_entity_type_id" ON "files"("entity_type","entity_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_files_uploaded_by" ON "files"("uploaded_by");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_files_category" ON "files"("category");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_files_is_active" ON "files"("is_active");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_files_deleted_at" ON "files"("deleted_at");--> statement-breakpoint
-- Verify the table was created
SELECT
column_name,
data_type,
is_nullable,
column_default
FROM information_schema.columns
WHERE table_name = 'files'
ORDER BY ordinal_position;
-- Migration: Add report_status enum and change rating to decimal
-- Run this on your development database
-- Step 1: Create report_status enum type if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'report_status') THEN
CREATE TYPE "public"."report_status" AS ENUM('saved', 'finished');
END IF;
END $$;
-- Step 2: Change rating column from integer to decimal(5,2)
-- This preserves existing data by converting integer values to decimal
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'reports'
AND column_name = 'rating'
AND data_type = 'integer'
) THEN
ALTER TABLE "reports"
ALTER COLUMN "rating" TYPE numeric(5,2) USING rating::numeric(5,2);
END IF;
END $$;
-- Step 3: Add status column to reports table if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'reports' AND column_name = 'status'
) THEN
ALTER TABLE "reports"
ADD COLUMN "status" "report_status" DEFAULT 'saved';
END IF;
END $$;
-- Verify the changes
SELECT
column_name,
data_type,
udt_name,
column_default
FROM information_schema.columns
WHERE table_name = 'reports'
AND column_name IN ('rating', 'status')
ORDER BY column_name;
-- Add user_id column to reports table
-- This script can be run directly on your development database
-- Add user_id column if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'reports' AND column_name = 'user_id'
) THEN
ALTER TABLE "reports" ADD COLUMN "user_id" integer;
RAISE NOTICE 'Added user_id column to reports table';
ELSE
RAISE NOTICE 'user_id column already exists in reports table';
END IF;
END $$;
-- Add foreign key constraint if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_name = 'reports'
AND constraint_name = 'reports_user_id_users_id_fk'
AND constraint_type = 'FOREIGN KEY'
) THEN
ALTER TABLE "reports"
ADD CONSTRAINT "reports_user_id_users_id_fk"
FOREIGN KEY ("user_id")
REFERENCES "users"("id")
ON DELETE CASCADE;
RAISE NOTICE 'Added foreign key constraint reports_user_id_users_id_fk';
ELSE
RAISE NOTICE 'Foreign key constraint reports_user_id_users_id_fk already exists';
END IF;
END $$;
-- Add index on user_id for better query performance
CREATE INDEX IF NOT EXISTS "idx_reports_user_id" ON "reports"("user_id");
CREATE TYPE "public"."list_type" AS ENUM('shortlist', 'shadow_team', 'target_list');--> statement-breakpoint
CREATE TABLE "areas" (
"id" serial PRIMARY KEY NOT NULL,
"wy_id" integer NOT NULL,
"name" varchar(255) NOT NULL,
"alpha2code" varchar(2),
"alpha3code" varchar(3),
"created_at" timestamp with time zone DEFAULT now(),
"updated_at" timestamp with time zone DEFAULT now(),
"deleted_at" timestamp with time zone,
CONSTRAINT "areas_wy_id_unique" UNIQUE("wy_id")
);
--> statement-breakpoint
CREATE TABLE "list_shares" (
"id" serial PRIMARY KEY NOT NULL,
"list_id" integer NOT NULL,
"user_id" integer NOT NULL,
"created_at" timestamp with time zone DEFAULT now(),
"updated_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "lists" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"name" text NOT NULL,
"season" varchar(50) NOT NULL,
"type" "list_type" NOT NULL,
"players_by_position" json,
"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 "players" ADD COLUMN "email" varchar(255);--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "phone" varchar(50);--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "on_loan" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "agent" varchar(255);--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "ranking" varchar(255);--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "roi" varchar(255);--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "market_value" numeric(15, 2);--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "value_range" varchar(100);--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "transfer_value" numeric(15, 2);--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "salary" numeric(15, 2);--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "feasible" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "morphology" varchar(100);--> statement-breakpoint
ALTER TABLE "list_shares" ADD CONSTRAINT "list_shares_list_id_lists_id_fk" FOREIGN KEY ("list_id") REFERENCES "public"."lists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "list_shares" ADD CONSTRAINT "list_shares_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "lists" ADD CONSTRAINT "lists_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_areas_wy_id" ON "areas" USING btree ("wy_id");--> statement-breakpoint
CREATE INDEX "idx_areas_alpha2code" ON "areas" USING btree ("alpha2code");--> statement-breakpoint
CREATE INDEX "idx_areas_alpha3code" ON "areas" USING btree ("alpha3code");--> statement-breakpoint
CREATE INDEX "idx_areas_deleted_at" ON "areas" USING btree ("deleted_at");--> statement-breakpoint
CREATE INDEX "idx_list_shares_list_id" ON "list_shares" USING btree ("list_id");--> statement-breakpoint
CREATE INDEX "idx_list_shares_user_id" ON "list_shares" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "idx_list_shares_list_user" ON "list_shares" USING btree ("list_id","user_id");--> statement-breakpoint
CREATE INDEX "idx_lists_user_id" ON "lists" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_lists_type" ON "lists" USING btree ("type");--> statement-breakpoint
CREATE INDEX "idx_lists_season" ON "lists" USING btree ("season");--> statement-breakpoint
CREATE INDEX "idx_lists_is_active" ON "lists" USING btree ("is_active");--> statement-breakpoint
CREATE INDEX "idx_lists_deleted_at" ON "lists" USING btree ("deleted_at");--> statement-breakpoint
CREATE INDEX "idx_lists_user_type" ON "lists" USING btree ("user_id","type");
\ No newline at end of file
......@@ -64,6 +64,13 @@
"when": 1762768010792,
"tag": "0008_third_black_panther",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1763057316693,
"tag": "0009_tough_greymalkin",
"breakpoints": true
}
]
}
\ No newline at end of file
......@@ -15,6 +15,9 @@ import { ReportsModule } from './modules/reports/reports.module';
import { FilesModule } from './modules/files/files.module';
import { CalendarModule } from './modules/calendar/calendar.module';
import { AreasModule } from './modules/areas/areas.module';
import { ListsModule } from './modules/lists/lists.module';
import { PositionsModule } from './modules/positions/positions.module';
import { AuditLogsModule } from './modules/audit-logs/audit-logs.module';
@Module({
imports: [
......@@ -36,6 +39,9 @@ import { AreasModule } from './modules/areas/areas.module';
FilesModule,
CalendarModule,
AreasModule,
ListsModule,
PositionsModule,
AuditLogsModule,
],
controllers: [AppController],
providers: [AppService],
......
import { IsOptional, IsEnum, IsInt, Min, Max } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
/**
* Base query DTO that provides common query functionality
* Extend this class in your query DTOs to include common parameters
*
* Each module can override sortBy with its own enum values
*/
export class BaseQueryDto {
@ApiPropertyOptional({
description: 'Number of results to return (default: 50, max: 1000)',
type: Number,
example: 50,
default: 50,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(1000)
limit?: number;
@ApiPropertyOptional({
description: 'Number of results to skip (default: 0)',
type: Number,
example: 0,
default: 0,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
offset?: number;
@ApiPropertyOptional({
description: 'Sort order - ascending or descending',
enum: ['asc', 'desc'],
example: 'asc',
default: 'asc',
})
@IsOptional()
@IsEnum(['asc', 'desc'])
sortOrder?: 'asc' | 'desc';
}
export * from './base-query.dto';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { eq, and, or } from 'drizzle-orm';
import { globalSettings, players, positions } from './schema';
interface PositionValue {
positions?: Array<{
name: string;
code2?: string;
code3?: string;
order?: number;
location?: { x: number; y: number };
}>;
bgColor?: string;
textColor?: string;
code2?: string;
code3?: string;
}
/**
* Maps old category keys from globalSettings to new position_category enum values
* Examples:
* - "goalkeeper-positions" -> "Goalkeeper"
* - "defender-positions" -> "Defender"
* - "forward-positions" -> "Forward"
* - "midfield-positions" -> "Midfield"
*/
function mapCategoryToEnum(
oldCategory: string | null | undefined,
): 'Forward' | 'Goalkeeper' | 'Defender' | 'Midfield' {
if (!oldCategory) {
// Default to Forward if no category found
return 'Forward';
}
const normalized = oldCategory.toLowerCase();
if (normalized.includes('goalkeeper') || normalized.includes('gk')) {
return 'Goalkeeper';
}
if (normalized.includes('defender') || normalized.includes('def')) {
return 'Defender';
}
if (normalized.includes('midfield') || normalized.includes('mid')) {
return 'Midfield';
}
if (normalized.includes('forward') || normalized.includes('fw') || normalized.includes('attacker')) {
return 'Forward';
}
// Default fallback
return 'Forward';
}
async function migratePositions() {
const databaseUrl =
process.env.DATABASE_URL ||
'postgresql://username:password@localhost:5432/scoutingsystem';
const sql = postgres(databaseUrl);
const db = drizzle(sql);
try {
console.log('🚀 Starting positions migration...');
// Step 1: Get all position settings from globalSettings
console.log('📥 Fetching position settings from globalSettings...');
const positionSettings = await db
.select()
.from(globalSettings)
.where(
and(
eq(globalSettings.category, 'positions'),
eq(globalSettings.isActive, true),
),
);
if (positionSettings.length === 0) {
console.log('⚠️ No position settings found in globalSettings');
return;
}
console.log(`✅ Found ${positionSettings.length} position setting(s)`);
// Step 2: Extract positions from JSON and insert into positions table
console.log('📝 Extracting and inserting positions...');
let totalPositionsCreated = 0;
for (const setting of positionSettings) {
const value = setting.value as PositionValue;
if (!value.positions || !Array.isArray(value.positions)) {
console.log(
`⚠️ Skipping setting "${setting.key}" - no positions array found`,
);
continue;
}
for (const pos of value.positions) {
try {
// Use position-specific code2/code3, fallback to setting-level codes
const code2 = pos.code2 || value.code2 || null;
const code3 = pos.code3 || value.code3 || null;
// Check if position already exists (by code2 or code3)
let existingPosition: any = null;
if (code2) {
const existing = await db
.select()
.from(positions)
.where(eq(positions.code2, code2))
.limit(1);
if (existing.length > 0) {
existingPosition = existing[0];
}
}
if (!existingPosition && code3) {
const existing = await db
.select()
.from(positions)
.where(eq(positions.code3, code3))
.limit(1);
if (existing.length > 0) {
existingPosition = existing[0];
}
}
if (existingPosition) {
console.log(
`⏭️ Position "${pos.name}" (${code2 || code3}) already exists, skipping...`,
);
continue;
}
// Map old category to new enum value
const mappedCategory = mapCategoryToEnum(setting.key);
// Insert new position
const [newPosition] = await db
.insert(positions)
.values({
name: pos.name,
code2: code2,
code3: code3,
order: pos.order || 0,
locationX: pos.location?.x || null,
locationY: pos.location?.y || null,
bgColor: value.bgColor || null,
textColor: value.textColor || null,
category: mappedCategory,
isActive: true,
})
.returning();
console.log(
`✅ Created position: ${newPosition.name} (${newPosition.code2 || newPosition.code3 || 'no code'})`,
);
totalPositionsCreated++;
} catch (error: any) {
console.error(
`❌ Error creating position "${pos.name}":`,
error.message,
);
// Continue with next position
}
}
}
console.log(`✅ Created ${totalPositionsCreated} position(s)`);
// Step 3: Update players table with position IDs
console.log('🔄 Updating players with position IDs...');
// Use raw SQL to access old columns that may still exist in the database
const allPlayers = await sql<
{
id: number;
position: string | null;
other_positions: string[] | null;
}[]
>`SELECT id, position, other_positions FROM players WHERE is_active = true`;
console.log(`📊 Found ${allPlayers.length} active player(s) to update`);
let playersUpdated = 0;
let playersSkipped = 0;
for (const player of allPlayers) {
try {
let positionId: number | null = null;
const otherPositionIds: number[] = [];
// Update main position
const playerPosition = player.position;
if (playerPosition) {
const foundPosition = await db
.select()
.from(positions)
.where(
and(
or(
eq(positions.name, playerPosition),
eq(positions.code2, playerPosition),
eq(positions.code3, playerPosition),
),
eq(positions.isActive, true),
),
)
.limit(1);
if (foundPosition.length > 0) {
positionId = foundPosition[0].id;
} else {
console.log(
`⚠️ Position "${playerPosition}" not found for player ID ${player.id}`,
);
}
}
// Update other positions
const playerOtherPositions = player.other_positions;
if (playerOtherPositions && playerOtherPositions.length > 0) {
for (const posName of playerOtherPositions) {
const foundPosition = await db
.select()
.from(positions)
.where(
and(
or(
eq(positions.name, posName),
eq(positions.code2, posName),
eq(positions.code3, posName),
),
eq(positions.isActive, true),
),
)
.limit(1);
if (foundPosition.length > 0) {
otherPositionIds.push(foundPosition[0].id);
}
}
}
// Update player if we found at least one position
if (positionId !== null || otherPositionIds.length > 0) {
await db
.update(players)
.set({
positionId: positionId,
otherPositionIds:
otherPositionIds.length > 0 ? otherPositionIds : null,
})
.where(eq(players.id, player.id));
playersUpdated++;
} else {
playersSkipped++;
}
} catch (error: any) {
console.error(
`❌ Error updating player ID ${player.id}:`,
error.message,
);
}
}
console.log(`✅ Updated ${playersUpdated} player(s) with position IDs`);
if (playersSkipped > 0) {
console.log(`⏭️ Skipped ${playersSkipped} player(s) (no matching positions)`);
}
console.log('🎉 Positions migration completed successfully!');
} catch (error) {
console.error('❌ Migration failed:', error);
throw error;
} finally {
await sql.end();
}
}
// Run migration
migratePositions()
.then(() => {
console.log('✅ Migration script finished');
process.exit(0);
})
.catch((error) => {
console.error('❌ Migration script failed:', error);
process.exit(1);
});
......@@ -17,7 +17,7 @@ import {
ApiTags,
} from '@nestjs/swagger';
import { SimplePaginatedResponse } from '../../common/utils/response.util';
import { CreateAreaDto } from './dto';
import { CreateAreaDto, QueryAreasDto } from './dto';
@ApiTags('Areas')
@Controller('areas')
......@@ -46,9 +46,9 @@ export class AreasController {
@Get()
@ApiOperation({
summary: 'List areas with pagination',
summary: 'List areas with pagination and filtering',
description:
'Search areas by name, alpha2code, or alpha3code. Uses normalized string comparison (accent-insensitive).',
'Search areas by name, alpha2code, or alpha3code. Uses normalized string comparison (accent-insensitive). Supports sorting.',
})
@ApiQuery({
name: 'search',
......@@ -69,15 +69,23 @@ export class AreasController {
type: Number,
description: 'Number of results to skip (default: 0)',
})
@ApiQuery({
name: 'sortBy',
required: false,
enum: ['name', 'alpha2code', 'alpha3code', 'createdAt', 'updatedAt'],
description: 'Field to sort by (default: name)',
})
@ApiQuery({
name: 'sortOrder',
required: false,
enum: ['asc', 'desc'],
description: 'Sort order - ascending or descending (default: asc)',
})
@ApiOkResponse({ description: 'Paginated list of areas' })
async findAll(
@Query('limit') limit?: string,
@Query('offset') offset?: string,
@Query('search') search?: string,
): Promise<SimplePaginatedResponse<any>> {
const l = limit ? parseInt(limit, 10) : 50;
const o = offset ? parseInt(offset, 10) : 0;
return this.areasService.findAll(l, o, search);
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);
}
}
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../../database/database.service';
import { areas, type NewArea, type Area } from '../../database/schema';
import { eq, count, and, or, ilike, isNull } from 'drizzle-orm';
import { eq, count, and, or, ilike, isNull, desc, asc } from 'drizzle-orm';
import { ResponseUtil, SimplePaginatedResponse } from '../../common/utils/response.util';
import { AreaWyIdRequiredError } from '../../common/errors';
import { QueryAreasDto } from './dto/query-areas.dto';
@Injectable()
export class AreasService {
......@@ -66,6 +67,7 @@ export class AreasService {
limit: number = 50,
offset: number = 0,
search?: string,
query?: QueryAreasDto,
): Promise<SimplePaginatedResponse<any>> {
const db = this.databaseService.getDatabase();
......@@ -92,15 +94,45 @@ export class AreasService {
let total = totalResult.count;
// Get data
const query = db.select().from(areas).where(whereCondition);
// Determine sort field and order
const sortBy = query?.sortBy || 'name';
const sortOrder = query?.sortOrder || 'asc';
let orderByColumn: any = areas.name; // Default sort
// Map sortBy to actual database column
switch (sortBy) {
case 'name':
orderByColumn = areas.name;
break;
case 'alpha2code':
orderByColumn = areas.alpha2code;
break;
case 'alpha3code':
orderByColumn = areas.alpha3code;
break;
case 'createdAt':
orderByColumn = areas.createdAt;
break;
case 'updatedAt':
orderByColumn = areas.updatedAt;
break;
default:
orderByColumn = areas.name;
}
// Get data with sorting
const baseQuery = db.select().from(areas).where(whereCondition);
const queryWithOrder = sortOrder === 'desc'
? baseQuery.orderBy(desc(orderByColumn))
: baseQuery.orderBy(asc(orderByColumn));
// If searching, fetch all matches for normalization filtering
// Otherwise use normal pagination
let rawData;
if (!search || !search.trim()) {
rawData = await query.limit(limit).offset(offset);
rawData = await queryWithOrder.limit(limit).offset(offset);
} else {
rawData = await query;
rawData = await queryWithOrder;
}
// Transform to match API structure
......@@ -121,7 +153,52 @@ export class AreasService {
);
});
// Apply limit and offset after filtering
// Apply sorting after filtering (if search was used)
if (query?.sortBy) {
filteredData.sort((a, b) => {
let aValue: any;
let bValue: any;
switch (sortBy) {
case 'name':
aValue = a.name || '';
bValue = b.name || '';
break;
case 'alpha2code':
aValue = a.alpha2code || '';
bValue = b.alpha2code || '';
break;
case 'alpha3code':
aValue = a.alpha3code || '';
bValue = b.alpha3code || '';
break;
case 'createdAt':
aValue = a.createdAt ? new Date(a.createdAt).getTime() : 0;
bValue = b.createdAt ? new Date(b.createdAt).getTime() : 0;
break;
case 'updatedAt':
aValue = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
bValue = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
break;
default:
aValue = a.name || '';
bValue = b.name || '';
}
if (typeof aValue === 'string' && typeof bValue === 'string') {
const comparison = aValue.localeCompare(bValue);
return sortOrder === 'asc' ? comparison : -comparison;
}
if (typeof aValue === 'number' && typeof bValue === 'number') {
return sortOrder === 'asc' ? aValue - bValue : bValue - aValue;
}
return 0;
});
}
// Apply limit and offset after filtering and sorting
transformedData = filteredData.slice(offset, offset + limit);
// Update total to the actual filtered count
......
export * from './create-area.dto';
export * from './query-areas.dto';
import {
IsOptional,
IsString,
IsEnum,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { BaseQueryDto } from '../../../common/dto';
export class QueryAreasDto extends BaseQueryDto {
@ApiPropertyOptional({
description: 'Search areas by name, alpha2code, or alpha3code',
example: 'Brazil',
})
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({
description: 'Field to sort by',
enum: ['name', 'alpha2code', 'alpha3code', 'createdAt', 'updatedAt'],
example: 'name',
default: 'name',
})
@IsOptional()
@IsEnum(['name', 'alpha2code', 'alpha3code', 'createdAt', 'updatedAt'])
sortBy?: string;
}
import {
Controller,
Get,
Query,
Param,
ParseIntPipe,
UseGuards,
} from '@nestjs/common';
import { AuditLogsService } from './audit-logs.service';
import { type AuditLog } from '../../database/schema';
import {
ApiOkResponse,
ApiOperation,
ApiParam,
ApiQuery,
ApiTags,
ApiBearerAuth,
} from '@nestjs/swagger';
import { QueryAuditLogsDto, AuditLogAction } from './dto';
import { JwtSimpleGuard } from '../../common/guards/jwt-simple.guard';
@ApiTags('Audit Logs')
@Controller('audit-logs')
@UseGuards(JwtSimpleGuard)
@ApiBearerAuth('bearer')
export class AuditLogsController {
constructor(private readonly auditLogsService: AuditLogsService) {}
@Get()
@ApiOperation({
summary: 'List audit logs with filtering',
description:
'List audit logs with various filtering options. Filter by user, action, entity type, entity ID, entity WyID, IP address, and date range.',
})
@ApiQuery({
name: 'userId',
required: false,
type: Number,
description: 'Filter by user ID (who made the change)',
})
@ApiQuery({
name: 'action',
required: false,
enum: AuditLogAction,
description: 'Filter by action type (create, update, delete, soft_delete, restore)',
})
@ApiQuery({
name: 'entityType',
required: false,
type: String,
description: 'Filter by entity type (table/model name, e.g., "players", "coaches", "matches")',
})
@ApiQuery({
name: 'entityId',
required: false,
type: Number,
description: 'Filter by entity ID (database ID)',
})
@ApiQuery({
name: 'entityWyId',
required: false,
type: Number,
description: 'Filter by entity WyID (for entities using wyId)',
})
@ApiQuery({
name: 'ipAddress',
required: false,
type: String,
description: 'Filter by IP address (partial match)',
})
@ApiQuery({
name: 'createdFrom',
required: false,
type: String,
description: 'Filter by created date (from) - ISO date string',
})
@ApiQuery({
name: 'createdTo',
required: false,
type: String,
description: 'Filter by created date (to) - ISO date string',
})
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: 'Number of results to return (default: 50, max: 1000)',
})
@ApiQuery({
name: 'offset',
required: false,
type: Number,
description: 'Number of results to skip (default: 0)',
})
@ApiQuery({
name: 'sortBy',
required: false,
enum: [
'id',
'userId',
'action',
'entityType',
'entityId',
'entityWyId',
'createdAt',
],
description: 'Field to sort by (default: createdAt)',
})
@ApiQuery({
name: 'sortOrder',
required: false,
enum: ['asc', 'desc'],
description: 'Sort order - ascending or descending (default: desc)',
})
@ApiOkResponse({
description: 'List of audit logs matching the filters',
type: [Object],
})
async list(@Query() query: QueryAuditLogsDto): Promise<AuditLog[]> {
return this.auditLogsService.findAll(query);
}
@Get('count')
@ApiOperation({
summary: 'Get count of audit logs matching filters',
description: 'Get the total count of audit logs that match the provided filters',
})
@ApiQuery({
name: 'userId',
required: false,
type: Number,
description: 'Filter by user ID',
})
@ApiQuery({
name: 'action',
required: false,
enum: AuditLogAction,
description: 'Filter by action type',
})
@ApiQuery({
name: 'entityType',
required: false,
type: String,
description: 'Filter by entity type',
})
@ApiQuery({
name: 'entityId',
required: false,
type: Number,
description: 'Filter by entity ID',
})
@ApiQuery({
name: 'entityWyId',
required: false,
type: Number,
description: 'Filter by entity WyID',
})
@ApiQuery({
name: 'ipAddress',
required: false,
type: String,
description: 'Filter by IP address',
})
@ApiQuery({
name: 'createdFrom',
required: false,
type: String,
description: 'Filter by created date (from)',
})
@ApiQuery({
name: 'createdTo',
required: false,
type: String,
description: 'Filter by created date (to)',
})
@ApiOkResponse({
description: 'Count of audit logs',
type: Number,
})
async count(@Query() query: QueryAuditLogsDto): Promise<{ count: number }> {
const count = await this.auditLogsService.count(query);
return { count };
}
@Get(':id')
@ApiOperation({ summary: 'Get audit log by ID' })
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the audit log',
})
@ApiOkResponse({
description: 'Audit log if found',
type: Object,
})
async getById(
@Param('id', ParseIntPipe) id: number,
): Promise<AuditLog | undefined> {
return this.auditLogsService.findById(id);
}
@Get('entity/:entityType')
@ApiOperation({
summary: 'Get audit logs for a specific entity type',
description:
'Get all audit logs for a specific entity type. Optionally filter by entityId or entityWyId.',
})
@ApiParam({
name: 'entityType',
type: String,
description: 'Entity type (e.g., "players", "coaches", "matches")',
})
@ApiQuery({
name: 'entityId',
required: false,
type: Number,
description: 'Filter by entity ID (database ID)',
})
@ApiQuery({
name: 'entityWyId',
required: false,
type: Number,
description: 'Filter by entity WyID',
})
@ApiOkResponse({
description: 'List of audit logs for the entity',
type: [Object],
})
async getByEntity(
@Param('entityType') entityType: string,
@Query('entityId') entityId?: number,
@Query('entityWyId') entityWyId?: number,
): Promise<AuditLog[]> {
return this.auditLogsService.findByEntity(
entityType,
entityId ? parseInt(entityId.toString(), 10) : undefined,
entityWyId ? parseInt(entityWyId.toString(), 10) : undefined,
);
}
@Get('user/:userId')
@ApiOperation({
summary: 'Get audit logs for a specific user',
description: 'Get recent audit logs for a specific user',
})
@ApiParam({
name: 'userId',
type: Number,
description: 'User ID',
})
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: 'Number of results to return (default: 50)',
})
@ApiOkResponse({
description: 'List of audit logs for the user',
type: [Object],
})
async getByUser(
@Param('userId', ParseIntPipe) userId: number,
@Query('limit') limit?: number,
): Promise<AuditLog[]> {
return this.auditLogsService.findByUser(
userId,
limit ? parseInt(limit.toString(), 10) : 50,
);
}
}
import { Module } from '@nestjs/common';
import { AuditLogsController } from './audit-logs.controller';
import { AuditLogsService } from './audit-logs.service';
@Module({
controllers: [AuditLogsController],
providers: [AuditLogsService],
exports: [AuditLogsService],
})
export class AuditLogsModule {}
import { Injectable, Logger } from '@nestjs/common';
import { DatabaseService } from '../../database/database.service';
import {
auditLogs,
type NewAuditLog,
type AuditLog,
} from '../../database/schema';
import {
eq,
and,
gte,
lte,
desc,
asc,
ilike,
count,
} from 'drizzle-orm';
import { QueryAuditLogsDto, AuditLogAction } from './dto/query-audit-logs.dto';
@Injectable()
export class AuditLogsService {
private readonly logger = new Logger(AuditLogsService.name);
constructor(private readonly databaseService: DatabaseService) {}
/**
* Create a new audit log entry
*/
async create(data: NewAuditLog): Promise<AuditLog> {
const db = this.databaseService.getDatabase();
try {
const [row] = await db
.insert(auditLogs)
.values(data)
.returning();
return row as AuditLog;
} catch (error: any) {
this.logger.error('Failed to create audit log:', error);
throw error;
}
}
/**
* Find audit log by ID
*/
async findById(id: number): Promise<AuditLog | undefined> {
const db = this.databaseService.getDatabase();
const rows = await db
.select()
.from(auditLogs)
.where(eq(auditLogs.id, id))
.limit(1);
return rows[0];
}
/**
* Find all audit logs with filtering
*/
async findAll(query: QueryAuditLogsDto): Promise<AuditLog[]> {
const db = this.databaseService.getDatabase();
const conditions: any[] = [];
// Filter by user ID
if (query.userId !== undefined) {
conditions.push(eq(auditLogs.userId, query.userId));
}
// Filter by action
if (query.action) {
conditions.push(eq(auditLogs.action, query.action));
}
// Filter by entity type
if (query.entityType) {
conditions.push(eq(auditLogs.entityType, query.entityType));
}
// Filter by entity ID
if (query.entityId !== undefined) {
conditions.push(eq(auditLogs.entityId, query.entityId));
}
// Filter by entity WyID
if (query.entityWyId !== undefined) {
conditions.push(eq(auditLogs.entityWyId, query.entityWyId));
}
// Filter by IP address (partial match)
if (query.ipAddress) {
conditions.push(ilike(auditLogs.ipAddress, `%${query.ipAddress}%`));
}
// Filter by date range
if (query.createdFrom) {
conditions.push(gte(auditLogs.createdAt, new Date(query.createdFrom)));
}
if (query.createdTo) {
conditions.push(lte(auditLogs.createdAt, new Date(query.createdTo)));
}
// Build the query
const limit = query.limit ?? 50;
const offset = query.offset ?? 0;
const baseQuery = db.select().from(auditLogs);
const queryWithWhere =
conditions.length > 0 ? baseQuery.where(and(...conditions)) : baseQuery;
// Determine sort field and order
const sortBy = query.sortBy || 'createdAt';
const sortOrder = query.sortOrder || 'desc';
let orderByColumn: any = auditLogs.createdAt; // Default sort
// Map sortBy to actual database column
switch (sortBy) {
case 'id':
orderByColumn = auditLogs.id;
break;
case 'userId':
orderByColumn = auditLogs.userId;
break;
case 'action':
orderByColumn = auditLogs.action;
break;
case 'entityType':
orderByColumn = auditLogs.entityType;
break;
case 'entityId':
orderByColumn = auditLogs.entityId;
break;
case 'entityWyId':
orderByColumn = auditLogs.entityWyId;
break;
case 'createdAt':
default:
orderByColumn = auditLogs.createdAt;
break;
}
// Apply sorting and pagination
return queryWithWhere
.orderBy(sortOrder === 'desc' ? desc(orderByColumn) : asc(orderByColumn))
.limit(limit)
.offset(offset);
}
/**
* Get audit logs for a specific entity
*/
async findByEntity(
entityType: string,
entityId?: number,
entityWyId?: number,
): Promise<AuditLog[]> {
const db = this.databaseService.getDatabase();
const conditions: any[] = [eq(auditLogs.entityType, entityType)];
if (entityId !== undefined) {
conditions.push(eq(auditLogs.entityId, entityId));
}
if (entityWyId !== undefined) {
conditions.push(eq(auditLogs.entityWyId, entityWyId));
}
return db
.select()
.from(auditLogs)
.where(and(...conditions))
.orderBy(desc(auditLogs.createdAt));
}
/**
* Get audit logs for a specific user
*/
async findByUser(userId: number, limit = 50): Promise<AuditLog[]> {
const db = this.databaseService.getDatabase();
return db
.select()
.from(auditLogs)
.where(eq(auditLogs.userId, userId))
.orderBy(desc(auditLogs.createdAt))
.limit(limit);
}
/**
* Get count of audit logs matching filters
*/
async count(query: QueryAuditLogsDto): Promise<number> {
const db = this.databaseService.getDatabase();
const conditions: any[] = [];
// Apply same filters as findAll
if (query.userId !== undefined) {
conditions.push(eq(auditLogs.userId, query.userId));
}
if (query.action) {
conditions.push(eq(auditLogs.action, query.action));
}
if (query.entityType) {
conditions.push(eq(auditLogs.entityType, query.entityType));
}
if (query.entityId !== undefined) {
conditions.push(eq(auditLogs.entityId, query.entityId));
}
if (query.entityWyId !== undefined) {
conditions.push(eq(auditLogs.entityWyId, query.entityWyId));
}
if (query.ipAddress) {
conditions.push(ilike(auditLogs.ipAddress, `%${query.ipAddress}%`));
}
if (query.createdFrom) {
conditions.push(gte(auditLogs.createdAt, new Date(query.createdFrom)));
}
if (query.createdTo) {
conditions.push(lte(auditLogs.createdAt, new Date(query.createdTo)));
}
// For count, we need to use SQL count function
const baseQuery = db
.select({ count: count(auditLogs.id) })
.from(auditLogs);
const queryWithWhere =
conditions.length > 0 ? baseQuery.where(and(...conditions)) : baseQuery;
const countResult = await queryWithWhere;
return countResult[0]?.count ?? 0;
}
}
export * from './query-audit-logs.dto';
import {
IsOptional,
IsInt,
IsString,
IsEnum,
IsDateString,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { BaseQueryDto } from '../../../common/dto';
export enum AuditLogAction {
CREATE = 'create',
UPDATE = 'update',
DELETE = 'delete',
SOFT_DELETE = 'soft_delete',
RESTORE = 'restore',
}
export class QueryAuditLogsDto extends BaseQueryDto {
@ApiPropertyOptional({
description: 'Filter by user ID (who made the change)',
type: Number,
example: 1,
})
@IsOptional()
@Type(() => Number)
@IsInt()
userId?: number;
@ApiPropertyOptional({
description: 'Filter by action type',
enum: AuditLogAction,
example: 'update',
})
@IsOptional()
@IsEnum(AuditLogAction)
action?: AuditLogAction;
@ApiPropertyOptional({
description: 'Filter by entity type (table/model name)',
example: 'players',
})
@IsOptional()
@IsString()
entityType?: string;
@ApiPropertyOptional({
description: 'Filter by entity ID (database ID)',
type: Number,
example: 123,
})
@IsOptional()
@Type(() => Number)
@IsInt()
entityId?: number;
@ApiPropertyOptional({
description: 'Filter by entity WyID (for entities using wyId)',
type: Number,
example: 1093815,
})
@IsOptional()
@Type(() => Number)
@IsInt()
entityWyId?: number;
@ApiPropertyOptional({
description: 'Filter by IP address',
example: '192.168.1.1',
})
@IsOptional()
@IsString()
ipAddress?: string;
@ApiPropertyOptional({
description: 'Filter by created date (from) - ISO date string',
example: '2024-01-01T00:00:00Z',
})
@IsOptional()
@IsDateString()
createdFrom?: string;
@ApiPropertyOptional({
description: 'Filter by created date (to) - ISO date string',
example: '2024-12-31T23:59:59Z',
})
@IsOptional()
@IsDateString()
createdTo?: string;
@ApiPropertyOptional({
description: 'Field to sort by',
enum: [
'id',
'userId',
'action',
'entityType',
'entityId',
'entityWyId',
'createdAt',
],
example: 'createdAt',
default: 'createdAt',
})
@IsOptional()
@IsEnum([
'id',
'userId',
'action',
'entityType',
'entityId',
'entityWyId',
'createdAt',
])
sortBy?: string;
}
......@@ -11,8 +11,9 @@ import {
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { BaseQueryDto } from '../../../common/dto';
export class QueryCalendarEventsDto {
export class QueryCalendarEventsDto extends BaseQueryDto {
@ApiPropertyOptional({
description: 'Filter by user ID',
example: 1,
......@@ -24,11 +25,31 @@ export class QueryCalendarEventsDto {
@ApiPropertyOptional({
description: 'Filter by event type',
enum: ['match', 'travel', 'player_observation', 'meeting', 'training', 'other'],
enum: [
'match',
'travel',
'player_observation',
'meeting',
'training',
'other',
],
})
@IsOptional()
@IsEnum(['match', 'travel', 'player_observation', 'meeting', 'training', 'other'])
eventType?: 'match' | 'travel' | 'player_observation' | 'meeting' | 'training' | 'other';
@IsEnum([
'match',
'travel',
'player_observation',
'meeting',
'training',
'other',
])
eventType?:
| 'match'
| 'travel'
| 'player_observation'
| 'meeting'
| 'training'
| 'other';
@ApiPropertyOptional({
description: 'Filter by multiple event types (comma-separated or array)',
......@@ -37,10 +58,20 @@ export class QueryCalendarEventsDto {
})
@IsOptional()
@IsArray()
@IsEnum(['match', 'travel', 'player_observation', 'meeting', 'training', 'other'], {
each: true,
})
eventTypes?: ('match' | 'travel' | 'player_observation' | 'meeting' | 'training' | 'other')[];
@IsEnum(
['match', 'travel', 'player_observation', 'meeting', 'training', 'other'],
{
each: true,
},
)
eventTypes?: (
| 'match'
| 'travel'
| 'player_observation'
| 'meeting'
| 'training'
| 'other'
)[];
@ApiPropertyOptional({
description: 'Filter by match WyID',
......@@ -93,7 +124,8 @@ export class QueryCalendarEventsDto {
endDateTo?: string;
@ApiPropertyOptional({
description: 'Filter events that overlap with this date range (events that start before endDate and end after startDate)',
description:
'Filter events that overlap with this date range (events that start before endDate and end after startDate)',
example: '2025-06-01T00:00:00Z',
})
@IsOptional()
......@@ -101,7 +133,8 @@ export class QueryCalendarEventsDto {
overlapStartDate?: string;
@ApiPropertyOptional({
description: 'Filter events that overlap with this date range (used with overlapStartDate)',
description:
'Filter events that overlap with this date range (used with overlapStartDate)',
example: '2025-06-30T23:59:59Z',
})
@IsOptional()
......@@ -125,7 +158,8 @@ export class QueryCalendarEventsDto {
description?: string;
@ApiPropertyOptional({
description: 'Search in title or description (case-insensitive partial match)',
description:
'Search in title or description (case-insensitive partial match)',
example: 'travel',
})
@IsOptional()
......@@ -169,7 +203,8 @@ export class QueryCalendarEventsDto {
hasEndDate?: boolean;
@ApiPropertyOptional({
description: 'Search in metadata location field (case-insensitive partial match)',
description:
'Search in metadata location field (case-insensitive partial match)',
example: 'Stadium',
})
@IsOptional()
......@@ -177,7 +212,8 @@ export class QueryCalendarEventsDto {
location?: string;
@ApiPropertyOptional({
description: 'Search in metadata venue field (case-insensitive partial match)',
description:
'Search in metadata venue field (case-insensitive partial match)',
example: 'Main Field',
})
@IsOptional()
......@@ -217,46 +253,32 @@ export class QueryCalendarEventsDto {
updatedTo?: string;
@ApiPropertyOptional({
description: 'Sort order',
enum: ['asc', 'desc'],
example: 'desc',
default: 'desc',
})
@IsOptional()
@IsEnum(['asc', 'desc'])
sortOrder?: 'asc' | 'desc';
@ApiPropertyOptional({
description: 'Sort by field',
enum: ['startDate', 'endDate', 'createdAt', 'updatedAt', 'title'],
description: 'Field to sort by',
enum: [
'title',
'eventType',
'startDate',
'endDate',
'createdAt',
'updatedAt',
],
example: 'startDate',
default: 'startDate',
})
@IsOptional()
@IsEnum(['startDate', 'endDate', 'createdAt', 'updatedAt', 'title'])
sortBy?: 'startDate' | 'endDate' | 'createdAt' | 'updatedAt' | 'title';
@ApiPropertyOptional({
description: 'Number of results to return (default: 50, max: 1000)',
example: 50,
default: 50,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(1000)
limit?: number;
@ApiPropertyOptional({
description: 'Number of results to skip (default: 0)',
example: 0,
default: 0,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
offset?: number;
@IsEnum([
'title',
'eventType',
'startDate',
'endDate',
'createdAt',
'updatedAt',
])
sortBy?:
| 'title'
| 'eventType'
| 'startDate'
| 'endDate'
| 'createdAt'
| 'updatedAt';
}
......@@ -17,7 +17,7 @@ import {
ApiTags,
} from '@nestjs/swagger';
import { SimplePaginatedResponse } from '../../common/utils/response.util';
import { CreateCoachDto } from './dto';
import { CreateCoachDto, QueryCoachesDto } from './dto';
@ApiTags('Coaches')
@Controller('coaches')
......@@ -46,9 +46,9 @@ export class CoachesController {
@Get()
@ApiOperation({
summary: 'List coaches with pagination',
summary: 'List coaches with pagination and filtering',
description:
'Search coaches by name (searches in firstName, lastName, middleName, shortName). Uses normalized string comparison (accent-insensitive).',
'Search coaches by name (searches in firstName, lastName, middleName, shortName). Uses normalized string comparison (accent-insensitive). Supports sorting.',
})
@ApiQuery({
name: 'name',
......@@ -57,14 +57,34 @@ export class CoachesController {
description:
'Search coaches by name (searches in firstName, lastName, middleName, shortName). Optional.',
})
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: 'Number of results to return (default: 50)',
})
@ApiQuery({
name: 'offset',
required: false,
type: Number,
description: 'Number of results to skip (default: 0)',
})
@ApiQuery({
name: 'sortBy',
required: false,
enum: ['firstName', 'lastName', 'shortName', 'position', 'status', 'createdAt', 'updatedAt'],
description: 'Field to sort by (default: lastName)',
})
@ApiQuery({
name: 'sortOrder',
required: false,
enum: ['asc', 'desc'],
description: 'Sort order - ascending or descending (default: asc)',
})
@ApiOkResponse({ description: 'Paginated list of coaches' })
async findAll(
@Query('limit') limit?: string,
@Query('offset') offset?: string,
@Query('name') name?: string,
): Promise<SimplePaginatedResponse<any>> {
const l = limit ? parseInt(limit, 10) : 50;
const o = offset ? parseInt(offset, 10) : 0;
return this.coachesService.findAll(l, o, name);
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);
}
}
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../../database/database.service';
import { coaches, type NewCoach, type Coach } from '../../database/schema';
import { eq, count, and, or, ilike } from 'drizzle-orm';
import { eq, count, and, or, ilike, desc, asc } from 'drizzle-orm';
import { ResponseUtil, SimplePaginatedResponse } from '../../common/utils/response.util';
import { QueryCoachesDto } from './dto/query-coaches.dto';
import { CoachWyIdRequiredError } from '../../common/errors';
@Injectable()
......@@ -96,6 +97,7 @@ export class CoachesService {
limit: number = 50,
offset: number = 0,
search?: string,
query?: QueryCoachesDto,
): Promise<SimplePaginatedResponse<any>> {
const db = this.databaseService.getDatabase();
......@@ -123,15 +125,51 @@ export class CoachesService {
let total = totalResult.count;
// Get data
const query = db.select().from(coaches).where(whereCondition);
// Determine sort field and order
const sortBy = query?.sortBy || 'lastName';
const sortOrder = query?.sortOrder || 'asc';
let orderByColumn: any = coaches.lastName; // Default sort
// Map sortBy to actual database column
switch (sortBy) {
case 'firstName':
orderByColumn = coaches.firstName;
break;
case 'lastName':
orderByColumn = coaches.lastName;
break;
case 'shortName':
orderByColumn = coaches.shortName;
break;
case 'position':
orderByColumn = coaches.position;
break;
case 'status':
orderByColumn = coaches.status;
break;
case 'createdAt':
orderByColumn = coaches.createdAt;
break;
case 'updatedAt':
orderByColumn = coaches.updatedAt;
break;
default:
orderByColumn = coaches.lastName;
}
// Get data with sorting
const baseQuery = db.select().from(coaches).where(whereCondition);
const queryWithOrder = sortOrder === 'desc'
? baseQuery.orderBy(desc(orderByColumn))
: baseQuery.orderBy(asc(orderByColumn));
// If searching, fetch all matches for normalization filtering
// Otherwise use normal pagination
let rawData;
if (!search || !search.trim()) {
rawData = await query.limit(limit).offset(offset);
rawData = await queryWithOrder.limit(limit).offset(offset);
} else {
rawData = await query;
rawData = await queryWithOrder;
}
// Transform to match API structure
......@@ -154,7 +192,60 @@ export class CoachesService {
);
});
// Apply limit and offset after filtering
// Apply sorting after filtering (if search was used)
if (query?.sortBy) {
filteredData.sort((a, b) => {
let aValue: any;
let bValue: any;
switch (sortBy) {
case 'firstName':
aValue = a.firstName || '';
bValue = b.firstName || '';
break;
case 'lastName':
aValue = a.lastName || '';
bValue = b.lastName || '';
break;
case 'shortName':
aValue = a.shortName || '';
bValue = b.shortName || '';
break;
case 'position':
aValue = a.position || '';
bValue = b.position || '';
break;
case 'status':
aValue = a.status || '';
bValue = b.status || '';
break;
case 'createdAt':
aValue = a.createdAt ? new Date(a.createdAt).getTime() : 0;
bValue = b.createdAt ? new Date(b.createdAt).getTime() : 0;
break;
case 'updatedAt':
aValue = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
bValue = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
break;
default:
aValue = a.lastName || '';
bValue = b.lastName || '';
}
if (typeof aValue === 'string' && typeof bValue === 'string') {
const comparison = aValue.localeCompare(bValue);
return sortOrder === 'asc' ? comparison : -comparison;
}
if (typeof aValue === 'number' && typeof bValue === 'number') {
return sortOrder === 'asc' ? aValue - bValue : bValue - aValue;
}
return 0;
});
}
// Apply limit and offset after filtering and sorting
transformedData = filteredData.slice(offset, offset + limit);
// Update total to the actual filtered count
......
export * from './create-coach.dto';
export * from './update-coach.dto';
export * from './query-coaches.dto';
import {
IsOptional,
IsString,
IsEnum,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { BaseQueryDto } from '../../../common/dto';
export class QueryCoachesDto extends BaseQueryDto {
@ApiPropertyOptional({
description: 'Search coaches by name (searches in firstName, lastName, middleName, shortName)',
example: 'Guardiola',
})
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional({
description: 'Field to sort by',
enum: ['firstName', 'lastName', 'shortName', 'position', 'status', 'createdAt', 'updatedAt'],
example: 'lastName',
default: 'lastName',
})
@IsOptional()
@IsEnum(['firstName', 'lastName', 'shortName', 'position', 'status', 'createdAt', 'updatedAt'])
sortBy?: string;
}
export * from './create-file.dto';
export * from './update-file.dto';
export * from './query-files.dto';
import {
IsOptional,
IsEnum,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { BaseQueryDto } from '../../../common/dto';
export class QueryFilesDto extends BaseQueryDto {
@ApiPropertyOptional({
description: 'Field to sort by',
enum: ['fileName', 'originalFileName', 'mimeType', 'fileSize', 'category', 'createdAt', 'updatedAt'],
example: 'createdAt',
default: 'createdAt',
})
@IsOptional()
@IsEnum(['fileName', 'originalFileName', 'mimeType', 'fileSize', 'category', 'createdAt', 'updatedAt'])
sortBy?: string;
}
......@@ -24,7 +24,7 @@ import {
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import { CreateFileDto, UpdateFileDto } from './dto';
import { CreateFileDto, UpdateFileDto, QueryFilesDto } from './dto';
@ApiTags('Files')
@Controller('files')
......@@ -101,15 +101,36 @@ export class FilesController {
}
@Get()
@ApiOperation({ summary: 'List files' })
@ApiOperation({ summary: 'List files with pagination and sorting' })
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: 'Number of results to return (default: 50)',
})
@ApiQuery({
name: 'offset',
required: false,
type: Number,
description: 'Number of results to skip (default: 0)',
})
@ApiQuery({
name: 'sortBy',
required: false,
enum: ['fileName', 'originalFileName', 'mimeType', 'fileSize', 'category', 'createdAt', 'updatedAt'],
description: 'Field to sort by (default: createdAt)',
})
@ApiQuery({
name: 'sortOrder',
required: false,
enum: ['asc', 'desc'],
description: 'Sort order - ascending or descending (default: asc)',
})
@ApiOkResponse({ description: 'List of files' })
async list(
@Query('limit') limit?: string,
@Query('offset') offset?: string,
): Promise<File[]> {
const l = limit ? parseInt(limit, 10) : 50;
const o = offset ? parseInt(offset, 10) : 0;
return this.filesService.list(l, o);
async list(@Query() query: QueryFilesDto): Promise<File[]> {
const l = query.limit ?? 50;
const o = query.offset ?? 0;
return this.filesService.list(l, o, query);
}
@Patch(':id')
......
import { Injectable, Logger } from '@nestjs/common';
import { DatabaseService } from '../../database/database.service';
import { files, type NewFile, type File } from '../../database/schema';
import { eq, and, or, isNull } from 'drizzle-orm';
import { eq, and, or, isNull, desc, asc } from 'drizzle-orm';
import {
DatabaseQueryError,
DatabaseColumnNotFoundError,
DatabaseRequiredFieldMissingError,
DatabaseDuplicateEntryError,
} from '../../common/errors';
import { QueryFilesDto } from './dto/query-files.dto';
@Injectable()
export class FilesService {
......@@ -114,14 +115,51 @@ export class FilesService {
);
}
async list(limit = 50, offset = 0): Promise<File[]> {
async list(limit = 50, offset = 0, query?: QueryFilesDto): Promise<File[]> {
const db = this.databaseService.getDatabase();
return db
// Determine sort field and order
const sortBy = query?.sortBy || 'createdAt';
const sortOrder = query?.sortOrder || 'asc';
let orderByColumn: any = files.createdAt; // Default sort
// Map sortBy to actual database column
switch (sortBy) {
case 'fileName':
orderByColumn = files.fileName;
break;
case 'originalFileName':
orderByColumn = files.originalFileName;
break;
case 'mimeType':
orderByColumn = files.mimeType;
break;
case 'fileSize':
orderByColumn = files.fileSize;
break;
case 'category':
orderByColumn = files.category;
break;
case 'createdAt':
orderByColumn = files.createdAt;
break;
case 'updatedAt':
orderByColumn = files.updatedAt;
break;
default:
orderByColumn = files.createdAt;
}
const baseQuery = db
.select()
.from(files)
.where(and(eq(files.isActive, true), isNull(files.deletedAt)))
.limit(limit)
.offset(offset);
.where(and(eq(files.isActive, true), isNull(files.deletedAt)));
const queryWithOrder = sortOrder === 'desc'
? baseQuery.orderBy(desc(orderByColumn))
: baseQuery.orderBy(asc(orderByColumn));
return queryWithOrder.limit(limit).offset(offset);
}
private normalizeFileData(data: any): Partial<NewFile> {
......
import {
IsString,
IsOptional,
IsObject,
IsEnum,
IsArray,
IsNumber,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateListDto {
@ApiProperty({
description: 'Name of the list',
example: 'Summer 2024 Shortlist',
})
@IsString()
name: string;
@ApiProperty({
description: 'Season for the list',
example: '2024-2025',
})
@IsString()
season: string;
@ApiProperty({
description: 'Type of list',
enum: ['shortlist', 'shadow_team', 'target_list'],
example: 'shortlist',
})
@IsEnum(['shortlist', 'shadow_team', 'target_list'])
type: 'shortlist' | 'shadow_team' | 'target_list';
@ApiProperty({
description:
'Players organized by position - JSON object with positions as keys and arrays of player WyIds as values',
example: {
GK: [1093815, 1093816],
DF: [1093817, 1093818],
MF: [1093819],
FW: [1093820, 1093821],
},
})
@IsObject()
playersByPosition: {
[position: string]: number[];
};
}
export * from './create-list.dto';
export * from './update-list.dto';
export * from './query-lists.dto';
export * from './share-list.dto';
import {
IsOptional,
IsInt,
IsString,
IsEnum,
IsDateString,
Min,
Max,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { BaseQueryDto } from '../../../common/dto';
export enum ListType {
SHORTLIST = 'shortlist',
SHADOW_TEAM = 'shadow_team',
TARGET_LIST = 'target_list',
}
export class QueryListsDto extends BaseQueryDto {
@ApiPropertyOptional({
description: 'Filter by list type',
enum: ListType,
example: 'shortlist',
})
@IsOptional()
@IsEnum(ListType)
type?: ListType;
@ApiPropertyOptional({
description: 'Filter by season',
example: '2024-2025',
})
@IsOptional()
@IsString()
season?: string;
@ApiPropertyOptional({
description: 'Filter by user ID (who created the list)',
type: Number,
example: 1,
})
@IsOptional()
@Type(() => Number)
@IsInt()
userId?: number;
@ApiPropertyOptional({
description: 'Search by list name (case-insensitive partial match)',
example: 'Summer',
})
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional({
description: 'Filter by player WyID (lists containing this player)',
type: Number,
example: 1093815,
})
@IsOptional()
@Type(() => Number)
@IsInt()
playerWyId?: number;
@ApiPropertyOptional({
description: 'Filter by position (lists containing players in this position)',
example: 'GK',
})
@IsOptional()
@IsString()
position?: string;
@ApiPropertyOptional({
description: 'Filter by active status',
type: Boolean,
example: true,
})
@IsOptional()
@Type(() => Boolean)
isActive?: boolean;
@ApiPropertyOptional({
description: 'Filter by created date (from) - ISO date string',
example: '2024-01-01T00:00:00Z',
})
@IsOptional()
@IsDateString()
createdFrom?: string;
@ApiPropertyOptional({
description: 'Filter by created date (to) - ISO date string',
example: '2024-12-31T23:59:59Z',
})
@IsOptional()
@IsDateString()
createdTo?: string;
@ApiPropertyOptional({
description: 'Filter by updated date (from) - ISO date string',
example: '2024-01-01T00:00:00Z',
})
@IsOptional()
@IsDateString()
updatedFrom?: string;
@ApiPropertyOptional({
description: 'Filter by updated date (to) - ISO date string',
example: '2024-12-31T23:59:59Z',
})
@IsOptional()
@IsDateString()
updatedTo?: string;
@ApiPropertyOptional({
description: 'Field to sort by',
enum: ['name', 'season', 'type', 'createdAt', 'updatedAt'],
example: 'createdAt',
default: 'createdAt',
})
@IsOptional()
@IsEnum(['name', 'season', 'type', 'createdAt', 'updatedAt'])
sortBy?: string;
}
import { IsArray, IsInt, Min } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class ShareListDto {
@ApiProperty({
description: 'Array of user IDs to share the list with',
example: [2, 3, 4],
type: [Number],
})
@IsArray()
@IsInt({ each: true })
@Min(1, { each: true })
userIds: number[];
}
export class UnshareListDto {
@ApiProperty({
description: 'Array of user IDs to unshare the list with',
example: [2, 3],
type: [Number],
})
@IsArray()
@IsInt({ each: true })
@Min(1, { each: true })
userIds: number[];
}
import {
IsString,
IsOptional,
IsObject,
IsEnum,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateListDto {
@ApiPropertyOptional({
description: 'Name of the list',
example: 'Updated List Name',
})
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional({
description: 'Season for the list',
example: '2024-2025',
})
@IsOptional()
@IsString()
season?: string;
@ApiPropertyOptional({
description: 'Type of list',
enum: ['shortlist', 'shadow_team', 'target_list'],
example: 'shortlist',
})
@IsOptional()
@IsEnum(['shortlist', 'shadow_team', 'target_list'])
type?: 'shortlist' | 'shadow_team' | 'target_list';
@ApiPropertyOptional({
description:
'Players organized by position - JSON object with positions as keys and arrays of player WyIds as values',
example: {
GK: [1093815, 1093816],
DF: [1093817, 1093818],
},
})
@IsOptional()
@IsObject()
playersByPosition?: {
[position: string]: number[];
};
}
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
NotFoundException,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ListsService } from './lists.service';
import { type List, type User } from '../../database/schema';
import {
ApiBody,
ApiNoContentResponse,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiQuery,
ApiTags,
ApiBearerAuth,
} from '@nestjs/swagger';
import {
CreateListDto,
UpdateListDto,
QueryListsDto,
ListType,
ShareListDto,
UnshareListDto,
} from './dto';
import { JwtSimpleGuard } from '../../common/guards/jwt-simple.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@ApiTags('Lists')
@Controller('lists')
export class ListsController {
constructor(private readonly listsService: ListsService) {}
@Post()
@UseGuards(JwtSimpleGuard)
@ApiBearerAuth('bearer')
@ApiOperation({ summary: 'Create a list' })
@ApiBody({
description:
'List payload. Requires name, season, type, and playersByPosition JSON object.',
type: CreateListDto,
})
@ApiOkResponse({ description: 'Created list' })
async create(
@Body() body: CreateListDto,
@CurrentUser() user: User,
): Promise<List> {
return this.listsService.create(body, user.id);
}
@Get(':id')
@ApiOperation({ summary: 'Get list by ID' })
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the list',
})
@ApiOkResponse({ description: 'List if found' })
@ApiNotFoundResponse({ description: 'List not found' })
async getById(@Param('id', ParseIntPipe) id: number): Promise<List> {
const list = await this.listsService.findById(id);
if (!list) {
throw new NotFoundException(`List with ID ${id} not found`);
}
return list;
}
@Get()
@ApiOperation({
summary: 'List lists with filtering',
description:
'List lists with various filtering options. Filter by type, season, user, name, player, position, dates, and more.',
})
@ApiQuery({
name: 'type',
required: false,
enum: ListType,
description: 'Filter by list type',
})
@ApiQuery({
name: 'season',
required: false,
type: String,
description: 'Filter by season',
})
@ApiQuery({
name: 'userId',
required: false,
type: Number,
description: 'Filter by user ID (who created the list)',
})
@ApiQuery({
name: 'name',
required: false,
type: String,
description: 'Search by list name (case-insensitive partial match)',
})
@ApiQuery({
name: 'playerWyId',
required: false,
type: Number,
description: 'Filter by player WyID (lists containing this player)',
})
@ApiQuery({
name: 'position',
required: false,
type: String,
description: 'Filter by position (lists containing players in this position)',
})
@ApiQuery({
name: 'isActive',
required: false,
type: Boolean,
description: 'Filter by active status',
})
@ApiQuery({
name: 'createdFrom',
required: false,
type: String,
description: 'Filter by created date (from) - ISO date string',
})
@ApiQuery({
name: 'createdTo',
required: false,
type: String,
description: 'Filter by created date (to) - ISO date string',
})
@ApiQuery({
name: 'updatedFrom',
required: false,
type: String,
description: 'Filter by updated date (from) - ISO date string',
})
@ApiQuery({
name: 'updatedTo',
required: false,
type: String,
description: 'Filter by updated date (to) - ISO date string',
})
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: 'Number of results to return (default: 50)',
})
@ApiQuery({
name: 'offset',
required: false,
type: Number,
description: 'Number of results to skip (default: 0)',
})
@ApiQuery({
name: 'sortBy',
required: false,
enum: ['name', 'season', 'type', 'createdAt', 'updatedAt'],
description: 'Field to sort by (default: createdAt)',
})
@ApiQuery({
name: 'sortOrder',
required: false,
enum: ['asc', 'desc'],
description: 'Sort order - ascending or descending (default: asc)',
})
@ApiOkResponse({ description: 'List of lists matching the filters' })
async list(
@Query() query: QueryListsDto,
@CurrentUser() user?: User,
): Promise<List[]> {
return this.listsService.findAll(query, user?.id);
}
@Patch(':id')
@UseGuards(JwtSimpleGuard)
@ApiBearerAuth('bearer')
@ApiOperation({ summary: 'Update a list' })
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the list to update',
})
@ApiBody({
description:
'List update payload. All fields are optional. playersByPosition is a JSON object.',
type: UpdateListDto,
})
@ApiOkResponse({ description: 'Updated list' })
@ApiNotFoundResponse({ description: 'List not found' })
async update(
@Param('id', ParseIntPipe) id: number,
@Body() body: UpdateListDto,
): Promise<List> {
const result = await this.listsService.update(id, body);
if (!result) {
throw new NotFoundException(`List with ID ${id} not found`);
}
return result;
}
@Delete(':id')
@UseGuards(JwtSimpleGuard)
@ApiBearerAuth('bearer')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete a list (soft delete)' })
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the list to delete',
})
@ApiNoContentResponse({ description: 'List deleted successfully' })
@ApiNotFoundResponse({ description: 'List not found' })
async delete(@Param('id', ParseIntPipe) id: number): Promise<void> {
const result = await this.listsService.delete(id);
if (!result) {
throw new NotFoundException(`List with ID ${id} not found`);
}
}
@Post(':id/share')
@UseGuards(JwtSimpleGuard)
@ApiBearerAuth('bearer')
@ApiOperation({
summary: 'Share a list with one or more users',
description:
'Share a list with other users. Only the list owner can share the list.',
})
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the list to share',
})
@ApiBody({
description: 'Array of user IDs to share the list with',
type: ShareListDto,
})
@ApiOkResponse({
description: 'List shared successfully',
schema: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'number' },
listId: { type: 'number' },
userId: { type: 'number' },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
},
},
})
@ApiNotFoundResponse({ description: 'List not found' })
async shareList(
@Param('id', ParseIntPipe) id: number,
@Body() body: ShareListDto,
@CurrentUser() user: User,
) {
try {
return await this.listsService.shareList(id, body.userIds, user.id);
} catch (error: any) {
if (error.message.includes('not found')) {
throw new NotFoundException(error.message);
}
throw error;
}
}
@Post(':id/unshare')
@UseGuards(JwtSimpleGuard)
@ApiBearerAuth('bearer')
@ApiOperation({
summary: 'Unshare a list with one or more users',
description:
'Remove sharing access for users. Only the list owner can unshare the list.',
})
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the list to unshare',
})
@ApiBody({
description: 'Array of user IDs to unshare the list with',
type: UnshareListDto,
})
@ApiOkResponse({ description: 'List unshared successfully' })
@ApiNotFoundResponse({ description: 'List not found' })
async unshareList(
@Param('id', ParseIntPipe) id: number,
@Body() body: UnshareListDto,
@CurrentUser() user: User,
) {
try {
await this.listsService.unshareList(id, body.userIds, user.id);
return { message: 'List unshared successfully' };
} catch (error: any) {
if (error.message.includes('not found')) {
throw new NotFoundException(error.message);
}
throw error;
}
}
@Get(':id/shared-users')
@UseGuards(JwtSimpleGuard)
@ApiBearerAuth('bearer')
@ApiOperation({
summary: 'Get all users who have access to a list',
description:
'Returns an array of user IDs who have access to the list (owner + shared users)',
})
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the list',
})
@ApiOkResponse({
description: 'Array of user IDs with access to the list',
schema: {
type: 'array',
items: { type: 'number' },
example: [1, 2, 3],
},
})
@ApiNotFoundResponse({ description: 'List not found' })
async getSharedUsers(@Param('id', ParseIntPipe) id: number): Promise<{
userIds: number[];
}> {
const list = await this.listsService.findById(id);
if (!list) {
throw new NotFoundException(`List with ID ${id} not found`);
}
const userIds = await this.listsService.getListSharedUsers(id);
return { userIds };
}
}
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ListsController } from './lists.controller';
import { ListsService } from './lists.service';
import { DatabaseModule } from '../../database/database.module';
import { JwtSimpleGuard } from '../../common/guards/jwt-simple.guard';
@Module({
imports: [
DatabaseModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET') || 'your-secret-key',
signOptions: { expiresIn: '24h' },
}),
inject: [ConfigService],
}),
],
controllers: [ListsController],
providers: [ListsService, JwtSimpleGuard],
exports: [ListsService],
})
export class ListsModule {}
export * from './create-match.dto';
export * from './update-match.dto';
export * from './query-matches.dto';
import {
IsOptional,
IsString,
IsEnum,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { BaseQueryDto } from '../../../common/dto';
export class QueryMatchesDto extends BaseQueryDto {
@ApiPropertyOptional({
description: 'Search matches by venue (searches in venue, venueCity, venueCountry)',
example: 'Stadium',
})
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional({
description: 'Field to sort by',
enum: ['matchDate', 'venue', 'venueCity', 'venueCountry', 'homeScore', 'awayScore', 'status', 'createdAt', 'updatedAt'],
example: 'matchDate',
default: 'matchDate',
})
@IsOptional()
@IsEnum(['matchDate', 'venue', 'venueCity', 'venueCountry', 'homeScore', 'awayScore', 'status', 'createdAt', 'updatedAt'])
sortBy?: string;
}
......@@ -17,7 +17,7 @@ import {
ApiTags,
} from '@nestjs/swagger';
import { MatchesPaginatedResponse } from '../../common/utils/response.util';
import { CreateMatchDto } from './dto';
import { CreateMatchDto, QueryMatchesDto } from './dto';
@ApiTags('Matches')
@Controller('matches')
......@@ -46,9 +46,9 @@ export class MatchesController {
@Get()
@ApiOperation({
summary: 'List matches with pagination',
summary: 'List matches with pagination and filtering',
description:
'Search matches by venue (searches in venue, venueCity, venueCountry). Uses normalized string comparison (accent-insensitive).',
'Search matches by venue (searches in venue, venueCity, venueCountry). Uses normalized string comparison (accent-insensitive). Supports sorting.',
})
@ApiQuery({
name: 'name',
......@@ -57,14 +57,34 @@ export class MatchesController {
description:
'Search matches by venue (searches in venue, venueCity, venueCountry). Optional.',
})
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: 'Number of results to return (default: 50)',
})
@ApiQuery({
name: 'offset',
required: false,
type: Number,
description: 'Number of results to skip (default: 0)',
})
@ApiQuery({
name: 'sortBy',
required: false,
enum: ['matchDate', 'venue', 'venueCity', 'venueCountry', 'homeScore', 'awayScore', 'status', 'createdAt', 'updatedAt'],
description: 'Field to sort by (default: matchDate)',
})
@ApiQuery({
name: 'sortOrder',
required: false,
enum: ['asc', 'desc'],
description: 'Sort order - ascending or descending (default: asc)',
})
@ApiOkResponse({ description: 'Paginated list of matches' })
async findAll(
@Query('limit') limit?: string,
@Query('offset') offset?: string,
@Query('name') name?: string,
): Promise<MatchesPaginatedResponse<any>> {
const l = limit ? parseInt(limit, 10) : 50;
const o = offset ? parseInt(offset, 10) : 0;
return this.matchesService.findAll(l, o, name);
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 { Injectable } from '@nestjs/common';
import { DatabaseService } from '../../database/database.service';
import { matches, type NewMatch, type Match } from '../../database/schema';
import { eq, count, or, ilike } from 'drizzle-orm';
import { eq, count, or, ilike, desc, asc } from 'drizzle-orm';
import { ResponseUtil, MatchesPaginatedResponse } from '../../common/utils/response.util';
import { MatchWyIdRequiredError } from '../../common/errors';
import { QueryMatchesDto } from './dto/query-matches.dto';
@Injectable()
export class MatchesService {
......@@ -101,6 +102,7 @@ export class MatchesService {
limit: number = 50,
offset: number = 0,
search?: string,
query?: QueryMatchesDto,
): Promise<MatchesPaginatedResponse<any>> {
const db = this.databaseService.getDatabase();
......@@ -132,23 +134,58 @@ export class MatchesService {
totalItems = totalResult.count;
}
// Get data
// Determine sort field and order
const sortBy = query?.sortBy || 'matchDate';
const sortOrder = query?.sortOrder || 'asc';
let orderByColumn: any = matches.matchDate; // Default sort
// Map sortBy to actual database column
switch (sortBy) {
case 'matchDate':
orderByColumn = matches.matchDate;
break;
case 'venue':
orderByColumn = matches.venue;
break;
case 'venueCity':
orderByColumn = matches.venueCity;
break;
case 'venueCountry':
orderByColumn = matches.venueCountry;
break;
case 'homeScore':
orderByColumn = matches.homeScore;
break;
case 'awayScore':
orderByColumn = matches.awayScore;
break;
case 'status':
orderByColumn = matches.status;
break;
case 'createdAt':
orderByColumn = matches.createdAt;
break;
case 'updatedAt':
orderByColumn = matches.updatedAt;
break;
default:
orderByColumn = matches.matchDate;
}
// Get data with sorting
const baseQuery = whereCondition
? db.select().from(matches).where(whereCondition)
: db.select().from(matches);
const queryWithOrder = sortOrder === 'desc'
? baseQuery.orderBy(desc(orderByColumn))
: baseQuery.orderBy(asc(orderByColumn));
let rawData;
if (!search || !search.trim()) {
rawData = await db
.select()
.from(matches)
.limit(limit)
.offset(offset);
rawData = await queryWithOrder.limit(limit).offset(offset);
} else {
if (whereCondition) {
rawData = await db
.select()
.from(matches)
.where(whereCondition);
} else {
rawData = await db.select().from(matches);
}
rawData = await queryWithOrder;
}
// Transform to match API structure
......@@ -171,7 +208,68 @@ export class MatchesService {
);
});
// Apply limit and offset after filtering
// Apply sorting after filtering (if search was used)
if (query?.sortBy) {
filteredData.sort((a, b) => {
let aValue: any;
let bValue: any;
switch (sortBy) {
case 'matchDate':
aValue = a.matchDate ? new Date(a.matchDate).getTime() : 0;
bValue = b.matchDate ? new Date(b.matchDate).getTime() : 0;
break;
case 'venue':
aValue = a.venue || '';
bValue = b.venue || '';
break;
case 'venueCity':
aValue = a.venueCity || '';
bValue = b.venueCity || '';
break;
case 'venueCountry':
aValue = a.venueCountry || '';
bValue = b.venueCountry || '';
break;
case 'homeScore':
aValue = a.homeScore || 0;
bValue = b.homeScore || 0;
break;
case 'awayScore':
aValue = a.awayScore || 0;
bValue = b.awayScore || 0;
break;
case 'status':
aValue = a.status || '';
bValue = b.status || '';
break;
case 'createdAt':
aValue = a.createdAt ? new Date(a.createdAt).getTime() : 0;
bValue = b.createdAt ? new Date(b.createdAt).getTime() : 0;
break;
case 'updatedAt':
aValue = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
bValue = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
break;
default:
aValue = a.matchDate ? new Date(a.matchDate).getTime() : 0;
bValue = b.matchDate ? new Date(b.matchDate).getTime() : 0;
}
if (typeof aValue === 'string' && typeof bValue === 'string') {
const comparison = aValue.localeCompare(bValue);
return sortOrder === 'asc' ? comparison : -comparison;
}
if (typeof aValue === 'number' && typeof bValue === 'number') {
return sortOrder === 'asc' ? aValue - bValue : bValue - aValue;
}
return 0;
});
}
// Apply limit and offset after filtering and sorting
transformedData = filteredData.slice(offset, offset + limit);
// Update totalItems to the actual filtered count
......
......@@ -9,6 +9,7 @@ import {
Min,
Max,
IsObject,
IsArray,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
......@@ -192,6 +193,17 @@ export class CreatePlayerDto {
position?: string;
@ApiPropertyOptional({
description: 'Other positions the player can play',
example: ['MF', 'DF'],
type: [String],
nullable: true,
})
@IsOptional()
@IsArray()
@IsString({ each: true })
otherPositions?: string[] | null;
@ApiPropertyOptional({
description: 'Role code (2 characters)',
example: 'FW',
})
......@@ -236,6 +248,15 @@ export class CreatePlayerDto {
birthAreaWyId?: number;
@ApiPropertyOptional({
description: 'Second birth area Wyscout ID',
example: 1002,
nullable: true,
})
@IsOptional()
@IsInt()
secondBirthAreaWyId?: number | null;
@ApiPropertyOptional({
description: 'Birth area object from external API',
example: { id: 75, wyId: 250, name: 'France' },
})
......@@ -261,6 +282,15 @@ export class CreatePlayerDto {
passportAreaWyId?: number;
@ApiPropertyOptional({
description: 'Second passport area Wyscout ID',
example: 1002,
nullable: true,
})
@IsOptional()
@IsInt()
secondPassportAreaWyId?: number | null;
@ApiPropertyOptional({
description: 'Passport area object from external API',
example: { id: 59, wyId: 384, name: "Côte d'Ivoire" },
})
......@@ -337,5 +367,137 @@ export class CreatePlayerDto {
@IsOptional()
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({
description: 'Email address of the player',
example: 'player@example.com',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
email?: string | null;
@ApiPropertyOptional({
description: 'Phone number of the player',
example: '+1234567890',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
phone?: string | null;
@ApiPropertyOptional({
description: 'Whether the player is on loan',
example: false,
default: false,
type: Boolean,
nullable: true,
})
@IsOptional()
@IsBoolean()
onLoan?: boolean | null;
@ApiPropertyOptional({
description: 'Agent name representing the player',
example: 'John Agent',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
agent?: string | null;
@ApiPropertyOptional({
description: 'Player ranking',
example: '1',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
ranking?: string | null;
@ApiPropertyOptional({
description: 'Return on Investment (ROI)',
example: '15.5',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
roi?: string | null;
@ApiPropertyOptional({
description: 'Market value of the player',
example: 5000000.00,
type: Number,
nullable: true,
})
@IsOptional()
@IsNumber()
marketValue?: number | null;
@ApiPropertyOptional({
description: 'Value range (e.g., "1000000-2000000")',
example: '1000000-2000000',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
valueRange?: string | null;
@ApiPropertyOptional({
description: 'Transfer value of the player',
example: 3000000.00,
type: Number,
nullable: true,
})
@IsOptional()
@IsNumber()
transferValue?: number | null;
@ApiPropertyOptional({
description: 'Salary of the player',
example: 100000.00,
type: Number,
nullable: true,
})
@IsOptional()
@IsNumber()
salary?: number | null;
@ApiPropertyOptional({
description: 'Contract end date (YYYY-MM-DD)',
example: '2025-02-02',
type: String,
nullable: true,
})
@IsOptional()
@IsDateString()
contractEndsAt?: string | null;
@ApiPropertyOptional({
description: 'Whether the transfer is feasible',
example: true,
default: false,
type: Boolean,
nullable: true,
})
@IsOptional()
@IsBoolean()
feasible?: boolean | null;
@ApiPropertyOptional({
description: 'Player morphology/body type',
example: 'Athletic',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
morphology?: string | null;
}
......@@ -12,10 +12,12 @@ import {
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type, Transform } from 'class-transformer';
import { BaseQueryDto } from '../../../common/dto';
export class QueryPlayersDto {
export class QueryPlayersDto extends BaseQueryDto {
@ApiPropertyOptional({
description: 'Search players by name (searches in firstName, lastName, middleName, shortName)',
description:
'Search players by name (searches in firstName, lastName, middleName, shortName)',
example: 'Messi',
})
@IsOptional()
......@@ -72,7 +74,8 @@ export class QueryPlayersDto {
passportAreaWyId?: number;
@ApiPropertyOptional({
description: 'Filter by EU passport (requires passportAreaWyId to be set for EU countries)',
description:
'Filter by EU passport (requires passportAreaWyId to be set for EU countries)',
type: Boolean,
example: true,
})
......@@ -92,13 +95,17 @@ export class QueryPlayersDto {
archived?: boolean;
@ApiPropertyOptional({
description: 'Filter by positions (comma-separated or array)',
example: 'FW,MF,DF',
description:
'Filter by role names (comma-separated or array, e.g., "Forward", "Midfielder", "Defender")',
example: 'Forward,Midfielder,Defender',
})
@IsOptional()
@Transform(({ value }) => {
if (typeof value === 'string') {
return value.split(',').map((v: string) => v.trim()).filter(Boolean);
return value
.split(',')
.map((v: string) => v.trim())
.filter(Boolean);
}
return Array.isArray(value) ? value : [value];
})
......@@ -117,7 +124,8 @@ export class QueryPlayersDto {
scoutId?: number;
@ApiPropertyOptional({
description: 'Filter by birth date (exact match) - ISO date string YYYY-MM-DD',
description:
'Filter by birth date (exact match) - ISO date string YYYY-MM-DD',
example: '1990-01-01',
})
@IsOptional()
......@@ -227,28 +235,38 @@ export class QueryPlayersDto {
status?: 'active' | 'inactive';
@ApiPropertyOptional({
description: 'Number of results to return',
type: Number,
example: 100,
default: 100,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(1000)
limit?: number;
@ApiPropertyOptional({
description: 'Number of results to skip',
type: Number,
example: 0,
default: 0,
description: 'Field to sort by',
enum: [
'firstName',
'lastName',
'shortName',
'height',
'weight',
'birthDate',
'position',
'foot',
'gender',
'status',
'createdAt',
'updatedAt',
],
example: 'lastName',
default: 'lastName',
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
offset?: number;
@IsEnum([
'firstName',
'lastName',
'shortName',
'height',
'weight',
'birthDate',
'position',
'foot',
'gender',
'status',
'createdAt',
'updatedAt',
])
sortBy?: string;
}
......@@ -8,6 +8,7 @@ import {
IsBoolean,
Min,
Max,
IsArray,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
......@@ -135,6 +136,17 @@ export class UpdatePlayerDto {
position?: string;
@ApiPropertyOptional({
description: 'Other positions the player can play',
example: ['MF', 'DF'],
type: [String],
nullable: true,
})
@IsOptional()
@IsArray()
@IsString({ each: true })
otherPositions?: string[] | null;
@ApiPropertyOptional({
description: 'Role code (2 characters)',
example: 'FW',
})
......@@ -167,6 +179,15 @@ export class UpdatePlayerDto {
birthAreaWyId?: number;
@ApiPropertyOptional({
description: 'Second birth area Wyscout ID',
example: 1002,
nullable: true,
})
@IsOptional()
@IsInt()
secondBirthAreaWyId?: number | null;
@ApiPropertyOptional({
description: 'Passport area Wyscout ID',
example: 1001,
})
......@@ -175,6 +196,15 @@ export class UpdatePlayerDto {
passportAreaWyId?: number;
@ApiPropertyOptional({
description: 'Second passport area Wyscout ID',
example: 1002,
nullable: true,
})
@IsOptional()
@IsInt()
secondPassportAreaWyId?: number | null;
@ApiPropertyOptional({
description: 'Status',
enum: ['active', 'inactive'],
example: 'active',
......@@ -225,5 +255,134 @@ export class UpdatePlayerDto {
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
@ApiPropertyOptional({
description: 'Email address of the player',
example: 'player@example.com',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
email?: string | null;
@ApiPropertyOptional({
description: 'Phone number of the player',
example: '+1234567890',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
phone?: string | null;
@ApiPropertyOptional({
description: 'Whether the player is on loan',
example: false,
type: Boolean,
nullable: true,
})
@IsOptional()
@IsBoolean()
onLoan?: boolean | null;
@ApiPropertyOptional({
description: 'Agent name representing the player',
example: 'John Agent',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
agent?: string | null;
@ApiPropertyOptional({
description: 'Player ranking',
example: '1',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
ranking?: string | null;
@ApiPropertyOptional({
description: 'Return on Investment (ROI)',
example: '15.5',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
roi?: string | null;
@ApiPropertyOptional({
description: 'Market value of the player',
example: 5000000.0,
type: Number,
nullable: true,
})
@IsOptional()
@IsNumber()
marketValue?: number | null;
@ApiPropertyOptional({
description: 'Value range (e.g., "1000000-2000000")',
example: '1000000-2000000',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
valueRange?: string | null;
@ApiPropertyOptional({
description: 'Transfer value of the player',
example: 3000000.0,
type: Number,
nullable: true,
})
@IsOptional()
@IsNumber()
transferValue?: number | null;
@ApiPropertyOptional({
description: 'Salary of the player',
example: 100000.0,
type: Number,
nullable: true,
})
@IsOptional()
@IsNumber()
salary?: number | null;
@ApiPropertyOptional({
description: 'Contract end date (YYYY-MM-DD)',
example: '2025-02-02',
type: String,
nullable: true,
})
@IsOptional()
@IsDateString()
contractEndsAt?: string | null;
@ApiPropertyOptional({
description: 'Whether the transfer is feasible',
example: true,
type: Boolean,
nullable: true,
})
@IsOptional()
@IsBoolean()
feasible?: boolean | null;
@ApiPropertyOptional({
description: 'Player morphology/body type',
example: 'Athletic',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
morphology?: string | null;
}
......@@ -10,6 +10,35 @@ export interface PlayerReport {
rating: number | null;
}
export interface PlayerArea {
id: number;
wyId: number;
name: string;
alpha2code: string | null;
alpha3code: string | null;
createdAt: string | null;
updatedAt: string | null;
deletedAt: string | null;
}
export type PositionCategory =
| 'Forward'
| 'Goalkeeper'
| 'Defender'
| 'Midfield';
export interface PlayerPosition {
id: number;
name: string;
code2: string | null;
code3: string | null;
order: number;
location: { x: number; y: number } | null;
bgColor: string | null;
textColor: string | null;
category: PositionCategory;
}
export interface StructuredPlayer {
id: number;
wyId: number;
......@@ -21,10 +50,13 @@ export interface StructuredPlayer {
height: number;
weight: number;
birthDate: string;
birthArea: any | null;
passportArea: any | null;
birthArea: PlayerArea | null;
secondBirthArea: PlayerArea | null;
passportArea: PlayerArea | null;
secondPassportArea: PlayerArea | null;
role: PlayerRole;
position: string | null;
position: PlayerPosition | null;
otherPositions: PlayerPosition[] | null;
foot: string | null;
currentTeamId: number | null;
currentNationalTeamId: number | null;
......@@ -38,5 +70,17 @@ export interface StructuredPlayer {
createdAt: string;
updatedAt: string;
reports: PlayerReport[];
email: string | null;
phone: string | null;
onLoan: boolean;
agent: string | null;
ranking: string | null;
roi: string | null;
marketValue: number | null;
valueRange: string | null;
transferValue: number | null;
salary: number | null;
contractEndsAt: string | null;
feasible: boolean;
morphology: string | null;
}
......@@ -8,6 +8,7 @@ import {
NotFoundException,
Param,
ParseIntPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
......@@ -25,7 +26,7 @@ import {
} from '@nestjs/swagger';
import { PaginatedResponse } from '../../common/utils/response.util';
import { StructuredPlayer } from './interfaces/player.interface';
import { CreatePlayerDto, QueryPlayersDto } from './dto';
import { CreatePlayerDto, QueryPlayersDto, UpdatePlayerDto } from './dto';
@ApiTags('Players')
@Controller('players')
......@@ -111,7 +112,8 @@ export class PlayersController {
name: 'positions',
required: false,
type: String,
description: 'Filter by positions (comma-separated, e.g., "FW,MF,DF")',
description:
'Filter by role names (comma-separated, e.g., "Forward,Midfielder,Defender")',
})
@ApiQuery({
name: 'scoutId',
......@@ -185,7 +187,34 @@ export class PlayersController {
type: Number,
description: 'Number of results to skip (default: 0)',
})
@ApiOkResponse({ description: 'Paginated list of players matching the filters' })
@ApiQuery({
name: 'sortBy',
required: false,
enum: [
'firstName',
'lastName',
'shortName',
'height',
'weight',
'birthDate',
'position',
'foot',
'gender',
'status',
'createdAt',
'updatedAt',
],
description: 'Field to sort by (default: lastName)',
})
@ApiQuery({
name: 'sortOrder',
required: false,
enum: ['asc', 'desc'],
description: 'Sort order - ascending or descending (default: asc)',
})
@ApiOkResponse({
description: 'Paginated list of players matching the filters',
})
async findAll(
@Query('limit') limit?: string,
@Query('offset') offset?: string,
......@@ -197,6 +226,30 @@ export class PlayersController {
return this.playersService.findAll(l, o, name, query);
}
@Patch(':wyId')
@ApiOperation({ summary: 'Update player by wyId' })
@ApiParam({
name: 'wyId',
type: Number,
description: 'Wyscout ID of the player to update',
})
@ApiBody({
description: 'Player update payload. All fields are optional.',
type: UpdatePlayerDto,
})
@ApiOkResponse({ description: 'Updated player', type: Object })
@ApiNotFoundResponse({ description: 'Player not found' })
async updateByWyId(
@Param('wyId', ParseIntPipe) wyId: number,
@Body() body: UpdatePlayerDto,
): Promise<Player> {
const result = await this.playersService.updateByWyId(wyId, body);
if (!result) {
throw new NotFoundException(`Player with wyId ${wyId} not found`);
}
return result;
}
@Delete(':wyId')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete player by wyId (soft delete)' })
......
import {
IsString,
IsOptional,
IsInt,
IsEnum,
IsBoolean,
Length,
Min,
Max,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export type PositionCategory = 'Forward' | 'Goalkeeper' | 'Defender' | 'Midfield';
export class CreatePositionDto {
@ApiProperty({
description: 'Position name',
example: 'Center Defender',
})
@IsString()
name: string;
@ApiPropertyOptional({
description: '2-character position code',
example: 'CD',
})
@IsOptional()
@IsString()
@Length(2, 10)
code2?: string;
@ApiPropertyOptional({
description: '3-character position code',
example: 'CDE',
})
@IsOptional()
@IsString()
@Length(3, 10)
code3?: string;
@ApiProperty({
description: 'Position category',
enum: ['Forward', 'Goalkeeper', 'Defender', 'Midfield'],
example: 'Defender',
})
@IsEnum(['Forward', 'Goalkeeper', 'Defender', 'Midfield'])
category: PositionCategory;
@ApiPropertyOptional({
description: 'Display order',
example: 0,
default: 0,
})
@IsOptional()
@IsInt()
@Min(0)
order?: number;
@ApiPropertyOptional({
description: 'X coordinate for position location',
example: 5,
})
@IsOptional()
@IsInt()
@Min(0)
@Max(100)
locationX?: number;
@ApiPropertyOptional({
description: 'Y coordinate for position location',
example: 50,
})
@IsOptional()
@IsInt()
@Min(0)
@Max(100)
locationY?: number;
@ApiPropertyOptional({
description: 'Background color (hex format)',
example: '#C14B50',
})
@IsOptional()
@IsString()
@Length(7, 7)
bgColor?: string;
@ApiPropertyOptional({
description: 'Text color (hex format)',
example: '#000000',
})
@IsOptional()
@IsString()
@Length(7, 7)
textColor?: string;
@ApiPropertyOptional({
description: 'Whether the position is active',
example: true,
default: true,
})
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export * from './create-position.dto';
export * from './update-position.dto';
export * from './query-positions.dto';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class PositionResponseDto {
@ApiProperty({ description: 'Position ID', example: 1 })
id: number;
@ApiProperty({ description: 'Position name', example: 'Center Defender' })
name: string;
@ApiPropertyOptional({
description: '2-character position code',
example: 'CD',
})
code2?: string | null;
@ApiPropertyOptional({
description: '3-character position code',
example: 'CDE',
})
code3?: string | null;
@ApiProperty({
description: 'Position category',
enum: ['Forward', 'Goalkeeper', 'Defender', 'Midfield'],
example: 'Defender',
})
category: 'Forward' | 'Goalkeeper' | 'Defender' | 'Midfield';
@ApiPropertyOptional({ description: 'Display order', example: 0 })
order?: number;
@ApiPropertyOptional({ description: 'X coordinate', example: 5 })
locationX?: number | null;
@ApiPropertyOptional({ description: 'Y coordinate', example: 50 })
locationY?: number | null;
@ApiPropertyOptional({
description: 'Background color (hex)',
example: '#C14B50',
})
bgColor?: string | null;
@ApiPropertyOptional({
description: 'Text color (hex)',
example: '#000000',
})
textColor?: string | null;
@ApiProperty({ description: 'Whether position is active', example: true })
isActive: boolean;
@ApiPropertyOptional({
description: 'Created at timestamp',
example: '2024-01-01T00:00:00.000Z',
})
createdAt?: string | null;
@ApiPropertyOptional({
description: 'Updated at timestamp',
example: '2024-01-01T00:00:00.000Z',
})
updatedAt?: string | null;
@ApiPropertyOptional({
description: 'Deleted at timestamp',
example: null,
})
deletedAt?: string | null;
}
import {
IsOptional,
IsString,
IsEnum,
IsBoolean,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { BaseQueryDto } from '../../../common/dto';
export class QueryPositionsDto extends BaseQueryDto {
@ApiPropertyOptional({
description: 'Search positions by name, code2, or code3',
example: 'Defender',
})
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({
description: 'Filter by category',
enum: ['Forward', 'Goalkeeper', 'Defender', 'Midfield'],
example: 'Defender',
})
@IsOptional()
@IsEnum(['Forward', 'Goalkeeper', 'Defender', 'Midfield'])
category?: 'Forward' | 'Goalkeeper' | 'Defender' | 'Midfield';
@ApiPropertyOptional({
description: 'Filter by active status',
example: true,
})
@IsOptional()
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({
description: 'Field to sort by',
enum: ['name', 'code2', 'code3', 'category', 'order', 'createdAt', 'updatedAt'],
example: 'name',
default: 'name',
})
@IsOptional()
@IsEnum(['name', 'code2', 'code3', 'category', 'order', 'createdAt', 'updatedAt'])
sortBy?: string;
}
import { PartialType } from '@nestjs/swagger';
import { CreatePositionDto } from './create-position.dto';
export class UpdatePositionDto extends PartialType(CreatePositionDto) {}
import { Module } from '@nestjs/common';
import { PositionsController } from './positions.controller';
import { PositionsService } from './positions.service';
@Module({
controllers: [PositionsController],
providers: [PositionsService],
exports: [PositionsService],
})
export class PositionsModule {}
......@@ -10,6 +10,7 @@ import {
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { BaseQueryDto } from '../../../common/dto';
export enum ReportEntityType {
PLAYER = 'player',
......@@ -17,7 +18,7 @@ export enum ReportEntityType {
MATCH = 'match',
}
export class QueryReportsDto {
export class QueryReportsDto extends BaseQueryDto {
@ApiPropertyOptional({
description: 'Filter by entity type (player, coach, or match)',
enum: ReportEntityType,
......@@ -169,28 +170,13 @@ export class QueryReportsDto {
updatedTo?: string;
@ApiPropertyOptional({
description: 'Number of results to return',
type: Number,
example: 50,
default: 50,
description: 'Field to sort by',
enum: ['name', 'type', 'status', 'grade', 'rating', 'createdAt', 'updatedAt'],
example: 'createdAt',
default: 'createdAt',
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(1000)
limit?: number;
@ApiPropertyOptional({
description: 'Number of results to skip',
type: Number,
example: 0,
default: 0,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
offset?: number;
@IsEnum(['name', 'type', 'status', 'grade', 'rating', 'createdAt', 'updatedAt'])
sortBy?: string;
}
......@@ -189,6 +189,18 @@ export class ReportsController {
type: Number,
description: 'Number of results to skip (default: 0)',
})
@ApiQuery({
name: 'sortBy',
required: false,
enum: ['name', 'type', 'status', 'grade', 'rating', 'createdAt', 'updatedAt'],
description: 'Field to sort by (default: createdAt)',
})
@ApiQuery({
name: 'sortOrder',
required: false,
enum: ['asc', 'desc'],
description: 'Sort order - ascending or descending (default: asc)',
})
@ApiOkResponse({ description: 'List of reports matching the filters' })
async list(@Query() query: QueryReportsDto): Promise<Report[]> {
return this.reportsService.findAll(query);
......
import { Injectable, Logger } from '@nestjs/common';
import { DatabaseService } from '../../database/database.service';
import { reports, type NewReport, type Report } from '../../database/schema';
import { eq, and, ilike, gte, lte, isNotNull, desc, sql } from 'drizzle-orm';
import {
eq,
and,
ilike,
gte,
lte,
isNotNull,
desc,
asc,
sql,
} from 'drizzle-orm';
import {
DatabaseQueryError,
DatabaseColumnNotFoundError,
......@@ -205,10 +215,44 @@ export class ReportsService {
const queryWithWhere =
conditions.length > 0 ? baseQuery.where(and(...conditions)) : baseQuery;
return await queryWithWhere
.orderBy(desc(reports.createdAt))
.limit(limit)
.offset(offset);
// Determine sort field and order
const sortBy = query.sortBy || 'createdAt';
const sortOrder = query.sortOrder || 'asc';
let orderByColumn: any = reports.createdAt; // Default sort
// Map sortBy to actual database column
switch (sortBy) {
case 'name':
orderByColumn = reports.name;
break;
case 'type':
orderByColumn = reports.type;
break;
case 'status':
orderByColumn = reports.status;
break;
case 'grade':
orderByColumn = reports.grade;
break;
case 'rating':
orderByColumn = reports.rating;
break;
case 'createdAt':
orderByColumn = reports.createdAt;
break;
case 'updatedAt':
orderByColumn = reports.updatedAt;
break;
default:
orderByColumn = reports.createdAt;
}
const queryWithOrder =
sortOrder === 'desc'
? queryWithWhere.orderBy(desc(orderByColumn))
: queryWithWhere.orderBy(asc(orderByColumn));
return await queryWithOrder.limit(limit).offset(offset);
}
// Helper method to normalize report data - use wyIds directly
......
......@@ -2,3 +2,4 @@ export * from './create-user.dto';
export * from './update-user.dto';
export * from './user-response.dto';
export * from './change-password.dto';
export * from './query-users.dto';
import {
IsOptional,
IsString,
IsEnum,
IsBoolean,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { BaseQueryDto } from '../../../common/dto';
export enum UserRole {
SUPERADMIN = 'superadmin',
ADMIN = 'admin',
GROUP_MANAGER = 'groupmanager',
PRESIDENT = 'president',
SPORTS_DIRECTOR = 'sportsdirector',
CHIEF_SCOUT = 'chiefscout',
CHIEF_TRANSFER_MARKET = 'chieftransfermarket',
CHIEF_DATA_ANALYST = 'chiefdataanalyst',
STAFF_TRANSFER_MARKET = 'stafftransfermarket',
STAFF_SCOUT = 'staffscout',
STAFF_DATA_ANALYST = 'staffdataanalyst',
VIEWER = 'viewer',
}
export class QueryUsersDto extends BaseQueryDto {
@ApiPropertyOptional({
description: 'Filter by user role',
enum: UserRole,
example: 'admin',
})
@IsOptional()
@IsEnum(UserRole)
role?: UserRole;
@ApiPropertyOptional({
description: 'Search by user name (case-insensitive partial match)',
example: 'John',
})
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional({
description: 'Search by email (case-insensitive partial match)',
example: 'john@example.com',
})
@IsOptional()
@IsString()
email?: string;
@ApiPropertyOptional({
description: 'Filter by active status',
type: Boolean,
example: true,
})
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({
description: 'Field to sort by',
enum: ['name', 'email', 'role', 'isActive', 'createdAt', 'updatedAt', 'lastLoginAt'],
example: 'name',
default: 'name',
})
@IsOptional()
@IsEnum(['name', 'email', 'role', 'isActive', 'createdAt', 'updatedAt', 'lastLoginAt'])
sortBy?: string;
}
......@@ -11,6 +11,7 @@ import {
HttpStatus,
UseGuards,
Request,
Query,
} from '@nestjs/common';
import {
ApiTags,
......@@ -19,9 +20,10 @@ import {
ApiParam,
ApiBearerAuth,
ApiBody,
ApiQuery,
} from '@nestjs/swagger';
import { UsersService } from './users.service';
import { UpdateUserDto, UserResponseDto } from './dto';
import { UpdateUserDto, UserResponseDto, QueryUsersDto, UserRole } from './dto';
import { JwtSimpleGuard } from '../../common/guards/jwt-simple.guard';
import { RolesGuard } from '../../common/guards/roles.guard';
import { Roles } from '../../common/decorators/roles.decorator';
......@@ -37,15 +39,51 @@ export class UsersController {
@ApiBearerAuth()
@ApiOperation({
summary: 'Get all users',
description: 'Retrieves a list of all users in the system.',
description: 'Retrieves a list of all users in the system with optional filtering by role, name, email, and active status.',
})
@ApiQuery({
name: 'role',
required: false,
enum: UserRole,
description: 'Filter by user role',
})
@ApiQuery({
name: 'name',
required: false,
type: String,
description: 'Search by user name (case-insensitive partial match)',
})
@ApiQuery({
name: 'email',
required: false,
type: String,
description: 'Search by email (case-insensitive partial match)',
})
@ApiQuery({
name: 'isActive',
required: false,
type: Boolean,
description: 'Filter by active status',
})
@ApiQuery({
name: 'sortBy',
required: false,
enum: ['name', 'email', 'role', 'isActive', 'createdAt', 'updatedAt', 'lastLoginAt'],
description: 'Field to sort by (default: name)',
})
@ApiQuery({
name: 'sortOrder',
required: false,
enum: ['asc', 'desc'],
description: 'Sort order - ascending or descending (default: asc)',
})
@ApiResponse({
status: 200,
description: 'List of users retrieved successfully',
type: [UserResponseDto],
})
async findAll(): Promise<UserResponseDto[]> {
return this.usersService.findAll();
async findAll(@Query() query: QueryUsersDto): Promise<UserResponseDto[]> {
return this.usersService.findAll(query);
}
@Get(':id')
......
......@@ -4,10 +4,10 @@ import {
ConflictException,
UnauthorizedException,
} from '@nestjs/common';
import { eq, and, isNull } from 'drizzle-orm';
import { eq, and, isNull, ilike, desc, asc } from 'drizzle-orm';
import { DatabaseService } from '../../database/database.service';
import { users, type User, type NewUser } from '../../database/schema';
import { CreateUserDto, UpdateUserDto, UserResponseDto } from './dto';
import { CreateUserDto, UpdateUserDto, UserResponseDto, QueryUsersDto } from './dto';
import * as bcrypt from 'bcrypt';
@Injectable()
......@@ -49,12 +49,68 @@ export class UsersService {
return this.mapToResponseDto(createdUser);
}
async findAll(): Promise<UserResponseDto[]> {
const allUsers = await this.databaseService
.getDatabase()
.select()
.from(users)
.where(isNull(users.deletedAt));
async findAll(query?: QueryUsersDto): Promise<UserResponseDto[]> {
const db = this.databaseService.getDatabase();
const conditions: any[] = [isNull(users.deletedAt)];
// Filter by role
if (query?.role) {
conditions.push(eq(users.role, query.role));
}
// Search by name (case-insensitive partial match)
if (query?.name) {
conditions.push(ilike(users.name, `%${query.name}%`));
}
// Search by email (case-insensitive partial match)
if (query?.email) {
conditions.push(ilike(users.email, `%${query.email}%`));
}
// Filter by active status
if (query?.isActive !== undefined) {
conditions.push(eq(users.isActive, query.isActive));
}
// Determine sort field and order
const sortBy = query?.sortBy || 'name';
const sortOrder = query?.sortOrder || 'asc';
let orderByColumn: any = users.name; // Default sort
// Map sortBy to actual database column
switch (sortBy) {
case 'name':
orderByColumn = users.name;
break;
case 'email':
orderByColumn = users.email;
break;
case 'role':
orderByColumn = users.role;
break;
case 'isActive':
orderByColumn = users.isActive;
break;
case 'createdAt':
orderByColumn = users.createdAt;
break;
case 'updatedAt':
orderByColumn = users.updatedAt;
break;
case 'lastLoginAt':
orderByColumn = users.lastLoginAt;
break;
default:
orderByColumn = users.name;
}
const baseQuery = db.select().from(users).where(and(...conditions));
const queryWithOrder = sortOrder === 'desc'
? baseQuery.orderBy(desc(orderByColumn))
: baseQuery.orderBy(asc(orderByColumn));
const allUsers = await queryWithOrder;
return allUsers.map((user) => this.mapToResponseDto(user));
}
......
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