Commit 78e29712 by Augusto

params, logs and positions update

parent 960e49bc
......@@ -3,6 +3,7 @@
/node_modules
/build
# Logs
logs
*.log
......@@ -55,3 +56,7 @@ pids
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
.env
# Documentation and SQL migrations
/docs
/sql-migrations
-- Migration: Add calendar_events table for user calendar management
-- Run this on your production database
-- Step 1: Create calendar_event_type enum type if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'calendar_event_type') THEN
CREATE TYPE "public"."calendar_event_type" AS ENUM('match', 'travel', 'player_observation', 'meeting', 'training', 'other');
RAISE NOTICE 'Created calendar_event_type enum';
ELSE
RAISE NOTICE 'calendar_event_type enum already exists';
END IF;
END $$;
-- Step 2: Create the calendar_events table
CREATE TABLE IF NOT EXISTS "calendar_events" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"title" text NOT NULL,
"description" text,
"event_type" "calendar_event_type" NOT NULL,
"start_date" timestamp with time zone NOT NULL,
"end_date" timestamp with time zone,
"match_wy_id" integer,
"player_wy_id" integer,
"metadata" jsonb,
"is_active" boolean DEFAULT true,
"created_at" timestamp with time zone DEFAULT now(),
"updated_at" timestamp with time zone DEFAULT now(),
"deleted_at" timestamp with time zone
);
-- Step 3: Add foreign key constraint if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_name = 'calendar_events'
AND constraint_name = 'calendar_events_user_id_users_id_fk'
AND constraint_type = 'FOREIGN KEY'
) THEN
ALTER TABLE "calendar_events"
ADD CONSTRAINT "calendar_events_user_id_users_id_fk"
FOREIGN KEY ("user_id")
REFERENCES "users"("id")
ON DELETE CASCADE;
RAISE NOTICE 'Added foreign key constraint calendar_events_user_id_users_id_fk';
ELSE
RAISE NOTICE 'Foreign key constraint calendar_events_user_id_users_id_fk already exists';
END IF;
END $$;
-- Step 4: Create indexes for better query performance
CREATE INDEX IF NOT EXISTS "idx_calendar_events_user_id" ON "calendar_events"("user_id");
CREATE INDEX IF NOT EXISTS "idx_calendar_events_event_type" ON "calendar_events"("event_type");
CREATE INDEX IF NOT EXISTS "idx_calendar_events_start_date" ON "calendar_events"("start_date");
CREATE INDEX IF NOT EXISTS "idx_calendar_events_match_wy_id" ON "calendar_events"("match_wy_id");
CREATE INDEX IF NOT EXISTS "idx_calendar_events_player_wy_id" ON "calendar_events"("player_wy_id");
CREATE INDEX IF NOT EXISTS "idx_calendar_events_is_active" ON "calendar_events"("is_active");
CREATE INDEX IF NOT EXISTS "idx_calendar_events_deleted_at" ON "calendar_events"("deleted_at");
CREATE INDEX IF NOT EXISTS "idx_calendar_events_user_start_date" ON "calendar_events"("user_id", "start_date");
-- Step 5: Verify the table was created
SELECT
column_name,
data_type,
udt_name,
is_nullable,
column_default
FROM information_schema.columns
WHERE table_name = 'calendar_events'
ORDER BY ordinal_position;
-- Step 6: Verify indexes were created
SELECT
indexname,
indexdef
FROM pg_indexes
WHERE tablename = 'calendar_events'
ORDER BY indexname;
-- Add current_team_name and current_team_official_name columns to players table
-- This script can be run directly on your development database
-- Add current_team_name column if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'players' AND column_name = 'current_team_name'
) THEN
ALTER TABLE "players" ADD COLUMN "current_team_name" varchar(255);
RAISE NOTICE 'Added current_team_name column to players table';
ELSE
RAISE NOTICE 'current_team_name column already exists in players table';
END IF;
END $$;
-- Add current_team_official_name column if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'players' AND column_name = 'current_team_official_name'
) THEN
ALTER TABLE "players" ADD COLUMN "current_team_official_name" varchar(255);
RAISE NOTICE 'Added current_team_official_name column to players table';
ELSE
RAISE NOTICE 'current_team_official_name column already exists in players table';
END IF;
END $$;
-- Migration: Add files table for file/image management
-- Production-ready SQL script with idempotent operations
-- Run this on your production database
-- Create the files table if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = 'files'
) THEN
CREATE TABLE "files" (
"id" serial PRIMARY KEY NOT NULL,
"file_name" text NOT NULL,
"original_file_name" text NOT NULL,
"file_path" text NOT NULL,
"mime_type" text NOT NULL,
"file_size" integer NOT NULL,
"entity_type" text NOT NULL,
"entity_id" integer,
"entity_wy_id" integer,
"category" text,
"description" text,
"uploaded_by" integer,
"is_active" boolean DEFAULT true,
"created_at" timestamp with time zone DEFAULT now(),
"updated_at" timestamp with time zone DEFAULT now(),
"deleted_at" timestamp with time zone
);
END IF;
END $$;--> statement-breakpoint
-- Add foreign key constraint if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_name = 'files'
AND constraint_name = 'files_uploaded_by_users_id_fk'
AND constraint_type = 'FOREIGN KEY'
) THEN
ALTER TABLE "files"
ADD CONSTRAINT "files_uploaded_by_users_id_fk"
FOREIGN KEY ("uploaded_by")
REFERENCES "users"("id")
ON DELETE SET NULL;
END IF;
END $$;--> statement-breakpoint
-- Create indexes for better query performance (idempotent)
CREATE INDEX IF NOT EXISTS "idx_files_entity_type" ON "files"("entity_type");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_files_entity_id" ON "files"("entity_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_files_entity_wy_id" ON "files"("entity_wy_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_files_entity_type_id" ON "files"("entity_type","entity_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_files_uploaded_by" ON "files"("uploaded_by");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_files_category" ON "files"("category");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_files_is_active" ON "files"("is_active");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_files_deleted_at" ON "files"("deleted_at");--> statement-breakpoint
-- Verify the table was created
SELECT
column_name,
data_type,
is_nullable,
column_default
FROM information_schema.columns
WHERE table_name = 'files'
ORDER BY ordinal_position;
-- Migration: Add files table for file/image management
-- Run this on your production database
-- Create the files table
CREATE TABLE IF NOT EXISTS "files" (
"id" serial PRIMARY KEY NOT NULL,
"file_name" text NOT NULL,
"original_file_name" text NOT NULL,
"file_path" text NOT NULL,
"mime_type" text NOT NULL,
"file_size" integer NOT NULL,
"entity_type" text NOT NULL,
"entity_id" integer,
"entity_wy_id" integer,
"category" text,
"description" text,
"uploaded_by" integer,
"is_active" boolean DEFAULT true,
"created_at" timestamp with time zone DEFAULT now(),
"updated_at" timestamp with time zone DEFAULT now(),
"deleted_at" timestamp with time zone,
CONSTRAINT "files_uploaded_by_users_id_fk" FOREIGN KEY ("uploaded_by") REFERENCES "users"("id") ON DELETE SET NULL
);--> statement-breakpoint
-- Create indexes for better query performance
CREATE INDEX IF NOT EXISTS "idx_files_entity_type" ON "files"("entity_type");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_files_entity_id" ON "files"("entity_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_files_entity_wy_id" ON "files"("entity_wy_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_files_entity_type_id" ON "files"("entity_type","entity_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_files_uploaded_by" ON "files"("uploaded_by");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_files_category" ON "files"("category");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_files_is_active" ON "files"("is_active");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_files_deleted_at" ON "files"("deleted_at");--> statement-breakpoint
-- Verify the table was created
SELECT
column_name,
data_type,
is_nullable,
column_default
FROM information_schema.columns
WHERE table_name = 'files'
ORDER BY ordinal_position;
-- Migration: Add report_status enum and change rating to decimal
-- Run this on your development database
-- Step 1: Create report_status enum type if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'report_status') THEN
CREATE TYPE "public"."report_status" AS ENUM('saved', 'finished');
END IF;
END $$;
-- Step 2: Change rating column from integer to decimal(5,2)
-- This preserves existing data by converting integer values to decimal
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'reports'
AND column_name = 'rating'
AND data_type = 'integer'
) THEN
ALTER TABLE "reports"
ALTER COLUMN "rating" TYPE numeric(5,2) USING rating::numeric(5,2);
END IF;
END $$;
-- Step 3: Add status column to reports table if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'reports' AND column_name = 'status'
) THEN
ALTER TABLE "reports"
ADD COLUMN "status" "report_status" DEFAULT 'saved';
END IF;
END $$;
-- Verify the changes
SELECT
column_name,
data_type,
udt_name,
column_default
FROM information_schema.columns
WHERE table_name = 'reports'
AND column_name IN ('rating', 'status')
ORDER BY column_name;
-- Add user_id column to reports table
-- This script can be run directly on your development database
-- Add user_id column if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'reports' AND column_name = 'user_id'
) THEN
ALTER TABLE "reports" ADD COLUMN "user_id" integer;
RAISE NOTICE 'Added user_id column to reports table';
ELSE
RAISE NOTICE 'user_id column already exists in reports table';
END IF;
END $$;
-- Add foreign key constraint if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_name = 'reports'
AND constraint_name = 'reports_user_id_users_id_fk'
AND constraint_type = 'FOREIGN KEY'
) THEN
ALTER TABLE "reports"
ADD CONSTRAINT "reports_user_id_users_id_fk"
FOREIGN KEY ("user_id")
REFERENCES "users"("id")
ON DELETE CASCADE;
RAISE NOTICE 'Added foreign key constraint reports_user_id_users_id_fk';
ELSE
RAISE NOTICE 'Foreign key constraint reports_user_id_users_id_fk already exists';
END IF;
END $$;
-- Add index on user_id for better query performance
CREATE INDEX IF NOT EXISTS "idx_reports_user_id" ON "reports"("user_id");
CREATE TYPE "public"."list_type" AS ENUM('shortlist', 'shadow_team', 'target_list');--> statement-breakpoint
CREATE TABLE "areas" (
"id" serial PRIMARY KEY NOT NULL,
"wy_id" integer NOT NULL,
"name" varchar(255) NOT NULL,
"alpha2code" varchar(2),
"alpha3code" varchar(3),
"created_at" timestamp with time zone DEFAULT now(),
"updated_at" timestamp with time zone DEFAULT now(),
"deleted_at" timestamp with time zone,
CONSTRAINT "areas_wy_id_unique" UNIQUE("wy_id")
);
--> statement-breakpoint
CREATE TABLE "list_shares" (
"id" serial PRIMARY KEY NOT NULL,
"list_id" integer NOT NULL,
"user_id" integer NOT NULL,
"created_at" timestamp with time zone DEFAULT now(),
"updated_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "lists" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"name" text NOT NULL,
"season" varchar(50) NOT NULL,
"type" "list_type" NOT NULL,
"players_by_position" json,
"is_active" boolean DEFAULT true,
"created_at" timestamp with time zone DEFAULT now(),
"updated_at" timestamp with time zone DEFAULT now(),
"deleted_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "email" varchar(255);--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "phone" varchar(50);--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "on_loan" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "agent" varchar(255);--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "ranking" varchar(255);--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "roi" varchar(255);--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "market_value" numeric(15, 2);--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "value_range" varchar(100);--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "transfer_value" numeric(15, 2);--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "salary" numeric(15, 2);--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "feasible" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "morphology" varchar(100);--> statement-breakpoint
ALTER TABLE "list_shares" ADD CONSTRAINT "list_shares_list_id_lists_id_fk" FOREIGN KEY ("list_id") REFERENCES "public"."lists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "list_shares" ADD CONSTRAINT "list_shares_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "lists" ADD CONSTRAINT "lists_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_areas_wy_id" ON "areas" USING btree ("wy_id");--> statement-breakpoint
CREATE INDEX "idx_areas_alpha2code" ON "areas" USING btree ("alpha2code");--> statement-breakpoint
CREATE INDEX "idx_areas_alpha3code" ON "areas" USING btree ("alpha3code");--> statement-breakpoint
CREATE INDEX "idx_areas_deleted_at" ON "areas" USING btree ("deleted_at");--> statement-breakpoint
CREATE INDEX "idx_list_shares_list_id" ON "list_shares" USING btree ("list_id");--> statement-breakpoint
CREATE INDEX "idx_list_shares_user_id" ON "list_shares" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "idx_list_shares_list_user" ON "list_shares" USING btree ("list_id","user_id");--> statement-breakpoint
CREATE INDEX "idx_lists_user_id" ON "lists" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_lists_type" ON "lists" USING btree ("type");--> statement-breakpoint
CREATE INDEX "idx_lists_season" ON "lists" USING btree ("season");--> statement-breakpoint
CREATE INDEX "idx_lists_is_active" ON "lists" USING btree ("is_active");--> statement-breakpoint
CREATE INDEX "idx_lists_deleted_at" ON "lists" USING btree ("deleted_at");--> statement-breakpoint
CREATE INDEX "idx_lists_user_type" ON "lists" USING btree ("user_id","type");
\ No newline at end of file
{
"id": "bb2b1f67-2011-4097-bd6f-8d34c12a3c0e",
"prevId": "568f0836-18cb-49b6-9a86-68bf62f40a09",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.areas": {
"name": "areas",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"wy_id": {
"name": "wy_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"alpha2code": {
"name": "alpha2code",
"type": "varchar(2)",
"primaryKey": false,
"notNull": false
},
"alpha3code": {
"name": "alpha3code",
"type": "varchar(3)",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"idx_areas_wy_id": {
"name": "idx_areas_wy_id",
"columns": [
{
"expression": "wy_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_areas_alpha2code": {
"name": "idx_areas_alpha2code",
"columns": [
{
"expression": "alpha2code",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_areas_alpha3code": {
"name": "idx_areas_alpha3code",
"columns": [
{
"expression": "alpha3code",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_areas_deleted_at": {
"name": "idx_areas_deleted_at",
"columns": [
{
"expression": "deleted_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"areas_wy_id_unique": {
"name": "areas_wy_id_unique",
"nullsNotDistinct": false,
"columns": [
"wy_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.calendar_events": {
"name": "calendar_events",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"event_type": {
"name": "event_type",
"type": "calendar_event_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"start_date": {
"name": "start_date",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"end_date": {
"name": "end_date",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"match_wy_id": {
"name": "match_wy_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"player_wy_id": {
"name": "player_wy_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"metadata": {
"name": "metadata",
"type": "json",
"primaryKey": false,
"notNull": false
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"idx_calendar_events_user_id": {
"name": "idx_calendar_events_user_id",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_calendar_events_event_type": {
"name": "idx_calendar_events_event_type",
"columns": [
{
"expression": "event_type",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_calendar_events_start_date": {
"name": "idx_calendar_events_start_date",
"columns": [
{
"expression": "start_date",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_calendar_events_match_wy_id": {
"name": "idx_calendar_events_match_wy_id",
"columns": [
{
"expression": "match_wy_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_calendar_events_player_wy_id": {
"name": "idx_calendar_events_player_wy_id",
"columns": [
{
"expression": "player_wy_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_calendar_events_is_active": {
"name": "idx_calendar_events_is_active",
"columns": [
{
"expression": "is_active",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_calendar_events_deleted_at": {
"name": "idx_calendar_events_deleted_at",
"columns": [
{
"expression": "deleted_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_calendar_events_user_start_date": {
"name": "idx_calendar_events_user_start_date",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "start_date",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"calendar_events_user_id_users_id_fk": {
"name": "calendar_events_user_id_users_id_fk",
"tableFrom": "calendar_events",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.categories": {
"name": "categories",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"categories_name_idx": {
"name": "categories_name_idx",
"columns": [
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"categories_type_idx": {
"name": "categories_type_idx",
"columns": [
{
"expression": "type",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"categories_is_active_idx": {
"name": "categories_is_active_idx",
"columns": [
{
"expression": "is_active",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"categories_name_unique": {
"name": "categories_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.client_modules": {
"name": "client_modules",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"client_id": {
"name": "client_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'default-client'"
},
"scouting": {
"name": "scouting",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"data_analytics": {
"name": "data_analytics",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"transfers": {
"name": "transfers",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"client_modules_client_id_idx": {
"name": "client_modules_client_id_idx",
"columns": [
{
"expression": "client_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"client_modules_is_active_idx": {
"name": "client_modules_is_active_idx",
"columns": [
{
"expression": "is_active",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"client_modules_client_id_unique": {
"name": "client_modules_client_id_unique",
"nullsNotDistinct": false,
"columns": [
"client_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.client_subscriptions": {
"name": "client_subscriptions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"client_id": {
"name": "client_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"features": {
"name": "features",
"type": "json",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"client_subscriptions_client_id_idx": {
"name": "client_subscriptions_client_id_idx",
"columns": [
{
"expression": "client_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"client_subscriptions_expires_at_idx": {
"name": "client_subscriptions_expires_at_idx",
"columns": [
{
"expression": "expires_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"client_subscriptions_client_id_unique": {
"name": "client_subscriptions_client_id_unique",
"nullsNotDistinct": false,
"columns": [
"client_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.coaches": {
"name": "coaches",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"wy_id": {
"name": "wy_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"gsm_id": {
"name": "gsm_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"first_name": {
"name": "first_name",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true
},
"last_name": {
"name": "last_name",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true
},
"middle_name": {
"name": "middle_name",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false
},
"short_name": {
"name": "short_name",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false
},
"date_of_birth": {
"name": "date_of_birth",
"type": "date",
"primaryKey": false,
"notNull": false
},
"nationality_wy_id": {
"name": "nationality_wy_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"current_team_wy_id": {
"name": "current_team_wy_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"position": {
"name": "position",
"type": "coach_position",
"typeSchema": "public",
"primaryKey": false,
"notNull": false,
"default": "'head_coach'"
},
"coaching_license": {
"name": "coaching_license",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false
},
"years_experience": {
"name": "years_experience",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"previous_teams": {
"name": "previous_teams",
"type": "text[]",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "status",
"typeSchema": "public",
"primaryKey": false,
"notNull": false,
"default": "'active'"
},
"image_data_url": {
"name": "image_data_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"api_last_synced_at": {
"name": "api_last_synced_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"api_sync_status": {
"name": "api_sync_status",
"type": "api_sync_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": false,
"default": "'pending'"
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"idx_coaches_wy_id": {
"name": "idx_coaches_wy_id",
"columns": [
{
"expression": "wy_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_coaches_gsm_id": {
"name": "idx_coaches_gsm_id",
"columns": [
{
"expression": "gsm_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_coaches_current_team_wy_id": {
"name": "idx_coaches_current_team_wy_id",
"columns": [
{
"expression": "current_team_wy_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_coaches_nationality_wy_id": {
"name": "idx_coaches_nationality_wy_id",
"columns": [
{
"expression": "nationality_wy_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_coaches_position": {
"name": "idx_coaches_position",
"columns": [
{
"expression": "position",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_coaches_status": {
"name": "idx_coaches_status",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_coaches_last_name": {
"name": "idx_coaches_last_name",
"columns": [
{
"expression": "last_name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_coaches_first_name": {
"name": "idx_coaches_first_name",
"columns": [
{
"expression": "first_name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_coaches_deleted_at": {
"name": "idx_coaches_deleted_at",
"columns": [
{
"expression": "deleted_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_coaches_is_active": {
"name": "idx_coaches_is_active",
"columns": [
{
"expression": "is_active",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_coaches_api_sync_status": {
"name": "idx_coaches_api_sync_status",
"columns": [
{
"expression": "api_sync_status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"coaches_wy_id_unique": {
"name": "coaches_wy_id_unique",
"nullsNotDistinct": false,
"columns": [
"wy_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.files": {
"name": "files",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"file_name": {
"name": "file_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"original_file_name": {
"name": "original_file_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"file_path": {
"name": "file_path",
"type": "text",
"primaryKey": false,
"notNull": true
},
"mime_type": {
"name": "mime_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"file_size": {
"name": "file_size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"entity_type": {
"name": "entity_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"entity_id": {
"name": "entity_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"entity_wy_id": {
"name": "entity_wy_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"uploaded_by": {
"name": "uploaded_by",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"idx_files_entity_type": {
"name": "idx_files_entity_type",
"columns": [
{
"expression": "entity_type",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_files_entity_id": {
"name": "idx_files_entity_id",
"columns": [
{
"expression": "entity_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_files_entity_wy_id": {
"name": "idx_files_entity_wy_id",
"columns": [
{
"expression": "entity_wy_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_files_entity_type_id": {
"name": "idx_files_entity_type_id",
"columns": [
{
"expression": "entity_type",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "entity_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_files_uploaded_by": {
"name": "idx_files_uploaded_by",
"columns": [
{
"expression": "uploaded_by",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_files_category": {
"name": "idx_files_category",
"columns": [
{
"expression": "category",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_files_is_active": {
"name": "idx_files_is_active",
"columns": [
{
"expression": "is_active",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_files_deleted_at": {
"name": "idx_files_deleted_at",
"columns": [
{
"expression": "deleted_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"files_uploaded_by_users_id_fk": {
"name": "files_uploaded_by_users_id_fk",
"tableFrom": "files",
"tableTo": "users",
"columnsFrom": [
"uploaded_by"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.global_settings": {
"name": "global_settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"value": {
"name": "value",
"type": "json",
"primaryKey": false,
"notNull": true
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"global_settings_category_idx": {
"name": "global_settings_category_idx",
"columns": [
{
"expression": "category",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"global_settings_key_idx": {
"name": "global_settings_key_idx",
"columns": [
{
"expression": "key",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"global_settings_is_active_idx": {
"name": "global_settings_is_active_idx",
"columns": [
{
"expression": "is_active",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"global_settings_sort_order_idx": {
"name": "global_settings_sort_order_idx",
"columns": [
{
"expression": "sort_order",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.list_shares": {
"name": "list_shares",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"list_id": {
"name": "list_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"idx_list_shares_list_id": {
"name": "idx_list_shares_list_id",
"columns": [
{
"expression": "list_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_list_shares_user_id": {
"name": "idx_list_shares_user_id",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_list_shares_list_user": {
"name": "idx_list_shares_list_user",
"columns": [
{
"expression": "list_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"list_shares_list_id_lists_id_fk": {
"name": "list_shares_list_id_lists_id_fk",
"tableFrom": "list_shares",
"tableTo": "lists",
"columnsFrom": [
"list_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"list_shares_user_id_users_id_fk": {
"name": "list_shares_user_id_users_id_fk",
"tableFrom": "list_shares",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.lists": {
"name": "lists",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"season": {
"name": "season",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "list_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"players_by_position": {
"name": "players_by_position",
"type": "json",
"primaryKey": false,
"notNull": false
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"idx_lists_user_id": {
"name": "idx_lists_user_id",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_lists_type": {
"name": "idx_lists_type",
"columns": [
{
"expression": "type",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_lists_season": {
"name": "idx_lists_season",
"columns": [
{
"expression": "season",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_lists_is_active": {
"name": "idx_lists_is_active",
"columns": [
{
"expression": "is_active",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_lists_deleted_at": {
"name": "idx_lists_deleted_at",
"columns": [
{
"expression": "deleted_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_lists_user_type": {
"name": "idx_lists_user_type",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "type",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"lists_user_id_users_id_fk": {
"name": "lists_user_id_users_id_fk",
"tableFrom": "lists",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.matches": {
"name": "matches",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"wy_id": {
"name": "wy_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"home_team_wy_id": {
"name": "home_team_wy_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"away_team_wy_id": {
"name": "away_team_wy_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"competition_wy_id": {
"name": "competition_wy_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"season_wy_id": {
"name": "season_wy_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"round_wy_id": {
"name": "round_wy_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"match_date": {
"name": "match_date",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"venue": {
"name": "venue",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"venue_city": {
"name": "venue_city",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false
},
"venue_country": {
"name": "venue_country",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false
},
"match_type": {
"name": "match_type",
"type": "match_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": false,
"default": "'regular'"
},
"status": {
"name": "status",
"type": "match_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": false,
"default": "'scheduled'"
},
"home_score": {
"name": "home_score",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"away_score": {
"name": "away_score",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"home_score_penalties": {
"name": "home_score_penalties",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"away_score_penalties": {
"name": "away_score_penalties",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"main_referee_id": {
"name": "main_referee_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"main_referee_name": {
"name": "main_referee_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"assistant_referee_1_id": {
"name": "assistant_referee_1_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"assistant_referee_1_name": {
"name": "assistant_referee_1_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"assistant_referee_2_id": {
"name": "assistant_referee_2_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"assistant_referee_2_name": {
"name": "assistant_referee_2_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fourth_referee_id": {
"name": "fourth_referee_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"fourth_referee_name": {
"name": "fourth_referee_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"var_referee_id": {
"name": "var_referee_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"var_referee_name": {
"name": "var_referee_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"weather": {
"name": "weather",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false
},
"temperature": {
"name": "temperature",
"type": "numeric(5, 2)",
"primaryKey": false,
"notNull": false
},
"api_last_synced_at": {
"name": "api_last_synced_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"api_sync_status": {
"name": "api_sync_status",
"type": "api_sync_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": false,
"default": "'pending'"
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"idx_matches_wy_id": {
"name": "idx_matches_wy_id",
"columns": [
{
"expression": "wy_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_matches_home_team_wy_id": {
"name": "idx_matches_home_team_wy_id",
"columns": [
{
"expression": "home_team_wy_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_matches_away_team_wy_id": {
"name": "idx_matches_away_team_wy_id",
"columns": [
{
"expression": "away_team_wy_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_matches_competition_wy_id": {
"name": "idx_matches_competition_wy_id",
"columns": [
{
"expression": "competition_wy_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_matches_season_wy_id": {
"name": "idx_matches_season_wy_id",
"columns": [
{
"expression": "season_wy_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_matches_round_wy_id": {
"name": "idx_matches_round_wy_id",
"columns": [
{
"expression": "round_wy_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_matches_match_date": {
"name": "idx_matches_match_date",
"columns": [
{
"expression": "match_date",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_matches_status": {
"name": "idx_matches_status",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_matches_match_type": {
"name": "idx_matches_match_type",
"columns": [
{
"expression": "match_type",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_matches_main_referee_id": {
"name": "idx_matches_main_referee_id",
"columns": [
{
"expression": "main_referee_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_matches_assistant_referee_1_id": {
"name": "idx_matches_assistant_referee_1_id",
"columns": [
{
"expression": "assistant_referee_1_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_matches_assistant_referee_2_id": {
"name": "idx_matches_assistant_referee_2_id",
"columns": [
{
"expression": "assistant_referee_2_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_matches_fourth_referee_id": {
"name": "idx_matches_fourth_referee_id",
"columns": [
{
"expression": "fourth_referee_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_matches_var_referee_id": {
"name": "idx_matches_var_referee_id",
"columns": [
{
"expression": "var_referee_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_matches_deleted_at": {
"name": "idx_matches_deleted_at",
"columns": [
{
"expression": "deleted_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_matches_api_sync_status": {
"name": "idx_matches_api_sync_status",
"columns": [
{
"expression": "api_sync_status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"matches_wy_id_unique": {
"name": "matches_wy_id_unique",
"nullsNotDistinct": false,
"columns": [
"wy_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.players": {
"name": "players",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"wy_id": {
"name": "wy_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"team_wy_id": {
"name": "team_wy_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"first_name": {
"name": "first_name",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true
},
"last_name": {
"name": "last_name",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true
},
"middle_name": {
"name": "middle_name",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false
},
"short_name": {
"name": "short_name",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false
},
"gsm_id": {
"name": "gsm_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"current_team_id": {
"name": "current_team_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"current_national_team_id": {
"name": "current_national_team_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"current_team_name": {
"name": "current_team_name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"current_team_official_name": {
"name": "current_team_official_name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"date_of_birth": {
"name": "date_of_birth",
"type": "date",
"primaryKey": false,
"notNull": false
},
"height_cm": {
"name": "height_cm",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"weight_kg": {
"name": "weight_kg",
"type": "numeric(5, 2)",
"primaryKey": false,
"notNull": false
},
"foot": {
"name": "foot",
"type": "foot",
"typeSchema": "public",
"primaryKey": false,
"notNull": false
},
"gender": {
"name": "gender",
"type": "gender",
"typeSchema": "public",
"primaryKey": false,
"notNull": false
},
"position": {
"name": "position",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false
},
"role_code2": {
"name": "role_code2",
"type": "varchar(10)",
"primaryKey": false,
"notNull": false
},
"role_code3": {
"name": "role_code3",
"type": "varchar(10)",
"primaryKey": false,
"notNull": false
},
"role_name": {
"name": "role_name",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false
},
"birth_area_wy_id": {
"name": "birth_area_wy_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"passport_area_wy_id": {
"name": "passport_area_wy_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "status",
"typeSchema": "public",
"primaryKey": false,
"notNull": false,
"default": "'active'"
},
"image_data_url": {
"name": "image_data_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"jersey_number": {
"name": "jersey_number",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"phone": {
"name": "phone",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false
},
"on_loan": {
"name": "on_loan",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"agent": {
"name": "agent",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"ranking": {
"name": "ranking",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"roi": {
"name": "roi",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"market_value": {
"name": "market_value",
"type": "numeric(15, 2)",
"primaryKey": false,
"notNull": false
},
"value_range": {
"name": "value_range",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false
},
"transfer_value": {
"name": "transfer_value",
"type": "numeric(15, 2)",
"primaryKey": false,
"notNull": false
},
"salary": {
"name": "salary",
"type": "numeric(15, 2)",
"primaryKey": false,
"notNull": false
},
"feasible": {
"name": "feasible",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"morphology": {
"name": "morphology",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false
},
"api_last_synced_at": {
"name": "api_last_synced_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"api_sync_status": {
"name": "api_sync_status",
"type": "api_sync_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": false,
"default": "'pending'"
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"idx_players_team_wy_id": {
"name": "idx_players_team_wy_id",
"columns": [
{
"expression": "team_wy_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_players_position": {
"name": "idx_players_position",
"columns": [
{
"expression": "position",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_players_deleted_at": {
"name": "idx_players_deleted_at",
"columns": [
{
"expression": "deleted_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_players_is_active": {
"name": "idx_players_is_active",
"columns": [
{
"expression": "is_active",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_players_wy_id": {
"name": "idx_players_wy_id",
"columns": [
{
"expression": "wy_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_players_gsm_id": {
"name": "idx_players_gsm_id",
"columns": [
{
"expression": "gsm_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_players_current_team_id": {
"name": "idx_players_current_team_id",
"columns": [
{
"expression": "current_team_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_players_current_national_team_id": {
"name": "idx_players_current_national_team_id",
"columns": [
{
"expression": "current_national_team_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_players_last_name": {
"name": "idx_players_last_name",
"columns": [
{
"expression": "last_name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_players_first_name": {
"name": "idx_players_first_name",
"columns": [
{
"expression": "first_name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_players_birth_area_wy_id": {
"name": "idx_players_birth_area_wy_id",
"columns": [
{
"expression": "birth_area_wy_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_players_passport_area_wy_id": {
"name": "idx_players_passport_area_wy_id",
"columns": [
{
"expression": "passport_area_wy_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_players_status": {
"name": "idx_players_status",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_players_gender": {
"name": "idx_players_gender",
"columns": [
{
"expression": "gender",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_players_foot": {
"name": "idx_players_foot",
"columns": [
{
"expression": "foot",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_players_api_sync_status": {
"name": "idx_players_api_sync_status",
"columns": [
{
"expression": "api_sync_status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_players_api_last_synced_at": {
"name": "idx_players_api_last_synced_at",
"columns": [
{
"expression": "api_last_synced_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"players_wy_id_unique": {
"name": "players_wy_id_unique",
"nullsNotDistinct": false,
"columns": [
"wy_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reports": {
"name": "reports",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"player_wy_id": {
"name": "player_wy_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"coach_wy_id": {
"name": "coach_wy_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"match_wy_id": {
"name": "match_wy_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "json",
"primaryKey": false,
"notNull": false
},
"grade": {
"name": "grade",
"type": "text",
"primaryKey": false,
"notNull": false
},
"rating": {
"name": "rating",
"type": "numeric(5, 2)",
"primaryKey": false,
"notNull": false
},
"decision": {
"name": "decision",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "report_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": false,
"default": "'saved'"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"reports_user_id_users_id_fk": {
"name": "reports_user_id_users_id_fk",
"tableFrom": "reports",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_sessions": {
"name": "user_sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"device_info": {
"name": "device_info",
"type": "json",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"user_sessions_token_idx": {
"name": "user_sessions_token_idx",
"columns": [
{
"expression": "token",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_sessions_user_id_idx": {
"name": "user_sessions_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_sessions_device_id_idx": {
"name": "user_sessions_device_id_idx",
"columns": [
{
"expression": "device_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_sessions_expires_at_idx": {
"name": "user_sessions_expires_at_idx",
"columns": [
{
"expression": "expires_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_sessions_user_id_users_id_fk": {
"name": "user_sessions_user_id_users_id_fk",
"tableFrom": "user_sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_sessions_token_unique": {
"name": "user_sessions_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_settings": {
"name": "user_settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "json",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"user_settings_user_id_idx": {
"name": "user_settings_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_settings_category_idx": {
"name": "user_settings_category_idx",
"columns": [
{
"expression": "category",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_settings_key_idx": {
"name": "user_settings_key_idx",
"columns": [
{
"expression": "key",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_settings_user_category_key_idx": {
"name": "user_settings_user_category_key_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "category",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "key",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_settings_user_id_users_id_fk": {
"name": "user_settings_user_id_users_id_fk",
"tableFrom": "user_settings",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'viewer'"
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"email_verified_at": {
"name": "email_verified_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"last_login_at": {
"name": "last_login_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"two_factor_enabled": {
"name": "two_factor_enabled",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"two_factor_secret": {
"name": "two_factor_secret",
"type": "text",
"primaryKey": false,
"notNull": false
},
"failed_login_attempts": {
"name": "failed_login_attempts",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"locked_until": {
"name": "locked_until",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"users_email_idx": {
"name": "users_email_idx",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"users_role_idx": {
"name": "users_role_idx",
"columns": [
{
"expression": "role",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"users_is_active_idx": {
"name": "users_is_active_idx",
"columns": [
{
"expression": "is_active",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"users_two_factor_enabled_idx": {
"name": "users_two_factor_enabled_idx",
"columns": [
{
"expression": "two_factor_enabled",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"users_locked_until_idx": {
"name": "users_locked_until_idx",
"columns": [
{
"expression": "locked_until",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.api_sync_status": {
"name": "api_sync_status",
"schema": "public",
"values": [
"pending",
"synced",
"error"
]
},
"public.calendar_event_type": {
"name": "calendar_event_type",
"schema": "public",
"values": [
"match",
"travel",
"player_observation",
"meeting",
"training",
"other"
]
},
"public.coach_position": {
"name": "coach_position",
"schema": "public",
"values": [
"head_coach",
"assistant_coach",
"analyst"
]
},
"public.foot": {
"name": "foot",
"schema": "public",
"values": [
"left",
"right",
"both"
]
},
"public.gender": {
"name": "gender",
"schema": "public",
"values": [
"male",
"female",
"other"
]
},
"public.list_type": {
"name": "list_type",
"schema": "public",
"values": [
"shortlist",
"shadow_team",
"target_list"
]
},
"public.match_status": {
"name": "match_status",
"schema": "public",
"values": [
"scheduled",
"in_progress",
"finished",
"postponed",
"cancelled"
]
},
"public.match_type": {
"name": "match_type",
"schema": "public",
"values": [
"regular",
"friendly",
"playoff",
"cup"
]
},
"public.report_status": {
"name": "report_status",
"schema": "public",
"values": [
"saved",
"finished"
]
},
"public.status": {
"name": "status",
"schema": "public",
"values": [
"active",
"inactive"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
\ No newline at end of file
......@@ -64,6 +64,13 @@
"when": 1762768010792,
"tag": "0008_third_black_panther",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1763057316693,
"tag": "0009_tough_greymalkin",
"breakpoints": true
}
]
}
\ No newline at end of file
......@@ -15,6 +15,9 @@ import { ReportsModule } from './modules/reports/reports.module';
import { FilesModule } from './modules/files/files.module';
import { CalendarModule } from './modules/calendar/calendar.module';
import { AreasModule } from './modules/areas/areas.module';
import { ListsModule } from './modules/lists/lists.module';
import { PositionsModule } from './modules/positions/positions.module';
import { AuditLogsModule } from './modules/audit-logs/audit-logs.module';
@Module({
imports: [
......@@ -36,6 +39,9 @@ import { AreasModule } from './modules/areas/areas.module';
FilesModule,
CalendarModule,
AreasModule,
ListsModule,
PositionsModule,
AuditLogsModule,
],
controllers: [AppController],
providers: [AppService],
......
import { IsOptional, IsEnum, IsInt, Min, Max } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
/**
* Base query DTO that provides common query functionality
* Extend this class in your query DTOs to include common parameters
*
* Each module can override sortBy with its own enum values
*/
export class BaseQueryDto {
@ApiPropertyOptional({
description: 'Number of results to return (default: 50, max: 1000)',
type: Number,
example: 50,
default: 50,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(1000)
limit?: number;
@ApiPropertyOptional({
description: 'Number of results to skip (default: 0)',
type: Number,
example: 0,
default: 0,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
offset?: number;
@ApiPropertyOptional({
description: 'Sort order - ascending or descending',
enum: ['asc', 'desc'],
example: 'asc',
default: 'asc',
})
@IsOptional()
@IsEnum(['asc', 'desc'])
sortOrder?: 'asc' | 'desc';
}
export * from './base-query.dto';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { eq, and, or } from 'drizzle-orm';
import { globalSettings, players, positions } from './schema';
interface PositionValue {
positions?: Array<{
name: string;
code2?: string;
code3?: string;
order?: number;
location?: { x: number; y: number };
}>;
bgColor?: string;
textColor?: string;
code2?: string;
code3?: string;
}
/**
* Maps old category keys from globalSettings to new position_category enum values
* Examples:
* - "goalkeeper-positions" -> "Goalkeeper"
* - "defender-positions" -> "Defender"
* - "forward-positions" -> "Forward"
* - "midfield-positions" -> "Midfield"
*/
function mapCategoryToEnum(
oldCategory: string | null | undefined,
): 'Forward' | 'Goalkeeper' | 'Defender' | 'Midfield' {
if (!oldCategory) {
// Default to Forward if no category found
return 'Forward';
}
const normalized = oldCategory.toLowerCase();
if (normalized.includes('goalkeeper') || normalized.includes('gk')) {
return 'Goalkeeper';
}
if (normalized.includes('defender') || normalized.includes('def')) {
return 'Defender';
}
if (normalized.includes('midfield') || normalized.includes('mid')) {
return 'Midfield';
}
if (normalized.includes('forward') || normalized.includes('fw') || normalized.includes('attacker')) {
return 'Forward';
}
// Default fallback
return 'Forward';
}
async function migratePositions() {
const databaseUrl =
process.env.DATABASE_URL ||
'postgresql://username:password@localhost:5432/scoutingsystem';
const sql = postgres(databaseUrl);
const db = drizzle(sql);
try {
console.log('🚀 Starting positions migration...');
// Step 1: Get all position settings from globalSettings
console.log('📥 Fetching position settings from globalSettings...');
const positionSettings = await db
.select()
.from(globalSettings)
.where(
and(
eq(globalSettings.category, 'positions'),
eq(globalSettings.isActive, true),
),
);
if (positionSettings.length === 0) {
console.log('⚠️ No position settings found in globalSettings');
return;
}
console.log(`✅ Found ${positionSettings.length} position setting(s)`);
// Step 2: Extract positions from JSON and insert into positions table
console.log('📝 Extracting and inserting positions...');
let totalPositionsCreated = 0;
for (const setting of positionSettings) {
const value = setting.value as PositionValue;
if (!value.positions || !Array.isArray(value.positions)) {
console.log(
`⚠️ Skipping setting "${setting.key}" - no positions array found`,
);
continue;
}
for (const pos of value.positions) {
try {
// Use position-specific code2/code3, fallback to setting-level codes
const code2 = pos.code2 || value.code2 || null;
const code3 = pos.code3 || value.code3 || null;
// Check if position already exists (by code2 or code3)
let existingPosition: any = null;
if (code2) {
const existing = await db
.select()
.from(positions)
.where(eq(positions.code2, code2))
.limit(1);
if (existing.length > 0) {
existingPosition = existing[0];
}
}
if (!existingPosition && code3) {
const existing = await db
.select()
.from(positions)
.where(eq(positions.code3, code3))
.limit(1);
if (existing.length > 0) {
existingPosition = existing[0];
}
}
if (existingPosition) {
console.log(
`⏭️ Position "${pos.name}" (${code2 || code3}) already exists, skipping...`,
);
continue;
}
// Map old category to new enum value
const mappedCategory = mapCategoryToEnum(setting.key);
// Insert new position
const [newPosition] = await db
.insert(positions)
.values({
name: pos.name,
code2: code2,
code3: code3,
order: pos.order || 0,
locationX: pos.location?.x || null,
locationY: pos.location?.y || null,
bgColor: value.bgColor || null,
textColor: value.textColor || null,
category: mappedCategory,
isActive: true,
})
.returning();
console.log(
`✅ Created position: ${newPosition.name} (${newPosition.code2 || newPosition.code3 || 'no code'})`,
);
totalPositionsCreated++;
} catch (error: any) {
console.error(
`❌ Error creating position "${pos.name}":`,
error.message,
);
// Continue with next position
}
}
}
console.log(`✅ Created ${totalPositionsCreated} position(s)`);
// Step 3: Update players table with position IDs
console.log('🔄 Updating players with position IDs...');
// Use raw SQL to access old columns that may still exist in the database
const allPlayers = await sql<
{
id: number;
position: string | null;
other_positions: string[] | null;
}[]
>`SELECT id, position, other_positions FROM players WHERE is_active = true`;
console.log(`📊 Found ${allPlayers.length} active player(s) to update`);
let playersUpdated = 0;
let playersSkipped = 0;
for (const player of allPlayers) {
try {
let positionId: number | null = null;
const otherPositionIds: number[] = [];
// Update main position
const playerPosition = player.position;
if (playerPosition) {
const foundPosition = await db
.select()
.from(positions)
.where(
and(
or(
eq(positions.name, playerPosition),
eq(positions.code2, playerPosition),
eq(positions.code3, playerPosition),
),
eq(positions.isActive, true),
),
)
.limit(1);
if (foundPosition.length > 0) {
positionId = foundPosition[0].id;
} else {
console.log(
`⚠️ Position "${playerPosition}" not found for player ID ${player.id}`,
);
}
}
// Update other positions
const playerOtherPositions = player.other_positions;
if (playerOtherPositions && playerOtherPositions.length > 0) {
for (const posName of playerOtherPositions) {
const foundPosition = await db
.select()
.from(positions)
.where(
and(
or(
eq(positions.name, posName),
eq(positions.code2, posName),
eq(positions.code3, posName),
),
eq(positions.isActive, true),
),
)
.limit(1);
if (foundPosition.length > 0) {
otherPositionIds.push(foundPosition[0].id);
}
}
}
// Update player if we found at least one position
if (positionId !== null || otherPositionIds.length > 0) {
await db
.update(players)
.set({
positionId: positionId,
otherPositionIds:
otherPositionIds.length > 0 ? otherPositionIds : null,
})
.where(eq(players.id, player.id));
playersUpdated++;
} else {
playersSkipped++;
}
} catch (error: any) {
console.error(
`❌ Error updating player ID ${player.id}:`,
error.message,
);
}
}
console.log(`✅ Updated ${playersUpdated} player(s) with position IDs`);
if (playersSkipped > 0) {
console.log(`⏭️ Skipped ${playersSkipped} player(s) (no matching positions)`);
}
console.log('🎉 Positions migration completed successfully!');
} catch (error) {
console.error('❌ Migration failed:', error);
throw error;
} finally {
await sql.end();
}
}
// Run migration
migratePositions()
.then(() => {
console.log('✅ Migration script finished');
process.exit(0);
})
.catch((error) => {
console.error('❌ Migration script failed:', error);
process.exit(1);
});
......@@ -53,6 +53,24 @@ export const calendarEventTypeEnum = pgEnum('calendar_event_type', [
'training',
'other',
]);
export const listTypeEnum = pgEnum('list_type', [
'shortlist',
'shadow_team',
'target_list',
]);
export const positionCategoryEnum = pgEnum('position_category', [
'Forward',
'Goalkeeper',
'Defender',
'Midfield',
]);
export const auditLogActionEnum = pgEnum('audit_log_action', [
'create',
'update',
'delete',
'soft_delete',
'restore',
]);
// ============================================================================
// USERS & AUTHENTICATION
// ============================================================================
......@@ -161,20 +179,44 @@ export const players = pgTable(
gender: genderEnum('gender'),
// Position and role
position: varchar('position', { length: 50 }),
positionId: integer('position_id').references(() => positions.id, {
onDelete: 'set null',
}),
otherPositionIds: integer('other_position_ids').array(), // Array of position IDs
roleCode2: varchar('role_code2', { length: 10 }), // e.g., 'FW'
roleCode3: varchar('role_code3', { length: 10 }), // e.g., 'FWD'
roleName: varchar('role_name', { length: 50 }), // e.g., 'Forward'
// Foreign keys (NORMALIZED) - CHANGED: Now using wy_id
birthAreaWyId: integer('birth_area_wy_id'),
secondBirthAreaWyId: integer('second_birth_area_wy_id'),
passportAreaWyId: integer('passport_area_wy_id'),
secondPassportAreaWyId: integer('second_passport_area_wy_id'),
// Status and metadata
status: statusEnum('status').default('active'),
imageDataUrl: text('image_data_url'),
jerseyNumber: integer('jersey_number'),
// Contact information
email: varchar('email', { length: 255 }),
phone: varchar('phone', { length: 50 }),
// Transfer and financial information
onLoan: boolean('on_loan').default(false),
agent: varchar('agent', { length: 255 }),
ranking: varchar('ranking', { length: 255 }),
roi: varchar('roi', { length: 255 }), // Return on Investment
marketValue: decimal('market_value', { precision: 15, scale: 2 }), // Monetary value
valueRange: varchar('value_range', { length: 100 }), // e.g., "100000-200000"
transferValue: decimal('transfer_value', { precision: 15, scale: 2 }), // Monetary value
salary: decimal('salary', { precision: 15, scale: 2 }), // Monetary value
contractEndsAt: date('contract_ends_at'),
feasible: boolean('feasible').default(false),
// Physical characteristics
morphology: varchar('morphology', { length: 100 }),
// API sync metadata
apiLastSyncedAt: timestamp('api_last_synced_at', { withTimezone: true }),
apiSyncStatus: apiSyncStatusEnum('api_sync_status').default('pending'),
......@@ -187,7 +229,7 @@ export const players = pgTable(
},
(table) => ({
teamWyIdIdx: index('idx_players_team_wy_id').on(table.teamWyId),
positionIdx: index('idx_players_position').on(table.position),
positionIdIdx: index('idx_players_position_id').on(table.positionId),
deletedAtIdx: index('idx_players_deleted_at').on(table.deletedAt),
isActiveIdx: index('idx_players_is_active').on(table.isActive),
wyIdIdx: index('idx_players_wy_id').on(table.wyId),
......@@ -443,6 +485,71 @@ export const calendarEvents = pgTable(
);
// ============================================================================
// Lists
// ============================================================================
export const lists = pgTable(
'lists',
{
id: serial('id').primaryKey(),
// User relation - each list belongs to a user (the creator/owner)
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
// List details
name: text('name').notNull(),
season: varchar('season', { length: 50 }).notNull(),
type: listTypeEnum('type').notNull(),
// Players organized by position - stored as JSON
// Structure: { "GK": [playerWyId1, playerWyId2], "DF": [playerWyId3], ... }
playersByPosition: json('players_by_position').$type<{
[position: string]: number[]; // Array of player wyIds
}>(),
// Standard fields
isActive: boolean('is_active').default(true),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
},
(table) => ({
userIdIdx: index('idx_lists_user_id').on(table.userId),
typeIdx: index('idx_lists_type').on(table.type),
seasonIdx: index('idx_lists_season').on(table.season),
isActiveIdx: index('idx_lists_is_active').on(table.isActive),
deletedAtIdx: index('idx_lists_deleted_at').on(table.deletedAt),
userIdTypeIdx: index('idx_lists_user_type').on(table.userId, table.type),
}),
);
// ============================================================================
// List Shares - Many-to-many relationship for sharing lists with users
// ============================================================================
export const listShares = pgTable(
'list_shares',
{
id: serial('id').primaryKey(),
listId: integer('list_id')
.notNull()
.references(() => lists.id, { onDelete: 'cascade' }),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
// Standard fields
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
},
(table) => ({
listIdIdx: index('idx_list_shares_list_id').on(table.listId),
userIdIdx: index('idx_list_shares_user_id').on(table.userId),
listUserIdIdx: uniqueIndex('idx_list_shares_list_user').on(
table.listId,
table.userId,
), // Prevent duplicate shares
}),
);
// ============================================================================
// Files & Images
// ============================================================================
......@@ -511,6 +618,50 @@ export const areas = pgTable(
}),
);
// ============================================================================
// POSITIONS
// ============================================================================
export const positions = pgTable(
'positions',
{
id: serial('id').primaryKey(),
// Position identifiers
name: varchar('name', { length: 100 }).notNull(), // "Goalkeeper"
code2: varchar('code2', { length: 10 }), // "GK"
code3: varchar('code3', { length: 10 }), // "GKP"
// Position metadata
order: integer('order').default(0),
locationX: integer('location_x'),
locationY: integer('location_y'),
bgColor: varchar('bg_color', { length: 7 }), // "#C14B50"
textColor: varchar('text_color', { length: 7 }), // "#000000"
// Category: One of the 4 main position categories
// - Forward: Forward positions (e.g., "Center Forward", "Right Winger", "Left Winger")
// - Goalkeeper: Goalkeeper positions (e.g., "Goalkeeper")
// - Defender: Defender positions (e.g., "Center Defender", "Right Defender", "Left Defender")
// - Midfield: Midfield positions (e.g., "Center Midfielder", "Right Midfielder", "Left Midfielder")
category: positionCategoryEnum('category').notNull(),
isActive: boolean('is_active').default(true),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
},
(table) => ({
// Unique constraints on codes for fast lookups
code2Idx: uniqueIndex('positions_code2_unique').on(table.code2),
code3Idx: uniqueIndex('positions_code3_unique').on(table.code3),
// Indexes for common queries
nameIdx: index('positions_name_idx').on(table.name),
categoryIdx: index('positions_category_idx').on(table.category),
isActiveIdx: index('positions_is_active_idx').on(table.isActive),
// Composite index for active positions by category
categoryActiveIdx: index('positions_category_active_idx').on(
table.category,
table.isActive,
),
deletedAtIdx: index('positions_deleted_at_idx').on(table.deletedAt),
}),
);
// ============================================================================
// SETTINGS
// ============================================================================
......@@ -628,6 +779,66 @@ export const clientModules = pgTable(
);
// ============================================================================
// AUDIT LOGS
// ============================================================================
export const auditLogs = pgTable(
'audit_logs',
{
id: serial('id').primaryKey(),
// User who made the change
userId: integer('user_id').references(() => users.id, {
onDelete: 'set null',
}),
// Action type
action: auditLogActionEnum('action').notNull(),
// Entity information
entityType: text('entity_type').notNull(), // e.g., 'players', 'coaches', 'matches', 'reports', etc.
entityId: integer('entity_id'), // The ID of the changed record
entityWyId: integer('entity_wy_id'), // For entities that use wyId (players, coaches, matches, areas)
// Change data
oldValues: json('old_values').$type<Record<string, any>>(), // Previous state of the record
newValues: json('new_values').$type<Record<string, any>>(), // New state of the record
changes: json('changes').$type<Record<string, any>>(), // Only the fields that changed (diff)
// Request metadata
ipAddress: varchar('ip_address', { length: 45 }), // IPv4 or IPv6
userAgent: text('user_agent'),
// Additional metadata
metadata: json('metadata').$type<{
endpoint?: string;
method?: string;
requestId?: string;
[key: string]: any;
}>(),
// Timestamp
createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => ({
userIdIdx: index('idx_audit_logs_user_id').on(table.userId),
actionIdx: index('idx_audit_logs_action').on(table.action),
entityTypeIdx: index('idx_audit_logs_entity_type').on(table.entityType),
entityIdIdx: index('idx_audit_logs_entity_id').on(table.entityId),
entityWyIdIdx: index('idx_audit_logs_entity_wy_id').on(table.entityWyId),
createdAtIdx: index('idx_audit_logs_created_at').on(table.createdAt),
// Composite indexes for common queries
entityTypeIdIdx: index('idx_audit_logs_entity_type_id').on(
table.entityType,
table.entityId,
),
entityTypeWyIdIdx: index('idx_audit_logs_entity_type_wy_id').on(
table.entityType,
table.entityWyId,
),
userIdCreatedAtIdx: index('idx_audit_logs_user_created_at').on(
table.userId,
table.createdAt,
),
}),
);
// ============================================================================
// ZOD SCHEMAS FOR VALIDATION
// ============================================================================
......@@ -669,10 +880,24 @@ export const selectFileSchema = createSelectSchema(files);
export const insertCalendarEventSchema = createInsertSchema(calendarEvents);
export const selectCalendarEventSchema = createSelectSchema(calendarEvents);
// Lists
export const insertListSchema = createInsertSchema(lists);
export const selectListSchema = createSelectSchema(lists);
export const insertListShareSchema = createInsertSchema(listShares);
export const selectListShareSchema = createSelectSchema(listShares);
// Areas
export const insertAreaSchema = createInsertSchema(areas);
export const selectAreaSchema = createSelectSchema(areas);
// Positions
export const insertPositionSchema = createInsertSchema(positions);
export const selectPositionSchema = createSelectSchema(positions);
// Audit Logs
export const insertAuditLogSchema = createInsertSchema(auditLogs);
export const selectAuditLogSchema = createSelectSchema(auditLogs);
// ============================================================================
// TYPE EXPORTS
// ============================================================================
......@@ -722,6 +947,20 @@ export type NewFile = typeof files.$inferInsert;
export type CalendarEvent = typeof calendarEvents.$inferSelect;
export type NewCalendarEvent = typeof calendarEvents.$inferInsert;
// Lists
export type List = typeof lists.$inferSelect;
export type NewList = typeof lists.$inferInsert;
export type ListShare = typeof listShares.$inferSelect;
export type NewListShare = typeof listShares.$inferInsert;
// Areas
export type Area = typeof areas.$inferSelect;
export type NewArea = typeof areas.$inferInsert;
// Positions
export type Position = typeof positions.$inferSelect;
export type NewPosition = typeof positions.$inferInsert;
// Audit Logs
export type AuditLog = typeof auditLogs.$inferSelect;
export type NewAuditLog = typeof auditLogs.$inferInsert;
......@@ -17,7 +17,7 @@ import {
ApiTags,
} from '@nestjs/swagger';
import { SimplePaginatedResponse } from '../../common/utils/response.util';
import { CreateAreaDto } from './dto';
import { CreateAreaDto, QueryAreasDto } from './dto';
@ApiTags('Areas')
@Controller('areas')
......@@ -46,9 +46,9 @@ export class AreasController {
@Get()
@ApiOperation({
summary: 'List areas with pagination',
summary: 'List areas with pagination and filtering',
description:
'Search areas by name, alpha2code, or alpha3code. Uses normalized string comparison (accent-insensitive).',
'Search areas by name, alpha2code, or alpha3code. Uses normalized string comparison (accent-insensitive). Supports sorting.',
})
@ApiQuery({
name: 'search',
......@@ -69,15 +69,23 @@ export class AreasController {
type: Number,
description: 'Number of results to skip (default: 0)',
})
@ApiQuery({
name: 'sortBy',
required: false,
enum: ['name', 'alpha2code', 'alpha3code', 'createdAt', 'updatedAt'],
description: 'Field to sort by (default: name)',
})
@ApiQuery({
name: 'sortOrder',
required: false,
enum: ['asc', 'desc'],
description: 'Sort order - ascending or descending (default: asc)',
})
@ApiOkResponse({ description: 'Paginated list of areas' })
async findAll(
@Query('limit') limit?: string,
@Query('offset') offset?: string,
@Query('search') search?: string,
): Promise<SimplePaginatedResponse<any>> {
const l = limit ? parseInt(limit, 10) : 50;
const o = offset ? parseInt(offset, 10) : 0;
return this.areasService.findAll(l, o, search);
async findAll(@Query() query: QueryAreasDto): Promise<SimplePaginatedResponse<any>> {
const l = query.limit ?? 50;
const o = query.offset ?? 0;
return this.areasService.findAll(l, o, query.search, query);
}
}
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../../database/database.service';
import { areas, type NewArea, type Area } from '../../database/schema';
import { eq, count, and, or, ilike, isNull } from 'drizzle-orm';
import { eq, count, and, or, ilike, isNull, desc, asc } from 'drizzle-orm';
import { ResponseUtil, SimplePaginatedResponse } from '../../common/utils/response.util';
import { AreaWyIdRequiredError } from '../../common/errors';
import { QueryAreasDto } from './dto/query-areas.dto';
@Injectable()
export class AreasService {
......@@ -66,6 +67,7 @@ export class AreasService {
limit: number = 50,
offset: number = 0,
search?: string,
query?: QueryAreasDto,
): Promise<SimplePaginatedResponse<any>> {
const db = this.databaseService.getDatabase();
......@@ -92,15 +94,45 @@ export class AreasService {
let total = totalResult.count;
// Get data
const query = db.select().from(areas).where(whereCondition);
// Determine sort field and order
const sortBy = query?.sortBy || 'name';
const sortOrder = query?.sortOrder || 'asc';
let orderByColumn: any = areas.name; // Default sort
// Map sortBy to actual database column
switch (sortBy) {
case 'name':
orderByColumn = areas.name;
break;
case 'alpha2code':
orderByColumn = areas.alpha2code;
break;
case 'alpha3code':
orderByColumn = areas.alpha3code;
break;
case 'createdAt':
orderByColumn = areas.createdAt;
break;
case 'updatedAt':
orderByColumn = areas.updatedAt;
break;
default:
orderByColumn = areas.name;
}
// Get data with sorting
const baseQuery = db.select().from(areas).where(whereCondition);
const queryWithOrder = sortOrder === 'desc'
? baseQuery.orderBy(desc(orderByColumn))
: baseQuery.orderBy(asc(orderByColumn));
// If searching, fetch all matches for normalization filtering
// Otherwise use normal pagination
let rawData;
if (!search || !search.trim()) {
rawData = await query.limit(limit).offset(offset);
rawData = await queryWithOrder.limit(limit).offset(offset);
} else {
rawData = await query;
rawData = await queryWithOrder;
}
// Transform to match API structure
......@@ -121,7 +153,52 @@ export class AreasService {
);
});
// Apply limit and offset after filtering
// Apply sorting after filtering (if search was used)
if (query?.sortBy) {
filteredData.sort((a, b) => {
let aValue: any;
let bValue: any;
switch (sortBy) {
case 'name':
aValue = a.name || '';
bValue = b.name || '';
break;
case 'alpha2code':
aValue = a.alpha2code || '';
bValue = b.alpha2code || '';
break;
case 'alpha3code':
aValue = a.alpha3code || '';
bValue = b.alpha3code || '';
break;
case 'createdAt':
aValue = a.createdAt ? new Date(a.createdAt).getTime() : 0;
bValue = b.createdAt ? new Date(b.createdAt).getTime() : 0;
break;
case 'updatedAt':
aValue = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
bValue = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
break;
default:
aValue = a.name || '';
bValue = b.name || '';
}
if (typeof aValue === 'string' && typeof bValue === 'string') {
const comparison = aValue.localeCompare(bValue);
return sortOrder === 'asc' ? comparison : -comparison;
}
if (typeof aValue === 'number' && typeof bValue === 'number') {
return sortOrder === 'asc' ? aValue - bValue : bValue - aValue;
}
return 0;
});
}
// Apply limit and offset after filtering and sorting
transformedData = filteredData.slice(offset, offset + limit);
// Update total to the actual filtered count
......
export * from './create-area.dto';
export * from './query-areas.dto';
import {
IsOptional,
IsString,
IsEnum,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { BaseQueryDto } from '../../../common/dto';
export class QueryAreasDto extends BaseQueryDto {
@ApiPropertyOptional({
description: 'Search areas by name, alpha2code, or alpha3code',
example: 'Brazil',
})
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({
description: 'Field to sort by',
enum: ['name', 'alpha2code', 'alpha3code', 'createdAt', 'updatedAt'],
example: 'name',
default: 'name',
})
@IsOptional()
@IsEnum(['name', 'alpha2code', 'alpha3code', 'createdAt', 'updatedAt'])
sortBy?: string;
}
import {
Controller,
Get,
Query,
Param,
ParseIntPipe,
UseGuards,
} from '@nestjs/common';
import { AuditLogsService } from './audit-logs.service';
import { type AuditLog } from '../../database/schema';
import {
ApiOkResponse,
ApiOperation,
ApiParam,
ApiQuery,
ApiTags,
ApiBearerAuth,
} from '@nestjs/swagger';
import { QueryAuditLogsDto, AuditLogAction } from './dto';
import { JwtSimpleGuard } from '../../common/guards/jwt-simple.guard';
@ApiTags('Audit Logs')
@Controller('audit-logs')
@UseGuards(JwtSimpleGuard)
@ApiBearerAuth('bearer')
export class AuditLogsController {
constructor(private readonly auditLogsService: AuditLogsService) {}
@Get()
@ApiOperation({
summary: 'List audit logs with filtering',
description:
'List audit logs with various filtering options. Filter by user, action, entity type, entity ID, entity WyID, IP address, and date range.',
})
@ApiQuery({
name: 'userId',
required: false,
type: Number,
description: 'Filter by user ID (who made the change)',
})
@ApiQuery({
name: 'action',
required: false,
enum: AuditLogAction,
description: 'Filter by action type (create, update, delete, soft_delete, restore)',
})
@ApiQuery({
name: 'entityType',
required: false,
type: String,
description: 'Filter by entity type (table/model name, e.g., "players", "coaches", "matches")',
})
@ApiQuery({
name: 'entityId',
required: false,
type: Number,
description: 'Filter by entity ID (database ID)',
})
@ApiQuery({
name: 'entityWyId',
required: false,
type: Number,
description: 'Filter by entity WyID (for entities using wyId)',
})
@ApiQuery({
name: 'ipAddress',
required: false,
type: String,
description: 'Filter by IP address (partial match)',
})
@ApiQuery({
name: 'createdFrom',
required: false,
type: String,
description: 'Filter by created date (from) - ISO date string',
})
@ApiQuery({
name: 'createdTo',
required: false,
type: String,
description: 'Filter by created date (to) - ISO date string',
})
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: 'Number of results to return (default: 50, max: 1000)',
})
@ApiQuery({
name: 'offset',
required: false,
type: Number,
description: 'Number of results to skip (default: 0)',
})
@ApiQuery({
name: 'sortBy',
required: false,
enum: [
'id',
'userId',
'action',
'entityType',
'entityId',
'entityWyId',
'createdAt',
],
description: 'Field to sort by (default: createdAt)',
})
@ApiQuery({
name: 'sortOrder',
required: false,
enum: ['asc', 'desc'],
description: 'Sort order - ascending or descending (default: desc)',
})
@ApiOkResponse({
description: 'List of audit logs matching the filters',
type: [Object],
})
async list(@Query() query: QueryAuditLogsDto): Promise<AuditLog[]> {
return this.auditLogsService.findAll(query);
}
@Get('count')
@ApiOperation({
summary: 'Get count of audit logs matching filters',
description: 'Get the total count of audit logs that match the provided filters',
})
@ApiQuery({
name: 'userId',
required: false,
type: Number,
description: 'Filter by user ID',
})
@ApiQuery({
name: 'action',
required: false,
enum: AuditLogAction,
description: 'Filter by action type',
})
@ApiQuery({
name: 'entityType',
required: false,
type: String,
description: 'Filter by entity type',
})
@ApiQuery({
name: 'entityId',
required: false,
type: Number,
description: 'Filter by entity ID',
})
@ApiQuery({
name: 'entityWyId',
required: false,
type: Number,
description: 'Filter by entity WyID',
})
@ApiQuery({
name: 'ipAddress',
required: false,
type: String,
description: 'Filter by IP address',
})
@ApiQuery({
name: 'createdFrom',
required: false,
type: String,
description: 'Filter by created date (from)',
})
@ApiQuery({
name: 'createdTo',
required: false,
type: String,
description: 'Filter by created date (to)',
})
@ApiOkResponse({
description: 'Count of audit logs',
type: Number,
})
async count(@Query() query: QueryAuditLogsDto): Promise<{ count: number }> {
const count = await this.auditLogsService.count(query);
return { count };
}
@Get(':id')
@ApiOperation({ summary: 'Get audit log by ID' })
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the audit log',
})
@ApiOkResponse({
description: 'Audit log if found',
type: Object,
})
async getById(
@Param('id', ParseIntPipe) id: number,
): Promise<AuditLog | undefined> {
return this.auditLogsService.findById(id);
}
@Get('entity/:entityType')
@ApiOperation({
summary: 'Get audit logs for a specific entity type',
description:
'Get all audit logs for a specific entity type. Optionally filter by entityId or entityWyId.',
})
@ApiParam({
name: 'entityType',
type: String,
description: 'Entity type (e.g., "players", "coaches", "matches")',
})
@ApiQuery({
name: 'entityId',
required: false,
type: Number,
description: 'Filter by entity ID (database ID)',
})
@ApiQuery({
name: 'entityWyId',
required: false,
type: Number,
description: 'Filter by entity WyID',
})
@ApiOkResponse({
description: 'List of audit logs for the entity',
type: [Object],
})
async getByEntity(
@Param('entityType') entityType: string,
@Query('entityId') entityId?: number,
@Query('entityWyId') entityWyId?: number,
): Promise<AuditLog[]> {
return this.auditLogsService.findByEntity(
entityType,
entityId ? parseInt(entityId.toString(), 10) : undefined,
entityWyId ? parseInt(entityWyId.toString(), 10) : undefined,
);
}
@Get('user/:userId')
@ApiOperation({
summary: 'Get audit logs for a specific user',
description: 'Get recent audit logs for a specific user',
})
@ApiParam({
name: 'userId',
type: Number,
description: 'User ID',
})
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: 'Number of results to return (default: 50)',
})
@ApiOkResponse({
description: 'List of audit logs for the user',
type: [Object],
})
async getByUser(
@Param('userId', ParseIntPipe) userId: number,
@Query('limit') limit?: number,
): Promise<AuditLog[]> {
return this.auditLogsService.findByUser(
userId,
limit ? parseInt(limit.toString(), 10) : 50,
);
}
}
import { Module } from '@nestjs/common';
import { AuditLogsController } from './audit-logs.controller';
import { AuditLogsService } from './audit-logs.service';
@Module({
controllers: [AuditLogsController],
providers: [AuditLogsService],
exports: [AuditLogsService],
})
export class AuditLogsModule {}
import { Injectable, Logger } from '@nestjs/common';
import { DatabaseService } from '../../database/database.service';
import {
auditLogs,
type NewAuditLog,
type AuditLog,
} from '../../database/schema';
import {
eq,
and,
gte,
lte,
desc,
asc,
ilike,
count,
} from 'drizzle-orm';
import { QueryAuditLogsDto, AuditLogAction } from './dto/query-audit-logs.dto';
@Injectable()
export class AuditLogsService {
private readonly logger = new Logger(AuditLogsService.name);
constructor(private readonly databaseService: DatabaseService) {}
/**
* Create a new audit log entry
*/
async create(data: NewAuditLog): Promise<AuditLog> {
const db = this.databaseService.getDatabase();
try {
const [row] = await db
.insert(auditLogs)
.values(data)
.returning();
return row as AuditLog;
} catch (error: any) {
this.logger.error('Failed to create audit log:', error);
throw error;
}
}
/**
* Find audit log by ID
*/
async findById(id: number): Promise<AuditLog | undefined> {
const db = this.databaseService.getDatabase();
const rows = await db
.select()
.from(auditLogs)
.where(eq(auditLogs.id, id))
.limit(1);
return rows[0];
}
/**
* Find all audit logs with filtering
*/
async findAll(query: QueryAuditLogsDto): Promise<AuditLog[]> {
const db = this.databaseService.getDatabase();
const conditions: any[] = [];
// Filter by user ID
if (query.userId !== undefined) {
conditions.push(eq(auditLogs.userId, query.userId));
}
// Filter by action
if (query.action) {
conditions.push(eq(auditLogs.action, query.action));
}
// Filter by entity type
if (query.entityType) {
conditions.push(eq(auditLogs.entityType, query.entityType));
}
// Filter by entity ID
if (query.entityId !== undefined) {
conditions.push(eq(auditLogs.entityId, query.entityId));
}
// Filter by entity WyID
if (query.entityWyId !== undefined) {
conditions.push(eq(auditLogs.entityWyId, query.entityWyId));
}
// Filter by IP address (partial match)
if (query.ipAddress) {
conditions.push(ilike(auditLogs.ipAddress, `%${query.ipAddress}%`));
}
// Filter by date range
if (query.createdFrom) {
conditions.push(gte(auditLogs.createdAt, new Date(query.createdFrom)));
}
if (query.createdTo) {
conditions.push(lte(auditLogs.createdAt, new Date(query.createdTo)));
}
// Build the query
const limit = query.limit ?? 50;
const offset = query.offset ?? 0;
const baseQuery = db.select().from(auditLogs);
const queryWithWhere =
conditions.length > 0 ? baseQuery.where(and(...conditions)) : baseQuery;
// Determine sort field and order
const sortBy = query.sortBy || 'createdAt';
const sortOrder = query.sortOrder || 'desc';
let orderByColumn: any = auditLogs.createdAt; // Default sort
// Map sortBy to actual database column
switch (sortBy) {
case 'id':
orderByColumn = auditLogs.id;
break;
case 'userId':
orderByColumn = auditLogs.userId;
break;
case 'action':
orderByColumn = auditLogs.action;
break;
case 'entityType':
orderByColumn = auditLogs.entityType;
break;
case 'entityId':
orderByColumn = auditLogs.entityId;
break;
case 'entityWyId':
orderByColumn = auditLogs.entityWyId;
break;
case 'createdAt':
default:
orderByColumn = auditLogs.createdAt;
break;
}
// Apply sorting and pagination
return queryWithWhere
.orderBy(sortOrder === 'desc' ? desc(orderByColumn) : asc(orderByColumn))
.limit(limit)
.offset(offset);
}
/**
* Get audit logs for a specific entity
*/
async findByEntity(
entityType: string,
entityId?: number,
entityWyId?: number,
): Promise<AuditLog[]> {
const db = this.databaseService.getDatabase();
const conditions: any[] = [eq(auditLogs.entityType, entityType)];
if (entityId !== undefined) {
conditions.push(eq(auditLogs.entityId, entityId));
}
if (entityWyId !== undefined) {
conditions.push(eq(auditLogs.entityWyId, entityWyId));
}
return db
.select()
.from(auditLogs)
.where(and(...conditions))
.orderBy(desc(auditLogs.createdAt));
}
/**
* Get audit logs for a specific user
*/
async findByUser(userId: number, limit = 50): Promise<AuditLog[]> {
const db = this.databaseService.getDatabase();
return db
.select()
.from(auditLogs)
.where(eq(auditLogs.userId, userId))
.orderBy(desc(auditLogs.createdAt))
.limit(limit);
}
/**
* Get count of audit logs matching filters
*/
async count(query: QueryAuditLogsDto): Promise<number> {
const db = this.databaseService.getDatabase();
const conditions: any[] = [];
// Apply same filters as findAll
if (query.userId !== undefined) {
conditions.push(eq(auditLogs.userId, query.userId));
}
if (query.action) {
conditions.push(eq(auditLogs.action, query.action));
}
if (query.entityType) {
conditions.push(eq(auditLogs.entityType, query.entityType));
}
if (query.entityId !== undefined) {
conditions.push(eq(auditLogs.entityId, query.entityId));
}
if (query.entityWyId !== undefined) {
conditions.push(eq(auditLogs.entityWyId, query.entityWyId));
}
if (query.ipAddress) {
conditions.push(ilike(auditLogs.ipAddress, `%${query.ipAddress}%`));
}
if (query.createdFrom) {
conditions.push(gte(auditLogs.createdAt, new Date(query.createdFrom)));
}
if (query.createdTo) {
conditions.push(lte(auditLogs.createdAt, new Date(query.createdTo)));
}
// For count, we need to use SQL count function
const baseQuery = db
.select({ count: count(auditLogs.id) })
.from(auditLogs);
const queryWithWhere =
conditions.length > 0 ? baseQuery.where(and(...conditions)) : baseQuery;
const countResult = await queryWithWhere;
return countResult[0]?.count ?? 0;
}
}
export * from './query-audit-logs.dto';
import {
IsOptional,
IsInt,
IsString,
IsEnum,
IsDateString,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { BaseQueryDto } from '../../../common/dto';
export enum AuditLogAction {
CREATE = 'create',
UPDATE = 'update',
DELETE = 'delete',
SOFT_DELETE = 'soft_delete',
RESTORE = 'restore',
}
export class QueryAuditLogsDto extends BaseQueryDto {
@ApiPropertyOptional({
description: 'Filter by user ID (who made the change)',
type: Number,
example: 1,
})
@IsOptional()
@Type(() => Number)
@IsInt()
userId?: number;
@ApiPropertyOptional({
description: 'Filter by action type',
enum: AuditLogAction,
example: 'update',
})
@IsOptional()
@IsEnum(AuditLogAction)
action?: AuditLogAction;
@ApiPropertyOptional({
description: 'Filter by entity type (table/model name)',
example: 'players',
})
@IsOptional()
@IsString()
entityType?: string;
@ApiPropertyOptional({
description: 'Filter by entity ID (database ID)',
type: Number,
example: 123,
})
@IsOptional()
@Type(() => Number)
@IsInt()
entityId?: number;
@ApiPropertyOptional({
description: 'Filter by entity WyID (for entities using wyId)',
type: Number,
example: 1093815,
})
@IsOptional()
@Type(() => Number)
@IsInt()
entityWyId?: number;
@ApiPropertyOptional({
description: 'Filter by IP address',
example: '192.168.1.1',
})
@IsOptional()
@IsString()
ipAddress?: string;
@ApiPropertyOptional({
description: 'Filter by created date (from) - ISO date string',
example: '2024-01-01T00:00:00Z',
})
@IsOptional()
@IsDateString()
createdFrom?: string;
@ApiPropertyOptional({
description: 'Filter by created date (to) - ISO date string',
example: '2024-12-31T23:59:59Z',
})
@IsOptional()
@IsDateString()
createdTo?: string;
@ApiPropertyOptional({
description: 'Field to sort by',
enum: [
'id',
'userId',
'action',
'entityType',
'entityId',
'entityWyId',
'createdAt',
],
example: 'createdAt',
default: 'createdAt',
})
@IsOptional()
@IsEnum([
'id',
'userId',
'action',
'entityType',
'entityId',
'entityWyId',
'createdAt',
])
sortBy?: string;
}
......@@ -11,8 +11,9 @@ import {
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { BaseQueryDto } from '../../../common/dto';
export class QueryCalendarEventsDto {
export class QueryCalendarEventsDto extends BaseQueryDto {
@ApiPropertyOptional({
description: 'Filter by user ID',
example: 1,
......@@ -24,11 +25,31 @@ export class QueryCalendarEventsDto {
@ApiPropertyOptional({
description: 'Filter by event type',
enum: ['match', 'travel', 'player_observation', 'meeting', 'training', 'other'],
enum: [
'match',
'travel',
'player_observation',
'meeting',
'training',
'other',
],
})
@IsOptional()
@IsEnum(['match', 'travel', 'player_observation', 'meeting', 'training', 'other'])
eventType?: 'match' | 'travel' | 'player_observation' | 'meeting' | 'training' | 'other';
@IsEnum([
'match',
'travel',
'player_observation',
'meeting',
'training',
'other',
])
eventType?:
| 'match'
| 'travel'
| 'player_observation'
| 'meeting'
| 'training'
| 'other';
@ApiPropertyOptional({
description: 'Filter by multiple event types (comma-separated or array)',
......@@ -37,10 +58,20 @@ export class QueryCalendarEventsDto {
})
@IsOptional()
@IsArray()
@IsEnum(['match', 'travel', 'player_observation', 'meeting', 'training', 'other'], {
@IsEnum(
['match', 'travel', 'player_observation', 'meeting', 'training', 'other'],
{
each: true,
})
eventTypes?: ('match' | 'travel' | 'player_observation' | 'meeting' | 'training' | 'other')[];
},
)
eventTypes?: (
| 'match'
| 'travel'
| 'player_observation'
| 'meeting'
| 'training'
| 'other'
)[];
@ApiPropertyOptional({
description: 'Filter by match WyID',
......@@ -93,7 +124,8 @@ export class QueryCalendarEventsDto {
endDateTo?: string;
@ApiPropertyOptional({
description: 'Filter events that overlap with this date range (events that start before endDate and end after startDate)',
description:
'Filter events that overlap with this date range (events that start before endDate and end after startDate)',
example: '2025-06-01T00:00:00Z',
})
@IsOptional()
......@@ -101,7 +133,8 @@ export class QueryCalendarEventsDto {
overlapStartDate?: string;
@ApiPropertyOptional({
description: 'Filter events that overlap with this date range (used with overlapStartDate)',
description:
'Filter events that overlap with this date range (used with overlapStartDate)',
example: '2025-06-30T23:59:59Z',
})
@IsOptional()
......@@ -125,7 +158,8 @@ export class QueryCalendarEventsDto {
description?: string;
@ApiPropertyOptional({
description: 'Search in title or description (case-insensitive partial match)',
description:
'Search in title or description (case-insensitive partial match)',
example: 'travel',
})
@IsOptional()
......@@ -169,7 +203,8 @@ export class QueryCalendarEventsDto {
hasEndDate?: boolean;
@ApiPropertyOptional({
description: 'Search in metadata location field (case-insensitive partial match)',
description:
'Search in metadata location field (case-insensitive partial match)',
example: 'Stadium',
})
@IsOptional()
......@@ -177,7 +212,8 @@ export class QueryCalendarEventsDto {
location?: string;
@ApiPropertyOptional({
description: 'Search in metadata venue field (case-insensitive partial match)',
description:
'Search in metadata venue field (case-insensitive partial match)',
example: 'Main Field',
})
@IsOptional()
......@@ -217,46 +253,32 @@ export class QueryCalendarEventsDto {
updatedTo?: string;
@ApiPropertyOptional({
description: 'Sort order',
enum: ['asc', 'desc'],
example: 'desc',
default: 'desc',
})
@IsOptional()
@IsEnum(['asc', 'desc'])
sortOrder?: 'asc' | 'desc';
@ApiPropertyOptional({
description: 'Sort by field',
enum: ['startDate', 'endDate', 'createdAt', 'updatedAt', 'title'],
description: 'Field to sort by',
enum: [
'title',
'eventType',
'startDate',
'endDate',
'createdAt',
'updatedAt',
],
example: 'startDate',
default: 'startDate',
})
@IsOptional()
@IsEnum(['startDate', 'endDate', 'createdAt', 'updatedAt', 'title'])
sortBy?: 'startDate' | 'endDate' | 'createdAt' | 'updatedAt' | 'title';
@ApiPropertyOptional({
description: 'Number of results to return (default: 50, max: 1000)',
example: 50,
default: 50,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(1000)
limit?: number;
@ApiPropertyOptional({
description: 'Number of results to skip (default: 0)',
example: 0,
default: 0,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
offset?: number;
@IsEnum([
'title',
'eventType',
'startDate',
'endDate',
'createdAt',
'updatedAt',
])
sortBy?:
| 'title'
| 'eventType'
| 'startDate'
| 'endDate'
| 'createdAt'
| 'updatedAt';
}
......@@ -17,7 +17,7 @@ import {
ApiTags,
} from '@nestjs/swagger';
import { SimplePaginatedResponse } from '../../common/utils/response.util';
import { CreateCoachDto } from './dto';
import { CreateCoachDto, QueryCoachesDto } from './dto';
@ApiTags('Coaches')
@Controller('coaches')
......@@ -46,9 +46,9 @@ export class CoachesController {
@Get()
@ApiOperation({
summary: 'List coaches with pagination',
summary: 'List coaches with pagination and filtering',
description:
'Search coaches by name (searches in firstName, lastName, middleName, shortName). Uses normalized string comparison (accent-insensitive).',
'Search coaches by name (searches in firstName, lastName, middleName, shortName). Uses normalized string comparison (accent-insensitive). Supports sorting.',
})
@ApiQuery({
name: 'name',
......@@ -57,14 +57,34 @@ export class CoachesController {
description:
'Search coaches by name (searches in firstName, lastName, middleName, shortName). Optional.',
})
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: 'Number of results to return (default: 50)',
})
@ApiQuery({
name: 'offset',
required: false,
type: Number,
description: 'Number of results to skip (default: 0)',
})
@ApiQuery({
name: 'sortBy',
required: false,
enum: ['firstName', 'lastName', 'shortName', 'position', 'status', 'createdAt', 'updatedAt'],
description: 'Field to sort by (default: lastName)',
})
@ApiQuery({
name: 'sortOrder',
required: false,
enum: ['asc', 'desc'],
description: 'Sort order - ascending or descending (default: asc)',
})
@ApiOkResponse({ description: 'Paginated list of coaches' })
async findAll(
@Query('limit') limit?: string,
@Query('offset') offset?: string,
@Query('name') name?: string,
): Promise<SimplePaginatedResponse<any>> {
const l = limit ? parseInt(limit, 10) : 50;
const o = offset ? parseInt(offset, 10) : 0;
return this.coachesService.findAll(l, o, name);
async findAll(@Query() query: QueryCoachesDto): Promise<SimplePaginatedResponse<any>> {
const l = query.limit ?? 50;
const o = query.offset ?? 0;
return this.coachesService.findAll(l, o, query.name, query);
}
}
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../../database/database.service';
import { coaches, type NewCoach, type Coach } from '../../database/schema';
import { eq, count, and, or, ilike } from 'drizzle-orm';
import { eq, count, and, or, ilike, desc, asc } from 'drizzle-orm';
import { ResponseUtil, SimplePaginatedResponse } from '../../common/utils/response.util';
import { QueryCoachesDto } from './dto/query-coaches.dto';
import { CoachWyIdRequiredError } from '../../common/errors';
@Injectable()
......@@ -96,6 +97,7 @@ export class CoachesService {
limit: number = 50,
offset: number = 0,
search?: string,
query?: QueryCoachesDto,
): Promise<SimplePaginatedResponse<any>> {
const db = this.databaseService.getDatabase();
......@@ -123,15 +125,51 @@ export class CoachesService {
let total = totalResult.count;
// Get data
const query = db.select().from(coaches).where(whereCondition);
// Determine sort field and order
const sortBy = query?.sortBy || 'lastName';
const sortOrder = query?.sortOrder || 'asc';
let orderByColumn: any = coaches.lastName; // Default sort
// Map sortBy to actual database column
switch (sortBy) {
case 'firstName':
orderByColumn = coaches.firstName;
break;
case 'lastName':
orderByColumn = coaches.lastName;
break;
case 'shortName':
orderByColumn = coaches.shortName;
break;
case 'position':
orderByColumn = coaches.position;
break;
case 'status':
orderByColumn = coaches.status;
break;
case 'createdAt':
orderByColumn = coaches.createdAt;
break;
case 'updatedAt':
orderByColumn = coaches.updatedAt;
break;
default:
orderByColumn = coaches.lastName;
}
// Get data with sorting
const baseQuery = db.select().from(coaches).where(whereCondition);
const queryWithOrder = sortOrder === 'desc'
? baseQuery.orderBy(desc(orderByColumn))
: baseQuery.orderBy(asc(orderByColumn));
// If searching, fetch all matches for normalization filtering
// Otherwise use normal pagination
let rawData;
if (!search || !search.trim()) {
rawData = await query.limit(limit).offset(offset);
rawData = await queryWithOrder.limit(limit).offset(offset);
} else {
rawData = await query;
rawData = await queryWithOrder;
}
// Transform to match API structure
......@@ -154,7 +192,60 @@ export class CoachesService {
);
});
// Apply limit and offset after filtering
// Apply sorting after filtering (if search was used)
if (query?.sortBy) {
filteredData.sort((a, b) => {
let aValue: any;
let bValue: any;
switch (sortBy) {
case 'firstName':
aValue = a.firstName || '';
bValue = b.firstName || '';
break;
case 'lastName':
aValue = a.lastName || '';
bValue = b.lastName || '';
break;
case 'shortName':
aValue = a.shortName || '';
bValue = b.shortName || '';
break;
case 'position':
aValue = a.position || '';
bValue = b.position || '';
break;
case 'status':
aValue = a.status || '';
bValue = b.status || '';
break;
case 'createdAt':
aValue = a.createdAt ? new Date(a.createdAt).getTime() : 0;
bValue = b.createdAt ? new Date(b.createdAt).getTime() : 0;
break;
case 'updatedAt':
aValue = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
bValue = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
break;
default:
aValue = a.lastName || '';
bValue = b.lastName || '';
}
if (typeof aValue === 'string' && typeof bValue === 'string') {
const comparison = aValue.localeCompare(bValue);
return sortOrder === 'asc' ? comparison : -comparison;
}
if (typeof aValue === 'number' && typeof bValue === 'number') {
return sortOrder === 'asc' ? aValue - bValue : bValue - aValue;
}
return 0;
});
}
// Apply limit and offset after filtering and sorting
transformedData = filteredData.slice(offset, offset + limit);
// Update total to the actual filtered count
......
export * from './create-coach.dto';
export * from './update-coach.dto';
export * from './query-coaches.dto';
import {
IsOptional,
IsString,
IsEnum,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { BaseQueryDto } from '../../../common/dto';
export class QueryCoachesDto extends BaseQueryDto {
@ApiPropertyOptional({
description: 'Search coaches by name (searches in firstName, lastName, middleName, shortName)',
example: 'Guardiola',
})
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional({
description: 'Field to sort by',
enum: ['firstName', 'lastName', 'shortName', 'position', 'status', 'createdAt', 'updatedAt'],
example: 'lastName',
default: 'lastName',
})
@IsOptional()
@IsEnum(['firstName', 'lastName', 'shortName', 'position', 'status', 'createdAt', 'updatedAt'])
sortBy?: string;
}
export * from './create-file.dto';
export * from './update-file.dto';
export * from './query-files.dto';
import {
IsOptional,
IsEnum,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { BaseQueryDto } from '../../../common/dto';
export class QueryFilesDto extends BaseQueryDto {
@ApiPropertyOptional({
description: 'Field to sort by',
enum: ['fileName', 'originalFileName', 'mimeType', 'fileSize', 'category', 'createdAt', 'updatedAt'],
example: 'createdAt',
default: 'createdAt',
})
@IsOptional()
@IsEnum(['fileName', 'originalFileName', 'mimeType', 'fileSize', 'category', 'createdAt', 'updatedAt'])
sortBy?: string;
}
......@@ -24,7 +24,7 @@ import {
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import { CreateFileDto, UpdateFileDto } from './dto';
import { CreateFileDto, UpdateFileDto, QueryFilesDto } from './dto';
@ApiTags('Files')
@Controller('files')
......@@ -101,15 +101,36 @@ export class FilesController {
}
@Get()
@ApiOperation({ summary: 'List files' })
@ApiOperation({ summary: 'List files with pagination and sorting' })
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: 'Number of results to return (default: 50)',
})
@ApiQuery({
name: 'offset',
required: false,
type: Number,
description: 'Number of results to skip (default: 0)',
})
@ApiQuery({
name: 'sortBy',
required: false,
enum: ['fileName', 'originalFileName', 'mimeType', 'fileSize', 'category', 'createdAt', 'updatedAt'],
description: 'Field to sort by (default: createdAt)',
})
@ApiQuery({
name: 'sortOrder',
required: false,
enum: ['asc', 'desc'],
description: 'Sort order - ascending or descending (default: asc)',
})
@ApiOkResponse({ description: 'List of files' })
async list(
@Query('limit') limit?: string,
@Query('offset') offset?: string,
): Promise<File[]> {
const l = limit ? parseInt(limit, 10) : 50;
const o = offset ? parseInt(offset, 10) : 0;
return this.filesService.list(l, o);
async list(@Query() query: QueryFilesDto): Promise<File[]> {
const l = query.limit ?? 50;
const o = query.offset ?? 0;
return this.filesService.list(l, o, query);
}
@Patch(':id')
......
import { Injectable, Logger } from '@nestjs/common';
import { DatabaseService } from '../../database/database.service';
import { files, type NewFile, type File } from '../../database/schema';
import { eq, and, or, isNull } from 'drizzle-orm';
import { eq, and, or, isNull, desc, asc } from 'drizzle-orm';
import {
DatabaseQueryError,
DatabaseColumnNotFoundError,
DatabaseRequiredFieldMissingError,
DatabaseDuplicateEntryError,
} from '../../common/errors';
import { QueryFilesDto } from './dto/query-files.dto';
@Injectable()
export class FilesService {
......@@ -114,14 +115,51 @@ export class FilesService {
);
}
async list(limit = 50, offset = 0): Promise<File[]> {
async list(limit = 50, offset = 0, query?: QueryFilesDto): Promise<File[]> {
const db = this.databaseService.getDatabase();
return db
// Determine sort field and order
const sortBy = query?.sortBy || 'createdAt';
const sortOrder = query?.sortOrder || 'asc';
let orderByColumn: any = files.createdAt; // Default sort
// Map sortBy to actual database column
switch (sortBy) {
case 'fileName':
orderByColumn = files.fileName;
break;
case 'originalFileName':
orderByColumn = files.originalFileName;
break;
case 'mimeType':
orderByColumn = files.mimeType;
break;
case 'fileSize':
orderByColumn = files.fileSize;
break;
case 'category':
orderByColumn = files.category;
break;
case 'createdAt':
orderByColumn = files.createdAt;
break;
case 'updatedAt':
orderByColumn = files.updatedAt;
break;
default:
orderByColumn = files.createdAt;
}
const baseQuery = db
.select()
.from(files)
.where(and(eq(files.isActive, true), isNull(files.deletedAt)))
.limit(limit)
.offset(offset);
.where(and(eq(files.isActive, true), isNull(files.deletedAt)));
const queryWithOrder = sortOrder === 'desc'
? baseQuery.orderBy(desc(orderByColumn))
: baseQuery.orderBy(asc(orderByColumn));
return queryWithOrder.limit(limit).offset(offset);
}
private normalizeFileData(data: any): Partial<NewFile> {
......
import {
IsString,
IsOptional,
IsObject,
IsEnum,
IsArray,
IsNumber,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateListDto {
@ApiProperty({
description: 'Name of the list',
example: 'Summer 2024 Shortlist',
})
@IsString()
name: string;
@ApiProperty({
description: 'Season for the list',
example: '2024-2025',
})
@IsString()
season: string;
@ApiProperty({
description: 'Type of list',
enum: ['shortlist', 'shadow_team', 'target_list'],
example: 'shortlist',
})
@IsEnum(['shortlist', 'shadow_team', 'target_list'])
type: 'shortlist' | 'shadow_team' | 'target_list';
@ApiProperty({
description:
'Players organized by position - JSON object with positions as keys and arrays of player WyIds as values',
example: {
GK: [1093815, 1093816],
DF: [1093817, 1093818],
MF: [1093819],
FW: [1093820, 1093821],
},
})
@IsObject()
playersByPosition: {
[position: string]: number[];
};
}
export * from './create-list.dto';
export * from './update-list.dto';
export * from './query-lists.dto';
export * from './share-list.dto';
import {
IsOptional,
IsInt,
IsString,
IsEnum,
IsDateString,
Min,
Max,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { BaseQueryDto } from '../../../common/dto';
export enum ListType {
SHORTLIST = 'shortlist',
SHADOW_TEAM = 'shadow_team',
TARGET_LIST = 'target_list',
}
export class QueryListsDto extends BaseQueryDto {
@ApiPropertyOptional({
description: 'Filter by list type',
enum: ListType,
example: 'shortlist',
})
@IsOptional()
@IsEnum(ListType)
type?: ListType;
@ApiPropertyOptional({
description: 'Filter by season',
example: '2024-2025',
})
@IsOptional()
@IsString()
season?: string;
@ApiPropertyOptional({
description: 'Filter by user ID (who created the list)',
type: Number,
example: 1,
})
@IsOptional()
@Type(() => Number)
@IsInt()
userId?: number;
@ApiPropertyOptional({
description: 'Search by list name (case-insensitive partial match)',
example: 'Summer',
})
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional({
description: 'Filter by player WyID (lists containing this player)',
type: Number,
example: 1093815,
})
@IsOptional()
@Type(() => Number)
@IsInt()
playerWyId?: number;
@ApiPropertyOptional({
description: 'Filter by position (lists containing players in this position)',
example: 'GK',
})
@IsOptional()
@IsString()
position?: string;
@ApiPropertyOptional({
description: 'Filter by active status',
type: Boolean,
example: true,
})
@IsOptional()
@Type(() => Boolean)
isActive?: boolean;
@ApiPropertyOptional({
description: 'Filter by created date (from) - ISO date string',
example: '2024-01-01T00:00:00Z',
})
@IsOptional()
@IsDateString()
createdFrom?: string;
@ApiPropertyOptional({
description: 'Filter by created date (to) - ISO date string',
example: '2024-12-31T23:59:59Z',
})
@IsOptional()
@IsDateString()
createdTo?: string;
@ApiPropertyOptional({
description: 'Filter by updated date (from) - ISO date string',
example: '2024-01-01T00:00:00Z',
})
@IsOptional()
@IsDateString()
updatedFrom?: string;
@ApiPropertyOptional({
description: 'Filter by updated date (to) - ISO date string',
example: '2024-12-31T23:59:59Z',
})
@IsOptional()
@IsDateString()
updatedTo?: string;
@ApiPropertyOptional({
description: 'Field to sort by',
enum: ['name', 'season', 'type', 'createdAt', 'updatedAt'],
example: 'createdAt',
default: 'createdAt',
})
@IsOptional()
@IsEnum(['name', 'season', 'type', 'createdAt', 'updatedAt'])
sortBy?: string;
}
import { IsArray, IsInt, Min } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class ShareListDto {
@ApiProperty({
description: 'Array of user IDs to share the list with',
example: [2, 3, 4],
type: [Number],
})
@IsArray()
@IsInt({ each: true })
@Min(1, { each: true })
userIds: number[];
}
export class UnshareListDto {
@ApiProperty({
description: 'Array of user IDs to unshare the list with',
example: [2, 3],
type: [Number],
})
@IsArray()
@IsInt({ each: true })
@Min(1, { each: true })
userIds: number[];
}
import {
IsString,
IsOptional,
IsObject,
IsEnum,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateListDto {
@ApiPropertyOptional({
description: 'Name of the list',
example: 'Updated List Name',
})
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional({
description: 'Season for the list',
example: '2024-2025',
})
@IsOptional()
@IsString()
season?: string;
@ApiPropertyOptional({
description: 'Type of list',
enum: ['shortlist', 'shadow_team', 'target_list'],
example: 'shortlist',
})
@IsOptional()
@IsEnum(['shortlist', 'shadow_team', 'target_list'])
type?: 'shortlist' | 'shadow_team' | 'target_list';
@ApiPropertyOptional({
description:
'Players organized by position - JSON object with positions as keys and arrays of player WyIds as values',
example: {
GK: [1093815, 1093816],
DF: [1093817, 1093818],
},
})
@IsOptional()
@IsObject()
playersByPosition?: {
[position: string]: number[];
};
}
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
NotFoundException,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ListsService } from './lists.service';
import { type List, type User } from '../../database/schema';
import {
ApiBody,
ApiNoContentResponse,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiQuery,
ApiTags,
ApiBearerAuth,
} from '@nestjs/swagger';
import {
CreateListDto,
UpdateListDto,
QueryListsDto,
ListType,
ShareListDto,
UnshareListDto,
} from './dto';
import { JwtSimpleGuard } from '../../common/guards/jwt-simple.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@ApiTags('Lists')
@Controller('lists')
export class ListsController {
constructor(private readonly listsService: ListsService) {}
@Post()
@UseGuards(JwtSimpleGuard)
@ApiBearerAuth('bearer')
@ApiOperation({ summary: 'Create a list' })
@ApiBody({
description:
'List payload. Requires name, season, type, and playersByPosition JSON object.',
type: CreateListDto,
})
@ApiOkResponse({ description: 'Created list' })
async create(
@Body() body: CreateListDto,
@CurrentUser() user: User,
): Promise<List> {
return this.listsService.create(body, user.id);
}
@Get(':id')
@ApiOperation({ summary: 'Get list by ID' })
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the list',
})
@ApiOkResponse({ description: 'List if found' })
@ApiNotFoundResponse({ description: 'List not found' })
async getById(@Param('id', ParseIntPipe) id: number): Promise<List> {
const list = await this.listsService.findById(id);
if (!list) {
throw new NotFoundException(`List with ID ${id} not found`);
}
return list;
}
@Get()
@ApiOperation({
summary: 'List lists with filtering',
description:
'List lists with various filtering options. Filter by type, season, user, name, player, position, dates, and more.',
})
@ApiQuery({
name: 'type',
required: false,
enum: ListType,
description: 'Filter by list type',
})
@ApiQuery({
name: 'season',
required: false,
type: String,
description: 'Filter by season',
})
@ApiQuery({
name: 'userId',
required: false,
type: Number,
description: 'Filter by user ID (who created the list)',
})
@ApiQuery({
name: 'name',
required: false,
type: String,
description: 'Search by list name (case-insensitive partial match)',
})
@ApiQuery({
name: 'playerWyId',
required: false,
type: Number,
description: 'Filter by player WyID (lists containing this player)',
})
@ApiQuery({
name: 'position',
required: false,
type: String,
description: 'Filter by position (lists containing players in this position)',
})
@ApiQuery({
name: 'isActive',
required: false,
type: Boolean,
description: 'Filter by active status',
})
@ApiQuery({
name: 'createdFrom',
required: false,
type: String,
description: 'Filter by created date (from) - ISO date string',
})
@ApiQuery({
name: 'createdTo',
required: false,
type: String,
description: 'Filter by created date (to) - ISO date string',
})
@ApiQuery({
name: 'updatedFrom',
required: false,
type: String,
description: 'Filter by updated date (from) - ISO date string',
})
@ApiQuery({
name: 'updatedTo',
required: false,
type: String,
description: 'Filter by updated date (to) - ISO date string',
})
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: 'Number of results to return (default: 50)',
})
@ApiQuery({
name: 'offset',
required: false,
type: Number,
description: 'Number of results to skip (default: 0)',
})
@ApiQuery({
name: 'sortBy',
required: false,
enum: ['name', 'season', 'type', 'createdAt', 'updatedAt'],
description: 'Field to sort by (default: createdAt)',
})
@ApiQuery({
name: 'sortOrder',
required: false,
enum: ['asc', 'desc'],
description: 'Sort order - ascending or descending (default: asc)',
})
@ApiOkResponse({ description: 'List of lists matching the filters' })
async list(
@Query() query: QueryListsDto,
@CurrentUser() user?: User,
): Promise<List[]> {
return this.listsService.findAll(query, user?.id);
}
@Patch(':id')
@UseGuards(JwtSimpleGuard)
@ApiBearerAuth('bearer')
@ApiOperation({ summary: 'Update a list' })
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the list to update',
})
@ApiBody({
description:
'List update payload. All fields are optional. playersByPosition is a JSON object.',
type: UpdateListDto,
})
@ApiOkResponse({ description: 'Updated list' })
@ApiNotFoundResponse({ description: 'List not found' })
async update(
@Param('id', ParseIntPipe) id: number,
@Body() body: UpdateListDto,
): Promise<List> {
const result = await this.listsService.update(id, body);
if (!result) {
throw new NotFoundException(`List with ID ${id} not found`);
}
return result;
}
@Delete(':id')
@UseGuards(JwtSimpleGuard)
@ApiBearerAuth('bearer')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete a list (soft delete)' })
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the list to delete',
})
@ApiNoContentResponse({ description: 'List deleted successfully' })
@ApiNotFoundResponse({ description: 'List not found' })
async delete(@Param('id', ParseIntPipe) id: number): Promise<void> {
const result = await this.listsService.delete(id);
if (!result) {
throw new NotFoundException(`List with ID ${id} not found`);
}
}
@Post(':id/share')
@UseGuards(JwtSimpleGuard)
@ApiBearerAuth('bearer')
@ApiOperation({
summary: 'Share a list with one or more users',
description:
'Share a list with other users. Only the list owner can share the list.',
})
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the list to share',
})
@ApiBody({
description: 'Array of user IDs to share the list with',
type: ShareListDto,
})
@ApiOkResponse({
description: 'List shared successfully',
schema: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'number' },
listId: { type: 'number' },
userId: { type: 'number' },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
},
},
})
@ApiNotFoundResponse({ description: 'List not found' })
async shareList(
@Param('id', ParseIntPipe) id: number,
@Body() body: ShareListDto,
@CurrentUser() user: User,
) {
try {
return await this.listsService.shareList(id, body.userIds, user.id);
} catch (error: any) {
if (error.message.includes('not found')) {
throw new NotFoundException(error.message);
}
throw error;
}
}
@Post(':id/unshare')
@UseGuards(JwtSimpleGuard)
@ApiBearerAuth('bearer')
@ApiOperation({
summary: 'Unshare a list with one or more users',
description:
'Remove sharing access for users. Only the list owner can unshare the list.',
})
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the list to unshare',
})
@ApiBody({
description: 'Array of user IDs to unshare the list with',
type: UnshareListDto,
})
@ApiOkResponse({ description: 'List unshared successfully' })
@ApiNotFoundResponse({ description: 'List not found' })
async unshareList(
@Param('id', ParseIntPipe) id: number,
@Body() body: UnshareListDto,
@CurrentUser() user: User,
) {
try {
await this.listsService.unshareList(id, body.userIds, user.id);
return { message: 'List unshared successfully' };
} catch (error: any) {
if (error.message.includes('not found')) {
throw new NotFoundException(error.message);
}
throw error;
}
}
@Get(':id/shared-users')
@UseGuards(JwtSimpleGuard)
@ApiBearerAuth('bearer')
@ApiOperation({
summary: 'Get all users who have access to a list',
description:
'Returns an array of user IDs who have access to the list (owner + shared users)',
})
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the list',
})
@ApiOkResponse({
description: 'Array of user IDs with access to the list',
schema: {
type: 'array',
items: { type: 'number' },
example: [1, 2, 3],
},
})
@ApiNotFoundResponse({ description: 'List not found' })
async getSharedUsers(@Param('id', ParseIntPipe) id: number): Promise<{
userIds: number[];
}> {
const list = await this.listsService.findById(id);
if (!list) {
throw new NotFoundException(`List with ID ${id} not found`);
}
const userIds = await this.listsService.getListSharedUsers(id);
return { userIds };
}
}
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ListsController } from './lists.controller';
import { ListsService } from './lists.service';
import { DatabaseModule } from '../../database/database.module';
import { JwtSimpleGuard } from '../../common/guards/jwt-simple.guard';
@Module({
imports: [
DatabaseModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET') || 'your-secret-key',
signOptions: { expiresIn: '24h' },
}),
inject: [ConfigService],
}),
],
controllers: [ListsController],
providers: [ListsService, JwtSimpleGuard],
exports: [ListsService],
})
export class ListsModule {}
import { Injectable, Logger } from '@nestjs/common';
import { DatabaseService } from '../../database/database.service';
import {
lists,
listShares,
type NewList,
type List,
type NewListShare,
type ListShare,
} from '../../database/schema';
import {
eq,
and,
or,
ilike,
gte,
lte,
desc,
asc,
sql,
isNull,
inArray,
} from 'drizzle-orm';
import {
DatabaseQueryError,
DatabaseColumnNotFoundError,
DatabaseRequiredFieldMissingError,
DatabaseDuplicateEntryError,
DatabaseForeignKeyConstraintError,
} from '../../common/errors';
import { QueryListsDto } from './dto/query-lists.dto';
@Injectable()
export class ListsService {
private readonly logger = new Logger(ListsService.name);
constructor(private readonly databaseService: DatabaseService) {}
async create(data: any, userId: number): Promise<List> {
const db = this.databaseService.getDatabase();
const normalizedData: Partial<NewList> = {
userId,
name: data.name,
season: data.season,
type: data.type,
playersByPosition: data.playersByPosition || {},
};
try {
const [row] = await db
.insert(lists)
.values(normalizedData as NewList)
.returning();
return row as List;
} catch (error: any) {
this.logger.error('Failed to create list:', error);
const errorCode = error.cause?.code || error.code;
const errorDetail = error.cause?.detail || error.detail;
const constraintName =
error.cause?.constraint_name || error.constraint_name;
this.logger.error('Error details:', {
message: error.message,
code: errorCode,
detail: errorDetail,
constraintName,
query: error.query,
params: error.params,
cause: error.cause,
});
if (errorCode === '42703') {
throw new DatabaseColumnNotFoundError(
errorDetail || 'Unknown column',
error.query || 'INSERT INTO lists',
);
} else if (errorCode === '23502') {
throw new DatabaseRequiredFieldMissingError(
errorDetail || 'Unknown field',
errorDetail || error.message,
);
} else if (errorCode === '23505') {
throw new DatabaseDuplicateEntryError(
'list',
errorDetail || error.message,
);
} else if (errorCode === '23503') {
throw new DatabaseForeignKeyConstraintError(
constraintName || 'unknown',
'user',
errorDetail || error.message,
);
} else {
const errorContext = {
technicalReason:
errorDetail || error.message || 'Unknown database error',
errorCode: errorCode,
errorDetail,
constraintName,
query: error.query,
params: error.params,
normalizedData,
};
this.logger.error('Error context:', errorContext);
throw new DatabaseQueryError(
error.query || 'INSERT INTO lists',
JSON.stringify(errorContext, null, 2),
);
}
}
}
async findById(id: number): Promise<List | undefined> {
const db = this.databaseService.getDatabase();
const rows = await db.select().from(lists).where(eq(lists.id, id));
return rows[0];
}
async list(limit = 50, offset = 0): Promise<List[]> {
const db = this.databaseService.getDatabase();
return db
.select()
.from(lists)
.where(isNull(lists.deletedAt))
.limit(limit)
.offset(offset)
.orderBy(desc(lists.createdAt));
}
async findAll(query: QueryListsDto, currentUserId?: number): Promise<List[]> {
const db = this.databaseService.getDatabase();
const conditions: any[] = [];
// Filter by type
if (query.type) {
conditions.push(eq(lists.type, query.type));
}
// Filter by season
if (query.season) {
conditions.push(eq(lists.season, query.season));
}
// Filter by user ID - if currentUserId is provided, show lists where user is owner OR has access via sharing
if (query.userId) {
if (currentUserId && query.userId === currentUserId) {
// User is querying their own lists - include owned and shared
const sharedListIds = await db
.select({ listId: listShares.listId })
.from(listShares)
.where(eq(listShares.userId, currentUserId));
const sharedIds = sharedListIds.map((s) => s.listId);
if (sharedIds.length > 0) {
conditions.push(
or(eq(lists.userId, query.userId), inArray(lists.id, sharedIds)),
);
} else {
conditions.push(eq(lists.userId, query.userId));
}
} else {
// Querying for a specific user's lists (admin or different user)
conditions.push(eq(lists.userId, query.userId));
}
} else if (currentUserId) {
// If no userId filter but currentUserId is provided, show user's accessible lists
const sharedListIds = await db
.select({ listId: listShares.listId })
.from(listShares)
.where(eq(listShares.userId, currentUserId));
const sharedIds = sharedListIds.map((s) => s.listId);
if (sharedIds.length > 0) {
conditions.push(
or(eq(lists.userId, currentUserId), inArray(lists.id, sharedIds)),
);
} else {
conditions.push(eq(lists.userId, currentUserId));
}
}
// Search by name (case-insensitive partial match)
if (query.name) {
conditions.push(ilike(lists.name, `%${query.name}%`));
}
// Filter by active status
if (query.isActive !== undefined) {
conditions.push(eq(lists.isActive, query.isActive));
}
// Filter by created date range
if (query.createdFrom) {
conditions.push(gte(lists.createdAt, new Date(query.createdFrom)));
}
if (query.createdTo) {
conditions.push(lte(lists.createdAt, new Date(query.createdTo)));
}
// Filter by updated date range
if (query.updatedFrom) {
conditions.push(gte(lists.updatedAt, new Date(query.updatedFrom)));
}
if (query.updatedTo) {
conditions.push(lte(lists.updatedAt, new Date(query.updatedTo)));
}
// Filter by player WyID (check if player exists in playersByPosition JSON)
if (query.playerWyId) {
conditions.push(
sql`${lists.playersByPosition}::text LIKE ${`%${query.playerWyId}%`}`,
);
}
// Filter by position (check if position key exists in playersByPosition JSON)
if (query.position) {
conditions.push(
sql`${lists.playersByPosition}::jsonb ? ${query.position}`,
);
}
// Always exclude deleted lists
conditions.push(isNull(lists.deletedAt));
// Build the query
const limit = query.limit ?? 50;
const offset = query.offset ?? 0;
const baseQuery = db.select().from(lists);
const queryWithWhere =
conditions.length > 0 ? baseQuery.where(and(...conditions)) : baseQuery;
// Determine sort field and order
const sortBy = query.sortBy || 'createdAt';
const sortOrder = query.sortOrder || 'asc';
let orderByColumn: any = lists.createdAt; // Default sort
// Map sortBy to actual database column
switch (sortBy) {
case 'name':
orderByColumn = lists.name;
break;
case 'season':
orderByColumn = lists.season;
break;
case 'type':
orderByColumn = lists.type;
break;
case 'createdAt':
orderByColumn = lists.createdAt;
break;
case 'updatedAt':
orderByColumn = lists.updatedAt;
break;
default:
orderByColumn = lists.createdAt;
}
const queryWithOrder =
sortOrder === 'desc'
? queryWithWhere.orderBy(desc(orderByColumn))
: queryWithWhere.orderBy(asc(orderByColumn));
return await queryWithOrder.limit(limit).offset(offset);
}
async update(id: number, data: any): Promise<List | null> {
const db = this.databaseService.getDatabase();
// Check if list exists
const existingList = await this.findById(id);
if (!existingList) {
return null;
}
const normalizedData: Partial<NewList> = {};
if (data.name !== undefined) normalizedData.name = data.name;
if (data.season !== undefined) normalizedData.season = data.season;
if (data.type !== undefined) normalizedData.type = data.type;
if (data.playersByPosition !== undefined)
normalizedData.playersByPosition = data.playersByPosition;
if (data.isActive !== undefined) normalizedData.isActive = data.isActive;
// Add updatedAt timestamp
normalizedData.updatedAt = new Date();
const [result] = await db
.update(lists)
.set(normalizedData)
.where(eq(lists.id, id))
.returning();
return result as List;
}
async delete(id: number): Promise<boolean> {
const db = this.databaseService.getDatabase();
// Check if list exists
const existingList = await this.findById(id);
if (!existingList) {
return false;
}
// Soft delete by setting deletedAt
await db
.update(lists)
.set({ deletedAt: new Date() })
.where(eq(lists.id, id));
return true;
}
/**
* Share a list with one or more users
*/
async shareList(
listId: number,
userIds: number[],
ownerId: number,
): Promise<ListShare[]> {
const db = this.databaseService.getDatabase();
// Verify list exists and user is the owner
const list = await this.findById(listId);
if (!list) {
throw new Error(`List with ID ${listId} not found`);
}
if (list.userId !== ownerId) {
throw new Error('Only the list owner can share the list');
}
// Remove owner from userIds if present (can't share with yourself)
const filteredUserIds = userIds.filter((id) => id !== ownerId);
if (filteredUserIds.length === 0) {
return [];
}
// Check which shares already exist
const existingShares = await db
.select()
.from(listShares)
.where(
and(
eq(listShares.listId, listId),
inArray(listShares.userId, filteredUserIds),
),
);
const existingUserIds = existingShares.map((s) => s.userId);
const newUserIds = filteredUserIds.filter(
(id) => !existingUserIds.includes(id),
);
if (newUserIds.length === 0) {
return existingShares;
}
// Insert new shares
const newShares: NewListShare[] = newUserIds.map((userId) => ({
listId,
userId,
createdAt: new Date(),
updatedAt: new Date(),
}));
const inserted = await db.insert(listShares).values(newShares).returning();
return [...existingShares, ...inserted];
}
/**
* Unshare a list with one or more users
*/
async unshareList(
listId: number,
userIds: number[],
ownerId: number,
): Promise<boolean> {
const db = this.databaseService.getDatabase();
// Verify list exists and user is the owner
const list = await this.findById(listId);
if (!list) {
throw new Error(`List with ID ${listId} not found`);
}
if (list.userId !== ownerId) {
throw new Error('Only the list owner can unshare the list');
}
if (userIds.length === 0) {
return true;
}
// Delete shares
await db
.delete(listShares)
.where(
and(eq(listShares.listId, listId), inArray(listShares.userId, userIds)),
);
return true;
}
/**
* Get all users who have access to a list (owner + shared users)
*/
async getListSharedUsers(listId: number): Promise<number[]> {
const db = this.databaseService.getDatabase();
const list = await this.findById(listId);
if (!list) {
return [];
}
const shares = await db
.select({ userId: listShares.userId })
.from(listShares)
.where(eq(listShares.listId, listId));
const sharedUserIds = shares.map((s) => s.userId);
// Return owner + shared users (remove duplicates)
return [list.userId, ...sharedUserIds].filter(
(id, index, self) => self.indexOf(id) === index,
);
}
/**
* Get lists shared with a specific user
*/
async getSharedLists(userId: number): Promise<List[]> {
const db = this.databaseService.getDatabase();
const sharedListIds = await db
.select({ listId: listShares.listId })
.from(listShares)
.where(eq(listShares.userId, userId));
const listIds = sharedListIds.map((s) => s.listId);
if (listIds.length === 0) {
return [];
}
return db
.select()
.from(lists)
.where(and(inArray(lists.id, listIds), isNull(lists.deletedAt)));
}
}
export * from './create-match.dto';
export * from './update-match.dto';
export * from './query-matches.dto';
import {
IsOptional,
IsString,
IsEnum,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { BaseQueryDto } from '../../../common/dto';
export class QueryMatchesDto extends BaseQueryDto {
@ApiPropertyOptional({
description: 'Search matches by venue (searches in venue, venueCity, venueCountry)',
example: 'Stadium',
})
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional({
description: 'Field to sort by',
enum: ['matchDate', 'venue', 'venueCity', 'venueCountry', 'homeScore', 'awayScore', 'status', 'createdAt', 'updatedAt'],
example: 'matchDate',
default: 'matchDate',
})
@IsOptional()
@IsEnum(['matchDate', 'venue', 'venueCity', 'venueCountry', 'homeScore', 'awayScore', 'status', 'createdAt', 'updatedAt'])
sortBy?: string;
}
......@@ -17,7 +17,7 @@ import {
ApiTags,
} from '@nestjs/swagger';
import { MatchesPaginatedResponse } from '../../common/utils/response.util';
import { CreateMatchDto } from './dto';
import { CreateMatchDto, QueryMatchesDto } from './dto';
@ApiTags('Matches')
@Controller('matches')
......@@ -46,9 +46,9 @@ export class MatchesController {
@Get()
@ApiOperation({
summary: 'List matches with pagination',
summary: 'List matches with pagination and filtering',
description:
'Search matches by venue (searches in venue, venueCity, venueCountry). Uses normalized string comparison (accent-insensitive).',
'Search matches by venue (searches in venue, venueCity, venueCountry). Uses normalized string comparison (accent-insensitive). Supports sorting.',
})
@ApiQuery({
name: 'name',
......@@ -57,14 +57,34 @@ export class MatchesController {
description:
'Search matches by venue (searches in venue, venueCity, venueCountry). Optional.',
})
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: 'Number of results to return (default: 50)',
})
@ApiQuery({
name: 'offset',
required: false,
type: Number,
description: 'Number of results to skip (default: 0)',
})
@ApiQuery({
name: 'sortBy',
required: false,
enum: ['matchDate', 'venue', 'venueCity', 'venueCountry', 'homeScore', 'awayScore', 'status', 'createdAt', 'updatedAt'],
description: 'Field to sort by (default: matchDate)',
})
@ApiQuery({
name: 'sortOrder',
required: false,
enum: ['asc', 'desc'],
description: 'Sort order - ascending or descending (default: asc)',
})
@ApiOkResponse({ description: 'Paginated list of matches' })
async findAll(
@Query('limit') limit?: string,
@Query('offset') offset?: string,
@Query('name') name?: string,
): Promise<MatchesPaginatedResponse<any>> {
const l = limit ? parseInt(limit, 10) : 50;
const o = offset ? parseInt(offset, 10) : 0;
return this.matchesService.findAll(l, o, name);
async findAll(@Query() query: QueryMatchesDto): Promise<MatchesPaginatedResponse<any>> {
const l = query.limit ?? 50;
const o = query.offset ?? 0;
return this.matchesService.findAll(l, o, query.name, query);
}
}
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../../database/database.service';
import { matches, type NewMatch, type Match } from '../../database/schema';
import { eq, count, or, ilike } from 'drizzle-orm';
import { eq, count, or, ilike, desc, asc } from 'drizzle-orm';
import { ResponseUtil, MatchesPaginatedResponse } from '../../common/utils/response.util';
import { MatchWyIdRequiredError } from '../../common/errors';
import { QueryMatchesDto } from './dto/query-matches.dto';
@Injectable()
export class MatchesService {
......@@ -101,6 +102,7 @@ export class MatchesService {
limit: number = 50,
offset: number = 0,
search?: string,
query?: QueryMatchesDto,
): Promise<MatchesPaginatedResponse<any>> {
const db = this.databaseService.getDatabase();
......@@ -132,23 +134,58 @@ export class MatchesService {
totalItems = totalResult.count;
}
// Get data
// Determine sort field and order
const sortBy = query?.sortBy || 'matchDate';
const sortOrder = query?.sortOrder || 'asc';
let orderByColumn: any = matches.matchDate; // Default sort
// Map sortBy to actual database column
switch (sortBy) {
case 'matchDate':
orderByColumn = matches.matchDate;
break;
case 'venue':
orderByColumn = matches.venue;
break;
case 'venueCity':
orderByColumn = matches.venueCity;
break;
case 'venueCountry':
orderByColumn = matches.venueCountry;
break;
case 'homeScore':
orderByColumn = matches.homeScore;
break;
case 'awayScore':
orderByColumn = matches.awayScore;
break;
case 'status':
orderByColumn = matches.status;
break;
case 'createdAt':
orderByColumn = matches.createdAt;
break;
case 'updatedAt':
orderByColumn = matches.updatedAt;
break;
default:
orderByColumn = matches.matchDate;
}
// Get data with sorting
const baseQuery = whereCondition
? db.select().from(matches).where(whereCondition)
: db.select().from(matches);
const queryWithOrder = sortOrder === 'desc'
? baseQuery.orderBy(desc(orderByColumn))
: baseQuery.orderBy(asc(orderByColumn));
let rawData;
if (!search || !search.trim()) {
rawData = await db
.select()
.from(matches)
.limit(limit)
.offset(offset);
rawData = await queryWithOrder.limit(limit).offset(offset);
} else {
if (whereCondition) {
rawData = await db
.select()
.from(matches)
.where(whereCondition);
} else {
rawData = await db.select().from(matches);
}
rawData = await queryWithOrder;
}
// Transform to match API structure
......@@ -171,7 +208,68 @@ export class MatchesService {
);
});
// Apply limit and offset after filtering
// Apply sorting after filtering (if search was used)
if (query?.sortBy) {
filteredData.sort((a, b) => {
let aValue: any;
let bValue: any;
switch (sortBy) {
case 'matchDate':
aValue = a.matchDate ? new Date(a.matchDate).getTime() : 0;
bValue = b.matchDate ? new Date(b.matchDate).getTime() : 0;
break;
case 'venue':
aValue = a.venue || '';
bValue = b.venue || '';
break;
case 'venueCity':
aValue = a.venueCity || '';
bValue = b.venueCity || '';
break;
case 'venueCountry':
aValue = a.venueCountry || '';
bValue = b.venueCountry || '';
break;
case 'homeScore':
aValue = a.homeScore || 0;
bValue = b.homeScore || 0;
break;
case 'awayScore':
aValue = a.awayScore || 0;
bValue = b.awayScore || 0;
break;
case 'status':
aValue = a.status || '';
bValue = b.status || '';
break;
case 'createdAt':
aValue = a.createdAt ? new Date(a.createdAt).getTime() : 0;
bValue = b.createdAt ? new Date(b.createdAt).getTime() : 0;
break;
case 'updatedAt':
aValue = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
bValue = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
break;
default:
aValue = a.matchDate ? new Date(a.matchDate).getTime() : 0;
bValue = b.matchDate ? new Date(b.matchDate).getTime() : 0;
}
if (typeof aValue === 'string' && typeof bValue === 'string') {
const comparison = aValue.localeCompare(bValue);
return sortOrder === 'asc' ? comparison : -comparison;
}
if (typeof aValue === 'number' && typeof bValue === 'number') {
return sortOrder === 'asc' ? aValue - bValue : bValue - aValue;
}
return 0;
});
}
// Apply limit and offset after filtering and sorting
transformedData = filteredData.slice(offset, offset + limit);
// Update totalItems to the actual filtered count
......
......@@ -9,6 +9,7 @@ import {
Min,
Max,
IsObject,
IsArray,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
......@@ -192,6 +193,17 @@ export class CreatePlayerDto {
position?: string;
@ApiPropertyOptional({
description: 'Other positions the player can play',
example: ['MF', 'DF'],
type: [String],
nullable: true,
})
@IsOptional()
@IsArray()
@IsString({ each: true })
otherPositions?: string[] | null;
@ApiPropertyOptional({
description: 'Role code (2 characters)',
example: 'FW',
})
......@@ -236,6 +248,15 @@ export class CreatePlayerDto {
birthAreaWyId?: number;
@ApiPropertyOptional({
description: 'Second birth area Wyscout ID',
example: 1002,
nullable: true,
})
@IsOptional()
@IsInt()
secondBirthAreaWyId?: number | null;
@ApiPropertyOptional({
description: 'Birth area object from external API',
example: { id: 75, wyId: 250, name: 'France' },
})
......@@ -261,6 +282,15 @@ export class CreatePlayerDto {
passportAreaWyId?: number;
@ApiPropertyOptional({
description: 'Second passport area Wyscout ID',
example: 1002,
nullable: true,
})
@IsOptional()
@IsInt()
secondPassportAreaWyId?: number | null;
@ApiPropertyOptional({
description: 'Passport area object from external API',
example: { id: 59, wyId: 384, name: "Côte d'Ivoire" },
})
......@@ -337,5 +367,137 @@ export class CreatePlayerDto {
@IsOptional()
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({
description: 'Email address of the player',
example: 'player@example.com',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
email?: string | null;
@ApiPropertyOptional({
description: 'Phone number of the player',
example: '+1234567890',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
phone?: string | null;
@ApiPropertyOptional({
description: 'Whether the player is on loan',
example: false,
default: false,
type: Boolean,
nullable: true,
})
@IsOptional()
@IsBoolean()
onLoan?: boolean | null;
@ApiPropertyOptional({
description: 'Agent name representing the player',
example: 'John Agent',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
agent?: string | null;
@ApiPropertyOptional({
description: 'Player ranking',
example: '1',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
ranking?: string | null;
@ApiPropertyOptional({
description: 'Return on Investment (ROI)',
example: '15.5',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
roi?: string | null;
@ApiPropertyOptional({
description: 'Market value of the player',
example: 5000000.00,
type: Number,
nullable: true,
})
@IsOptional()
@IsNumber()
marketValue?: number | null;
@ApiPropertyOptional({
description: 'Value range (e.g., "1000000-2000000")',
example: '1000000-2000000',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
valueRange?: string | null;
@ApiPropertyOptional({
description: 'Transfer value of the player',
example: 3000000.00,
type: Number,
nullable: true,
})
@IsOptional()
@IsNumber()
transferValue?: number | null;
@ApiPropertyOptional({
description: 'Salary of the player',
example: 100000.00,
type: Number,
nullable: true,
})
@IsOptional()
@IsNumber()
salary?: number | null;
@ApiPropertyOptional({
description: 'Contract end date (YYYY-MM-DD)',
example: '2025-02-02',
type: String,
nullable: true,
})
@IsOptional()
@IsDateString()
contractEndsAt?: string | null;
@ApiPropertyOptional({
description: 'Whether the transfer is feasible',
example: true,
default: false,
type: Boolean,
nullable: true,
})
@IsOptional()
@IsBoolean()
feasible?: boolean | null;
@ApiPropertyOptional({
description: 'Player morphology/body type',
example: 'Athletic',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
morphology?: string | null;
}
......@@ -12,10 +12,12 @@ import {
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type, Transform } from 'class-transformer';
import { BaseQueryDto } from '../../../common/dto';
export class QueryPlayersDto {
export class QueryPlayersDto extends BaseQueryDto {
@ApiPropertyOptional({
description: 'Search players by name (searches in firstName, lastName, middleName, shortName)',
description:
'Search players by name (searches in firstName, lastName, middleName, shortName)',
example: 'Messi',
})
@IsOptional()
......@@ -72,7 +74,8 @@ export class QueryPlayersDto {
passportAreaWyId?: number;
@ApiPropertyOptional({
description: 'Filter by EU passport (requires passportAreaWyId to be set for EU countries)',
description:
'Filter by EU passport (requires passportAreaWyId to be set for EU countries)',
type: Boolean,
example: true,
})
......@@ -92,13 +95,17 @@ export class QueryPlayersDto {
archived?: boolean;
@ApiPropertyOptional({
description: 'Filter by positions (comma-separated or array)',
example: 'FW,MF,DF',
description:
'Filter by role names (comma-separated or array, e.g., "Forward", "Midfielder", "Defender")',
example: 'Forward,Midfielder,Defender',
})
@IsOptional()
@Transform(({ value }) => {
if (typeof value === 'string') {
return value.split(',').map((v: string) => v.trim()).filter(Boolean);
return value
.split(',')
.map((v: string) => v.trim())
.filter(Boolean);
}
return Array.isArray(value) ? value : [value];
})
......@@ -117,7 +124,8 @@ export class QueryPlayersDto {
scoutId?: number;
@ApiPropertyOptional({
description: 'Filter by birth date (exact match) - ISO date string YYYY-MM-DD',
description:
'Filter by birth date (exact match) - ISO date string YYYY-MM-DD',
example: '1990-01-01',
})
@IsOptional()
......@@ -227,28 +235,38 @@ export class QueryPlayersDto {
status?: 'active' | 'inactive';
@ApiPropertyOptional({
description: 'Number of results to return',
type: Number,
example: 100,
default: 100,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(1000)
limit?: number;
@ApiPropertyOptional({
description: 'Number of results to skip',
type: Number,
example: 0,
default: 0,
description: 'Field to sort by',
enum: [
'firstName',
'lastName',
'shortName',
'height',
'weight',
'birthDate',
'position',
'foot',
'gender',
'status',
'createdAt',
'updatedAt',
],
example: 'lastName',
default: 'lastName',
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
offset?: number;
@IsEnum([
'firstName',
'lastName',
'shortName',
'height',
'weight',
'birthDate',
'position',
'foot',
'gender',
'status',
'createdAt',
'updatedAt',
])
sortBy?: string;
}
......@@ -8,6 +8,7 @@ import {
IsBoolean,
Min,
Max,
IsArray,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
......@@ -135,6 +136,17 @@ export class UpdatePlayerDto {
position?: string;
@ApiPropertyOptional({
description: 'Other positions the player can play',
example: ['MF', 'DF'],
type: [String],
nullable: true,
})
@IsOptional()
@IsArray()
@IsString({ each: true })
otherPositions?: string[] | null;
@ApiPropertyOptional({
description: 'Role code (2 characters)',
example: 'FW',
})
......@@ -167,6 +179,15 @@ export class UpdatePlayerDto {
birthAreaWyId?: number;
@ApiPropertyOptional({
description: 'Second birth area Wyscout ID',
example: 1002,
nullable: true,
})
@IsOptional()
@IsInt()
secondBirthAreaWyId?: number | null;
@ApiPropertyOptional({
description: 'Passport area Wyscout ID',
example: 1001,
})
......@@ -175,6 +196,15 @@ export class UpdatePlayerDto {
passportAreaWyId?: number;
@ApiPropertyOptional({
description: 'Second passport area Wyscout ID',
example: 1002,
nullable: true,
})
@IsOptional()
@IsInt()
secondPassportAreaWyId?: number | null;
@ApiPropertyOptional({
description: 'Status',
enum: ['active', 'inactive'],
example: 'active',
......@@ -225,5 +255,134 @@ export class UpdatePlayerDto {
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
@ApiPropertyOptional({
description: 'Email address of the player',
example: 'player@example.com',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
email?: string | null;
@ApiPropertyOptional({
description: 'Phone number of the player',
example: '+1234567890',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
phone?: string | null;
@ApiPropertyOptional({
description: 'Whether the player is on loan',
example: false,
type: Boolean,
nullable: true,
})
@IsOptional()
@IsBoolean()
onLoan?: boolean | null;
@ApiPropertyOptional({
description: 'Agent name representing the player',
example: 'John Agent',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
agent?: string | null;
@ApiPropertyOptional({
description: 'Player ranking',
example: '1',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
ranking?: string | null;
@ApiPropertyOptional({
description: 'Return on Investment (ROI)',
example: '15.5',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
roi?: string | null;
@ApiPropertyOptional({
description: 'Market value of the player',
example: 5000000.0,
type: Number,
nullable: true,
})
@IsOptional()
@IsNumber()
marketValue?: number | null;
@ApiPropertyOptional({
description: 'Value range (e.g., "1000000-2000000")',
example: '1000000-2000000',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
valueRange?: string | null;
@ApiPropertyOptional({
description: 'Transfer value of the player',
example: 3000000.0,
type: Number,
nullable: true,
})
@IsOptional()
@IsNumber()
transferValue?: number | null;
@ApiPropertyOptional({
description: 'Salary of the player',
example: 100000.0,
type: Number,
nullable: true,
})
@IsOptional()
@IsNumber()
salary?: number | null;
@ApiPropertyOptional({
description: 'Contract end date (YYYY-MM-DD)',
example: '2025-02-02',
type: String,
nullable: true,
})
@IsOptional()
@IsDateString()
contractEndsAt?: string | null;
@ApiPropertyOptional({
description: 'Whether the transfer is feasible',
example: true,
type: Boolean,
nullable: true,
})
@IsOptional()
@IsBoolean()
feasible?: boolean | null;
@ApiPropertyOptional({
description: 'Player morphology/body type',
example: 'Athletic',
type: String,
nullable: true,
})
@IsOptional()
@IsString()
morphology?: string | null;
}
......@@ -10,6 +10,35 @@ export interface PlayerReport {
rating: number | null;
}
export interface PlayerArea {
id: number;
wyId: number;
name: string;
alpha2code: string | null;
alpha3code: string | null;
createdAt: string | null;
updatedAt: string | null;
deletedAt: string | null;
}
export type PositionCategory =
| 'Forward'
| 'Goalkeeper'
| 'Defender'
| 'Midfield';
export interface PlayerPosition {
id: number;
name: string;
code2: string | null;
code3: string | null;
order: number;
location: { x: number; y: number } | null;
bgColor: string | null;
textColor: string | null;
category: PositionCategory;
}
export interface StructuredPlayer {
id: number;
wyId: number;
......@@ -21,10 +50,13 @@ export interface StructuredPlayer {
height: number;
weight: number;
birthDate: string;
birthArea: any | null;
passportArea: any | null;
birthArea: PlayerArea | null;
secondBirthArea: PlayerArea | null;
passportArea: PlayerArea | null;
secondPassportArea: PlayerArea | null;
role: PlayerRole;
position: string | null;
position: PlayerPosition | null;
otherPositions: PlayerPosition[] | null;
foot: string | null;
currentTeamId: number | null;
currentNationalTeamId: number | null;
......@@ -38,5 +70,17 @@ export interface StructuredPlayer {
createdAt: string;
updatedAt: string;
reports: PlayerReport[];
email: string | null;
phone: string | null;
onLoan: boolean;
agent: string | null;
ranking: string | null;
roi: string | null;
marketValue: number | null;
valueRange: string | null;
transferValue: number | null;
salary: number | null;
contractEndsAt: string | null;
feasible: boolean;
morphology: string | null;
}
......@@ -8,6 +8,7 @@ import {
NotFoundException,
Param,
ParseIntPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
......@@ -25,7 +26,7 @@ import {
} from '@nestjs/swagger';
import { PaginatedResponse } from '../../common/utils/response.util';
import { StructuredPlayer } from './interfaces/player.interface';
import { CreatePlayerDto, QueryPlayersDto } from './dto';
import { CreatePlayerDto, QueryPlayersDto, UpdatePlayerDto } from './dto';
@ApiTags('Players')
@Controller('players')
......@@ -111,7 +112,8 @@ export class PlayersController {
name: 'positions',
required: false,
type: String,
description: 'Filter by positions (comma-separated, e.g., "FW,MF,DF")',
description:
'Filter by role names (comma-separated, e.g., "Forward,Midfielder,Defender")',
})
@ApiQuery({
name: 'scoutId',
......@@ -185,7 +187,34 @@ export class PlayersController {
type: Number,
description: 'Number of results to skip (default: 0)',
})
@ApiOkResponse({ description: 'Paginated list of players matching the filters' })
@ApiQuery({
name: 'sortBy',
required: false,
enum: [
'firstName',
'lastName',
'shortName',
'height',
'weight',
'birthDate',
'position',
'foot',
'gender',
'status',
'createdAt',
'updatedAt',
],
description: 'Field to sort by (default: lastName)',
})
@ApiQuery({
name: 'sortOrder',
required: false,
enum: ['asc', 'desc'],
description: 'Sort order - ascending or descending (default: asc)',
})
@ApiOkResponse({
description: 'Paginated list of players matching the filters',
})
async findAll(
@Query('limit') limit?: string,
@Query('offset') offset?: string,
......@@ -197,6 +226,30 @@ export class PlayersController {
return this.playersService.findAll(l, o, name, query);
}
@Patch(':wyId')
@ApiOperation({ summary: 'Update player by wyId' })
@ApiParam({
name: 'wyId',
type: Number,
description: 'Wyscout ID of the player to update',
})
@ApiBody({
description: 'Player update payload. All fields are optional.',
type: UpdatePlayerDto,
})
@ApiOkResponse({ description: 'Updated player', type: Object })
@ApiNotFoundResponse({ description: 'Player not found' })
async updateByWyId(
@Param('wyId', ParseIntPipe) wyId: number,
@Body() body: UpdatePlayerDto,
): Promise<Player> {
const result = await this.playersService.updateByWyId(wyId, body);
if (!result) {
throw new NotFoundException(`Player with wyId ${wyId} not found`);
}
return result;
}
@Delete(':wyId')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete player by wyId (soft delete)' })
......
......@@ -3,8 +3,11 @@ import { DatabaseService } from '../../database/database.service';
import {
players,
reports,
areas,
positions,
type NewPlayer,
type Player,
type Position,
} from '../../database/schema';
import {
eq,
......@@ -17,6 +20,8 @@ import {
lte,
isNotNull,
sql,
desc,
asc,
} from 'drizzle-orm';
import { StructuredPlayer, PlayerReport } from './interfaces/player.interface';
import { ApiResponse } from '../../interfaces/common/api';
......@@ -46,6 +51,83 @@ export class PlayersService {
.trim();
}
/**
* Resolves position ID from either a position ID (number) or position code/name (string)
*/
private async resolvePositionId(
positionCodeOrName?: string | null,
positionId?: number | null,
): Promise<number | null> {
const db = this.databaseService.getDatabase();
// If positionId is provided, use it directly
if (positionId !== null && positionId !== undefined) {
return positionId;
}
// If position code/name is provided, look it up
if (positionCodeOrName) {
const foundPosition = await db
.select({ id: positions.id })
.from(positions)
.where(
and(
or(
eq(positions.code2, positionCodeOrName),
eq(positions.code3, positionCodeOrName),
eq(positions.name, positionCodeOrName),
),
eq(positions.isActive, true),
),
)
.limit(1);
if (foundPosition.length > 0) {
return foundPosition[0].id;
}
}
return null;
}
/**
* Resolves position IDs from either position IDs (number[]) or position codes/names (string[])
*/
private async resolvePositionIds(
positionCodesOrNames?: string[] | null,
positionIds?: number[] | null,
): Promise<number[] | null> {
const db = this.databaseService.getDatabase();
// If positionIds are provided, use them directly
if (positionIds && positionIds.length > 0) {
return positionIds;
}
// If position codes/names are provided, look them up
if (positionCodesOrNames && positionCodesOrNames.length > 0) {
const foundPositions = await db
.select({ id: positions.id })
.from(positions)
.where(
and(
or(
inArray(positions.code2, positionCodesOrNames),
inArray(positions.code3, positionCodesOrNames),
inArray(positions.name, positionCodesOrNames),
),
eq(positions.isActive, true),
),
);
if (foundPositions.length > 0) {
return foundPositions.map((p) => p.id);
}
}
return null;
}
// Transform raw database data to structured format
private transformToStructuredPlayer(rawPlayer: any): StructuredPlayer {
// Helper to format date as YYYY-MM-DD string
......@@ -89,6 +171,28 @@ export class PlayersService {
return null;
};
// Helper to format area object
const formatArea = (area: any): any | null => {
if (!area) return null;
// Check if area has required fields (id, wyId, name)
// Use != null to check for both null and undefined, but allow 0
if (area.id != null && area.wyId != null && area.name != null) {
return {
id: area.id,
wyId: area.wyId,
name: area.name,
alpha2code: area.alpha2code ?? null,
alpha3code: area.alpha3code ?? null,
createdAt: formatTimestamp(area.createdAt) ?? null,
updatedAt: formatTimestamp(area.updatedAt) ?? null,
deletedAt: formatTimestamp(area.deletedAt) ?? null,
};
}
return null;
};
return {
id: rawPlayer.id ?? 0,
wyId: rawPlayer.wyId ?? rawPlayer.wy_id ?? 0,
......@@ -100,14 +204,36 @@ export class PlayersService {
height: rawPlayer.heightCm ?? rawPlayer.height_cm ?? 0,
weight: parseDecimal(rawPlayer.weightKg ?? rawPlayer.weight_kg),
birthDate: formatDate(rawPlayer.dateOfBirth ?? rawPlayer.date_of_birth),
birthArea: rawPlayer.birthArea ?? null,
passportArea: rawPlayer.passportArea ?? null,
birthArea: formatArea(rawPlayer.birthArea),
secondBirthArea: formatArea(rawPlayer.secondBirthArea),
passportArea: formatArea(rawPlayer.passportArea),
secondPassportArea: formatArea(rawPlayer.secondPassportArea),
role: {
name: rawPlayer.roleName ?? rawPlayer.role_name ?? '',
code2: rawPlayer.roleCode2 ?? rawPlayer.role_code2 ?? '',
code3: rawPlayer.roleCode3 ?? rawPlayer.role_code3 ?? '',
},
position: rawPlayer.position ?? null,
position: rawPlayer.positionName
? {
id: rawPlayer.positionId ?? null,
name: rawPlayer.positionName,
code2: rawPlayer.positionCode2 ?? null,
code3: rawPlayer.positionCode3 ?? null,
order: rawPlayer.positionOrder ?? 0,
location:
rawPlayer.positionLocationX !== null &&
rawPlayer.positionLocationY !== null
? {
x: rawPlayer.positionLocationX,
y: rawPlayer.positionLocationY,
}
: null,
bgColor: rawPlayer.positionBgColor ?? null,
textColor: rawPlayer.positionTextColor ?? null,
category: rawPlayer.positionCategory ?? null,
}
: null,
otherPositions: null, // Will be populated separately if needed
foot: rawPlayer.foot ?? null,
currentTeamId:
rawPlayer.currentTeamId ?? rawPlayer.current_team_id ?? null,
......@@ -194,6 +320,25 @@ export class PlayersService {
? (formatTimestamp(rawPlayer.updated_at) ?? '')
: '',
reports: rawPlayer.reports ?? [],
email: rawPlayer.email ?? null,
phone: rawPlayer.phone ?? null,
onLoan: rawPlayer.onLoan ?? rawPlayer.on_loan ?? false,
agent: rawPlayer.agent ?? null,
ranking: rawPlayer.ranking ? String(rawPlayer.ranking) : null,
roi: rawPlayer.roi ?? null,
marketValue: parseDecimal(
rawPlayer.marketValue ?? rawPlayer.market_value,
),
valueRange: rawPlayer.valueRange ?? rawPlayer.value_range ?? null,
transferValue: parseDecimal(
rawPlayer.transferValue ?? rawPlayer.transfer_value,
),
salary: parseDecimal(rawPlayer.salary),
contractEndsAt: formatDate(
rawPlayer.contractEndsAt ?? rawPlayer.contract_ends_at,
),
feasible: rawPlayer.feasible ?? false,
morphology: rawPlayer.morphology ?? null,
};
}
......@@ -307,9 +452,39 @@ export class PlayersService {
conditions.push(eq(players.passportAreaWyId, query.passportAreaWyId));
}
// Position filter (array)
// Position filter (array) - filters by position codes or names
if (query?.positions && query.positions.length > 0) {
conditions.push(inArray(players.position, query.positions));
// First, find position IDs by matching codes or names
const foundPositions = await db
.select({ id: positions.id })
.from(positions)
.where(
and(
or(
inArray(positions.code2, query.positions),
inArray(positions.code3, query.positions),
inArray(positions.name, query.positions),
),
eq(positions.isActive, true),
),
);
if (foundPositions.length > 0) {
const positionIds = foundPositions.map((p) => p.id);
// Filter by positionId or otherPositionIds array overlap
conditions.push(
or(
inArray(players.positionId, positionIds),
sql`${players.otherPositionIds} && ARRAY[${sql.join(
positionIds.map((id) => sql`${id}`),
sql`, `,
)}]::integer[]`,
),
);
} else {
// If no positions found, return no results
conditions.push(eq(players.id, -1)); // Impossible condition
}
}
// Gender filter
......@@ -330,19 +505,43 @@ export class PlayersService {
}
// Age range filter (calculated from dateOfBirth)
// Age calculation matches calculateAge(): current year - birth year,
// subtracting 1 if birthday hasn't occurred yet this year
if (query?.minAge !== undefined || query?.maxAge !== undefined) {
const today = new Date();
const currentYear = today.getFullYear();
const currentMonth = today.getMonth(); // 0-11
const currentDay = today.getDate();
if (query.minAge !== undefined) {
const maxBirthDate = new Date(today);
maxBirthDate.setFullYear(today.getFullYear() - query.minAge);
maxBirthDate.setMonth(11, 31); // End of year
// For minAge: player must be at least minAge years old
// A player is at least minAge if: (currentYear - birthYear) >= minAge
// AND if birthday hasn't occurred: (currentYear - birthYear - 1) >= minAge
// This means: birthYear <= (currentYear - minAge) if birthday occurred
// OR birthYear <= (currentYear - minAge - 1) if birthday hasn't occurred
// Simplest: dateOfBirth <= (today - minAge years)
const maxBirthDate = new Date(
currentYear - query.minAge,
currentMonth,
currentDay,
);
// Handle edge case: if today is the exact date, include players born on that date
const maxBirthDateStr = maxBirthDate.toISOString().split('T')[0];
conditions.push(lte(players.dateOfBirth, maxBirthDateStr));
}
if (query.maxAge !== undefined) {
const minBirthDate = new Date(today);
minBirthDate.setFullYear(today.getFullYear() - query.maxAge - 1);
minBirthDate.setMonth(0, 1); // Start of year
// For maxAge: player must be at most maxAge years old
// A player is at most maxAge if they haven't turned (maxAge + 1) yet
// A player turns (maxAge + 1) on their birthday in year (currentYear - maxAge)
// So we want: dateOfBirth > (today - maxAge - 1 years)
// Which is: dateOfBirth >= (today - maxAge - 1 years + 1 day)
const minBirthDate = new Date(
currentYear - query.maxAge - 1,
currentMonth,
currentDay,
);
// Add 1 day to exclude players who have already turned (maxAge + 1)
minBirthDate.setDate(minBirthDate.getDate() + 1);
const minBirthDateStr = minBirthDate.toISOString().split('T')[0];
conditions.push(gte(players.dateOfBirth, minBirthDateStr));
}
......@@ -383,6 +582,54 @@ export class PlayersService {
const whereCondition =
conditions.length > 0 ? and(...conditions) : undefined;
// Determine sort field and order for database query
const sortBy = query?.sortBy || 'lastName';
const sortOrder = query?.sortOrder || 'asc';
let orderByColumn: any = players.lastName; // Default sort
// Map sortBy to actual database column
switch (sortBy) {
case 'firstName':
orderByColumn = players.firstName;
break;
case 'lastName':
orderByColumn = players.lastName;
break;
case 'shortName':
orderByColumn = players.shortName;
break;
case 'height':
orderByColumn = players.heightCm;
break;
case 'weight':
orderByColumn = players.weightKg;
break;
case 'birthDate':
orderByColumn = players.dateOfBirth;
break;
case 'position':
// Sort by position name from joined positions table
orderByColumn = positions.name;
break;
case 'foot':
orderByColumn = players.foot;
break;
case 'gender':
orderByColumn = players.gender;
break;
case 'status':
orderByColumn = players.status;
break;
case 'createdAt':
orderByColumn = players.createdAt;
break;
case 'updatedAt':
orderByColumn = players.updatedAt;
break;
default:
orderByColumn = players.lastName;
}
// Get total count
const [totalResult] = await db
.select({ count: count() })
......@@ -434,17 +681,237 @@ export class PlayersService {
throw new QueryApiNotAvailableError();
}
} catch (error) {
// Fallback to simple select if relations are not configured
const baseQuery = db.select().from(players);
// Fallback to simple select with manual joins if relations are not configured
const baseQuery = db
.select({
// Player fields
id: players.id,
wyId: players.wyId,
gsmId: players.gsmId,
shortName: players.shortName,
firstName: players.firstName,
middleName: players.middleName,
lastName: players.lastName,
heightCm: players.heightCm,
weightKg: players.weightKg,
dateOfBirth: players.dateOfBirth,
roleName: players.roleName,
roleCode2: players.roleCode2,
roleCode3: players.roleCode3,
positionId: players.positionId,
otherPositionIds: players.otherPositionIds,
foot: players.foot,
currentTeamId: players.currentTeamId,
currentNationalTeamId: players.currentNationalTeamId,
currentTeamName: players.currentTeamName,
currentTeamOfficialName: players.currentTeamOfficialName,
gender: players.gender,
status: players.status,
jerseyNumber: players.jerseyNumber,
imageDataUrl: players.imageDataUrl,
apiLastSyncedAt: players.apiLastSyncedAt,
apiSyncStatus: players.apiSyncStatus,
createdAt: players.createdAt,
updatedAt: players.updatedAt,
deletedAt: players.deletedAt,
isActive: players.isActive,
email: players.email,
phone: players.phone,
onLoan: players.onLoan,
agent: players.agent,
ranking: players.ranking,
roi: players.roi,
marketValue: players.marketValue,
valueRange: players.valueRange,
transferValue: players.transferValue,
salary: players.salary,
contractEndsAt: players.contractEndsAt,
feasible: players.feasible,
morphology: players.morphology,
// Birth area fields
birthAreaId: areas.id,
birthAreaWyId: areas.wyId,
birthAreaName: areas.name,
birthAreaAlpha2code: areas.alpha2code,
birthAreaAlpha3code: areas.alpha3code,
birthAreaCreatedAt: areas.createdAt,
birthAreaUpdatedAt: areas.updatedAt,
birthAreaDeletedAt: areas.deletedAt,
// Second birth area fields
secondBirthAreaWyId: players.secondBirthAreaWyId,
// Passport area fields (will be fetched separately)
passportAreaWyId: players.passportAreaWyId,
secondPassportAreaWyId: players.secondPassportAreaWyId,
// Position fields (from positions table)
positionName: positions.name,
positionCode2: positions.code2,
positionCode3: positions.code3,
positionOrder: positions.order,
positionLocationX: positions.locationX,
positionLocationY: positions.locationY,
positionBgColor: positions.bgColor,
positionTextColor: positions.textColor,
positionCategory: positions.category,
})
.from(players)
.leftJoin(areas, eq(players.birthAreaWyId, areas.wyId))
.leftJoin(positions, eq(players.positionId, positions.id));
// Add passport area join using a subquery approach
// We'll need to do a second query or use a different approach
// For now, let's use a simpler approach with separate queries
const queryWithWhere = whereCondition
? baseQuery.where(whereCondition)
: baseQuery;
// Add sorting to database query when not searching
const queryWithOrder =
sortOrder === 'desc'
? queryWithWhere.orderBy(desc(orderByColumn))
: queryWithWhere.orderBy(asc(orderByColumn));
// If searching, fetch all for normalization filtering
let rawDataWithBirthArea: any[];
if ((!search || !search.trim()) && !query?.name) {
rawData = await queryWithWhere.limit(finalLimit).offset(finalOffset);
rawDataWithBirthArea = await queryWithOrder
.limit(finalLimit)
.offset(finalOffset);
} else {
rawData = await queryWithWhere;
rawDataWithBirthArea = await queryWithOrder;
}
// Now fetch passport areas and second birth area separately and merge
const playerWyIds = rawDataWithBirthArea.map((p) => p.wyId);
const passportAreasMap = new Map();
const secondPassportAreasMap = new Map();
const secondBirthAreasMap = new Map();
if (playerWyIds.length > 0) {
// Fetch passport areas
const playersWithPassport = await db
.select({
playerWyId: players.wyId,
areaId: areas.id,
areaWyId: areas.wyId,
areaName: areas.name,
areaAlpha2code: areas.alpha2code,
areaAlpha3code: areas.alpha3code,
areaCreatedAt: areas.createdAt,
areaUpdatedAt: areas.updatedAt,
areaDeletedAt: areas.deletedAt,
})
.from(players)
.leftJoin(areas, eq(players.passportAreaWyId, areas.wyId))
.where(inArray(players.wyId, playerWyIds));
for (const row of playersWithPassport) {
if (row.areaId) {
passportAreasMap.set(row.playerWyId, {
id: row.areaId,
wyId: row.areaWyId,
name: row.areaName,
alpha2code: row.areaAlpha2code,
alpha3code: row.areaAlpha3code,
createdAt: row.areaCreatedAt,
updatedAt: row.areaUpdatedAt,
deletedAt: row.areaDeletedAt,
});
}
}
// Fetch second passport areas
const playersWithSecondPassport = await db
.select({
playerWyId: players.wyId,
areaId: areas.id,
areaWyId: areas.wyId,
areaName: areas.name,
areaAlpha2code: areas.alpha2code,
areaAlpha3code: areas.alpha3code,
areaCreatedAt: areas.createdAt,
areaUpdatedAt: areas.updatedAt,
areaDeletedAt: areas.deletedAt,
})
.from(players)
.leftJoin(areas, eq(players.secondPassportAreaWyId, areas.wyId))
.where(inArray(players.wyId, playerWyIds));
for (const row of playersWithSecondPassport) {
if (row.areaId) {
secondPassportAreasMap.set(row.playerWyId, {
id: row.areaId,
wyId: row.areaWyId,
name: row.areaName,
alpha2code: row.areaAlpha2code,
alpha3code: row.areaAlpha3code,
createdAt: row.areaCreatedAt,
updatedAt: row.areaUpdatedAt,
deletedAt: row.areaDeletedAt,
});
}
}
// Fetch second birth areas
const playersWithSecondBirth = await db
.select({
playerWyId: players.wyId,
areaId: areas.id,
areaWyId: areas.wyId,
areaName: areas.name,
areaAlpha2code: areas.alpha2code,
areaAlpha3code: areas.alpha3code,
areaCreatedAt: areas.createdAt,
areaUpdatedAt: areas.updatedAt,
areaDeletedAt: areas.deletedAt,
})
.from(players)
.leftJoin(areas, eq(players.secondBirthAreaWyId, areas.wyId))
.where(inArray(players.wyId, playerWyIds));
for (const row of playersWithSecondBirth) {
if (row.areaId) {
secondBirthAreasMap.set(row.playerWyId, {
id: row.areaId,
wyId: row.areaWyId,
name: row.areaName,
alpha2code: row.areaAlpha2code,
alpha3code: row.areaAlpha3code,
createdAt: row.areaCreatedAt,
updatedAt: row.areaUpdatedAt,
deletedAt: row.areaDeletedAt,
});
}
}
}
// Merge areas into raw data
rawData = rawDataWithBirthArea.map((player) => {
const birthArea = player.birthAreaId
? {
id: player.birthAreaId,
wyId: player.birthAreaWyId,
name: player.birthAreaName,
alpha2code: player.birthAreaAlpha2code,
alpha3code: player.birthAreaAlpha3code,
createdAt: player.birthAreaCreatedAt,
updatedAt: player.birthAreaUpdatedAt,
deletedAt: player.birthAreaDeletedAt,
}
: null;
const secondBirthArea = secondBirthAreasMap.get(player.wyId) || null;
const passportArea = passportAreasMap.get(player.wyId) || null;
const secondPassportArea =
secondPassportAreasMap.get(player.wyId) || null;
return {
...player,
birthArea,
secondBirthArea,
passportArea,
secondPassportArea,
};
});
}
// Transform to structured format
......@@ -483,11 +950,60 @@ export class PlayersService {
}
}
// Attach reports to each player
structuredData = structuredData.map((player) => ({
// Fetch other positions for players that have otherPositionIds
const allOtherPositionIds = new Set<number>();
rawData.forEach((player) => {
if (player.otherPositionIds && Array.isArray(player.otherPositionIds)) {
player.otherPositionIds.forEach((id) => allOtherPositionIds.add(id));
}
});
let otherPositionsMap: Map<number, any> = new Map();
if (allOtherPositionIds.size > 0) {
const otherPositions = await db
.select()
.from(positions)
.where(
and(
inArray(positions.id, Array.from(allOtherPositionIds)),
eq(positions.isActive, true),
),
);
otherPositions.forEach((pos) => {
otherPositionsMap.set(pos.id, {
id: pos.id,
name: pos.name,
code2: pos.code2,
code3: pos.code3,
order: pos.order ?? 0,
location:
pos.locationX !== null && pos.locationY !== null
? { x: pos.locationX, y: pos.locationY }
: null,
bgColor: pos.bgColor,
textColor: pos.textColor,
category: pos.category,
});
});
}
// Attach reports and other positions to each player
structuredData = structuredData.map((player, index) => {
const rawPlayer = rawData[index];
const otherPositionsArray =
rawPlayer.otherPositionIds && Array.isArray(rawPlayer.otherPositionIds)
? rawPlayer.otherPositionIds
.map((id) => otherPositionsMap.get(id))
.filter((pos) => pos !== undefined)
: null;
return {
...player,
reports: playerReportsMap.get(player.wyId) ?? [],
}));
otherPositions: otherPositionsArray,
};
});
// If search is provided, apply normalized filtering
const searchTerm = search || query?.name;
......@@ -507,18 +1023,103 @@ export class PlayersService {
);
});
// Apply limit and offset after filtering
structuredData = filteredData.slice(
finalOffset,
finalOffset + finalLimit,
);
// Update structuredData to filtered data
structuredData = filteredData;
// Update totalItems to the actual filtered count
// Note: This requires counting all matches, so we use the filtered length
// For better performance, you might want to cache this or use a different approach
totalItems = filteredData.length;
}
// Apply sorting if specified (before pagination)
if (query?.sortBy) {
const sortBy = query.sortBy;
const sortOrder = query.sortOrder || 'asc';
structuredData.sort((a, b) => {
let aValue: any;
let bValue: any;
switch (sortBy) {
case 'firstName':
aValue = a.firstName || '';
bValue = b.firstName || '';
break;
case 'lastName':
aValue = a.lastName || '';
bValue = b.lastName || '';
break;
case 'shortName':
aValue = a.shortName || '';
bValue = b.shortName || '';
break;
case 'height':
aValue = a.height || 0;
bValue = b.height || 0;
break;
case 'weight':
aValue = a.weight || 0;
bValue = b.weight || 0;
break;
case 'birthDate':
aValue = a.birthDate ? new Date(a.birthDate).getTime() : 0;
bValue = b.birthDate ? new Date(b.birthDate).getTime() : 0;
break;
case 'position':
aValue = a.position?.name || '';
bValue = b.position?.name || '';
break;
case 'foot':
aValue = a.foot || '';
bValue = b.foot || '';
break;
case 'gender':
aValue = a.gender || '';
bValue = b.gender || '';
break;
case 'status':
aValue = a.status || '';
bValue = b.status || '';
break;
case 'createdAt':
aValue = a.createdAt ? new Date(a.createdAt).getTime() : 0;
bValue = b.createdAt ? new Date(b.createdAt).getTime() : 0;
break;
case 'updatedAt':
aValue = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
bValue = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
break;
default:
// Default to lastName if invalid sortBy
aValue = a.lastName || '';
bValue = b.lastName || '';
}
// Handle string comparison
if (typeof aValue === 'string' && typeof bValue === 'string') {
const comparison = aValue.localeCompare(bValue);
return sortOrder === 'asc' ? comparison : -comparison;
}
// Handle number comparison
if (typeof aValue === 'number' && typeof bValue === 'number') {
return sortOrder === 'asc' ? aValue - bValue : bValue - aValue;
}
// Fallback
return 0;
});
}
// Apply pagination after sorting (only if we have all data in memory)
// If search was used, we already have all filtered data, so apply pagination here
if (searchTerm && searchTerm.trim()) {
structuredData = structuredData.slice(
finalOffset,
finalOffset + finalLimit,
);
}
return ResponseUtil.createPaginatedResponse(
structuredData,
'/players',
......@@ -581,6 +1182,18 @@ export class PlayersService {
role,
currentTeam,
imageDataURL,
email,
phone,
onLoan,
agent,
ranking,
roi,
marketValue,
valueRange,
transferValue,
salary,
feasible,
morphology,
...restData
} = data;
......@@ -600,17 +1213,23 @@ export class PlayersService {
foot: normalizeToNull(data.foot),
gender: normalizeToNull(data.gender),
// Position and role - extract from role object if present
position: normalizeToNull(data.position),
// Position and role - handle position ID or code/name lookup
positionId: await this.resolvePositionId(data.position, data.positionId),
otherPositionIds: await this.resolvePositionIds(
data.otherPositions,
data.otherPositionIds,
),
roleCode2: normalizeToNull(role?.code2 ?? data.roleCode2),
roleCode3: normalizeToNull(role?.code3 ?? data.roleCode3),
roleName: normalizeToNull(role?.name ?? data.roleName),
// Extract wyIds from nested objects (external API sends full objects, DB stores only wyIds)
birthAreaWyId: normalizeToNull(birthArea?.wyId ?? data.birthAreaWyId),
secondBirthAreaWyId: normalizeToNull(data.secondBirthAreaWyId),
passportAreaWyId: normalizeToNull(
passportArea?.wyId ?? data.passportAreaWyId,
),
secondPassportAreaWyId: normalizeToNull(data.secondPassportAreaWyId),
currentTeamId: normalizeToNull(currentTeam?.wyId ?? data.currentTeamId),
currentNationalTeamId: normalizeToNull(data.currentNationalTeamId),
// Store current team name and official name from API
......@@ -636,6 +1255,21 @@ export class PlayersService {
updatedAt: toDate(data.updatedAt) as any,
deletedAt: toDate(data.deletedAt) as any,
// Contact and financial information
email: normalizeToNull(data.email),
phone: normalizeToNull(data.phone),
onLoan: data.onLoan ?? false,
agent: normalizeToNull(data.agent),
ranking: normalizeToNull(data.ranking),
roi: normalizeToNull(data.roi),
marketValue: normalizeToNull(data.marketValue),
valueRange: normalizeToNull(data.valueRange),
transferValue: normalizeToNull(data.transferValue),
salary: normalizeToNull(data.salary),
contractEndsAt: toDateString(data.contractEndsAt) as any,
feasible: data.feasible ?? false,
morphology: normalizeToNull(data.morphology),
// Include any other fields from restData that match the schema
...(restData.teamWyId !== undefined
? { teamWyId: restData.teamWyId }
......@@ -675,12 +1309,203 @@ export class PlayersService {
throw new QueryApiNotAvailableError();
}
} catch (error) {
// Fallback to simple select if relations are not configured
const rows = await db
.select()
// Fallback to simple select with manual joins if relations are not configured
const playerRow = await db
.select({
// Player fields
id: players.id,
wyId: players.wyId,
gsmId: players.gsmId,
shortName: players.shortName,
firstName: players.firstName,
middleName: players.middleName,
lastName: players.lastName,
heightCm: players.heightCm,
weightKg: players.weightKg,
dateOfBirth: players.dateOfBirth,
roleName: players.roleName,
roleCode2: players.roleCode2,
roleCode3: players.roleCode3,
positionId: players.positionId,
otherPositionIds: players.otherPositionIds,
foot: players.foot,
// Position fields
positionName: positions.name,
positionCode2: positions.code2,
positionCode3: positions.code3,
positionOrder: positions.order,
positionLocationX: positions.locationX,
positionLocationY: positions.locationY,
positionBgColor: positions.bgColor,
positionTextColor: positions.textColor,
positionCategory: positions.category,
currentTeamId: players.currentTeamId,
currentNationalTeamId: players.currentNationalTeamId,
currentTeamName: players.currentTeamName,
currentTeamOfficialName: players.currentTeamOfficialName,
gender: players.gender,
status: players.status,
jerseyNumber: players.jerseyNumber,
imageDataUrl: players.imageDataUrl,
apiLastSyncedAt: players.apiLastSyncedAt,
apiSyncStatus: players.apiSyncStatus,
createdAt: players.createdAt,
updatedAt: players.updatedAt,
deletedAt: players.deletedAt,
isActive: players.isActive,
email: players.email,
phone: players.phone,
onLoan: players.onLoan,
agent: players.agent,
ranking: players.ranking,
roi: players.roi,
marketValue: players.marketValue,
valueRange: players.valueRange,
transferValue: players.transferValue,
salary: players.salary,
contractEndsAt: players.contractEndsAt,
feasible: players.feasible,
morphology: players.morphology,
secondBirthAreaWyId: players.secondBirthAreaWyId,
passportAreaWyId: players.passportAreaWyId,
secondPassportAreaWyId: players.secondPassportAreaWyId,
// Birth area fields
birthAreaId: areas.id,
birthAreaWyId: areas.wyId,
birthAreaName: areas.name,
birthAreaAlpha2code: areas.alpha2code,
birthAreaAlpha3code: areas.alpha3code,
birthAreaCreatedAt: areas.createdAt,
birthAreaUpdatedAt: areas.updatedAt,
birthAreaDeletedAt: areas.deletedAt,
})
.from(players)
.leftJoin(areas, eq(players.birthAreaWyId, areas.wyId))
.leftJoin(positions, eq(players.positionId, positions.id))
.where(eq(players.wyId, wyId))
.limit(1);
const player = playerRow[0];
if (!player) {
rawPlayer = undefined;
} else {
// Fetch passport area separately
const passportAreaRow = await db
.select({
areaId: areas.id,
areaWyId: areas.wyId,
areaName: areas.name,
areaAlpha2code: areas.alpha2code,
areaAlpha3code: areas.alpha3code,
areaCreatedAt: areas.createdAt,
areaUpdatedAt: areas.updatedAt,
areaDeletedAt: areas.deletedAt,
})
.from(players)
.leftJoin(areas, eq(players.passportAreaWyId, areas.wyId))
.where(eq(players.wyId, wyId))
.limit(1);
const passportAreaData = passportAreaRow[0];
// Fetch second passport area
const secondPassportAreaRow = await db
.select({
areaId: areas.id,
areaWyId: areas.wyId,
areaName: areas.name,
areaAlpha2code: areas.alpha2code,
areaAlpha3code: areas.alpha3code,
areaCreatedAt: areas.createdAt,
areaUpdatedAt: areas.updatedAt,
areaDeletedAt: areas.deletedAt,
})
.from(players)
.leftJoin(areas, eq(players.secondPassportAreaWyId, areas.wyId))
.where(eq(players.wyId, wyId))
.limit(1);
const secondPassportAreaData = secondPassportAreaRow[0];
// Fetch second birth area
const secondBirthAreaRow = await db
.select({
areaId: areas.id,
areaWyId: areas.wyId,
areaName: areas.name,
areaAlpha2code: areas.alpha2code,
areaAlpha3code: areas.alpha3code,
areaCreatedAt: areas.createdAt,
areaUpdatedAt: areas.updatedAt,
areaDeletedAt: areas.deletedAt,
})
.from(players)
.where(eq(players.wyId, wyId));
rawPlayer = rows[0];
.leftJoin(areas, eq(players.secondBirthAreaWyId, areas.wyId))
.where(eq(players.wyId, wyId))
.limit(1);
const secondBirthAreaData = secondBirthAreaRow[0];
const birthArea = player.birthAreaId
? {
id: player.birthAreaId,
wyId: player.birthAreaWyId,
name: player.birthAreaName,
alpha2code: player.birthAreaAlpha2code,
alpha3code: player.birthAreaAlpha3code,
createdAt: player.birthAreaCreatedAt,
updatedAt: player.birthAreaUpdatedAt,
deletedAt: player.birthAreaDeletedAt,
}
: null;
const secondBirthArea = secondBirthAreaData?.areaId
? {
id: secondBirthAreaData.areaId,
wyId: secondBirthAreaData.areaWyId,
name: secondBirthAreaData.areaName,
alpha2code: secondBirthAreaData.areaAlpha2code,
alpha3code: secondBirthAreaData.areaAlpha3code,
createdAt: secondBirthAreaData.areaCreatedAt,
updatedAt: secondBirthAreaData.areaUpdatedAt,
deletedAt: secondBirthAreaData.areaDeletedAt,
}
: null;
const passportArea = passportAreaData?.areaId
? {
id: passportAreaData.areaId,
wyId: passportAreaData.areaWyId,
name: passportAreaData.areaName,
alpha2code: passportAreaData.areaAlpha2code,
alpha3code: passportAreaData.areaAlpha3code,
createdAt: passportAreaData.areaCreatedAt,
updatedAt: passportAreaData.areaUpdatedAt,
deletedAt: passportAreaData.areaDeletedAt,
}
: null;
const secondPassportArea = secondPassportAreaData?.areaId
? {
id: secondPassportAreaData.areaId,
wyId: secondPassportAreaData.areaWyId,
name: secondPassportAreaData.areaName,
alpha2code: secondPassportAreaData.areaAlpha2code,
alpha3code: secondPassportAreaData.areaAlpha3code,
createdAt: secondPassportAreaData.areaCreatedAt,
updatedAt: secondPassportAreaData.areaUpdatedAt,
deletedAt: secondPassportAreaData.areaDeletedAt,
}
: null;
rawPlayer = {
...player,
birthArea,
secondBirthArea,
passportArea,
secondPassportArea,
};
}
}
if (!rawPlayer) {
......@@ -690,6 +1515,38 @@ export class PlayersService {
// Transform to structured format
const structuredPlayer = this.transformToStructuredPlayer(rawPlayer);
// Fetch other positions if otherPositionIds exist
if (
rawPlayer.otherPositionIds &&
Array.isArray(rawPlayer.otherPositionIds) &&
rawPlayer.otherPositionIds.length > 0
) {
const otherPositions = await db
.select()
.from(positions)
.where(
and(
inArray(positions.id, rawPlayer.otherPositionIds),
eq(positions.isActive, true),
),
);
structuredPlayer.otherPositions = otherPositions.map((pos) => ({
id: pos.id,
name: pos.name,
code2: pos.code2,
code3: pos.code3,
order: pos.order ?? 0,
location:
pos.locationX !== null && pos.locationY !== null
? { x: pos.locationX, y: pos.locationY }
: null,
bgColor: pos.bgColor,
textColor: pos.textColor,
category: pos.category,
}));
}
// Fetch reports for this player
const playerReports = await db
.select({
......@@ -716,6 +1573,172 @@ export class PlayersService {
return db.select().from(players).limit(limit).offset(offset);
}
async updateByWyId(
wyId: number,
data: Partial<NewPlayer | any>,
): Promise<Player | null> {
const db = this.databaseService.getDatabase();
// Check if player exists
const existingPlayer = await db
.select()
.from(players)
.leftJoin(areas, eq(players.birthAreaWyId, areas.wyId))
.leftJoin(positions, eq(players.positionId, positions.id))
.where(eq(players.wyId, wyId))
.limit(1);
if (!existingPlayer || existingPlayer.length === 0) {
return null;
}
// 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;
};
// Convert to date string (YYYY-MM-DD) for date columns
const toDateString = (value: unknown): string | null => {
if (value == null || value === '') return null;
if (value instanceof Date) {
return value.toISOString().split('T')[0];
}
if (typeof value === 'string') {
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return value;
}
const d = new Date(value);
if (!isNaN(d.getTime())) {
return d.toISOString().split('T')[0];
}
}
return null;
};
// Normalize empty strings to null for nullable fields
const normalizeToNull = (value: unknown): any => {
if (value === '' || value === null || value === undefined) return null;
return value;
};
// Build update object with only provided fields
const updateData: Partial<NewPlayer> = {
updatedAt: new Date(),
};
// Transform and include only fields that are provided
if (data.firstName !== undefined) updateData.firstName = data.firstName;
if (data.lastName !== undefined) updateData.lastName = data.lastName;
if (data.middleName !== undefined)
updateData.middleName = normalizeToNull(data.middleName);
if (data.shortName !== undefined)
updateData.shortName = normalizeToNull(data.shortName);
if (data.gsmId !== undefined)
updateData.gsmId = normalizeToNull(data.gsmId);
if (data.teamWyId !== undefined)
updateData.teamWyId = normalizeToNull(data.teamWyId);
if (data.currentTeamId !== undefined)
updateData.currentTeamId = normalizeToNull(data.currentTeamId);
if (data.currentNationalTeamId !== undefined)
updateData.currentNationalTeamId = normalizeToNull(
data.currentNationalTeamId,
);
if (data.currentTeamName !== undefined)
updateData.currentTeamName = normalizeToNull(data.currentTeamName);
if (data.currentTeamOfficialName !== undefined)
updateData.currentTeamOfficialName = normalizeToNull(
data.currentTeamOfficialName,
);
if (data.dateOfBirth !== undefined)
updateData.dateOfBirth = toDateString(data.dateOfBirth) as any;
if (data.heightCm !== undefined)
updateData.heightCm = normalizeToNull(data.heightCm);
if (data.weightKg !== undefined)
updateData.weightKg = normalizeToNull(data.weightKg);
if (data.foot !== undefined) updateData.foot = normalizeToNull(data.foot);
if (data.gender !== undefined)
updateData.gender = normalizeToNull(data.gender);
if (data.position !== undefined || (data as any).positionId !== undefined) {
updateData.positionId = await this.resolvePositionId(
data.position,
(data as any).positionId,
);
}
if (
data.otherPositions !== undefined ||
(data as any).otherPositionIds !== undefined
) {
updateData.otherPositionIds = await this.resolvePositionIds(
data.otherPositions,
(data as any).otherPositionIds,
);
}
if (data.roleCode2 !== undefined)
updateData.roleCode2 = normalizeToNull(data.roleCode2);
if (data.roleCode3 !== undefined)
updateData.roleCode3 = normalizeToNull(data.roleCode3);
if (data.roleName !== undefined)
updateData.roleName = normalizeToNull(data.roleName);
if (data.birthAreaWyId !== undefined)
updateData.birthAreaWyId = normalizeToNull(data.birthAreaWyId);
if (data.secondBirthAreaWyId !== undefined)
updateData.secondBirthAreaWyId = normalizeToNull(
data.secondBirthAreaWyId,
);
if (data.passportAreaWyId !== undefined)
updateData.passportAreaWyId = normalizeToNull(data.passportAreaWyId);
if (data.secondPassportAreaWyId !== undefined)
updateData.secondPassportAreaWyId = normalizeToNull(
data.secondPassportAreaWyId,
);
if (data.status !== undefined) updateData.status = data.status;
if (data.imageDataUrl !== undefined)
updateData.imageDataUrl = normalizeToNull(data.imageDataUrl);
if (data.jerseyNumber !== undefined)
updateData.jerseyNumber = normalizeToNull(data.jerseyNumber);
if (data.apiLastSyncedAt !== undefined)
updateData.apiLastSyncedAt = toDate(data.apiLastSyncedAt) as any;
if (data.apiSyncStatus !== undefined)
updateData.apiSyncStatus = data.apiSyncStatus;
if (data.isActive !== undefined) updateData.isActive = data.isActive;
// New fields added for contact and financial information
if (data.email !== undefined)
updateData.email = normalizeToNull(data.email);
if (data.phone !== undefined)
updateData.phone = normalizeToNull(data.phone);
if (data.onLoan !== undefined) updateData.onLoan = data.onLoan;
if (data.agent !== undefined)
updateData.agent = normalizeToNull(data.agent);
if (data.ranking !== undefined)
updateData.ranking = normalizeToNull(data.ranking);
if (data.roi !== undefined) updateData.roi = normalizeToNull(data.roi);
if (data.marketValue !== undefined)
updateData.marketValue = normalizeToNull(data.marketValue);
if (data.valueRange !== undefined)
updateData.valueRange = normalizeToNull(data.valueRange);
if (data.transferValue !== undefined)
updateData.transferValue = normalizeToNull(data.transferValue);
if (data.salary !== undefined)
updateData.salary = normalizeToNull(data.salary);
if (data.contractEndsAt !== undefined)
updateData.contractEndsAt = toDateString(data.contractEndsAt) as any;
if (data.feasible !== undefined) updateData.feasible = data.feasible;
if (data.morphology !== undefined)
updateData.morphology = normalizeToNull(data.morphology);
const [result] = await db
.update(players)
.set(updateData)
.where(eq(players.wyId, wyId))
.returning();
return result as Player;
}
async deleteByWyId(wyId: number): Promise<Player | null> {
const db = this.databaseService.getDatabase();
......
import {
IsString,
IsOptional,
IsInt,
IsEnum,
IsBoolean,
Length,
Min,
Max,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export type PositionCategory = 'Forward' | 'Goalkeeper' | 'Defender' | 'Midfield';
export class CreatePositionDto {
@ApiProperty({
description: 'Position name',
example: 'Center Defender',
})
@IsString()
name: string;
@ApiPropertyOptional({
description: '2-character position code',
example: 'CD',
})
@IsOptional()
@IsString()
@Length(2, 10)
code2?: string;
@ApiPropertyOptional({
description: '3-character position code',
example: 'CDE',
})
@IsOptional()
@IsString()
@Length(3, 10)
code3?: string;
@ApiProperty({
description: 'Position category',
enum: ['Forward', 'Goalkeeper', 'Defender', 'Midfield'],
example: 'Defender',
})
@IsEnum(['Forward', 'Goalkeeper', 'Defender', 'Midfield'])
category: PositionCategory;
@ApiPropertyOptional({
description: 'Display order',
example: 0,
default: 0,
})
@IsOptional()
@IsInt()
@Min(0)
order?: number;
@ApiPropertyOptional({
description: 'X coordinate for position location',
example: 5,
})
@IsOptional()
@IsInt()
@Min(0)
@Max(100)
locationX?: number;
@ApiPropertyOptional({
description: 'Y coordinate for position location',
example: 50,
})
@IsOptional()
@IsInt()
@Min(0)
@Max(100)
locationY?: number;
@ApiPropertyOptional({
description: 'Background color (hex format)',
example: '#C14B50',
})
@IsOptional()
@IsString()
@Length(7, 7)
bgColor?: string;
@ApiPropertyOptional({
description: 'Text color (hex format)',
example: '#000000',
})
@IsOptional()
@IsString()
@Length(7, 7)
textColor?: string;
@ApiPropertyOptional({
description: 'Whether the position is active',
example: true,
default: true,
})
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export * from './create-position.dto';
export * from './update-position.dto';
export * from './query-positions.dto';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class PositionResponseDto {
@ApiProperty({ description: 'Position ID', example: 1 })
id: number;
@ApiProperty({ description: 'Position name', example: 'Center Defender' })
name: string;
@ApiPropertyOptional({
description: '2-character position code',
example: 'CD',
})
code2?: string | null;
@ApiPropertyOptional({
description: '3-character position code',
example: 'CDE',
})
code3?: string | null;
@ApiProperty({
description: 'Position category',
enum: ['Forward', 'Goalkeeper', 'Defender', 'Midfield'],
example: 'Defender',
})
category: 'Forward' | 'Goalkeeper' | 'Defender' | 'Midfield';
@ApiPropertyOptional({ description: 'Display order', example: 0 })
order?: number;
@ApiPropertyOptional({ description: 'X coordinate', example: 5 })
locationX?: number | null;
@ApiPropertyOptional({ description: 'Y coordinate', example: 50 })
locationY?: number | null;
@ApiPropertyOptional({
description: 'Background color (hex)',
example: '#C14B50',
})
bgColor?: string | null;
@ApiPropertyOptional({
description: 'Text color (hex)',
example: '#000000',
})
textColor?: string | null;
@ApiProperty({ description: 'Whether position is active', example: true })
isActive: boolean;
@ApiPropertyOptional({
description: 'Created at timestamp',
example: '2024-01-01T00:00:00.000Z',
})
createdAt?: string | null;
@ApiPropertyOptional({
description: 'Updated at timestamp',
example: '2024-01-01T00:00:00.000Z',
})
updatedAt?: string | null;
@ApiPropertyOptional({
description: 'Deleted at timestamp',
example: null,
})
deletedAt?: string | null;
}
import {
IsOptional,
IsString,
IsEnum,
IsBoolean,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { BaseQueryDto } from '../../../common/dto';
export class QueryPositionsDto extends BaseQueryDto {
@ApiPropertyOptional({
description: 'Search positions by name, code2, or code3',
example: 'Defender',
})
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({
description: 'Filter by category',
enum: ['Forward', 'Goalkeeper', 'Defender', 'Midfield'],
example: 'Defender',
})
@IsOptional()
@IsEnum(['Forward', 'Goalkeeper', 'Defender', 'Midfield'])
category?: 'Forward' | 'Goalkeeper' | 'Defender' | 'Midfield';
@ApiPropertyOptional({
description: 'Filter by active status',
example: true,
})
@IsOptional()
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({
description: 'Field to sort by',
enum: ['name', 'code2', 'code3', 'category', 'order', 'createdAt', 'updatedAt'],
example: 'name',
default: 'name',
})
@IsOptional()
@IsEnum(['name', 'code2', 'code3', 'category', 'order', 'createdAt', 'updatedAt'])
sortBy?: string;
}
import { PartialType } from '@nestjs/swagger';
import { CreatePositionDto } from './create-position.dto';
export class UpdatePositionDto extends PartialType(CreatePositionDto) {}
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { PositionsService } from './positions.service';
import { type Position } from '../../database/schema';
import {
ApiBody,
ApiCreatedResponse,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiQuery,
ApiTags,
ApiBadRequestResponse,
} from '@nestjs/swagger';
import { SimplePaginatedResponse } from '../../common/utils/response.util';
import {
CreatePositionDto,
UpdatePositionDto,
QueryPositionsDto,
} from './dto';
import { PositionResponseDto } from './dto/position-response.dto';
@ApiTags('Positions')
@Controller('positions')
export class PositionsController {
constructor(private readonly positionsService: PositionsService) {}
@Post()
@ApiOperation({
summary: 'Create a new position',
description: 'Creates a new position with the specified category, name, and optional metadata.',
})
@ApiBody({
description: 'Position payload',
type: CreatePositionDto,
examples: {
centerDefender: {
summary: 'Center Defender',
description: 'Example: Creating a center defender position',
value: {
name: 'Center Defender',
code2: 'CD',
code3: 'CDE',
category: 'Defender',
order: 0,
locationX: 5,
locationY: 50,
bgColor: '#C14B50',
textColor: '#000000',
isActive: true,
},
},
goalkeeper: {
summary: 'Goalkeeper',
description: 'Example: Creating a goalkeeper position',
value: {
name: 'Goalkeeper',
code2: 'GK',
code3: 'GKP',
category: 'Goalkeeper',
order: 0,
locationX: 5,
locationY: 5,
bgColor: '#FF6B6B',
textColor: '#FFFFFF',
isActive: true,
},
},
centerForward: {
summary: 'Center Forward',
description: 'Example: Creating a center forward position',
value: {
name: 'Center Forward',
code2: 'CF',
code3: 'CFW',
category: 'Forward',
order: 0,
locationX: 5,
locationY: 95,
bgColor: '#4ECDC4',
textColor: '#000000',
isActive: true,
},
},
},
})
@ApiCreatedResponse({
description: 'Position created successfully',
type: PositionResponseDto,
example: {
id: 1,
name: 'Center Defender',
code2: 'CD',
code3: 'CDE',
category: 'Defender',
order: 0,
locationX: 5,
locationY: 50,
bgColor: '#C14B50',
textColor: '#000000',
isActive: true,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
deletedAt: null,
},
})
@ApiBadRequestResponse({
description: 'Invalid input data',
example: {
statusCode: 400,
message: [
'name must be a string',
'category must be one of the following values: Forward, Goalkeeper, Defender, Midfield',
],
error: 'Bad Request',
},
})
async create(@Body() body: CreatePositionDto): Promise<Position> {
return this.positionsService.create(body as any);
}
@Get()
@ApiOperation({
summary: 'List positions with pagination and filtering',
description:
'Search positions by name, code2, or code3. Filter by category and active status. Supports sorting.',
})
@ApiQuery({
name: 'search',
required: false,
type: String,
description: 'Search positions by name, code2, or code3. Optional.',
example: 'Defender',
})
@ApiQuery({
name: 'category',
required: false,
enum: ['Forward', 'Goalkeeper', 'Defender', 'Midfield'],
description: 'Filter by position category. Optional.',
example: 'Defender',
})
@ApiQuery({
name: 'isActive',
required: false,
type: Boolean,
description: 'Filter by active status. Optional.',
example: true,
})
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: 'Number of results per page (default: 50)',
example: 50,
})
@ApiQuery({
name: 'offset',
required: false,
type: Number,
description: 'Number of results to skip (default: 0)',
example: 0,
})
@ApiQuery({
name: 'sortBy',
required: false,
enum: ['name', 'code2', 'code3', 'category', 'order', 'createdAt', 'updatedAt'],
description: 'Field to sort by (default: name)',
example: 'name',
})
@ApiQuery({
name: 'sortOrder',
required: false,
enum: ['asc', 'desc'],
description: 'Sort order - ascending or descending (default: asc)',
example: 'asc',
})
@ApiOkResponse({
description: 'Paginated list of positions',
schema: {
type: 'object',
properties: {
data: {
type: 'array',
items: { $ref: '#/components/schemas/PositionResponseDto' },
example: [
{
id: 1,
name: 'Center Defender',
code2: 'CD',
code3: 'CDE',
category: 'Defender',
order: 0,
locationX: 5,
locationY: 50,
bgColor: '#C14B50',
textColor: '#000000',
isActive: true,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
deletedAt: null,
},
{
id: 2,
name: 'Right Defender',
code2: 'RD',
code3: 'RDE',
category: 'Defender',
order: 1,
locationX: 95,
locationY: 50,
bgColor: '#C14B50',
textColor: '#000000',
isActive: true,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
deletedAt: null,
},
],
},
total: { type: 'number', example: 25 },
limit: { type: 'number', example: 50 },
offset: { type: 'number', example: 0 },
},
},
})
async findAll(
@Query() query: QueryPositionsDto,
): Promise<SimplePaginatedResponse<any>> {
const l = query.limit ?? 50;
const o = query.offset ?? 0;
return this.positionsService.findAll(l, o, query.search, query);
}
@Get(':id')
@ApiOperation({
summary: 'Get position by ID',
description: 'Retrieves a single position by its unique identifier.',
})
@ApiParam({
name: 'id',
type: Number,
description: 'Position ID',
example: 1,
})
@ApiOkResponse({
description: 'Position found',
type: PositionResponseDto,
example: {
id: 1,
name: 'Center Defender',
code2: 'CD',
code3: 'CDE',
category: 'Defender',
order: 0,
locationX: 5,
locationY: 50,
bgColor: '#C14B50',
textColor: '#000000',
isActive: true,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
deletedAt: null,
},
})
@ApiNotFoundResponse({
description: 'Position not found',
example: {
statusCode: 404,
message: 'Position with ID 999 not found',
error: 'Not Found',
},
})
async findById(
@Param('id', ParseIntPipe) id: number,
): Promise<Position | undefined> {
return this.positionsService.findById(id);
}
@Patch(':id')
@ApiOperation({
summary: 'Update position by ID',
description: 'Updates an existing position. Only provided fields will be updated.',
})
@ApiParam({
name: 'id',
type: Number,
description: 'Position ID',
example: 1,
})
@ApiBody({
description: 'Position update payload (all fields are optional)',
type: UpdatePositionDto,
examples: {
updateName: {
summary: 'Update name only',
description: 'Example: Updating just the position name',
value: {
name: 'Central Defender',
},
},
updateColors: {
summary: 'Update colors',
description: 'Example: Updating background and text colors',
value: {
bgColor: '#FF5733',
textColor: '#FFFFFF',
},
},
updateLocation: {
summary: 'Update location',
description: 'Example: Updating position coordinates',
value: {
locationX: 10,
locationY: 55,
},
},
fullUpdate: {
summary: 'Full update',
description: 'Example: Updating multiple fields',
value: {
name: 'Center Back',
code2: 'CB',
code3: 'CBE',
order: 1,
locationX: 5,
locationY: 50,
bgColor: '#C14B50',
textColor: '#FFFFFF',
isActive: true,
},
},
},
})
@ApiOkResponse({
description: 'Position updated successfully',
type: PositionResponseDto,
example: {
id: 1,
name: 'Central Defender',
code2: 'CD',
code3: 'CDE',
category: 'Defender',
order: 0,
locationX: 5,
locationY: 50,
bgColor: '#C14B50',
textColor: '#000000',
isActive: true,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-15T10:30:00.000Z',
deletedAt: null,
},
})
@ApiNotFoundResponse({
description: 'Position not found',
example: {
statusCode: 404,
message: 'Position with ID 999 not found',
error: 'Not Found',
},
})
@ApiBadRequestResponse({
description: 'Invalid input data',
example: {
statusCode: 400,
message: [
'category must be one of the following values: Forward, Goalkeeper, Defender, Midfield',
'locationX must be a number conforming to the specified constraints',
],
error: 'Bad Request',
},
})
async update(
@Param('id', ParseIntPipe) id: number,
@Body() body: UpdatePositionDto,
): Promise<Position> {
return this.positionsService.update(id, body as any);
}
@Delete(':id')
@ApiOperation({
summary: 'Delete position by ID (soft delete)',
description:
'Soft deletes a position by setting the deletedAt timestamp. The position will not appear in normal queries but can be recovered.',
})
@ApiParam({
name: 'id',
type: Number,
description: 'Position ID',
example: 1,
})
@ApiOkResponse({
description: 'Position deleted successfully',
schema: {
type: 'object',
properties: {
message: {
type: 'string',
example: 'Position deleted successfully',
},
},
},
})
@ApiNotFoundResponse({
description: 'Position not found',
example: {
statusCode: 404,
message: 'Position with ID 999 not found',
error: 'Not Found',
},
})
async delete(@Param('id', ParseIntPipe) id: number): Promise<void> {
return this.positionsService.delete(id);
}
}
import { Module } from '@nestjs/common';
import { PositionsController } from './positions.controller';
import { PositionsService } from './positions.service';
@Module({
controllers: [PositionsController],
providers: [PositionsService],
exports: [PositionsService],
})
export class PositionsModule {}
import { Injectable, NotFoundException } from '@nestjs/common';
import { DatabaseService } from '../../database/database.service';
import {
positions,
type NewPosition,
type Position,
} from '../../database/schema';
import {
eq,
count,
and,
or,
ilike,
isNull,
desc,
asc,
inArray,
} from 'drizzle-orm';
import {
ResponseUtil,
SimplePaginatedResponse,
} from '../../common/utils/response.util';
import { QueryPositionsDto } from './dto/query-positions.dto';
@Injectable()
export class PositionsService {
constructor(private readonly databaseService: DatabaseService) {}
/**
* Normalizes a string by removing accents and converting to lowercase
*/
private normalizeString(str: string): string {
return str
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim();
}
/**
* Transform raw database data to match the API structure
*/
private transformPosition(rawPosition: any): any {
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: rawPosition.id ?? 0,
name: rawPosition.name ?? '',
code2: rawPosition.code2 ?? null,
code3: rawPosition.code3 ?? null,
category: rawPosition.category ?? null,
order: rawPosition.order ?? 0,
locationX: rawPosition.locationX ?? rawPosition.location_x ?? null,
locationY: rawPosition.locationY ?? rawPosition.location_y ?? null,
bgColor: rawPosition.bgColor ?? rawPosition.bg_color ?? null,
textColor: rawPosition.textColor ?? rawPosition.text_color ?? null,
isActive: rawPosition.isActive ?? rawPosition.is_active ?? true,
createdAt: rawPosition.createdAt
? formatTimestamp(rawPosition.createdAt) ?? null
: rawPosition.created_at
? formatTimestamp(rawPosition.created_at) ?? null
: null,
updatedAt: rawPosition.updatedAt
? formatTimestamp(rawPosition.updatedAt) ?? null
: rawPosition.updated_at
? formatTimestamp(rawPosition.updated_at) ?? null
: null,
deletedAt: rawPosition.deletedAt
? formatTimestamp(rawPosition.deletedAt) ?? null
: rawPosition.deleted_at
? formatTimestamp(rawPosition.deleted_at) ?? null
: null,
};
}
async findAll(
limit: number = 50,
offset: number = 0,
search?: string,
query?: QueryPositionsDto,
): Promise<SimplePaginatedResponse<any>> {
const db = this.databaseService.getDatabase();
// Build where conditions - exclude soft-deleted records
const baseConditions = isNull(positions.deletedAt);
const conditions: any[] = [baseConditions];
// Category filter
if (query?.category) {
conditions.push(eq(positions.category, query.category));
}
// Active status filter
if (query?.isActive !== undefined) {
conditions.push(eq(positions.isActive, query.isActive));
}
// Search filter
if (search && search.trim()) {
const searchPattern = `%${search.trim()}%`;
const searchConditions = or(
ilike(positions.name, searchPattern),
ilike(positions.code2, searchPattern),
ilike(positions.code3, searchPattern),
) as any;
conditions.push(searchConditions);
}
const whereCondition = and(...conditions) as any;
// Get total count
const [totalResult] = await db
.select({ count: count() })
.from(positions)
.where(whereCondition);
let total = totalResult.count;
// Determine sort field and order
const sortBy = query?.sortBy || 'name';
const sortOrder = query?.sortOrder || 'asc';
let orderByColumn: any = positions.name;
// Map sortBy to actual database column
switch (sortBy) {
case 'name':
orderByColumn = positions.name;
break;
case 'code2':
orderByColumn = positions.code2;
break;
case 'code3':
orderByColumn = positions.code3;
break;
case 'category':
orderByColumn = positions.category;
break;
case 'order':
orderByColumn = positions.order;
break;
case 'createdAt':
orderByColumn = positions.createdAt;
break;
case 'updatedAt':
orderByColumn = positions.updatedAt;
break;
default:
orderByColumn = positions.name;
}
// Get data with sorting
const baseQuery = db
.select()
.from(positions)
.where(whereCondition);
const queryWithOrder =
sortOrder === 'desc'
? baseQuery.orderBy(desc(orderByColumn))
: baseQuery.orderBy(asc(orderByColumn));
// If searching, fetch all matches for normalization filtering
let rawData;
if (!search || !search.trim()) {
rawData = await queryWithOrder.limit(limit).offset(offset);
} else {
rawData = await queryWithOrder;
}
// Transform to match API structure
let transformedData = rawData.map((position) =>
this.transformPosition(position),
);
// If search is provided, apply normalized filtering
if (search && search.trim()) {
const normalizedSearch = this.normalizeString(search);
const filteredData = transformedData.filter((position) => {
const normalizedName = this.normalizeString(position.name || '');
const normalizedCode2 = this.normalizeString(position.code2 || '');
const normalizedCode3 = this.normalizeString(position.code3 || '');
return (
normalizedName.includes(normalizedSearch) ||
normalizedCode2.includes(normalizedSearch) ||
normalizedCode3.includes(normalizedSearch)
);
});
// Apply sorting after filtering
if (query?.sortBy) {
filteredData.sort((a, b) => {
let aValue: any;
let bValue: any;
switch (sortBy) {
case 'name':
aValue = a.name || '';
bValue = b.name || '';
break;
case 'code2':
aValue = a.code2 || '';
bValue = b.code2 || '';
break;
case 'code3':
aValue = a.code3 || '';
bValue = b.code3 || '';
break;
case 'category':
aValue = a.category || '';
bValue = b.category || '';
break;
case 'order':
aValue = a.order || 0;
bValue = b.order || 0;
break;
case 'createdAt':
aValue = a.createdAt ? new Date(a.createdAt).getTime() : 0;
bValue = b.createdAt ? new Date(b.createdAt).getTime() : 0;
break;
case 'updatedAt':
aValue = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
bValue = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
break;
default:
aValue = a.name || '';
bValue = b.name || '';
}
if (typeof aValue === 'string' && typeof bValue === 'string') {
const comparison = aValue.localeCompare(bValue);
return sortOrder === 'asc' ? comparison : -comparison;
}
if (typeof aValue === 'number' && typeof bValue === 'number') {
return sortOrder === 'asc' ? aValue - bValue : bValue - aValue;
}
return 0;
});
}
// Apply limit and offset after filtering and sorting
transformedData = filteredData.slice(offset, offset + limit);
total = filteredData.length;
}
return ResponseUtil.createSimplePaginatedResponse(
transformedData,
total,
limit,
offset,
);
}
async findById(id: number): Promise<Position | undefined> {
const db = this.databaseService.getDatabase();
const rows = await db
.select()
.from(positions)
.where(and(eq(positions.id, id), isNull(positions.deletedAt)));
return rows[0];
}
async create(data: NewPosition): Promise<Position> {
const db = this.databaseService.getDatabase();
// Normalize empty strings to null for nullable fields
const normalizeToNull = (value: unknown): any => {
if (value === '' || value === null || value === undefined) return null;
return value;
};
const transformed: NewPosition = {
name: data.name,
code2: normalizeToNull(data.code2),
code3: normalizeToNull(data.code3),
category: data.category,
order: data.order ?? 0,
locationX: normalizeToNull(data.locationX),
locationY: normalizeToNull(data.locationY),
bgColor: normalizeToNull(data.bgColor),
textColor: normalizeToNull(data.textColor),
isActive: data.isActive ?? true,
};
const [result] = await db.insert(positions).values(transformed).returning();
return result as Position;
}
async update(id: number, data: Partial<NewPosition>): Promise<Position> {
const db = this.databaseService.getDatabase();
// Check if position exists
const existing = await this.findById(id);
if (!existing) {
throw new NotFoundException(`Position with ID ${id} not found`);
}
// Normalize empty strings to null for nullable fields
const normalizeToNull = (value: unknown): any => {
if (value === '' || value === null || value === undefined) return null;
return value;
};
const updateData: Partial<NewPosition> = {
updatedAt: new Date() as any,
};
if (data.name !== undefined) updateData.name = data.name;
if (data.code2 !== undefined) updateData.code2 = normalizeToNull(data.code2);
if (data.code3 !== undefined) updateData.code3 = normalizeToNull(data.code3);
if (data.category !== undefined) updateData.category = data.category;
if (data.order !== undefined) updateData.order = data.order;
if (data.locationX !== undefined)
updateData.locationX = normalizeToNull(data.locationX);
if (data.locationY !== undefined)
updateData.locationY = normalizeToNull(data.locationY);
if (data.bgColor !== undefined)
updateData.bgColor = normalizeToNull(data.bgColor);
if (data.textColor !== undefined)
updateData.textColor = normalizeToNull(data.textColor);
if (data.isActive !== undefined) updateData.isActive = data.isActive;
const [result] = await db
.update(positions)
.set(updateData)
.where(eq(positions.id, id))
.returning();
return result as Position;
}
async delete(id: number): Promise<void> {
const db = this.databaseService.getDatabase();
// Check if position exists
const existing = await this.findById(id);
if (!existing) {
throw new NotFoundException(`Position with ID ${id} not found`);
}
// Soft delete
await db
.update(positions)
.set({ deletedAt: new Date() as any })
.where(eq(positions.id, id));
}
}
......@@ -10,6 +10,7 @@ import {
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { BaseQueryDto } from '../../../common/dto';
export enum ReportEntityType {
PLAYER = 'player',
......@@ -17,7 +18,7 @@ export enum ReportEntityType {
MATCH = 'match',
}
export class QueryReportsDto {
export class QueryReportsDto extends BaseQueryDto {
@ApiPropertyOptional({
description: 'Filter by entity type (player, coach, or match)',
enum: ReportEntityType,
......@@ -169,28 +170,13 @@ export class QueryReportsDto {
updatedTo?: string;
@ApiPropertyOptional({
description: 'Number of results to return',
type: Number,
example: 50,
default: 50,
description: 'Field to sort by',
enum: ['name', 'type', 'status', 'grade', 'rating', 'createdAt', 'updatedAt'],
example: 'createdAt',
default: 'createdAt',
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(1000)
limit?: number;
@ApiPropertyOptional({
description: 'Number of results to skip',
type: Number,
example: 0,
default: 0,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
offset?: number;
@IsEnum(['name', 'type', 'status', 'grade', 'rating', 'createdAt', 'updatedAt'])
sortBy?: string;
}
......@@ -189,6 +189,18 @@ export class ReportsController {
type: Number,
description: 'Number of results to skip (default: 0)',
})
@ApiQuery({
name: 'sortBy',
required: false,
enum: ['name', 'type', 'status', 'grade', 'rating', 'createdAt', 'updatedAt'],
description: 'Field to sort by (default: createdAt)',
})
@ApiQuery({
name: 'sortOrder',
required: false,
enum: ['asc', 'desc'],
description: 'Sort order - ascending or descending (default: asc)',
})
@ApiOkResponse({ description: 'List of reports matching the filters' })
async list(@Query() query: QueryReportsDto): Promise<Report[]> {
return this.reportsService.findAll(query);
......
import { Injectable, Logger } from '@nestjs/common';
import { DatabaseService } from '../../database/database.service';
import { reports, type NewReport, type Report } from '../../database/schema';
import { eq, and, ilike, gte, lte, isNotNull, desc, sql } from 'drizzle-orm';
import {
eq,
and,
ilike,
gte,
lte,
isNotNull,
desc,
asc,
sql,
} from 'drizzle-orm';
import {
DatabaseQueryError,
DatabaseColumnNotFoundError,
......@@ -205,10 +215,44 @@ export class ReportsService {
const queryWithWhere =
conditions.length > 0 ? baseQuery.where(and(...conditions)) : baseQuery;
return await queryWithWhere
.orderBy(desc(reports.createdAt))
.limit(limit)
.offset(offset);
// Determine sort field and order
const sortBy = query.sortBy || 'createdAt';
const sortOrder = query.sortOrder || 'asc';
let orderByColumn: any = reports.createdAt; // Default sort
// Map sortBy to actual database column
switch (sortBy) {
case 'name':
orderByColumn = reports.name;
break;
case 'type':
orderByColumn = reports.type;
break;
case 'status':
orderByColumn = reports.status;
break;
case 'grade':
orderByColumn = reports.grade;
break;
case 'rating':
orderByColumn = reports.rating;
break;
case 'createdAt':
orderByColumn = reports.createdAt;
break;
case 'updatedAt':
orderByColumn = reports.updatedAt;
break;
default:
orderByColumn = reports.createdAt;
}
const queryWithOrder =
sortOrder === 'desc'
? queryWithWhere.orderBy(desc(orderByColumn))
: queryWithWhere.orderBy(asc(orderByColumn));
return await queryWithOrder.limit(limit).offset(offset);
}
// Helper method to normalize report data - use wyIds directly
......
......@@ -2,3 +2,4 @@ export * from './create-user.dto';
export * from './update-user.dto';
export * from './user-response.dto';
export * from './change-password.dto';
export * from './query-users.dto';
import {
IsOptional,
IsString,
IsEnum,
IsBoolean,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { BaseQueryDto } from '../../../common/dto';
export enum UserRole {
SUPERADMIN = 'superadmin',
ADMIN = 'admin',
GROUP_MANAGER = 'groupmanager',
PRESIDENT = 'president',
SPORTS_DIRECTOR = 'sportsdirector',
CHIEF_SCOUT = 'chiefscout',
CHIEF_TRANSFER_MARKET = 'chieftransfermarket',
CHIEF_DATA_ANALYST = 'chiefdataanalyst',
STAFF_TRANSFER_MARKET = 'stafftransfermarket',
STAFF_SCOUT = 'staffscout',
STAFF_DATA_ANALYST = 'staffdataanalyst',
VIEWER = 'viewer',
}
export class QueryUsersDto extends BaseQueryDto {
@ApiPropertyOptional({
description: 'Filter by user role',
enum: UserRole,
example: 'admin',
})
@IsOptional()
@IsEnum(UserRole)
role?: UserRole;
@ApiPropertyOptional({
description: 'Search by user name (case-insensitive partial match)',
example: 'John',
})
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional({
description: 'Search by email (case-insensitive partial match)',
example: 'john@example.com',
})
@IsOptional()
@IsString()
email?: string;
@ApiPropertyOptional({
description: 'Filter by active status',
type: Boolean,
example: true,
})
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({
description: 'Field to sort by',
enum: ['name', 'email', 'role', 'isActive', 'createdAt', 'updatedAt', 'lastLoginAt'],
example: 'name',
default: 'name',
})
@IsOptional()
@IsEnum(['name', 'email', 'role', 'isActive', 'createdAt', 'updatedAt', 'lastLoginAt'])
sortBy?: string;
}
......@@ -11,6 +11,7 @@ import {
HttpStatus,
UseGuards,
Request,
Query,
} from '@nestjs/common';
import {
ApiTags,
......@@ -19,9 +20,10 @@ import {
ApiParam,
ApiBearerAuth,
ApiBody,
ApiQuery,
} from '@nestjs/swagger';
import { UsersService } from './users.service';
import { UpdateUserDto, UserResponseDto } from './dto';
import { UpdateUserDto, UserResponseDto, QueryUsersDto, UserRole } from './dto';
import { JwtSimpleGuard } from '../../common/guards/jwt-simple.guard';
import { RolesGuard } from '../../common/guards/roles.guard';
import { Roles } from '../../common/decorators/roles.decorator';
......@@ -37,15 +39,51 @@ export class UsersController {
@ApiBearerAuth()
@ApiOperation({
summary: 'Get all users',
description: 'Retrieves a list of all users in the system.',
description: 'Retrieves a list of all users in the system with optional filtering by role, name, email, and active status.',
})
@ApiQuery({
name: 'role',
required: false,
enum: UserRole,
description: 'Filter by user role',
})
@ApiQuery({
name: 'name',
required: false,
type: String,
description: 'Search by user name (case-insensitive partial match)',
})
@ApiQuery({
name: 'email',
required: false,
type: String,
description: 'Search by email (case-insensitive partial match)',
})
@ApiQuery({
name: 'isActive',
required: false,
type: Boolean,
description: 'Filter by active status',
})
@ApiQuery({
name: 'sortBy',
required: false,
enum: ['name', 'email', 'role', 'isActive', 'createdAt', 'updatedAt', 'lastLoginAt'],
description: 'Field to sort by (default: name)',
})
@ApiQuery({
name: 'sortOrder',
required: false,
enum: ['asc', 'desc'],
description: 'Sort order - ascending or descending (default: asc)',
})
@ApiResponse({
status: 200,
description: 'List of users retrieved successfully',
type: [UserResponseDto],
})
async findAll(): Promise<UserResponseDto[]> {
return this.usersService.findAll();
async findAll(@Query() query: QueryUsersDto): Promise<UserResponseDto[]> {
return this.usersService.findAll(query);
}
@Get(':id')
......
......@@ -4,10 +4,10 @@ import {
ConflictException,
UnauthorizedException,
} from '@nestjs/common';
import { eq, and, isNull } from 'drizzle-orm';
import { eq, and, isNull, ilike, desc, asc } from 'drizzle-orm';
import { DatabaseService } from '../../database/database.service';
import { users, type User, type NewUser } from '../../database/schema';
import { CreateUserDto, UpdateUserDto, UserResponseDto } from './dto';
import { CreateUserDto, UpdateUserDto, UserResponseDto, QueryUsersDto } from './dto';
import * as bcrypt from 'bcrypt';
@Injectable()
......@@ -49,12 +49,68 @@ export class UsersService {
return this.mapToResponseDto(createdUser);
}
async findAll(): Promise<UserResponseDto[]> {
const allUsers = await this.databaseService
.getDatabase()
.select()
.from(users)
.where(isNull(users.deletedAt));
async findAll(query?: QueryUsersDto): Promise<UserResponseDto[]> {
const db = this.databaseService.getDatabase();
const conditions: any[] = [isNull(users.deletedAt)];
// Filter by role
if (query?.role) {
conditions.push(eq(users.role, query.role));
}
// Search by name (case-insensitive partial match)
if (query?.name) {
conditions.push(ilike(users.name, `%${query.name}%`));
}
// Search by email (case-insensitive partial match)
if (query?.email) {
conditions.push(ilike(users.email, `%${query.email}%`));
}
// Filter by active status
if (query?.isActive !== undefined) {
conditions.push(eq(users.isActive, query.isActive));
}
// Determine sort field and order
const sortBy = query?.sortBy || 'name';
const sortOrder = query?.sortOrder || 'asc';
let orderByColumn: any = users.name; // Default sort
// Map sortBy to actual database column
switch (sortBy) {
case 'name':
orderByColumn = users.name;
break;
case 'email':
orderByColumn = users.email;
break;
case 'role':
orderByColumn = users.role;
break;
case 'isActive':
orderByColumn = users.isActive;
break;
case 'createdAt':
orderByColumn = users.createdAt;
break;
case 'updatedAt':
orderByColumn = users.updatedAt;
break;
case 'lastLoginAt':
orderByColumn = users.lastLoginAt;
break;
default:
orderByColumn = users.name;
}
const baseQuery = db.select().from(users).where(and(...conditions));
const queryWithOrder = sortOrder === 'desc'
? baseQuery.orderBy(desc(orderByColumn))
: baseQuery.orderBy(asc(orderByColumn));
const allUsers = await queryWithOrder;
return allUsers.map((user) => this.mapToResponseDto(user));
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment