Commit 960e49bc by Augusto

Scouting Module - Missing Lists

parent fa6af417
-- 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"."api_sync_status" AS ENUM('pending', 'synced', 'error');--> statement-breakpoint
CREATE TYPE "public"."coach_position" AS ENUM('head_coach', 'assistant_coach', 'analyst');--> statement-breakpoint
CREATE TYPE "public"."foot" AS ENUM('left', 'right', 'both');--> statement-breakpoint
CREATE TYPE "public"."gender" AS ENUM('male', 'female', 'other');--> statement-breakpoint
CREATE TYPE "public"."match_status" AS ENUM('scheduled', 'in_progress', 'finished', 'postponed', 'cancelled');--> statement-breakpoint
CREATE TYPE "public"."match_type" AS ENUM('regular', 'friendly', 'playoff', 'cup');--> statement-breakpoint
CREATE TYPE "public"."status" AS ENUM('active', 'inactive');--> statement-breakpoint
CREATE TABLE "coaches" (
"id" serial PRIMARY KEY NOT NULL,
"wy_id" integer NOT NULL,
"gsm_id" integer,
"first_name" varchar(100) NOT NULL,
"last_name" varchar(100) NOT NULL,
"middle_name" varchar(100),
"short_name" varchar(100),
"date_of_birth" date,
"nationality_wy_id" integer,
"current_team_wy_id" integer,
"position" "coach_position" DEFAULT 'head_coach',
"coaching_license" varchar(100),
"years_experience" integer,
"previous_teams" text[],
"status" "status" DEFAULT 'active',
"image_data_url" text,
"api_last_synced_at" timestamp with time zone,
"api_sync_status" "api_sync_status" DEFAULT 'pending',
"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 "coaches_wy_id_unique" UNIQUE("wy_id")
);
--> statement-breakpoint
CREATE TABLE "matches" (
"id" serial PRIMARY KEY NOT NULL,
"wy_id" integer NOT NULL,
"home_team_wy_id" integer,
"away_team_wy_id" integer,
"competition_wy_id" integer,
"season_wy_id" integer,
"round_wy_id" integer,
"match_date" timestamp with time zone NOT NULL,
"venue" varchar(255),
"venue_city" varchar(100),
"venue_country" varchar(100),
"match_type" "match_type" DEFAULT 'regular',
"status" "match_status" DEFAULT 'scheduled',
"home_score" integer DEFAULT 0,
"away_score" integer DEFAULT 0,
"home_score_penalties" integer DEFAULT 0,
"away_score_penalties" integer DEFAULT 0,
"main_referee_id" integer,
"main_referee_name" text,
"assistant_referee_1_id" integer,
"assistant_referee_1_name" text,
"assistant_referee_2_id" integer,
"assistant_referee_2_name" text,
"fourth_referee_id" integer,
"fourth_referee_name" text,
"var_referee_id" integer,
"var_referee_name" text,
"weather" varchar(50),
"temperature" numeric(5, 2),
"api_last_synced_at" timestamp with time zone,
"api_sync_status" "api_sync_status" DEFAULT 'pending',
"notes" text,
"created_at" timestamp with time zone DEFAULT now(),
"updated_at" timestamp with time zone DEFAULT now(),
"deleted_at" timestamp with time zone,
CONSTRAINT "matches_wy_id_unique" UNIQUE("wy_id")
);
--> statement-breakpoint
CREATE TABLE "players" (
"id" serial PRIMARY KEY NOT NULL,
"wy_id" integer NOT NULL,
"team_wy_id" integer,
"first_name" varchar(100) NOT NULL,
"last_name" varchar(100) NOT NULL,
"middle_name" varchar(100),
"short_name" varchar(100),
"gsm_id" integer,
"current_team_id" integer,
"current_national_team_id" integer,
"date_of_birth" date,
"height_cm" integer,
"weight_kg" numeric(5, 2),
"foot" "foot",
"gender" "gender",
"position" varchar(50),
"role_code2" varchar(10),
"role_code3" varchar(10),
"role_name" varchar(50),
"birth_area_wy_id" integer,
"passport_area_wy_id" integer,
"status" "status" DEFAULT 'active',
"image_data_url" text,
"jersey_number" integer,
"api_last_synced_at" timestamp with time zone,
"api_sync_status" "api_sync_status" DEFAULT 'pending',
"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 "players_wy_id_unique" UNIQUE("wy_id")
);
--> statement-breakpoint
CREATE TABLE "reports" (
"id" serial PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"player_id" integer,
"coach_id" integer,
"match_id" integer,
"description" json,
"grade" text,
"rating" integer,
"decision" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "client_modules" ALTER COLUMN "client_id" SET DEFAULT 'default-client';--> statement-breakpoint
ALTER TABLE "reports" ADD CONSTRAINT "reports_player_id_players_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reports" ADD CONSTRAINT "reports_coach_id_coaches_id_fk" FOREIGN KEY ("coach_id") REFERENCES "public"."coaches"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reports" ADD CONSTRAINT "reports_match_id_matches_id_fk" FOREIGN KEY ("match_id") REFERENCES "public"."matches"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_coaches_wy_id" ON "coaches" USING btree ("wy_id");--> statement-breakpoint
CREATE INDEX "idx_coaches_gsm_id" ON "coaches" USING btree ("gsm_id");--> statement-breakpoint
CREATE INDEX "idx_coaches_current_team_wy_id" ON "coaches" USING btree ("current_team_wy_id");--> statement-breakpoint
CREATE INDEX "idx_coaches_nationality_wy_id" ON "coaches" USING btree ("nationality_wy_id");--> statement-breakpoint
CREATE INDEX "idx_coaches_position" ON "coaches" USING btree ("position");--> statement-breakpoint
CREATE INDEX "idx_coaches_status" ON "coaches" USING btree ("status");--> statement-breakpoint
CREATE INDEX "idx_coaches_last_name" ON "coaches" USING btree ("last_name");--> statement-breakpoint
CREATE INDEX "idx_coaches_first_name" ON "coaches" USING btree ("first_name");--> statement-breakpoint
CREATE INDEX "idx_coaches_deleted_at" ON "coaches" USING btree ("deleted_at");--> statement-breakpoint
CREATE INDEX "idx_coaches_is_active" ON "coaches" USING btree ("is_active");--> statement-breakpoint
CREATE INDEX "idx_coaches_api_sync_status" ON "coaches" USING btree ("api_sync_status");--> statement-breakpoint
CREATE INDEX "idx_matches_wy_id" ON "matches" USING btree ("wy_id");--> statement-breakpoint
CREATE INDEX "idx_matches_home_team_wy_id" ON "matches" USING btree ("home_team_wy_id");--> statement-breakpoint
CREATE INDEX "idx_matches_away_team_wy_id" ON "matches" USING btree ("away_team_wy_id");--> statement-breakpoint
CREATE INDEX "idx_matches_competition_wy_id" ON "matches" USING btree ("competition_wy_id");--> statement-breakpoint
CREATE INDEX "idx_matches_season_wy_id" ON "matches" USING btree ("season_wy_id");--> statement-breakpoint
CREATE INDEX "idx_matches_round_wy_id" ON "matches" USING btree ("round_wy_id");--> statement-breakpoint
CREATE INDEX "idx_matches_match_date" ON "matches" USING btree ("match_date");--> statement-breakpoint
CREATE INDEX "idx_matches_status" ON "matches" USING btree ("status");--> statement-breakpoint
CREATE INDEX "idx_matches_match_type" ON "matches" USING btree ("match_type");--> statement-breakpoint
CREATE INDEX "idx_matches_main_referee_id" ON "matches" USING btree ("main_referee_id");--> statement-breakpoint
CREATE INDEX "idx_matches_assistant_referee_1_id" ON "matches" USING btree ("assistant_referee_1_id");--> statement-breakpoint
CREATE INDEX "idx_matches_assistant_referee_2_id" ON "matches" USING btree ("assistant_referee_2_id");--> statement-breakpoint
CREATE INDEX "idx_matches_fourth_referee_id" ON "matches" USING btree ("fourth_referee_id");--> statement-breakpoint
CREATE INDEX "idx_matches_var_referee_id" ON "matches" USING btree ("var_referee_id");--> statement-breakpoint
CREATE INDEX "idx_matches_deleted_at" ON "matches" USING btree ("deleted_at");--> statement-breakpoint
CREATE INDEX "idx_matches_api_sync_status" ON "matches" USING btree ("api_sync_status");--> statement-breakpoint
CREATE INDEX "idx_players_team_wy_id" ON "players" USING btree ("team_wy_id");--> statement-breakpoint
CREATE INDEX "idx_players_position" ON "players" USING btree ("position");--> statement-breakpoint
CREATE INDEX "idx_players_deleted_at" ON "players" USING btree ("deleted_at");--> statement-breakpoint
CREATE INDEX "idx_players_is_active" ON "players" USING btree ("is_active");--> statement-breakpoint
CREATE INDEX "idx_players_wy_id" ON "players" USING btree ("wy_id");--> statement-breakpoint
CREATE INDEX "idx_players_gsm_id" ON "players" USING btree ("gsm_id");--> statement-breakpoint
CREATE INDEX "idx_players_current_team_id" ON "players" USING btree ("current_team_id");--> statement-breakpoint
CREATE INDEX "idx_players_current_national_team_id" ON "players" USING btree ("current_national_team_id");--> statement-breakpoint
CREATE INDEX "idx_players_last_name" ON "players" USING btree ("last_name");--> statement-breakpoint
CREATE INDEX "idx_players_first_name" ON "players" USING btree ("first_name");--> statement-breakpoint
CREATE INDEX "idx_players_birth_area_wy_id" ON "players" USING btree ("birth_area_wy_id");--> statement-breakpoint
CREATE INDEX "idx_players_passport_area_wy_id" ON "players" USING btree ("passport_area_wy_id");--> statement-breakpoint
CREATE INDEX "idx_players_status" ON "players" USING btree ("status");--> statement-breakpoint
CREATE INDEX "idx_players_gender" ON "players" USING btree ("gender");--> statement-breakpoint
CREATE INDEX "idx_players_foot" ON "players" USING btree ("foot");--> statement-breakpoint
CREATE INDEX "idx_players_api_sync_status" ON "players" USING btree ("api_sync_status");--> statement-breakpoint
CREATE INDEX "idx_players_api_last_synced_at" ON "players" USING btree ("api_last_synced_at");
\ No newline at end of file
-- Drop foreign key constraints if they exist (including all variations of constraint names)
ALTER TABLE "reports" DROP CONSTRAINT IF EXISTS "reports_player_id_players_id_fk";--> statement-breakpoint
ALTER TABLE "reports" DROP CONSTRAINT IF EXISTS "reports_coach_id_coaches_id_fk";--> statement-breakpoint
ALTER TABLE "reports" DROP CONSTRAINT IF EXISTS "reports_match_id_matches_id_fk";--> statement-breakpoint
ALTER TABLE "reports" DROP CONSTRAINT IF EXISTS "reports_player_id_fkey";--> statement-breakpoint
ALTER TABLE "reports" DROP CONSTRAINT IF EXISTS "reports_coach_id_fkey";--> statement-breakpoint
ALTER TABLE "reports" DROP CONSTRAINT IF EXISTS "reports_match_id_fkey";--> statement-breakpoint
-- Drop any other variations of foreign key constraints
DO $$
BEGIN
-- Drop any constraint that references player_id, coach_id, or match_id
FOR constraint_rec IN
SELECT constraint_name
FROM information_schema.table_constraints
WHERE table_name = 'reports'
AND constraint_type = 'FOREIGN KEY'
AND (constraint_name LIKE '%player%' OR constraint_name LIKE '%coach%' OR constraint_name LIKE '%match%')
LOOP
EXECUTE 'ALTER TABLE "reports" DROP CONSTRAINT IF EXISTS "' || constraint_rec.constraint_name || '"';
END LOOP;
END $$;--> statement-breakpoint
-- Rename columns from _id to _wy_id (only if old columns exist and new ones don't)
DO $$
BEGIN
-- Rename player_id to player_wy_id if player_id exists and player_wy_id doesn't
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'reports' AND column_name = 'player_id'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'reports' AND column_name = 'player_wy_id'
) THEN
ALTER TABLE "reports" RENAME COLUMN "player_id" TO "player_wy_id";
END IF;
-- Rename coach_id to coach_wy_id if coach_id exists and coach_wy_id doesn't
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'reports' AND column_name = 'coach_id'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'reports' AND column_name = 'coach_wy_id'
) THEN
ALTER TABLE "reports" RENAME COLUMN "coach_id" TO "coach_wy_id";
END IF;
-- Rename match_id to match_wy_id if match_id exists and match_wy_id doesn't
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'reports' AND column_name = 'match_id'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'reports' AND column_name = 'match_wy_id'
) THEN
ALTER TABLE "reports" RENAME COLUMN "match_id" TO "match_wy_id";
END IF;
END $$;--> statement-breakpoint
-- Add user_id 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 = 'user_id'
) THEN
ALTER TABLE "reports" ADD COLUMN "user_id" integer;
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 = '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;
END IF;
END $$;--> statement-breakpoint
-- Add index on user_id for better query performance
CREATE INDEX IF NOT EXISTS "idx_reports_user_id" ON "reports"("user_id");--> statement-breakpoint
ALTER TABLE "reports" DROP CONSTRAINT "reports_player_id_players_id_fk";
--> statement-breakpoint
ALTER TABLE "reports" DROP CONSTRAINT "reports_coach_id_coaches_id_fk";
--> statement-breakpoint
ALTER TABLE "reports" DROP CONSTRAINT "reports_match_id_matches_id_fk";
--> statement-breakpoint
ALTER TABLE "reports" ADD COLUMN "player_wy_id" integer;--> statement-breakpoint
ALTER TABLE "reports" ADD COLUMN "coach_wy_id" integer;--> statement-breakpoint
ALTER TABLE "reports" ADD COLUMN "match_wy_id" integer;--> statement-breakpoint
ALTER TABLE "reports" ADD COLUMN "type" text NOT NULL;--> statement-breakpoint
ALTER TABLE "reports" ADD COLUMN "user_id" integer;--> statement-breakpoint
ALTER TABLE "reports" ADD CONSTRAINT "reports_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reports" DROP COLUMN "player_id";--> statement-breakpoint
ALTER TABLE "reports" DROP COLUMN "coach_id";--> statement-breakpoint
ALTER TABLE "reports" DROP COLUMN "match_id";
\ No newline at end of file
-- 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 $$;--> statement-breakpoint
-- Change rating column from integer to decimal(5,2)
DO $$
BEGIN
-- Check if rating column exists and is integer type
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'reports'
AND column_name = 'rating'
AND data_type = 'integer'
) THEN
-- Convert integer to decimal, preserving existing data
ALTER TABLE "reports"
ALTER COLUMN "rating" TYPE numeric(5,2) USING rating::numeric(5,2);
END IF;
END $$;--> statement-breakpoint
-- 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 $$;--> statement-breakpoint
CREATE TYPE "public"."report_status" AS ENUM('saved', 'finished');--> statement-breakpoint
ALTER TABLE "reports" ALTER COLUMN "rating" SET DATA TYPE numeric(5, 2);--> statement-breakpoint
ALTER TABLE "reports" ADD COLUMN "status" "report_status" DEFAULT 'saved';
\ No newline at end of file
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
);
--> statement-breakpoint
ALTER TABLE "files" ADD CONSTRAINT "files_uploaded_by_users_id_fk" FOREIGN KEY ("uploaded_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_files_entity_type" ON "files" USING btree ("entity_type");--> statement-breakpoint
CREATE INDEX "idx_files_entity_id" ON "files" USING btree ("entity_id");--> statement-breakpoint
CREATE INDEX "idx_files_entity_wy_id" ON "files" USING btree ("entity_wy_id");--> statement-breakpoint
CREATE INDEX "idx_files_entity_type_id" ON "files" USING btree ("entity_type","entity_id");--> statement-breakpoint
CREATE INDEX "idx_files_uploaded_by" ON "files" USING btree ("uploaded_by");--> statement-breakpoint
CREATE INDEX "idx_files_category" ON "files" USING btree ("category");--> statement-breakpoint
CREATE INDEX "idx_files_is_active" ON "files" USING btree ("is_active");--> statement-breakpoint
CREATE INDEX "idx_files_deleted_at" ON "files" USING btree ("deleted_at");
\ No newline at end of file
CREATE TYPE "public"."calendar_event_type" AS ENUM('match', 'travel', 'player_observation', 'meeting', 'training', 'other');--> statement-breakpoint
CREATE TABLE "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" 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 "calendar_events" ADD CONSTRAINT "calendar_events_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_calendar_events_user_id" ON "calendar_events" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_calendar_events_event_type" ON "calendar_events" USING btree ("event_type");--> statement-breakpoint
CREATE INDEX "idx_calendar_events_start_date" ON "calendar_events" USING btree ("start_date");--> statement-breakpoint
CREATE INDEX "idx_calendar_events_match_wy_id" ON "calendar_events" USING btree ("match_wy_id");--> statement-breakpoint
CREATE INDEX "idx_calendar_events_player_wy_id" ON "calendar_events" USING btree ("player_wy_id");--> statement-breakpoint
CREATE INDEX "idx_calendar_events_is_active" ON "calendar_events" USING btree ("is_active");--> statement-breakpoint
CREATE INDEX "idx_calendar_events_deleted_at" ON "calendar_events" USING btree ("deleted_at");--> statement-breakpoint
CREATE INDEX "idx_calendar_events_user_start_date" ON "calendar_events" USING btree ("user_id","start_date");
\ No newline at end of file
ALTER TABLE "players" ADD COLUMN "current_team_name" varchar(255);--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "current_team_official_name" varchar(255);
\ No newline at end of file
......@@ -15,6 +15,55 @@
"when": 1761216792037,
"tag": "0001_loose_gideon",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1761826157172,
"tag": "0002_whole_randall",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1761826157173,
"tag": "0003_update_reports_to_wy_id",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1762277197827,
"tag": "0004_lively_forgotten_one",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1762361284321,
"tag": "0005_concerned_deathstrike",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1762426824345,
"tag": "0006_wakeful_cammi",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1762532548825,
"tag": "0007_concerned_tinkerer",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1762768010792,
"tag": "0008_third_black_panther",
"breakpoints": true
}
]
}
\ No newline at end of file
-- Quick fix: Drop all foreign key constraints on reports table
-- Run this directly on your database to fix the immediate issue
-- Drop all foreign key constraints related to player, coach, or match
ALTER TABLE "reports" DROP CONSTRAINT IF EXISTS "reports_player_id_players_id_fk";
ALTER TABLE "reports" DROP CONSTRAINT IF EXISTS "reports_coach_id_coaches_id_fk";
ALTER TABLE "reports" DROP CONSTRAINT IF EXISTS "reports_match_id_matches_id_fk";
ALTER TABLE "reports" DROP CONSTRAINT IF EXISTS "reports_player_id_fkey";
ALTER TABLE "reports" DROP CONSTRAINT IF EXISTS "reports_coach_id_fkey";
ALTER TABLE "reports" DROP CONSTRAINT IF EXISTS "reports_match_id_fkey";
-- Drop any other variations dynamically
DO $$
DECLARE
constraint_rec RECORD;
BEGIN
FOR constraint_rec IN
SELECT constraint_name
FROM information_schema.table_constraints
WHERE table_schema = 'public'
AND table_name = 'reports'
AND constraint_type = 'FOREIGN KEY'
AND (constraint_name LIKE '%player%' OR constraint_name LIKE '%coach%' OR constraint_name LIKE '%match%')
LOOP
EXECUTE 'ALTER TABLE "reports" DROP CONSTRAINT IF EXISTS "' || constraint_rec.constraint_name || '"';
RAISE NOTICE 'Dropped constraint: %', constraint_rec.constraint_name;
END LOOP;
END $$;
......@@ -18,10 +18,10 @@
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"db:generate": "drizzle-kit generate:pg",
"db:generate": "drizzle-kit generate",
"db:migrate": "ts-node src/database/migrate.ts",
"db:studio": "drizzle-kit studio",
"db:push": "drizzle-kit push:pg",
"db:push": "drizzle-kit push",
"db:import-users": "ts-node src/database/import-users.ts",
"db:import-users-json": "ts-node src/database/import-users-from-json.ts"
},
......
......@@ -8,6 +8,13 @@ import { UsersModule } from './modules/users/users.module';
import { AuthModule } from './modules/auth/auth.module';
import { SuperAdminModule } from './modules/superadmin/superadmin.module';
import { SettingsModule } from './modules/settings/settings.module';
import { PlayersModule } from './modules/players/players.module';
import { MatchesModule } from './modules/matches/matches.module';
import { CoachesModule } from './modules/coaches/coaches.module';
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';
@Module({
imports: [
......@@ -22,6 +29,13 @@ import { SettingsModule } from './modules/settings/settings.module';
AuthModule,
SuperAdminModule,
SettingsModule,
PlayersModule,
MatchesModule,
CoachesModule,
ReportsModule,
FilesModule,
CalendarModule,
AreasModule,
],
controllers: [AppController],
providers: [AppService],
......
import { HttpStatus } from '@nestjs/common';
import { BaseError } from './base.error';
/**
* Areas management related errors
*/
export class AreasError extends BaseError {
constructor(message: string, context?: Record<string, any>) {
super(message, HttpStatus.INTERNAL_SERVER_ERROR, 'AREAS_ERROR', context);
}
}
export class AreaNotFoundError extends BaseError {
constructor(areaId: string | number) {
super(
`Area with ID '${areaId}' not found`,
HttpStatus.NOT_FOUND,
'AREA_NOT_FOUND',
{ areaId },
);
}
}
export class AreaWyIdRequiredError extends BaseError {
constructor() {
super(
'wyId is required to upsert an area',
HttpStatus.BAD_REQUEST,
'AREA_WY_ID_REQUIRED',
);
}
}
import { HttpStatus } from '@nestjs/common';
import { BaseError } from './base.error';
/**
* Coaches management related errors
*/
export class CoachesError extends BaseError {
constructor(message: string, context?: Record<string, any>) {
super(message, HttpStatus.INTERNAL_SERVER_ERROR, 'COACHES_ERROR', context);
}
}
export class CoachNotFoundError extends BaseError {
constructor(coachId: string | number) {
super(
`Coach with ID '${coachId}' not found`,
HttpStatus.NOT_FOUND,
'COACH_NOT_FOUND',
{ coachId },
);
}
}
export class CoachWyIdRequiredError extends BaseError {
constructor() {
super(
'wyId is required to upsert a coach',
HttpStatus.BAD_REQUEST,
'COACH_WY_ID_REQUIRED',
);
}
}
......@@ -97,3 +97,52 @@ export class DatabaseRestoreError extends BaseError {
);
}
}
export class DatabaseColumnNotFoundError extends BaseError {
constructor(column: string, query?: string) {
super(
'Database column does not exist. The database schema may be out of date. Please contact support to run migrations.',
HttpStatus.INTERNAL_SERVER_ERROR,
'DATABASE_COLUMN_NOT_FOUND',
{ column, query },
);
}
}
export class DatabaseRequiredFieldMissingError extends BaseError {
constructor(field: string, detail?: string) {
super(
'Required field is missing or invalid',
HttpStatus.BAD_REQUEST,
'DATABASE_REQUIRED_FIELD_MISSING',
{ field, detail },
);
}
}
export class DatabaseDuplicateEntryError extends BaseError {
constructor(entity: string, detail?: string) {
super(
`A ${entity} with these details already exists`,
HttpStatus.CONFLICT,
'DATABASE_DUPLICATE_ENTRY',
{ entity, detail },
);
}
}
export class DatabaseForeignKeyConstraintError extends BaseError {
constructor(constraintName: string, referencedEntity?: string, detail?: string) {
const isMigrationIssue = constraintName?.includes('_id');
const message = isMigrationIssue
? 'Database migration incomplete. The foreign key constraints need to be removed. Please contact support.'
: `The referenced ${referencedEntity || 'entity'} does not exist in the system`;
super(
message,
isMigrationIssue ? HttpStatus.INTERNAL_SERVER_ERROR : HttpStatus.BAD_REQUEST,
'DATABASE_FOREIGN_KEY_CONSTRAINT_ERROR',
{ constraintName, referencedEntity, detail, isMigrationIssue },
);
}
}
......@@ -66,6 +66,8 @@ export {
PlayerDataInsufficientError,
PlayerReportAccessDeniedError,
PlayerPerformanceDataOutdatedError,
PlayerWyIdRequiredError,
QueryApiNotAvailableError,
} from './player.errors';
// Transfer management errors
......@@ -122,6 +124,10 @@ export {
DatabaseMigrationError,
DatabaseBackupError,
DatabaseRestoreError,
DatabaseColumnNotFoundError,
DatabaseRequiredFieldMissingError,
DatabaseDuplicateEntryError,
DatabaseForeignKeyConstraintError,
} from './database.errors';
// Data import errors
......@@ -142,3 +148,31 @@ export {
createRefereeImportError,
createBulkImportError,
} from './data-import.errors';
// Areas errors
export {
AreasError,
AreaNotFoundError,
AreaWyIdRequiredError,
} from './areas.errors';
// Matches errors
export {
MatchesError,
MatchNotFoundError,
MatchWyIdRequiredError,
} from './matches.errors';
// Coaches errors
export {
CoachesError,
CoachNotFoundError,
CoachWyIdRequiredError,
} from './coaches.errors';
// SuperAdmin errors
export {
SuperAdminError,
ClientModuleNotFoundError,
ClientModuleAlreadyExistsError,
} from './superadmin.errors';
import { HttpStatus } from '@nestjs/common';
import { BaseError } from './base.error';
/**
* Matches management related errors
*/
export class MatchesError extends BaseError {
constructor(message: string, context?: Record<string, any>) {
super(message, HttpStatus.INTERNAL_SERVER_ERROR, 'MATCHES_ERROR', context);
}
}
export class MatchNotFoundError extends BaseError {
constructor(matchId: string | number) {
super(
`Match with ID '${matchId}' not found`,
HttpStatus.NOT_FOUND,
'MATCH_NOT_FOUND',
{ matchId },
);
}
}
export class MatchWyIdRequiredError extends BaseError {
constructor() {
super(
'wyId is required to upsert a match',
HttpStatus.BAD_REQUEST,
'MATCH_WY_ID_REQUIRED',
);
}
}
......@@ -97,3 +97,23 @@ export class PlayerPerformanceDataOutdatedError extends BaseError {
);
}
}
export class PlayerWyIdRequiredError extends BaseError {
constructor() {
super(
'wyId is required to upsert a player',
HttpStatus.BAD_REQUEST,
'PLAYER_WY_ID_REQUIRED',
);
}
}
export class QueryApiNotAvailableError extends BaseError {
constructor() {
super(
'Query API not available',
HttpStatus.SERVICE_UNAVAILABLE,
'QUERY_API_NOT_AVAILABLE',
);
}
}
import { HttpStatus } from '@nestjs/common';
import { BaseError } from './base.error';
/**
* SuperAdmin management related errors
*/
export class SuperAdminError extends BaseError {
constructor(message: string, context?: Record<string, any>) {
super(message, HttpStatus.INTERNAL_SERVER_ERROR, 'SUPERADMIN_ERROR', context);
}
}
export class ClientModuleNotFoundError extends BaseError {
constructor(identifier: string | number, type: 'id' | 'clientId' = 'id') {
const field = type === 'id' ? 'ID' : 'client ID';
super(
`Client module with ${field} '${identifier}' not found`,
HttpStatus.NOT_FOUND,
'CLIENT_MODULE_NOT_FOUND',
{ identifier, type },
);
}
}
export class ClientModuleAlreadyExistsError extends BaseError {
constructor(clientId: string) {
super(
`Client module for client '${clientId}' already exists`,
HttpStatus.CONFLICT,
'CLIENT_MODULE_ALREADY_EXISTS',
{ clientId },
);
}
}
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { DatabaseService } from '../../database/database.service';
import { eq, and, isNull } from 'drizzle-orm';
import { users, userSessions } from '../../database/schema';
@Injectable()
export class JwtSimpleGuard implements CanActivate {
constructor(
private readonly jwtService: JwtService,
private readonly databaseService: DatabaseService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException('No token provided');
}
try {
// Validate JWT token
const payload = this.jwtService.verify(token);
if (!payload || !payload.sub) {
throw new UnauthorizedException('Invalid token');
}
// Get user from database
const db = this.databaseService.getDatabase();
const [user] = await db
.select()
.from(users)
.where(and(eq(users.id, payload.sub), isNull(users.deletedAt)))
.limit(1);
if (!user || !user.isActive) {
throw new UnauthorizedException('User not found or inactive');
}
// Check if session exists (for single-device login)
const [session] = await db
.select()
.from(userSessions)
.where(
and(eq(userSessions.token, token), eq(userSessions.userId, user.id)),
)
.limit(1);
if (!session) {
throw new UnauthorizedException('Session expired or invalid');
}
// Attach user to request
request.user = {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isActive: user.isActive,
};
return true;
} catch (error) {
if (error instanceof UnauthorizedException) {
throw error;
}
throw new UnauthorizedException('Invalid token');
}
}
private extractTokenFromHeader(request: any): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
export interface PaginationMeta {
timestamp: string;
endpoint: string;
method: string;
totalItems: number;
page: number;
limit: number;
hasMore: boolean;
}
export interface SimplePaginationMeta {
total: number;
limit: number;
offset: number;
}
export interface MatchesPaginationMeta {
timestamp: string;
endpoint: string;
method: string;
totalItems: number;
limit: number;
offset: number;
}
export interface PaginatedResponse<T> {
data: T[];
meta: PaginationMeta;
}
export interface SimplePaginatedResponse<T> {
data: T[];
meta: SimplePaginationMeta;
}
export interface MatchesPaginatedResponse<T> {
data: T[];
meta: MatchesPaginationMeta;
}
export class ResponseUtil {
static createPaginatedResponse<T>(
data: T[],
basePath: string,
totalItems: number,
currentPage: number,
itemsPerPage: number,
): PaginatedResponse<T> {
const totalPages = Math.ceil(totalItems / itemsPerPage);
const hasMore = currentPage < totalPages;
const meta: PaginationMeta = {
timestamp: new Date().toISOString(),
endpoint: basePath,
method: 'GET',
totalItems,
page: currentPage,
limit: itemsPerPage,
hasMore,
};
return {
data,
meta,
};
}
static createSimplePaginatedResponse<T>(
data: T[],
total: number,
limit: number,
offset: number,
): SimplePaginatedResponse<T> {
const meta: SimplePaginationMeta = {
total,
limit,
offset,
};
return {
data,
meta,
};
}
static createMatchesPaginatedResponse<T>(
data: T[],
basePath: string,
totalItems: number,
limit: number,
offset: number,
): MatchesPaginatedResponse<T> {
const meta: MatchesPaginationMeta = {
timestamp: new Date().toISOString(),
endpoint: basePath,
method: 'GET',
totalItems,
limit,
offset,
};
return {
data,
meta,
};
}
}
import { NestFactory } from '@nestjs/core';
import { Logger, ValidationPipe } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { json, urlencoded } from 'express';
import { AppModule } from './app.module';
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
import { LoggingInterceptor } from './common/interceptors/logging.interceptor';
......@@ -32,6 +33,13 @@ async function bootstrap() {
// Enable CORS for cross-origin requests
app.enableCors();
// Configure body size limits for file uploads
// Default is 1mb, increase for file uploads (e.g., 50mb for images/documents)
// Note: For multipart/form-data file uploads, use @UseInterceptors(FileInterceptor('file'))
// or FilesInterceptor in controllers. Multer is automatically configured via @nestjs/platform-express
app.use(json({ limit: '50mb' }));
app.use(urlencoded({ limit: '50mb', extended: true }));
// Set global prefix for all routes
app.setGlobalPrefix('api');
......
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
Post,
Query,
} from '@nestjs/common';
import { AreasService } from './areas.service';
import { type Area } from '../../database/schema';
import {
ApiBody,
ApiOkResponse,
ApiOperation,
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import { SimplePaginatedResponse } from '../../common/utils/response.util';
import { CreateAreaDto } from './dto';
@ApiTags('Areas')
@Controller('areas')
export class AreasController {
constructor(private readonly areasService: AreasService) {}
@Post()
@ApiOperation({ summary: 'Create or update area by wyId' })
@ApiBody({
description: 'Area payload',
type: CreateAreaDto,
})
@ApiOkResponse({ description: 'Upserted area' })
async save(@Body() body: CreateAreaDto): Promise<Area> {
return this.areasService.upsertByWyId(body as any);
}
@Get(':wyId')
@ApiOperation({ summary: 'Get area by wyId' })
@ApiOkResponse({ description: 'Area if found' })
async getByWyId(
@Param('wyId', ParseIntPipe) wyId: number,
): Promise<Area | undefined> {
return this.areasService.findByWyId(wyId);
}
@Get()
@ApiOperation({
summary: 'List areas with pagination',
description:
'Search areas by name, alpha2code, or alpha3code. Uses normalized string comparison (accent-insensitive).',
})
@ApiQuery({
name: 'search',
required: false,
type: String,
description:
'Search areas by name, alpha2code, or alpha3code. Optional.',
})
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: 'Number of results per page (default: 50)',
})
@ApiQuery({
name: 'offset',
required: false,
type: Number,
description: 'Number of results to skip (default: 0)',
})
@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);
}
}
import { Module } from '@nestjs/common';
import { AreasController } from './areas.controller';
import { AreasService } from './areas.service';
@Module({
controllers: [AreasController],
providers: [AreasService],
exports: [AreasService],
})
export class AreasModule {}
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 { ResponseUtil, SimplePaginatedResponse } from '../../common/utils/response.util';
import { AreaWyIdRequiredError } from '../../common/errors';
@Injectable()
export class AreasService {
constructor(private readonly databaseService: DatabaseService) {}
/**
* Normalizes a string by removing accents and converting to lowercase
* Example: "José" -> "jose", "Müller" -> "muller"
*/
private normalizeString(str: string): string {
return str
.normalize('NFD') // Decompose characters (é -> e + ́)
.replace(/[\u0300-\u036f]/g, '') // Remove diacritical marks
.toLowerCase()
.trim();
}
// Transform raw database data to match the API structure
private transformArea(rawArea: any): any {
// Helper to convert timestamp to ISO string or null
const formatTimestamp = (
value: Date | string | null | undefined,
): string | null => {
if (!value) return null;
if (typeof value === 'string') {
const d = new Date(value);
return isNaN(d.getTime()) ? null : d.toISOString();
}
if (value instanceof Date) {
return value.toISOString();
}
return null;
};
return {
id: rawArea.id ?? 0,
wyId: rawArea.wyId ?? rawArea.wy_id ?? 0,
name: rawArea.name ?? '',
alpha2code: rawArea.alpha2code ?? null,
alpha3code: rawArea.alpha3code ?? null,
createdAt: rawArea.createdAt
? (formatTimestamp(rawArea.createdAt) ?? null)
: rawArea.created_at
? (formatTimestamp(rawArea.created_at) ?? null)
: null,
updatedAt: rawArea.updatedAt
? (formatTimestamp(rawArea.updatedAt) ?? null)
: rawArea.updated_at
? (formatTimestamp(rawArea.updated_at) ?? null)
: null,
deletedAt: rawArea.deletedAt
? (formatTimestamp(rawArea.deletedAt) ?? null)
: rawArea.deleted_at
? (formatTimestamp(rawArea.deleted_at) ?? null)
: null,
};
}
async findAll(
limit: number = 50,
offset: number = 0,
search?: string,
): Promise<SimplePaginatedResponse<any>> {
const db = this.databaseService.getDatabase();
// Build where conditions - exclude soft-deleted records
const baseConditions = isNull(areas.deletedAt);
let whereCondition = baseConditions;
// If search is provided, add name search conditions
if (search && search.trim()) {
const searchPattern = `%${search.trim()}%`;
const nameSearchConditions = or(
ilike(areas.name, searchPattern),
ilike(areas.alpha2code, searchPattern),
ilike(areas.alpha3code, searchPattern),
) as any;
whereCondition = and(baseConditions, nameSearchConditions) as any;
}
// Get total count
const [totalResult] = await db
.select({ count: count() })
.from(areas)
.where(whereCondition);
let total = totalResult.count;
// Get data
const query = db.select().from(areas).where(whereCondition);
// 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);
} else {
rawData = await query;
}
// Transform to match API structure
let transformedData = rawData.map((area) => this.transformArea(area));
// If search is provided, apply normalized filtering
if (search && search.trim()) {
const normalizedSearch = this.normalizeString(search);
const filteredData = transformedData.filter((area) => {
const normalizedName = this.normalizeString(area.name || '');
const normalizedAlpha2 = this.normalizeString(area.alpha2code || '');
const normalizedAlpha3 = this.normalizeString(area.alpha3code || '');
return (
normalizedName.includes(normalizedSearch) ||
normalizedAlpha2.includes(normalizedSearch) ||
normalizedAlpha3.includes(normalizedSearch)
);
});
// Apply limit and offset after filtering
transformedData = filteredData.slice(offset, offset + limit);
// Update total to the actual filtered count
total = filteredData.length;
}
return ResponseUtil.createSimplePaginatedResponse(
transformedData,
total,
limit,
offset,
);
}
async upsertByWyId(data: NewArea | any): Promise<Area> {
const db = this.databaseService.getDatabase();
if (data.wyId == null) {
throw new AreaWyIdRequiredError();
}
// Normalize timestamp fields that may arrive as strings
const toDate = (value: unknown): Date | undefined => {
if (value == null) return undefined;
if (value instanceof Date) return value;
const d = new Date(value as any);
return isNaN(d.getTime()) ? undefined : d;
};
// Normalize empty strings to null for nullable fields
const normalizeToNull = (value: unknown): any => {
if (value === '' || value === null || value === undefined) return null;
return value;
};
// Transform external API format to database schema format
// Extract all fields, excluding 'id' field
const { id, ...restData } = data;
const transformed: NewArea = {
wyId: data.wyId,
name: data.name,
alpha2code: normalizeToNull(data.alpha2code),
alpha3code: normalizeToNull(data.alpha3code),
createdAt: toDate(data.createdAt) as any,
updatedAt: toDate(data.updatedAt) as any,
deletedAt: toDate(data.deletedAt) as any,
};
const [result] = await db
.insert(areas)
.values(transformed)
.onConflictDoUpdate({ target: areas.wyId, set: transformed })
.returning();
return result as Area;
}
async findByWyId(wyId: number): Promise<Area | undefined> {
const db = this.databaseService.getDatabase();
const rows = await db
.select()
.from(areas)
.where(and(eq(areas.wyId, wyId), isNull(areas.deletedAt)));
return rows[0];
}
async list(limit = 50, offset = 0): Promise<Area[]> {
const db = this.databaseService.getDatabase();
return db
.select()
.from(areas)
.where(isNull(areas.deletedAt))
.limit(limit)
.offset(offset);
}
}
import { IsInt, IsString, IsOptional, Length } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateAreaDto {
@ApiProperty({
description: 'Wyscout ID of the area',
example: 39,
})
@IsInt()
wyId: number;
@ApiProperty({
description: 'Name of the area',
example: 'England',
})
@IsString()
name: string;
@ApiPropertyOptional({
description: 'ISO 3166-1 alpha-2 code (2-letter country code)',
example: 'GB',
})
@IsOptional()
@IsString()
@Length(2, 2)
alpha2code?: string;
@ApiPropertyOptional({
description: 'ISO 3166-1 alpha-3 code (3-letter country code)',
example: 'GBR',
})
@IsOptional()
@IsString()
@Length(3, 3)
alpha3code?: string;
}
export * from './create-area.dto';
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
NotFoundException,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { CalendarService } from './calendar.service';
import { type CalendarEvent } from '../../database/schema';
import {
ApiBody,
ApiNoContentResponse,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiQuery,
ApiTags,
ApiBearerAuth,
} from '@nestjs/swagger';
import {
CreateCalendarEventDto,
UpdateCalendarEventDto,
QueryCalendarEventsDto,
} from './dto';
import { JwtAuthGuard } from '../../common/guards/auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { User } from '../../database/schema';
@ApiTags('Calendar')
@Controller('calendar')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('bearer')
export class CalendarController {
constructor(private readonly calendarService: CalendarService) {}
@Post()
@ApiOperation({ summary: 'Create a calendar event' })
@ApiBody({
description:
'Calendar event payload. Event type can be: match, travel, player_observation, meeting, training, or other.',
type: CreateCalendarEventDto,
})
@ApiOkResponse({ description: 'Created calendar event' })
async create(
@Body() body: CreateCalendarEventDto,
@CurrentUser() user: User,
): Promise<CalendarEvent> {
return this.calendarService.create(body, user.id);
}
@Get()
@ApiOperation({
summary: 'List calendar events with advanced filtering',
description:
'List calendar events with comprehensive filtering options. Supports filtering by user, event types, dates, text search, metadata, and more. Defaults to current user if userId is not specified.',
})
@ApiQuery({
name: 'userId',
required: false,
type: Number,
description: 'Filter by user ID (defaults to current user if not specified)',
})
@ApiQuery({
name: 'eventType',
required: false,
enum: ['match', 'travel', 'player_observation', 'meeting', 'training', 'other'],
description: 'Filter by single event type',
})
@ApiQuery({
name: 'eventTypes',
required: false,
type: String,
description: 'Filter by multiple event types (comma-separated: match,travel,meeting)',
})
@ApiQuery({
name: 'matchWyId',
required: false,
type: Number,
description: 'Filter by match WyID',
})
@ApiQuery({
name: 'playerWyId',
required: false,
type: Number,
description: 'Filter by player WyID',
})
@ApiQuery({
name: 'startDateFrom',
required: false,
type: String,
description: 'Filter events starting from this date (ISO 8601)',
})
@ApiQuery({
name: 'startDateTo',
required: false,
type: String,
description: 'Filter events starting until this date (ISO 8601)',
})
@ApiQuery({
name: 'endDateFrom',
required: false,
type: String,
description: 'Filter events ending from this date (ISO 8601)',
})
@ApiQuery({
name: 'endDateTo',
required: false,
type: String,
description: 'Filter events ending until this date (ISO 8601)',
})
@ApiQuery({
name: 'overlapStartDate',
required: false,
type: String,
description: 'Filter events that overlap with date range (start date, ISO 8601)',
})
@ApiQuery({
name: 'overlapEndDate',
required: false,
type: String,
description: 'Filter events that overlap with date range (end date, ISO 8601)',
})
@ApiQuery({
name: 'title',
required: false,
type: String,
description: 'Search by title (case-insensitive partial match)',
})
@ApiQuery({
name: 'description',
required: false,
type: String,
description: 'Search in description (case-insensitive partial match)',
})
@ApiQuery({
name: 'search',
required: false,
type: String,
description: 'Search in title or description (case-insensitive partial match)',
})
@ApiQuery({
name: 'isActive',
required: false,
type: Boolean,
description: 'Filter by active status',
})
@ApiQuery({
name: 'hasMatch',
required: false,
type: Boolean,
description: 'Filter events that have a match (match_wy_id is not null)',
})
@ApiQuery({
name: 'hasPlayer',
required: false,
type: Boolean,
description: 'Filter events that have a player (player_wy_id is not null)',
})
@ApiQuery({
name: 'hasEndDate',
required: false,
type: Boolean,
description: 'Filter events that have an end date (end_date is not null)',
})
@ApiQuery({
name: 'location',
required: false,
type: String,
description: 'Search in metadata location field (case-insensitive partial match)',
})
@ApiQuery({
name: 'venue',
required: false,
type: String,
description: 'Search in metadata venue field (case-insensitive partial match)',
})
@ApiQuery({
name: 'createdFrom',
required: false,
type: String,
description: 'Filter by created date (from, ISO 8601)',
})
@ApiQuery({
name: 'createdTo',
required: false,
type: String,
description: 'Filter by created date (to, ISO 8601)',
})
@ApiQuery({
name: 'updatedFrom',
required: false,
type: String,
description: 'Filter by updated date (from, ISO 8601)',
})
@ApiQuery({
name: 'updatedTo',
required: false,
type: String,
description: 'Filter by updated date (to, ISO 8601)',
})
@ApiQuery({
name: 'sortBy',
required: false,
enum: ['startDate', 'endDate', 'createdAt', 'updatedAt', 'title'],
description: 'Sort by field (default: startDate)',
})
@ApiQuery({
name: 'sortOrder',
required: false,
enum: ['asc', 'desc'],
description: 'Sort order (default: desc)',
})
@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)',
})
@ApiOkResponse({ description: 'List of calendar events matching the filters' })
async list(
@Query() query: QueryCalendarEventsDto,
@CurrentUser() user: User,
): Promise<CalendarEvent[]> {
// If userId is not specified, default to current user
if (!query.userId) {
query.userId = user.id;
}
// Handle eventTypes as comma-separated string from query params
if (query.eventTypes && typeof query.eventTypes === 'string') {
query.eventTypes = (query.eventTypes as any)
.split(',')
.map((t: string) => t.trim())
.filter((t: string) => t.length > 0) as any;
}
return this.calendarService.findAll(query);
}
@Get('my-events')
@ApiOperation({
summary: 'Get all calendar events for the current user',
description: 'Returns all calendar events for the authenticated user',
})
@ApiOkResponse({ description: 'List of calendar events for the current user' })
async getMyEvents(@CurrentUser() user: User): Promise<CalendarEvent[]> {
return this.calendarService.findByUserId(user.id);
}
@Get(':id')
@ApiOperation({ summary: 'Get calendar event by ID' })
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the calendar event',
})
@ApiOkResponse({ description: 'Calendar event if found' })
@ApiNotFoundResponse({ description: 'Calendar event not found' })
async getById(
@Param('id', ParseIntPipe) id: number,
): Promise<CalendarEvent | undefined> {
return this.calendarService.findById(id);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a calendar event' })
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the calendar event to update',
})
@ApiBody({
description:
'Calendar event update payload. All fields are optional.',
type: UpdateCalendarEventDto,
})
@ApiOkResponse({ description: 'Updated calendar event' })
@ApiNotFoundResponse({ description: 'Calendar event not found' })
async update(
@Param('id', ParseIntPipe) id: number,
@Body() body: UpdateCalendarEventDto,
): Promise<CalendarEvent> {
const result = await this.calendarService.update(id, body);
if (!result) {
throw new NotFoundException(`Calendar event with ID ${id} not found`);
}
return result;
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete a calendar event' })
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the calendar event to delete',
})
@ApiNoContentResponse({ description: 'Calendar event deleted successfully' })
@ApiNotFoundResponse({ description: 'Calendar event not found' })
async delete(@Param('id', ParseIntPipe) id: number): Promise<void> {
const result = await this.calendarService.delete(id);
if (!result) {
throw new NotFoundException(`Calendar event with ID ${id} not found`);
}
}
}
import { Module } from '@nestjs/common';
import { CalendarController } from './calendar.controller';
import { CalendarService } from './calendar.service';
import { DatabaseModule } from '../../database/database.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [DatabaseModule, AuthModule],
controllers: [CalendarController],
providers: [CalendarService],
exports: [CalendarService],
})
export class CalendarModule {}
import {
IsString,
IsOptional,
IsInt,
IsEnum,
IsDateString,
IsObject,
IsBoolean,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateCalendarEventDto {
@ApiProperty({
description: 'Title of the calendar event',
example: 'Match: Team A vs Team B',
})
@IsString()
title: string;
@ApiPropertyOptional({
description: 'Description of the event',
example: 'Important match to observe',
})
@IsOptional()
@IsString()
description?: string;
@ApiProperty({
description: 'Type of calendar event',
enum: ['match', 'travel', 'player_observation', 'meeting', 'training', 'other'],
example: 'match',
})
@IsEnum(['match', 'travel', 'player_observation', 'meeting', 'training', 'other'])
eventType: 'match' | 'travel' | 'player_observation' | 'meeting' | 'training' | 'other';
@ApiProperty({
description: 'Start date and time of the event (ISO 8601)',
example: '2025-01-15T15:00:00Z',
})
@IsDateString()
startDate: string;
@ApiPropertyOptional({
description: 'End date and time of the event (ISO 8601)',
example: '2025-01-15T17:00:00Z',
})
@IsOptional()
@IsDateString()
endDate?: string;
@ApiPropertyOptional({
description: 'Wyscout ID of the match (for match events)',
example: 999001,
})
@IsOptional()
@IsInt()
matchWyId?: number;
@ApiPropertyOptional({
description: 'Wyscout ID of the player (for player observation events)',
example: 1093815,
})
@IsOptional()
@IsInt()
playerWyId?: number;
@ApiPropertyOptional({
description: 'Additional metadata as JSON object',
example: { location: 'Stadium Name', venue: 'Main Field', notes: 'Bring camera' },
})
@IsOptional()
@IsObject()
metadata?: {
location?: string;
venue?: string;
notes?: string;
[key: string]: any;
};
@ApiPropertyOptional({
description: 'Whether the event is active',
example: true,
default: true,
})
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export { CreateCalendarEventDto } from './create-calendar-event.dto';
export { UpdateCalendarEventDto } from './update-calendar-event.dto';
export { QueryCalendarEventsDto } from './query-calendar-events.dto';
import {
IsOptional,
IsInt,
IsEnum,
IsDateString,
IsString,
IsBoolean,
IsArray,
Min,
Max,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export class QueryCalendarEventsDto {
@ApiPropertyOptional({
description: 'Filter by user ID',
example: 1,
})
@IsOptional()
@Type(() => Number)
@IsInt()
userId?: number;
@ApiPropertyOptional({
description: 'Filter by event type',
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';
@ApiPropertyOptional({
description: 'Filter by multiple event types (comma-separated or array)',
example: ['match', 'travel'],
type: [String],
})
@IsOptional()
@IsArray()
@IsEnum(['match', 'travel', 'player_observation', 'meeting', 'training', 'other'], {
each: true,
})
eventTypes?: ('match' | 'travel' | 'player_observation' | 'meeting' | 'training' | 'other')[];
@ApiPropertyOptional({
description: 'Filter by match WyID',
example: 999001,
})
@IsOptional()
@Type(() => Number)
@IsInt()
matchWyId?: number;
@ApiPropertyOptional({
description: 'Filter by player WyID',
example: 1093815,
})
@IsOptional()
@Type(() => Number)
@IsInt()
playerWyId?: number;
@ApiPropertyOptional({
description: 'Filter events starting from this date (ISO 8601)',
example: '2025-01-01T00:00:00Z',
})
@IsOptional()
@IsDateString()
startDateFrom?: string;
@ApiPropertyOptional({
description: 'Filter events starting until this date (ISO 8601)',
example: '2025-12-31T23:59:59Z',
})
@IsOptional()
@IsDateString()
startDateTo?: string;
@ApiPropertyOptional({
description: 'Filter events ending from this date (ISO 8601)',
example: '2025-01-01T00:00:00Z',
})
@IsOptional()
@IsDateString()
endDateFrom?: string;
@ApiPropertyOptional({
description: 'Filter events ending until this date (ISO 8601)',
example: '2025-12-31T23:59:59Z',
})
@IsOptional()
@IsDateString()
endDateTo?: string;
@ApiPropertyOptional({
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()
@IsDateString()
overlapStartDate?: string;
@ApiPropertyOptional({
description: 'Filter events that overlap with this date range (used with overlapStartDate)',
example: '2025-06-30T23:59:59Z',
})
@IsOptional()
@IsDateString()
overlapEndDate?: string;
@ApiPropertyOptional({
description: 'Search by title (case-insensitive partial match)',
example: 'match',
})
@IsOptional()
@IsString()
title?: string;
@ApiPropertyOptional({
description: 'Search in description (case-insensitive partial match)',
example: 'important',
})
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({
description: 'Search in title or description (case-insensitive partial match)',
example: 'travel',
})
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({
description: 'Filter by active status',
example: true,
})
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({
description: 'Filter events that have a match (match_wy_id is not null)',
example: true,
})
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
hasMatch?: boolean;
@ApiPropertyOptional({
description: 'Filter events that have a player (player_wy_id is not null)',
example: true,
})
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
hasPlayer?: boolean;
@ApiPropertyOptional({
description: 'Filter events that have an end date (end_date is not null)',
example: true,
})
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
hasEndDate?: boolean;
@ApiPropertyOptional({
description: 'Search in metadata location field (case-insensitive partial match)',
example: 'Stadium',
})
@IsOptional()
@IsString()
location?: string;
@ApiPropertyOptional({
description: 'Search in metadata venue field (case-insensitive partial match)',
example: 'Main Field',
})
@IsOptional()
@IsString()
venue?: string;
@ApiPropertyOptional({
description: 'Filter by created date (from) - ISO date string',
example: '2025-01-01T00:00:00Z',
})
@IsOptional()
@IsDateString()
createdFrom?: string;
@ApiPropertyOptional({
description: 'Filter by created date (to) - ISO date string',
example: '2025-12-31T23:59:59Z',
})
@IsOptional()
@IsDateString()
createdTo?: string;
@ApiPropertyOptional({
description: 'Filter by updated date (from) - ISO date string',
example: '2025-01-01T00:00:00Z',
})
@IsOptional()
@IsDateString()
updatedFrom?: string;
@ApiPropertyOptional({
description: 'Filter by updated date (to) - ISO date string',
example: '2025-12-31T23:59:59Z',
})
@IsOptional()
@IsDateString()
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'],
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;
}
import {
IsString,
IsOptional,
IsInt,
IsEnum,
IsDateString,
IsObject,
IsBoolean,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateCalendarEventDto {
@ApiPropertyOptional({
description: 'Title of the calendar event',
example: 'Match: Team A vs Team B',
})
@IsOptional()
@IsString()
title?: string;
@ApiPropertyOptional({
description: 'Description of the event',
example: 'Important match to observe',
})
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({
description: 'Type of calendar event',
enum: ['match', 'travel', 'player_observation', 'meeting', 'training', 'other'],
example: 'match',
})
@IsOptional()
@IsEnum(['match', 'travel', 'player_observation', 'meeting', 'training', 'other'])
eventType?: 'match' | 'travel' | 'player_observation' | 'meeting' | 'training' | 'other';
@ApiPropertyOptional({
description: 'Start date and time of the event (ISO 8601)',
example: '2025-01-15T15:00:00Z',
})
@IsOptional()
@IsDateString()
startDate?: string;
@ApiPropertyOptional({
description: 'End date and time of the event (ISO 8601)',
example: '2025-01-15T17:00:00Z',
})
@IsOptional()
@IsDateString()
endDate?: string;
@ApiPropertyOptional({
description: 'Wyscout ID of the match (for match events)',
example: 999001,
})
@IsOptional()
@IsInt()
matchWyId?: number;
@ApiPropertyOptional({
description: 'Wyscout ID of the player (for player observation events)',
example: 1093815,
})
@IsOptional()
@IsInt()
playerWyId?: number;
@ApiPropertyOptional({
description: 'Additional metadata as JSON object',
example: { location: 'Stadium Name', venue: 'Main Field', notes: 'Bring camera' },
})
@IsOptional()
@IsObject()
metadata?: {
location?: string;
venue?: string;
notes?: string;
[key: string]: any;
};
@ApiPropertyOptional({
description: 'Whether the event is active',
example: true,
})
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
Post,
Query,
} from '@nestjs/common';
import { CoachesService } from './coaches.service';
import { type Coach } from '../../database/schema';
import {
ApiBody,
ApiOkResponse,
ApiOperation,
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import { SimplePaginatedResponse } from '../../common/utils/response.util';
import { CreateCoachDto } from './dto';
@ApiTags('Coaches')
@Controller('coaches')
export class CoachesController {
constructor(private readonly coachesService: CoachesService) {}
@Post()
@ApiOperation({ summary: 'Create or update coach by wyId' })
@ApiBody({
description: 'Coach payload',
type: CreateCoachDto,
})
@ApiOkResponse({ description: 'Upserted coach' })
async save(@Body() body: CreateCoachDto): Promise<Coach> {
return this.coachesService.upsertByWyId(body as any);
}
@Get(':wyId')
@ApiOperation({ summary: 'Get coach by wyId' })
@ApiOkResponse({ description: 'Coach if found' })
async getByWyId(
@Param('wyId', ParseIntPipe) wyId: number,
): Promise<Coach | undefined> {
return this.coachesService.findByWyId(wyId);
}
@Get()
@ApiOperation({
summary: 'List coaches with pagination',
description:
'Search coaches by name (searches in firstName, lastName, middleName, shortName). Uses normalized string comparison (accent-insensitive).',
})
@ApiQuery({
name: 'name',
required: false,
type: String,
description:
'Search coaches by name (searches in firstName, lastName, middleName, shortName). Optional.',
})
@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);
}
}
import { Module } from '@nestjs/common';
import { CoachesController } from './coaches.controller';
import { CoachesService } from './coaches.service';
@Module({
controllers: [CoachesController],
providers: [CoachesService],
exports: [CoachesService],
})
export class CoachesModule {}
import {
IsInt,
IsString,
IsOptional,
IsEnum,
IsDateString,
IsBoolean,
IsArray,
Min,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateCoachDto {
@ApiProperty({
description: 'Wyscout ID of the coach',
example: 70001,
})
@IsInt()
wyId: number;
@ApiProperty({
description: 'First name of the coach',
example: 'Alex',
})
@IsString()
firstName: string;
@ApiProperty({
description: 'Last name of the coach',
example: 'Smith',
})
@IsString()
lastName: string;
@ApiPropertyOptional({
description: 'Global Sports Media ID',
example: 880,
})
@IsOptional()
@IsInt()
gsmId?: number;
@ApiPropertyOptional({
description: 'Middle name of the coach',
example: 'J',
})
@IsOptional()
@IsString()
middleName?: string;
@ApiPropertyOptional({
description: 'Short name of the coach',
example: 'A. Smith',
})
@IsOptional()
@IsString()
shortName?: string;
@ApiPropertyOptional({
description: 'Date of birth (YYYY-MM-DD)',
example: '1975-03-10',
})
@IsOptional()
@IsDateString()
dateOfBirth?: string;
@ApiPropertyOptional({
description: 'Nationality Wyscout ID',
example: 39,
})
@IsOptional()
@IsInt()
nationalityWyId?: number;
@ApiPropertyOptional({
description: 'Current team Wyscout ID',
example: 1234,
})
@IsOptional()
@IsInt()
currentTeamWyId?: number;
@ApiPropertyOptional({
description: 'Coach position',
enum: ['head_coach', 'assistant_coach', 'analyst'],
example: 'head_coach',
})
@IsOptional()
@IsEnum(['head_coach', 'assistant_coach', 'analyst'])
position?: 'head_coach' | 'assistant_coach' | 'analyst';
@ApiPropertyOptional({
description: 'Coaching license',
example: 'UEFA Pro',
})
@IsOptional()
@IsString()
coachingLicense?: string;
@ApiPropertyOptional({
description: 'Years of coaching experience',
example: 14,
})
@IsOptional()
@IsInt()
@Min(0)
yearsExperience?: number;
@ApiPropertyOptional({
description: 'Previous teams (array of team names)',
example: ['Team A', 'Team B'],
type: [String],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
previousTeams?: string[];
@ApiPropertyOptional({
description: 'Status',
enum: ['active', 'inactive'],
example: 'active',
})
@IsOptional()
@IsEnum(['active', 'inactive'])
status?: 'active' | 'inactive';
@ApiPropertyOptional({
description: 'Image data URL',
example: 'data:image/png;base64,...',
})
@IsOptional()
@IsString()
imageDataUrl?: string;
@ApiPropertyOptional({
description: 'API last synced timestamp',
example: '2025-01-01T10:00:00Z',
})
@IsOptional()
@IsDateString()
apiLastSyncedAt?: string;
@ApiPropertyOptional({
description: 'API sync status',
enum: ['pending', 'synced', 'error'],
example: 'pending',
})
@IsOptional()
@IsEnum(['pending', 'synced', 'error'])
apiSyncStatus?: 'pending' | 'synced' | 'error';
@ApiPropertyOptional({
description: 'Whether the coach is active',
example: true,
default: true,
})
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export * from './create-coach.dto';
export * from './update-coach.dto';
import {
IsInt,
IsString,
IsOptional,
IsEnum,
IsDateString,
IsBoolean,
IsArray,
Min,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateCoachDto {
@ApiPropertyOptional({
description: 'Wyscout ID of the coach',
example: 70001,
})
@IsOptional()
@IsInt()
wyId?: number;
@ApiPropertyOptional({
description: 'First name of the coach',
example: 'Alex',
})
@IsOptional()
@IsString()
firstName?: string;
@ApiPropertyOptional({
description: 'Last name of the coach',
example: 'Smith',
})
@IsOptional()
@IsString()
lastName?: string;
@ApiPropertyOptional({
description: 'Global Sports Media ID',
example: 880,
})
@IsOptional()
@IsInt()
gsmId?: number;
@ApiPropertyOptional({
description: 'Middle name of the coach',
example: 'J',
})
@IsOptional()
@IsString()
middleName?: string;
@ApiPropertyOptional({
description: 'Short name of the coach',
example: 'A. Smith',
})
@IsOptional()
@IsString()
shortName?: string;
@ApiPropertyOptional({
description: 'Date of birth (YYYY-MM-DD)',
example: '1975-03-10',
})
@IsOptional()
@IsDateString()
dateOfBirth?: string;
@ApiPropertyOptional({
description: 'Nationality Wyscout ID',
example: 39,
})
@IsOptional()
@IsInt()
nationalityWyId?: number;
@ApiPropertyOptional({
description: 'Current team Wyscout ID',
example: 1234,
})
@IsOptional()
@IsInt()
currentTeamWyId?: number;
@ApiPropertyOptional({
description: 'Coach position',
enum: ['head_coach', 'assistant_coach', 'analyst'],
example: 'head_coach',
})
@IsOptional()
@IsEnum(['head_coach', 'assistant_coach', 'analyst'])
position?: 'head_coach' | 'assistant_coach' | 'analyst';
@ApiPropertyOptional({
description: 'Coaching license',
example: 'UEFA Pro',
})
@IsOptional()
@IsString()
coachingLicense?: string;
@ApiPropertyOptional({
description: 'Years of coaching experience',
example: 14,
})
@IsOptional()
@IsInt()
@Min(0)
yearsExperience?: number;
@ApiPropertyOptional({
description: 'Previous teams (array of team names)',
example: ['Team A', 'Team B'],
type: [String],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
previousTeams?: string[];
@ApiPropertyOptional({
description: 'Status',
enum: ['active', 'inactive'],
example: 'active',
})
@IsOptional()
@IsEnum(['active', 'inactive'])
status?: 'active' | 'inactive';
@ApiPropertyOptional({
description: 'Image data URL',
example: 'data:image/png;base64,...',
})
@IsOptional()
@IsString()
imageDataUrl?: string;
@ApiPropertyOptional({
description: 'API last synced timestamp',
example: '2025-01-01T10:00:00Z',
})
@IsOptional()
@IsDateString()
apiLastSyncedAt?: string;
@ApiPropertyOptional({
description: 'API sync status',
enum: ['pending', 'synced', 'error'],
example: 'pending',
})
@IsOptional()
@IsEnum(['pending', 'synced', 'error'])
apiSyncStatus?: 'pending' | 'synced' | 'error';
@ApiPropertyOptional({
description: 'Whether the coach is active',
example: true,
})
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
import {
IsString,
IsOptional,
IsInt,
IsEnum,
IsNotEmpty,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateFileDto {
@ApiProperty({
description: 'Original file name',
example: 'player-photo.jpg',
})
@IsString()
@IsNotEmpty()
originalFileName: string;
@ApiProperty({
description: 'File name (stored name)',
example: 'abc123-def456-ghi789.jpg',
})
@IsString()
@IsNotEmpty()
fileName: string;
@ApiProperty({
description: 'File path or URL where the file is stored',
example: '/uploads/images/abc123-def456-ghi789.jpg',
})
@IsString()
@IsNotEmpty()
filePath: string;
@ApiProperty({
description: 'MIME type of the file',
example: 'image/jpeg',
})
@IsString()
@IsNotEmpty()
mimeType: string;
@ApiProperty({
description: 'File size in bytes',
example: 1024000,
})
@IsInt()
fileSize: number;
@ApiProperty({
description: 'Type of entity this file is related to',
enum: ['player', 'coach', 'match', 'report', 'user'],
example: 'player',
})
@IsString()
@IsEnum(['player', 'coach', 'match', 'report', 'user'])
entityType: string;
@ApiPropertyOptional({
description: 'ID of the related entity (database ID)',
example: 123,
type: Number,
})
@IsOptional()
@IsInt()
entityId?: number;
@ApiPropertyOptional({
description: 'Wyscout ID of the related entity (for player, coach, match)',
example: 1093815,
type: Number,
})
@IsOptional()
@IsInt()
entityWyId?: number;
@ApiPropertyOptional({
description: 'Category of the file',
example: 'profile_image',
enum: [
'profile_image',
'document',
'attachment',
'evidence',
'contract',
'medical',
'other',
],
})
@IsOptional()
@IsString()
category?: string;
@ApiPropertyOptional({
description: 'Description of the file',
example: 'Player profile photo from 2024 season',
})
@IsOptional()
@IsString()
description?: string;
}
export * from './create-file.dto';
export * from './update-file.dto';
import {
IsString,
IsOptional,
IsInt,
IsEnum,
IsBoolean,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateFileDto {
@ApiPropertyOptional({
description: 'Original file name',
example: 'updated-player-photo.jpg',
})
@IsOptional()
@IsString()
originalFileName?: string;
@ApiPropertyOptional({
description: 'File name (stored name)',
example: 'new-abc123-def456-ghi789.jpg',
})
@IsOptional()
@IsString()
fileName?: string;
@ApiPropertyOptional({
description: 'File path or URL where the file is stored',
example: '/uploads/images/new-abc123-def456-ghi789.jpg',
})
@IsOptional()
@IsString()
filePath?: string;
@ApiPropertyOptional({
description: 'MIME type of the file',
example: 'image/png',
})
@IsOptional()
@IsString()
mimeType?: string;
@ApiPropertyOptional({
description: 'File size in bytes',
example: 2048000,
type: Number,
})
@IsOptional()
@IsInt()
fileSize?: number;
@ApiPropertyOptional({
description: 'Type of entity this file is related to',
enum: ['player', 'coach', 'match', 'report', 'user'],
example: 'coach',
})
@IsOptional()
@IsEnum(['player', 'coach', 'match', 'report', 'user'])
entityType?: string;
@ApiPropertyOptional({
description: 'ID of the related entity (database ID)',
example: 456,
type: Number,
nullable: true,
})
@IsOptional()
@IsInt()
entityId?: number | null;
@ApiPropertyOptional({
description: 'Wyscout ID of the related entity (for player, coach, match)',
example: 70001,
type: Number,
nullable: true,
})
@IsOptional()
@IsInt()
entityWyId?: number | null;
@ApiPropertyOptional({
description: 'Category of the file',
example: 'document',
enum: [
'profile_image',
'document',
'attachment',
'evidence',
'contract',
'medical',
'other',
],
})
@IsOptional()
@IsString()
category?: string;
@ApiPropertyOptional({
description: 'Description of the file',
example: 'Updated player profile photo',
})
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({
description: 'Whether the file is active',
example: true,
default: true,
})
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
NotFoundException,
Param,
ParseIntPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { FilesService } from './files.service';
import { type File } from '../../database/schema';
import {
ApiBody,
ApiNoContentResponse,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import { CreateFileDto, UpdateFileDto } from './dto';
@ApiTags('Files')
@Controller('files')
export class FilesController {
constructor(private readonly filesService: FilesService) {}
@Post()
@ApiOperation({ summary: 'Create a file record' })
@ApiBody({
description:
'File metadata. Provide entityType and either entityId or entityWyId depending on the entity type.',
type: CreateFileDto,
})
@ApiOkResponse({ description: 'Created file record' })
async create(@Body() body: CreateFileDto): Promise<File> {
return this.filesService.create(body);
}
@Get('by-entity/:entityType')
@ApiOperation({ summary: 'Get files by entity type and ID' })
@ApiParam({
name: 'entityType',
type: String,
description: 'Type of entity (player, coach, match, report, user)',
example: 'player',
})
@ApiQuery({
name: 'entityId',
required: false,
type: Number,
description: 'Database ID of the entity',
example: 123,
})
@ApiQuery({
name: 'entityWyId',
required: false,
type: Number,
description: 'Wyscout ID of the entity (for player, coach, match)',
example: 1093815,
})
@ApiOkResponse({ description: 'List of files for the entity' })
async getByEntity(
@Param('entityType') entityType: string,
@Query('entityId') entityId?: string,
@Query('entityWyId') entityWyId?: string,
): Promise<File[]> {
const entityIdNum = entityId ? parseInt(entityId, 10) : undefined;
const entityWyIdNum = entityWyId ? parseInt(entityWyId, 10) : undefined;
return this.filesService.findByEntity(entityType, entityIdNum, entityWyIdNum);
}
@Get('by-category/:category')
@ApiOperation({ summary: 'Get files by category' })
@ApiParam({
name: 'category',
type: String,
description: 'Category of files',
example: 'profile_image',
})
@ApiOkResponse({ description: 'List of files in the category' })
async getByCategory(
@Param('category') category: string,
): Promise<File[]> {
return this.filesService.findByCategory(category);
}
@Get(':id')
@ApiOperation({ summary: 'Get file by ID' })
@ApiOkResponse({ description: 'File if found' })
async getById(
@Param('id', ParseIntPipe) id: number,
): Promise<File | undefined> {
return this.filesService.findById(id);
}
@Get()
@ApiOperation({ summary: 'List files' })
@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);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a file record' })
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the file to update',
})
@ApiBody({
description: 'File update payload. All fields are optional.',
type: UpdateFileDto,
})
@ApiOkResponse({ description: 'Updated file record' })
@ApiNotFoundResponse({ description: 'File not found' })
async update(
@Param('id', ParseIntPipe) id: number,
@Body() body: UpdateFileDto,
): Promise<File> {
const result = await this.filesService.update(id, body);
if (!result) {
throw new NotFoundException(`File with ID ${id} not found`);
}
return result;
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete a file record (soft delete)' })
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the file to delete',
})
@ApiNoContentResponse({ description: 'File deleted successfully' })
@ApiNotFoundResponse({ description: 'File not found' })
async delete(@Param('id', ParseIntPipe) id: number): Promise<void> {
const result = await this.filesService.delete(id);
if (!result) {
throw new NotFoundException(`File with ID ${id} not found`);
}
}
}
import { Module } from '@nestjs/common';
import { FilesController } from './files.controller';
import { FilesService } from './files.service';
@Module({
controllers: [FilesController],
providers: [FilesService],
exports: [FilesService],
})
export class FilesModule {}
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 {
DatabaseQueryError,
DatabaseColumnNotFoundError,
DatabaseRequiredFieldMissingError,
DatabaseDuplicateEntryError,
} from '../../common/errors';
@Injectable()
export class FilesService {
private readonly logger = new Logger(FilesService.name);
constructor(private readonly databaseService: DatabaseService) {}
async create(data: any, uploadedBy?: number): Promise<File> {
const db = this.databaseService.getDatabase();
this.logger.log('Creating file with data:', JSON.stringify(data, null, 2));
const normalizedData = this.normalizeFileData(data);
if (uploadedBy) {
normalizedData.uploadedBy = uploadedBy;
}
this.logger.log('Normalized data:', JSON.stringify(normalizedData, null, 2));
try {
const [row] = await db
.insert(files)
.values(normalizedData as NewFile)
.returning();
this.logger.log('File created successfully with ID:', row.id);
return row as File;
} catch (error: any) {
this.logger.error('Failed to create file:', error);
const errorCode = error.cause?.code || error.code;
const errorDetail = error.cause?.detail || error.detail;
// Check for common database errors and throw specific error classes
if (errorCode === '42703') {
throw new DatabaseColumnNotFoundError(
errorDetail || 'Unknown column',
error.query || 'INSERT INTO files',
);
} else if (errorCode === '23502') {
throw new DatabaseRequiredFieldMissingError(
errorDetail || 'Unknown field',
errorDetail || error.message,
);
} else if (errorCode === '23505') {
throw new DatabaseDuplicateEntryError('file', errorDetail || error.message);
} else {
// For unknown errors, use DatabaseQueryError with full context
const errorContext = {
technicalReason: errorDetail || error.message || 'Unknown database error',
errorCode: errorCode,
errorDetail,
normalizedData,
};
this.logger.error('Error context:', errorContext);
throw new DatabaseQueryError(
error.query || 'INSERT INTO files',
JSON.stringify(errorContext, null, 2),
);
}
}
}
async findById(id: number): Promise<File | undefined> {
const db = this.databaseService.getDatabase();
const rows = await db
.select()
.from(files)
.where(and(eq(files.id, id), eq(files.isActive, true), isNull(files.deletedAt)));
return rows[0];
}
async findByEntity(
entityType: string,
entityId?: number,
entityWyId?: number,
): Promise<File[]> {
const db = this.databaseService.getDatabase();
const conditions = [eq(files.entityType, entityType), eq(files.isActive, true), isNull(files.deletedAt)];
if (entityId !== undefined) {
conditions.push(eq(files.entityId, entityId));
}
if (entityWyId !== undefined) {
conditions.push(eq(files.entityWyId, entityWyId));
}
return db.select().from(files).where(and(...conditions));
}
async findByCategory(category: string): Promise<File[]> {
const db = this.databaseService.getDatabase();
return db
.select()
.from(files)
.where(
and(
eq(files.category, category),
eq(files.isActive, true),
isNull(files.deletedAt),
),
);
}
async list(limit = 50, offset = 0): Promise<File[]> {
const db = this.databaseService.getDatabase();
return db
.select()
.from(files)
.where(and(eq(files.isActive, true), isNull(files.deletedAt)))
.limit(limit)
.offset(offset);
}
private normalizeFileData(data: any): Partial<NewFile> {
const normalizedData: Partial<NewFile> = {};
if (data.fileName !== undefined)
normalizedData.fileName = data.fileName;
if (data.originalFileName !== undefined)
normalizedData.originalFileName = data.originalFileName;
if (data.filePath !== undefined) normalizedData.filePath = data.filePath;
if (data.mimeType !== undefined) normalizedData.mimeType = data.mimeType;
if (data.fileSize !== undefined) normalizedData.fileSize = data.fileSize;
if (data.entityType !== undefined)
normalizedData.entityType = data.entityType;
if (data.category !== undefined) {
normalizedData.category = data.category === '' ? null : data.category;
}
if (data.description !== undefined) {
normalizedData.description =
data.description === '' ? null : data.description;
}
// Handle entityId
if (data.entityId !== undefined && data.entityId !== null && data.entityId !== 0) {
normalizedData.entityId = data.entityId;
}
// Handle entityWyId
if (
data.entityWyId !== undefined &&
data.entityWyId !== null &&
data.entityWyId !== 0
) {
normalizedData.entityWyId = data.entityWyId;
}
return normalizedData;
}
async update(id: number, data: any): Promise<File | null> {
const db = this.databaseService.getDatabase();
// Check if file exists
const existingFile = await this.findById(id);
if (!existingFile) {
return null;
}
// Normalize the update data
const normalizedData = this.normalizeFileData(data);
// Add updatedAt timestamp
normalizedData.updatedAt = new Date();
const [result] = await db
.update(files)
.set(normalizedData)
.where(eq(files.id, id))
.returning();
return result as File;
}
async delete(id: number): Promise<boolean> {
const db = this.databaseService.getDatabase();
// Check if file exists
const existingFile = await this.findById(id);
if (!existingFile) {
return false;
}
// Soft delete by setting deletedAt timestamp
await db
.update(files)
.set({ deletedAt: new Date(), isActive: false })
.where(eq(files.id, id));
return true;
}
}
import {
IsInt,
IsString,
IsOptional,
IsEnum,
IsDateString,
IsNumber,
Min,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateMatchDto {
@ApiProperty({
description: 'Wyscout ID of the match',
example: 999001,
})
@IsInt()
wyId: number;
@ApiProperty({
description: 'Match date and time',
example: '2025-01-01T15:00:00Z',
})
@IsDateString()
matchDate: string;
@ApiPropertyOptional({
description: 'Home team Wyscout ID',
example: 111,
})
@IsOptional()
@IsInt()
homeTeamWyId?: number;
@ApiPropertyOptional({
description: 'Away team Wyscout ID',
example: 222,
})
@IsOptional()
@IsInt()
awayTeamWyId?: number;
@ApiPropertyOptional({
description: 'Competition Wyscout ID',
example: 333,
})
@IsOptional()
@IsInt()
competitionWyId?: number;
@ApiPropertyOptional({
description: 'Season Wyscout ID',
example: 2025,
})
@IsOptional()
@IsInt()
seasonWyId?: number;
@ApiPropertyOptional({
description: 'Round Wyscout ID',
example: 12,
})
@IsOptional()
@IsInt()
roundWyId?: number;
@ApiPropertyOptional({
description: 'Venue name',
example: 'Stadium Name',
})
@IsOptional()
@IsString()
venue?: string;
@ApiPropertyOptional({
description: 'Venue city',
example: 'City',
})
@IsOptional()
@IsString()
venueCity?: string;
@ApiPropertyOptional({
description: 'Venue country',
example: 'Country',
})
@IsOptional()
@IsString()
venueCountry?: string;
@ApiPropertyOptional({
description: 'Match type',
enum: ['regular', 'friendly', 'playoff', 'cup'],
example: 'regular',
})
@IsOptional()
@IsEnum(['regular', 'friendly', 'playoff', 'cup'])
matchType?: string;
@ApiPropertyOptional({
description: 'Match status',
enum: ['scheduled', 'in_progress', 'finished', 'postponed', 'cancelled'],
example: 'scheduled',
})
@IsOptional()
@IsEnum(['scheduled', 'in_progress', 'finished', 'postponed', 'cancelled'])
status?: string;
@ApiPropertyOptional({
description: 'Home team score',
example: 0,
default: 0,
})
@IsOptional()
@IsInt()
@Min(0)
homeScore?: number;
@ApiPropertyOptional({
description: 'Away team score',
example: 0,
default: 0,
})
@IsOptional()
@IsInt()
@Min(0)
awayScore?: number;
@ApiPropertyOptional({
description: 'Home team score (penalties)',
example: 0,
default: 0,
})
@IsOptional()
@IsInt()
@Min(0)
homeScorePenalties?: number;
@ApiPropertyOptional({
description: 'Away team score (penalties)',
example: 0,
default: 0,
})
@IsOptional()
@IsInt()
@Min(0)
awayScorePenalties?: number;
@ApiPropertyOptional({
description: 'Main referee ID',
example: 100,
})
@IsOptional()
@IsInt()
mainRefereeId?: number;
@ApiPropertyOptional({
description: 'Main referee name',
example: 'Ref A',
})
@IsOptional()
@IsString()
mainRefereeName?: string;
@ApiPropertyOptional({
description: 'Assistant referee 1 ID',
example: 101,
})
@IsOptional()
@IsInt()
assistantReferee1Id?: number;
@ApiPropertyOptional({
description: 'Assistant referee 1 name',
example: 'Ref B',
})
@IsOptional()
@IsString()
assistantReferee1Name?: string;
@ApiPropertyOptional({
description: 'Assistant referee 2 ID',
example: 102,
})
@IsOptional()
@IsInt()
assistantReferee2Id?: number;
@ApiPropertyOptional({
description: 'Assistant referee 2 name',
example: 'Ref C',
})
@IsOptional()
@IsString()
assistantReferee2Name?: string;
@ApiPropertyOptional({
description: 'Fourth referee ID',
example: 103,
})
@IsOptional()
@IsInt()
fourthRefereeId?: number;
@ApiPropertyOptional({
description: 'Fourth referee name',
example: 'Ref D',
})
@IsOptional()
@IsString()
fourthRefereeName?: string;
@ApiPropertyOptional({
description: 'VAR referee ID',
example: 104,
})
@IsOptional()
@IsInt()
varRefereeId?: number;
@ApiPropertyOptional({
description: 'VAR referee name',
example: 'Ref E',
})
@IsOptional()
@IsString()
varRefereeName?: string;
@ApiPropertyOptional({
description: 'Weather conditions',
example: 'Sunny',
})
@IsOptional()
@IsString()
weather?: string;
@ApiPropertyOptional({
description: 'Temperature in Celsius',
example: 22.5,
})
@IsOptional()
@IsNumber()
temperature?: number;
@ApiPropertyOptional({
description: 'API last synced timestamp',
example: '2025-01-01T16:00:00Z',
})
@IsOptional()
@IsDateString()
apiLastSyncedAt?: string;
@ApiPropertyOptional({
description: 'API sync status',
enum: ['pending', 'synced', 'error'],
example: 'pending',
})
@IsOptional()
@IsEnum(['pending', 'synced', 'error'])
apiSyncStatus?: string;
@ApiPropertyOptional({
description: 'Additional notes',
example: 'Kickoff at 3PM',
})
@IsOptional()
@IsString()
notes?: string;
}
export * from './create-match.dto';
export * from './update-match.dto';
import {
IsInt,
IsString,
IsOptional,
IsEnum,
IsDateString,
IsNumber,
Min,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateMatchDto {
@ApiPropertyOptional({
description: 'Wyscout ID of the match',
example: 999001,
})
@IsOptional()
@IsInt()
wyId?: number;
@ApiPropertyOptional({
description: 'Match date and time',
example: '2025-01-01T15:00:00Z',
})
@IsOptional()
@IsDateString()
matchDate?: string;
@ApiPropertyOptional({
description: 'Home team Wyscout ID',
example: 111,
})
@IsOptional()
@IsInt()
homeTeamWyId?: number;
@ApiPropertyOptional({
description: 'Away team Wyscout ID',
example: 222,
})
@IsOptional()
@IsInt()
awayTeamWyId?: number;
@ApiPropertyOptional({
description: 'Competition Wyscout ID',
example: 333,
})
@IsOptional()
@IsInt()
competitionWyId?: number;
@ApiPropertyOptional({
description: 'Season Wyscout ID',
example: 2025,
})
@IsOptional()
@IsInt()
seasonWyId?: number;
@ApiPropertyOptional({
description: 'Round Wyscout ID',
example: 12,
})
@IsOptional()
@IsInt()
roundWyId?: number;
@ApiPropertyOptional({
description: 'Venue name',
example: 'Stadium Name',
})
@IsOptional()
@IsString()
venue?: string;
@ApiPropertyOptional({
description: 'Venue city',
example: 'City',
})
@IsOptional()
@IsString()
venueCity?: string;
@ApiPropertyOptional({
description: 'Venue country',
example: 'Country',
})
@IsOptional()
@IsString()
venueCountry?: string;
@ApiPropertyOptional({
description: 'Match type',
enum: ['regular', 'friendly', 'playoff', 'cup'],
example: 'regular',
})
@IsOptional()
@IsEnum(['regular', 'friendly', 'playoff', 'cup'])
matchType?: string;
@ApiPropertyOptional({
description: 'Match status',
enum: ['scheduled', 'in_progress', 'finished', 'postponed', 'cancelled'],
example: 'scheduled',
})
@IsOptional()
@IsEnum(['scheduled', 'in_progress', 'finished', 'postponed', 'cancelled'])
status?: string;
@ApiPropertyOptional({
description: 'Home team score',
example: 0,
})
@IsOptional()
@IsInt()
@Min(0)
homeScore?: number;
@ApiPropertyOptional({
description: 'Away team score',
example: 0,
})
@IsOptional()
@IsInt()
@Min(0)
awayScore?: number;
@ApiPropertyOptional({
description: 'Home team score (penalties)',
example: 0,
})
@IsOptional()
@IsInt()
@Min(0)
homeScorePenalties?: number;
@ApiPropertyOptional({
description: 'Away team score (penalties)',
example: 0,
})
@IsOptional()
@IsInt()
@Min(0)
awayScorePenalties?: number;
@ApiPropertyOptional({
description: 'Main referee ID',
example: 100,
})
@IsOptional()
@IsInt()
mainRefereeId?: number;
@ApiPropertyOptional({
description: 'Main referee name',
example: 'Ref A',
})
@IsOptional()
@IsString()
mainRefereeName?: string;
@ApiPropertyOptional({
description: 'Assistant referee 1 ID',
example: 101,
})
@IsOptional()
@IsInt()
assistantReferee1Id?: number;
@ApiPropertyOptional({
description: 'Assistant referee 1 name',
example: 'Ref B',
})
@IsOptional()
@IsString()
assistantReferee1Name?: string;
@ApiPropertyOptional({
description: 'Assistant referee 2 ID',
example: 102,
})
@IsOptional()
@IsInt()
assistantReferee2Id?: number;
@ApiPropertyOptional({
description: 'Assistant referee 2 name',
example: 'Ref C',
})
@IsOptional()
@IsString()
assistantReferee2Name?: string;
@ApiPropertyOptional({
description: 'Fourth referee ID',
example: 103,
})
@IsOptional()
@IsInt()
fourthRefereeId?: number;
@ApiPropertyOptional({
description: 'Fourth referee name',
example: 'Ref D',
})
@IsOptional()
@IsString()
fourthRefereeName?: string;
@ApiPropertyOptional({
description: 'VAR referee ID',
example: 104,
})
@IsOptional()
@IsInt()
varRefereeId?: number;
@ApiPropertyOptional({
description: 'VAR referee name',
example: 'Ref E',
})
@IsOptional()
@IsString()
varRefereeName?: string;
@ApiPropertyOptional({
description: 'Weather conditions',
example: 'Sunny',
})
@IsOptional()
@IsString()
weather?: string;
@ApiPropertyOptional({
description: 'Temperature in Celsius',
example: 22.5,
})
@IsOptional()
@IsNumber()
temperature?: number;
@ApiPropertyOptional({
description: 'API last synced timestamp',
example: '2025-01-01T16:00:00Z',
})
@IsOptional()
@IsDateString()
apiLastSyncedAt?: string;
@ApiPropertyOptional({
description: 'API sync status',
enum: ['pending', 'synced', 'error'],
example: 'pending',
})
@IsOptional()
@IsEnum(['pending', 'synced', 'error'])
apiSyncStatus?: string;
@ApiPropertyOptional({
description: 'Additional notes',
example: 'Kickoff at 3PM',
})
@IsOptional()
@IsString()
notes?: string;
}
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
Post,
Query,
} from '@nestjs/common';
import { MatchesService } from './matches.service';
import { type Match } from '../../database/schema';
import {
ApiBody,
ApiOkResponse,
ApiOperation,
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import { MatchesPaginatedResponse } from '../../common/utils/response.util';
import { CreateMatchDto } from './dto';
@ApiTags('Matches')
@Controller('matches')
export class MatchesController {
constructor(private readonly matchesService: MatchesService) {}
@Post()
@ApiOperation({ summary: 'Create or update match by wyId' })
@ApiBody({
description: 'Match payload',
type: CreateMatchDto,
})
@ApiOkResponse({ description: 'Upserted match' })
async save(@Body() body: CreateMatchDto): Promise<Match> {
return this.matchesService.upsertByWyId(body as any);
}
@Get(':wyId')
@ApiOperation({ summary: 'Get match by wyId' })
@ApiOkResponse({ description: 'Match if found' })
async getByWyId(
@Param('wyId', ParseIntPipe) wyId: number,
): Promise<Match | undefined> {
return this.matchesService.findByWyId(wyId);
}
@Get()
@ApiOperation({
summary: 'List matches with pagination',
description:
'Search matches by venue (searches in venue, venueCity, venueCountry). Uses normalized string comparison (accent-insensitive).',
})
@ApiQuery({
name: 'name',
required: false,
type: String,
description:
'Search matches by venue (searches in venue, venueCity, venueCountry). Optional.',
})
@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);
}
}
import { Module } from '@nestjs/common';
import { MatchesController } from './matches.controller';
import { MatchesService } from './matches.service';
@Module({
controllers: [MatchesController],
providers: [MatchesService],
exports: [MatchesService],
})
export class MatchesModule {}
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 { ResponseUtil, MatchesPaginatedResponse } from '../../common/utils/response.util';
import { MatchWyIdRequiredError } from '../../common/errors';
@Injectable()
export class MatchesService {
constructor(private readonly databaseService: DatabaseService) {}
/**
* Normalizes a string by removing accents and converting to lowercase
* Example: "José" -> "jose", "Müller" -> "muller"
*/
private normalizeString(str: string): string {
return str
.normalize('NFD') // Decompose characters (é -> e + ́)
.replace(/[\u0300-\u036f]/g, '') // Remove diacritical marks
.toLowerCase()
.trim();
}
// Transform raw database data to match the API structure
private transformMatch(rawMatch: any): any {
// Helper to convert timestamp to ISO string or null
const formatTimestamp = (
value: Date | string | null | undefined,
): string | null => {
if (!value) return null;
if (typeof value === 'string') {
const d = new Date(value);
return isNaN(d.getTime()) ? null : d.toISOString();
}
if (value instanceof Date) {
return value.toISOString();
}
return null;
};
// Helper to convert decimal to number or null
const parseDecimal = (
value: string | number | null | undefined,
): number | null => {
if (value === null || value === undefined) return null;
if (typeof value === 'number') return value;
const parsed = parseFloat(value);
return isNaN(parsed) ? null : parsed;
};
return {
id: rawMatch.id ?? 0,
wyId: rawMatch.wyId ?? rawMatch.wy_id ?? 0,
homeTeamWyId:
rawMatch.homeTeamWyId ?? rawMatch.home_team_wy_id ?? null,
awayTeamWyId:
rawMatch.awayTeamWyId ?? rawMatch.away_team_wy_id ?? null,
competitionWyId:
rawMatch.competitionWyId ?? rawMatch.competition_wy_id ?? null,
seasonWyId: rawMatch.seasonWyId ?? rawMatch.season_wy_id ?? null,
roundWyId: rawMatch.roundWyId ?? rawMatch.round_wy_id ?? null,
matchDate: formatTimestamp(rawMatch.matchDate ?? rawMatch.match_date),
venue: rawMatch.venue ?? null,
venueCity: rawMatch.venueCity ?? rawMatch.venue_city ?? null,
venueCountry: rawMatch.venueCountry ?? rawMatch.venue_country ?? null,
matchType: rawMatch.matchType ?? rawMatch.match_type ?? 'regular',
status: rawMatch.status ?? 'scheduled',
homeScore: rawMatch.homeScore ?? rawMatch.home_score ?? 0,
awayScore: rawMatch.awayScore ?? rawMatch.away_score ?? 0,
homeScorePenalties:
rawMatch.homeScorePenalties ?? rawMatch.home_score_penalties ?? 0,
awayScorePenalties:
rawMatch.awayScorePenalties ?? rawMatch.away_score_penalties ?? 0,
attendance: null, // Field doesn't exist in schema, always null
mainRefereeWyId:
rawMatch.mainRefereeId ?? rawMatch.main_referee_id ?? null,
assistantReferee1WyId:
rawMatch.assistantReferee1Id ?? rawMatch.assistant_referee_1_id ?? null,
assistantReferee2WyId:
rawMatch.assistantReferee2Id ?? rawMatch.assistant_referee_2_id ?? null,
fourthRefereeWyId:
rawMatch.fourthRefereeId ?? rawMatch.fourth_referee_id ?? null,
varRefereeWyId:
rawMatch.varRefereeId ?? rawMatch.var_referee_id ?? null,
weather: rawMatch.weather ?? null,
temperature: parseDecimal(rawMatch.temperature),
createdAt: rawMatch.createdAt
? (formatTimestamp(rawMatch.createdAt) ?? null)
: rawMatch.created_at
? (formatTimestamp(rawMatch.created_at) ?? null)
: null,
updatedAt: rawMatch.updatedAt
? (formatTimestamp(rawMatch.updatedAt) ?? null)
: rawMatch.updated_at
? (formatTimestamp(rawMatch.updated_at) ?? null)
: null,
};
}
async findAll(
limit: number = 50,
offset: number = 0,
search?: string,
): Promise<MatchesPaginatedResponse<any>> {
const db = this.databaseService.getDatabase();
// Build where conditions
let whereCondition: any = undefined;
// If search is provided, add venue search conditions
if (search && search.trim()) {
const searchPattern = `%${search.trim()}%`;
whereCondition = or(
ilike(matches.venue, searchPattern),
ilike(matches.venueCity, searchPattern),
ilike(matches.venueCountry, searchPattern),
) as any;
}
// Get total count
let totalItems = 0;
if (whereCondition) {
const [totalResult] = await db
.select({ count: count() })
.from(matches)
.where(whereCondition);
totalItems = totalResult.count;
} else {
const [totalResult] = await db
.select({ count: count() })
.from(matches);
totalItems = totalResult.count;
}
// Get data
let rawData;
if (!search || !search.trim()) {
rawData = await db
.select()
.from(matches)
.limit(limit)
.offset(offset);
} else {
if (whereCondition) {
rawData = await db
.select()
.from(matches)
.where(whereCondition);
} else {
rawData = await db.select().from(matches);
}
}
// Transform to match API structure
let transformedData = rawData.map((match) => this.transformMatch(match));
// If search is provided, apply normalized filtering
if (search && search.trim()) {
const normalizedSearch = this.normalizeString(search);
const filteredData = transformedData.filter((match) => {
const normalizedVenue = this.normalizeString(match.venue || '');
const normalizedVenueCity = this.normalizeString(match.venueCity || '');
const normalizedVenueCountry = this.normalizeString(
match.venueCountry || '',
);
return (
normalizedVenue.includes(normalizedSearch) ||
normalizedVenueCity.includes(normalizedSearch) ||
normalizedVenueCountry.includes(normalizedSearch)
);
});
// Apply limit and offset after filtering
transformedData = filteredData.slice(offset, offset + limit);
// Update totalItems to the actual filtered count
totalItems = filteredData.length;
}
return ResponseUtil.createMatchesPaginatedResponse(
transformedData,
'/matches',
totalItems,
limit,
offset,
);
}
async upsertByWyId(data: NewMatch): Promise<Match> {
const db = this.databaseService.getDatabase();
if (data.wyId == null) {
throw new MatchWyIdRequiredError();
}
// Normalize date/timestamp fields that may arrive as strings
const toDate = (value: unknown): Date | undefined => {
if (value == null) return undefined;
if (value instanceof Date) return value;
const d = new Date(value as any);
return isNaN(d.getTime()) ? undefined : d;
};
const normalized: NewMatch = {
...data,
matchDate: toDate((data as any).matchDate) as any,
apiLastSyncedAt: toDate((data as any).apiLastSyncedAt) as any,
createdAt: toDate((data as any).createdAt) as any,
updatedAt: toDate((data as any).updatedAt) as any,
deletedAt: toDate((data as any).deletedAt) as any,
};
const [result] = await db
.insert(matches)
.values(normalized)
.onConflictDoUpdate({ target: matches.wyId, set: normalized })
.returning();
return result as Match;
}
async findByWyId(wyId: number): Promise<Match | undefined> {
const db = this.databaseService.getDatabase();
const rows = await db.select().from(matches).where(eq(matches.wyId, wyId));
return rows[0];
}
async list(limit = 50, offset = 0): Promise<Match[]> {
const db = this.databaseService.getDatabase();
return db.select().from(matches).limit(limit).offset(offset);
}
}
import {
IsInt,
IsString,
IsOptional,
IsEnum,
IsNumber,
IsDateString,
IsBoolean,
Min,
Max,
IsObject,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreatePlayerDto {
@ApiProperty({
description: 'Wyscout ID of the player',
example: 123456,
})
@IsInt()
wyId: number;
@ApiProperty({
description: 'First name of the player',
example: 'John',
})
@IsString()
firstName: string;
@ApiProperty({
description: 'Last name of the player',
example: 'Doe',
})
@IsString()
lastName: string;
@ApiPropertyOptional({
description: 'Team Wyscout ID',
example: 7890,
})
@IsOptional()
@IsInt()
teamWyId?: number;
@ApiPropertyOptional({
description: 'Middle name of the player',
example: 'M',
})
@IsOptional()
@IsString()
middleName?: string;
@ApiPropertyOptional({
description: 'Short name of the player',
example: 'J. Doe',
})
@IsOptional()
@IsString()
shortName?: string;
@ApiPropertyOptional({
description: 'Global Sports Media ID',
example: 555,
})
@IsOptional()
@IsInt()
gsmId?: number;
@ApiPropertyOptional({
description: 'Current team ID from API',
example: 4321,
})
@IsOptional()
@IsInt()
currentTeamId?: number;
@ApiPropertyOptional({
description: 'Current team object from external API',
example: { id: 49, wyId: 3168, name: 'Lecce' },
})
@IsOptional()
@IsObject()
currentTeam?: {
id?: number;
wyId?: number;
gsmId?: number | null;
name?: string;
officialName?: string;
shortName?: string | null;
description?: string | null;
type?: string;
category?: string;
gender?: string;
areaWyId?: number | null;
city?: string;
coachWyId?: number | null;
competitionWyId?: number;
seasonWyId?: number;
league?: any;
season?: any;
status?: string;
isActive?: boolean;
apiLastSyncedAt?: string;
apiSyncStatus?: string;
createdAt?: string;
updatedAt?: string;
deletedAt?: string | null;
area?: any;
coach?: any;
};
@ApiPropertyOptional({
description: 'National team ID',
example: 44,
})
@IsOptional()
@IsInt()
currentNationalTeamId?: number;
@ApiPropertyOptional({
description: 'Date of birth (YYYY-MM-DD)',
example: '1998-06-01',
})
@IsOptional()
@IsDateString()
dateOfBirth?: string;
@ApiPropertyOptional({
description: 'Birth date (YYYY-MM-DD) - from external API',
example: '1998-06-01',
})
@IsOptional()
@IsDateString()
birthDate?: string;
@ApiPropertyOptional({
description: 'Height in centimeters',
example: 182,
})
@IsOptional()
@IsInt()
heightCm?: number;
@ApiPropertyOptional({
description: 'Height in centimeters - from external API',
example: 182,
})
@IsOptional()
@IsInt()
height?: number;
@ApiPropertyOptional({
description: 'Weight in kilograms',
example: 78.5,
})
@IsOptional()
@IsNumber()
weightKg?: number;
@ApiPropertyOptional({
description: 'Weight in kilograms - from external API',
example: 78.5,
})
@IsOptional()
@IsNumber()
weight?: number;
@ApiPropertyOptional({
description: 'Preferred foot',
enum: ['left', 'right', 'both'],
example: 'right',
})
@IsOptional()
@IsEnum(['left', 'right', 'both'])
foot?: 'left' | 'right' | 'both';
@ApiPropertyOptional({
description: 'Gender',
enum: ['male', 'female', 'other'],
example: 'male',
})
@IsOptional()
@IsEnum(['male', 'female', 'other'])
gender?: 'male' | 'female' | 'other';
@ApiPropertyOptional({
description: 'Position',
example: 'FW',
})
@IsOptional()
@IsString()
position?: string;
@ApiPropertyOptional({
description: 'Role code (2 characters)',
example: 'FW',
})
@IsOptional()
@IsString()
roleCode2?: string;
@ApiPropertyOptional({
description: 'Role code (3 characters)',
example: 'FWD',
})
@IsOptional()
@IsString()
roleCode3?: string;
@ApiPropertyOptional({
description: 'Role name',
example: 'Forward',
})
@IsOptional()
@IsString()
roleName?: string;
@ApiPropertyOptional({
description: 'Role object from external API',
example: { name: 'Defender', code2: 'DF', code3: 'DEF' },
})
@IsOptional()
@IsObject()
role?: {
name?: string;
code2?: string;
code3?: string;
};
@ApiPropertyOptional({
description: 'Birth area Wyscout ID',
example: 1001,
})
@IsOptional()
@IsInt()
birthAreaWyId?: number;
@ApiPropertyOptional({
description: 'Birth area object from external API',
example: { id: 75, wyId: 250, name: 'France' },
})
@IsOptional()
@IsObject()
birthArea?: {
id?: number;
wyId?: number;
name?: string;
alpha2code?: string;
alpha3code?: string;
createdAt?: string;
updatedAt?: string;
deletedAt?: string | null;
};
@ApiPropertyOptional({
description: 'Passport area Wyscout ID',
example: 1001,
})
@IsOptional()
@IsInt()
passportAreaWyId?: number;
@ApiPropertyOptional({
description: 'Passport area object from external API',
example: { id: 59, wyId: 384, name: "Côte d'Ivoire" },
})
@IsOptional()
@IsObject()
passportArea?: {
id?: number;
wyId?: number;
name?: string;
alpha2code?: string;
alpha3code?: string;
createdAt?: string;
updatedAt?: string;
deletedAt?: string | null;
};
@ApiPropertyOptional({
description: 'Status',
enum: ['active', 'inactive'],
example: 'active',
})
@IsOptional()
@IsEnum(['active', 'inactive'])
status?: 'active' | 'inactive';
@ApiPropertyOptional({
description: 'Image data URL',
example: 'data:image/png;base64,...',
})
@IsOptional()
@IsString()
imageDataUrl?: string;
@ApiPropertyOptional({
description: 'Image data URL - from external API',
example: 'https://cdn5.wyscout.com/photos/players/public/ndplayer_100x130.png',
})
@IsOptional()
@IsString()
imageDataURL?: string;
@ApiPropertyOptional({
description: 'Jersey number',
example: 9,
})
@IsOptional()
@IsInt()
@Min(1)
@Max(99)
jerseyNumber?: number;
@ApiPropertyOptional({
description: 'API last synced timestamp',
example: '2025-01-01T10:00:00Z',
})
@IsOptional()
@IsDateString()
apiLastSyncedAt?: string;
@ApiPropertyOptional({
description: 'API sync status',
enum: ['pending', 'synced', 'error'],
example: 'pending',
})
@IsOptional()
@IsEnum(['pending', 'synced', 'error'])
apiSyncStatus?: 'pending' | 'synced' | 'error';
@ApiPropertyOptional({
description: 'Whether the player is active',
example: true,
default: true,
})
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
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