Compare commits

..

31 Commits

Author SHA1 Message Date
ff96ef796e chore: commit remaining workspace changes 2026-05-08 21:51:29 +02:00
8d108b8a76 Homepage: add stable intro copy; mark footer utility links data-nosnippet; add render test 2026-05-08 21:51:28 +02:00
6b83d76cd1 SEO: gallery VisualArtwork contentUrl -> single best URL; update gallery unit test 2026-05-08 21:51:28 +02:00
0c5dde9b22 Featured artworks thumbnails 2026-05-06 19:11:31 +02:00
82f2b1f660 Add tests for featured thumbnail generation; apply Pint formatting and related edits 2026-05-06 18:55:40 +02:00
7a8bc8e22a Wire homepage hero to featured thumbnail family; add featured-picture component 2026-05-06 18:55:20 +02:00
8fa3adf4df Add job and artisan command for generating featured thumbnails 2026-05-06 18:55:08 +02:00
bd8a5c14a0 Add FeaturedArtworkThumbnailGenerator and FeaturedArtworkSelector 2026-05-06 18:54:57 +02:00
2c2c0f6722 Add Artwork model convenience methods for featured thumbnails 2026-05-06 18:54:31 +02:00
ee24111d59 Add featured thumbnail config and ArtworkFeaturedImagePath helper 2026-05-06 18:54:18 +02:00
a3cfc6c17f feat(academy): prepare AI Academy v1 for production enablement 2026-05-03 19:59:27 +02:00
90e93f0d42 Fixes 2026-05-03 09:21:13 +02:00
44354e5bea News: normalize category select values; fix Studio news editor category persistence; add CSV→SQL generator and news_dates.sql 2026-05-03 09:12:38 +02:00
a9dfa6ea11 Refine SEO, uploads, and deploy handling 2026-05-02 10:48:08 +02:00
b6be6ed2ac chore: commit regenerated ssr assets 2026-05-02 09:37:28 +02:00
caf1464aa5 chore: commit current workspace changes 2026-05-02 09:37:14 +02:00
79235133f0 Remove obsolete project SQL dump 2026-05-01 11:47:10 +02:00
396712bb3d Sync deploy mirror and upstream error page 2026-05-01 11:46:56 +02:00
18cea8b0f0 Wire admin studio SSR and search infrastructure 2026-05-01 11:46:06 +02:00
257b0dbef6 Build world campaigns rewards and recaps 2026-05-01 11:44:41 +02:00
28e7e46e13 Add news article comments and reactions 2026-05-01 11:43:49 +02:00
874f8feb9c Add homepage announcement module 2026-05-01 11:43:08 +02:00
961d21e91e Optimize anonymous public sessions 2026-05-01 11:42:10 +02:00
35011001ba Replace native selects with NovaSelect 2026-05-01 07:45:37 +02:00
67be537c86 new test files 2026-04-25 08:36:03 +02:00
19d5a9ed3e removed files 2026-04-25 08:35:52 +02:00
c8c7a4d100 cleanup project
Removed unused files
2026-04-25 08:25:04 +02:00
157c6d49e8 Sanitize browse categories HTML output 2026-04-25 08:12:46 +02:00
a0b903f09d UI: use free FontAwesome globe icon for Worlds menu item 2026-04-18 17:13:28 +02:00
87d60af5a9 Save workspace changes 2026-04-18 17:02:56 +02:00
f02ea9a711 Add CLI audit command docs 2026-04-17 07:56:10 +02:00
4956 changed files with 1070423 additions and 7345 deletions

View File

@@ -0,0 +1,310 @@
# API-First Architecture Canonical Rules (SkinBase)
> **This document defines how Copilot must generate API-first code.**
> It applies to **Artworks and all future modules**.
> If generated code conflicts with this file, **this file wins**.
---
## 1. What “API-First” Means (MANDATORY)
API-first means:
* Business logic lives in **Services**
* Controllers are **thin adapters**
* Output is defined by **API Resources**
* Web (Blade), API, Admin all use **the same services**
* No duplicated logic between web & API
Copilot MUST assume:
* Web UI exists (Blade / SSR)
* API exists (JSON)
* Both consume the **same backend logic**
---
## 2. Layered Architecture (STRICT)
Copilot MUST generate code following this flow:
```
Request
→ Controller (Web or API)
→ Service (business rules)
→ Models / Queries
→ Resource (output shape)
```
### Forbidden shortcuts
❌ Controller → Model directly
❌ Controller → DB query
❌ Resource contains logic
❌ Model contains business logic
---
## 3. Directory Structure (REFERENCE)
Copilot MUST follow this structure:
```
app/
├── Http/
│ ├── Controllers/
│ │ ├── Api/
│ │ │ └── ArtworkController.php
│ │ └── Web/
│ │ └── ArtworkController.php
│ │
│ ├── Resources/
│ │ ├── ArtworkResource.php
│ │ ├── ArtworkListResource.php
│ │ └── CategoryResource.php
├── Services/
│ ├── ArtworkService.php
│ ├── ArtworkStatsService.php
│ └── CategoryService.php
├── Policies/
│ └── ArtworkPolicy.php
├── Models/
│ └── Artwork.php
```
---
## 4. Services Layer (CORE)
### 4.1 ArtworkService (MANDATORY)
Copilot MUST generate an `ArtworkService` that handles:
* Fetch public artwork by slug
* Fetch artworks by category
* Apply visibility rules
* Apply soft delete rules
* Throw domain-appropriate exceptions
Example responsibilities (NOT code):
* `getPublicArtworkBySlug(string $slug)`
* `getCategoryArtworks(Category $category)`
* `getLatestArtworks(int $limit)`
### Rules
* Services MUST NOT return JSON
* Services MUST return models or collections
* Services MUST enforce visibility rules
---
## 5. Controllers (ADAPTERS ONLY)
### 5.1 API Controllers
Location:
```
app/Http/Controllers/Api/
```
Rules:
* Return API Resources only
* Never return models directly
* No business logic
* Stateless
Example:
```php
return new ArtworkResource(
$this->service->getPublicArtworkBySlug($slug)
);
```
---
### 5.2 Web Controllers
Location:
```
app/Http/Controllers/Web/
```
Rules:
* Use same services as API
* Prepare data for Blade
* No duplication of logic
* SEO handled here (meta tags, schema)
Example:
```php
return view('artworks.show', [
'artwork' => new ArtworkResource($artwork),
]);
```
---
## 6. API Resources (OUTPUT CONTRACT)
### Rules
Copilot MUST:
* Use Laravel `JsonResource`
* Define explicit fields
* Never expose internal fields accidentally
* Avoid N+1 queries
* Include relations conditionally
### ArtworkResource MUST include:
* slug
* title
* description
* dimensions
* categories
* URLs (canonical)
### ArtworkListResource MUST:
* Be lightweight
* Exclude heavy relations
* Exclude stats unless requested
---
## 7. Routes (SEO-Safe)
### API routes
Location:
```
routes/api.php
```
Rules:
* Stateless
* Slug-based
* Versionable (`/api/v1/...`)
Example:
```
GET /api/v1/artworks/{slug}
GET /api/v1/categories/{slug}/artworks
```
---
### Web routes
Location:
```
routes/web.php
```
Rules:
* Slug-based
* No IDs
* SEO-friendly
* SSR output
---
## 8. Stats Handling (High Load Rule)
Copilot MUST:
* Use `ArtworkStatsService`
* Increment stats via Jobs
* Never mutate counters inline
* Assume Redis may be present
Forbidden:
`$artwork->increment('views')`
❌ Updating stats inside controllers
---
## 9. Caching Rules
Copilot SHOULD assume:
* Redis is available
* Cache keys are service-level
* Resources are cacheable
Examples:
* `artwork:{slug}`
* `category:{slug}:artworks`
Cache invalidation:
* On update
* On delete
* On restore
---
## 10. Error Handling Rules
Copilot MUST:
* Return 404 for missing public content
* Return 410 or 301 for soft-deleted content (if requested)
* Never expose private content via API
---
## 11. Testing Philosophy
Copilot MUST generate tests that:
* Hit API endpoints
* Validate JSON structure
* Test visibility & approval
* Do NOT test Blade HTML
---
## 12. Forbidden Patterns (ABSOLUTE)
❌ Controllers with logic
❌ Models with business rules
❌ Duplicate logic between API & Web
❌ Direct DB queries in controllers
❌ Different rules for API vs Web
---
## 13. Final Instruction (NON-NEGOTIABLE)
> **API is the primary contract.**
> Web UI, Admin UI, Mobile apps are **clients**.
Copilot MUST always ask:
> “Can this logic live in a service?”
If yes → put it there.
---
### ✅ End of API-First Architecture Instructions

View File

@@ -0,0 +1,370 @@
# Artworks Module Canonical Architecture (SkinBase)
> **Authoritative documentation for Copilot AI agent**
> This file defines the **single source of truth** for the `artworks` domain.
> All generated code **MUST follow this document**.
---
## 1. Purpose
The **Artworks module** is the core content system of SkinBase.
It must support:
* high-read traffic (browse/search)
* safe moderation (soft deletes, approvals)
* multilingual content
* SEO-friendly URLs
* scalable statistics
* future extensions (tags, EXIF, search engines)
Legacy tables **must NOT influence new code**.
---
## 2. Core Design Principles (DO NOT VIOLATE)
1. **Single responsibility per table**
2. **No counters on hot tables**
3. **Soft deletes on user-generated content**
4. **No legacy fields**
5. **Slug-based routing only**
6. **FK integrity everywhere**
7. **Indexes optimized for browsing**
8. **Stats updated asynchronously**
---
## 3. Database Schema (Canonical)
### 3.1 `artworks` (CORE TABLE)
**Purpose:**
Stores the authoritative artwork entity.
```sql
artworks
```
**Fields:**
| Field | Type | Notes |
| ------------ | ------------ | ---------------- |
| id | bigint | Primary key |
| user_id | bigint | Owner |
| title | varchar(150) | Default language |
| slug | varchar(160) | UNIQUE, URL |
| description | text | Optional |
| file_name | varchar | Original file |
| file_path | varchar | Storage path |
| file_size | bigint | Bytes |
| mime_type | varchar(64) | e.g. image/jpeg |
| width | int | Pixels |
| height | int | Pixels |
| is_public | boolean | Visibility |
| is_approved | boolean | Moderation |
| published_at | datetime | SEO timing |
| created_at | datetime | |
| updated_at | datetime | |
| deleted_at | datetime | Soft delete |
**Indexes:**
* `UNIQUE(slug)`
* `(is_public, is_approved, published_at)`
* `deleted_at`
---
### 3.2 `artwork_translations`
**Purpose:**
Multilingual titles and descriptions.
```sql
artwork_translations
```
| Field | Type |
| ----------- | -------- |
| artwork_id | FK |
| locale | char(2) |
| title | varchar |
| description | text |
| deleted_at | datetime |
**Rules:**
* One row per `(artwork_id, locale)`
* Default language lives in `artworks`
---
### 3.3 `artwork_stats`
**Purpose:**
High-write counters isolated from core table.
```sql
artwork_stats
```
| Field | Type |
| ------------ | ------- |
| artwork_id | PK + FK |
| views | bigint |
| downloads | bigint |
| favorites | bigint |
| rating_avg | float |
| rating_count | int |
**Rules:**
* NO soft deletes
* Updated via jobs / async
* Never eager-loaded by default
---
### 3.4 `artwork_category`
**Purpose:**
Many-to-many relation with categories.
```sql
artwork_category
```
| Field | Type |
| ----------- | ---- |
| artwork_id | FK |
| category_id | FK |
**Rules:**
* Categories handle hierarchy
* Artworks can belong to multiple categories
---
### 3.5 `artwork_comments`
**Purpose:**
User comments with moderation.
```sql
artwork_comments
```
| Field | Type |
| ----------- | -------- |
| artwork_id | FK |
| user_id | FK |
| content | text |
| is_approved | boolean |
| deleted_at | datetime |
---
### 3.6 `artwork_downloads`
**Purpose:**
Audit log of downloads.
```sql
artwork_downloads
```
| Field | Type |
| ---------- | ---------- |
| artwork_id | FK |
| user_id | nullable |
| ip | binary(16) |
| user_agent | varchar |
| created_at | datetime |
**Rules:**
* Append-only
* No soft deletes
* Used for abuse detection & stats aggregation
---
## 4. Eloquent Models (REQUIRED)
### 4.1 Artwork Model
```php
App\Models\Artwork
```
**Traits:**
* `SoftDeletes`
**Relationships:**
```php
belongsTo(User::class)
hasMany(ArtworkTranslation::class)
hasOne(ArtworkStats::class)
belongsToMany(Category::class)
hasMany(ArtworkComment::class)
hasMany(ArtworkDownload::class)
```
**Required Scopes:**
```php
public function scopePublic($q)
public function scopeApproved($q)
public function scopePublished($q)
```
---
### 4.2 ArtworkTranslation
```php
App\Models\ArtworkTranslation
```
* SoftDeletes
* BelongsTo Artwork
---
### 4.3 ArtworkStats
```php
App\Models\ArtworkStats
```
* No SoftDeletes
* BelongsTo Artwork
---
### 4.4 ArtworkComment
```php
App\Models\ArtworkComment
```
* SoftDeletes
* BelongsTo Artwork
* BelongsTo User
---
### 4.5 ArtworkDownload
```php
App\Models\ArtworkDownload
```
* Append-only
* BelongsTo Artwork
---
## 5. Query Rules (IMPORTANT)
### Public browsing MUST always include:
```sql
WHERE
deleted_at IS NULL
AND is_public = 1
AND is_approved = 1
```
### NEVER:
* eager-load stats on lists
* update counters inline
* expose IDs in URLs
---
## 6. Routing Rules
### Canonical URLs
```
/{content_type}/{category_path}/{artwork_slug}
```
Example:
```
/photography/abstract/dark/night-city
```
### Slug uniqueness is GLOBAL.
---
## 7. Search Rules
* Use MySQL FULLTEXT as fallback
* Prefer external search engines later
* Never search on `file_name` or paths
---
## 8. Caching Rules
* Category listings → Redis
* Homepage feeds → Redis
* Artwork stats → cached
* DB is source of truth
---
## 9. Soft Delete Behavior
| Action | Result |
| ------------------- | ------------------ |
| Soft delete artwork | Hidden from public |
| Restore | Fully restored |
| Force delete | Rare, GDPR only |
SEO:
* Soft-deleted artworks → 410 or 301
---
## 10. Forbidden Patterns (NEVER GENERATE)
❌ Counters on `artworks`
❌ IDs in URLs
❌ Hard deletes
❌ Category logic inside artworks
❌ Mixed language columns
❌ MyISAM
❌ Polymorphic abuse
---
## 11. Future Extensions (Allowed)
Copilot MAY extend with:
* `artwork_tags`
* `artwork_exif`
* `artwork_versions`
* `artwork_reports`
* external search engines
BUT must not modify core tables without migrations.
---
## 12. Final Rule (MANDATORY)
> **If generated code conflicts with this document,
> THIS DOCUMENT WINS.**

View File

@@ -0,0 +1,49 @@
Current DB & Models Analysis — 2026-02-10
Summary
- `content_types` is the master namespace (see screenshot). Rows: e.g. id=1 Photography (slug `photography`), id=2 Wallpapers, id=3 Skins, id=544 Members.
- `categories` references `content_types` through `content_type_id`; hierarchical parent/child relation via `parent_id`.
Observed DB columns (categories)
- id, content_type_id, parent_id, name, slug, description, image, is_active, sort_order, created_at, updated_at, deleted_at
Models verified
- `ContentType` (app/Models/ContentType.php)
- hasMany `categories()` and `rootCategories()`
- uses `slug` for route binding
- Status: OK and aligns with DB
- `Category` (app/Models/Category.php)
- belongsTo `contentType()`
- self-referential `parent()` / `children()` (ordered by `sort_order`, then `name`)
- `descendants()` recursive helper
- `seo()` relation, `artworks()` pivot
- scopes: `active()`, `roots()`
- accessors: `full_slug_path`, `url`, `canonical_url`, `breadcrumbs`
- slug validation enforced in `boot()` (lowercase; only a-z0-9- and dashes)
- Status: OK and consistent with screenshots
Key behaviors and checks
- URL formation: `$category->url` -> `/{content_type.slug}/{category_path}`; canonical URL -> `https://skinbase.org{url}`
- Slug policy: generation with `Str::slug()` + model validation. Do not bypass.
- Ordering: use `children()` (sort_order then name) for deterministic menus.
- Soft deletes: model uses `SoftDeletes`; be explicit when you need trashed categories.
- Eager-loading: `full_slug_path` walks parents — eager-load `parent` (or ancestors) to avoid N+1 when computing multiple paths.
Copilot / Dev rules (short checklist)
- Always look up content types by `slug`, not by numeric ID.
- Use `->roots()->active()->with('children')` for public category lists.
- Use `$category->url` and `$category->canonical_url` for links and canonical tags.
- Maintain slug rules: lowercase, only `a-z0-9-`.
- When reparenting categories, consider invalidating any cached derived paths for descendants.
- Avoid using legacy `artworks_categories` directly in new controllers; create an adapter if you must read old data.
Suggested next steps
- Add a PHPUnit test asserting slug validation and `url` generation for nested categories.
- (Optional) Generate a small ER diagram showing `content_types -> categories -> artwork_category`.
Files referenced
- [app/Models/ContentType.php](app/Models/ContentType.php)
- [app/Models/Category.php](app/Models/Category.php)
If you want, I can now add the PHPUnit test or generate the ER diagram.

View File

@@ -0,0 +1,259 @@
Nice, this is a **perfect use-case for Copilot AI Agent** 👍
Below is a **clean, copy-paste ready Markdown file** you can put straight into your repo (e.g. `.copilot/categories.md`) and tell Copilot to follow.
This is written **explicitly for an AI agent**: clear goals, constraints, SQL, Laravel expectations.
---
````markdown
# SkinBase Category System (NEW SQL Structure)
This document defines the **new category & taxonomy system** for SkinBase.
Copilot AI Agent must follow this structure strictly and MUST NOT reuse legacy logic.
---
## 🎯 Goals
- SEO-friendly URLs (no IDs in public routes)
- Clear separation of content types (Photography, Skins, Wallpapers, etc.)
- Unlimited category nesting
- Laravel-friendly (Eloquent, migrations, relations)
- Ready for sitemap, breadcrumbs, translations
---
## 🚫 Legacy System (DO NOT USE)
The old table `artworks_categories` is deprecated.
DO NOT:
- use `section_id`
- use `rootid`
- use `num_artworks`
- expose IDs in URLs
- infer hierarchy from numeric hacks
---
## ✅ New Database Structure
### 1⃣ content_types
Top-level sections (URL level 1)
```sql
CREATE TABLE content_types (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(64) NOT NULL,
slug VARCHAR(64) NOT NULL UNIQUE,
description TEXT NULL,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL
) ENGINE=InnoDB;
````
Examples:
* Photography → `photography`
* Skins → `skins`
* Wallpapers → `wallpapers`
Used in URLs as:
```
/photography
/skins
/wallpapers
```
---
### 2⃣ categories
Hierarchical categories (unlimited depth)
```sql
CREATE TABLE categories (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
content_type_id INT UNSIGNED NOT NULL,
parent_id INT UNSIGNED NULL,
name VARCHAR(128) NOT NULL,
slug VARCHAR(128) NOT NULL,
description TEXT NULL,
image VARCHAR(255) NULL,
is_active BOOLEAN DEFAULT TRUE,
sort_order INT DEFAULT 0,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
UNIQUE KEY uniq_category_slug (content_type_id, slug),
FOREIGN KEY (content_type_id) REFERENCES content_types(id),
FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE CASCADE
) ENGINE=InnoDB;
```
Hierarchy example:
```
Photography
├── Abstract
├── Comic
└── Dark
```
---
### 3⃣ artwork_category (pivot)
```sql
CREATE TABLE artwork_category (
artwork_id INT UNSIGNED NOT NULL,
category_id INT UNSIGNED NOT NULL,
PRIMARY KEY (artwork_id, category_id),
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
) ENGINE=InnoDB;
```
---
## 🧭 Routing Rules (MANDATORY)
Public URLs MUST use slugs only.
### Category pages
```
/{content_type}
/{content_type}/{category}
/{content_type}/{parent-category}/{category}
```
Examples:
```
/photography
/photography/abstract
/skins/desktop-shell
```
### Artwork pages
```
/photography/abstract/fresh-red-apple
/skins/darkstep/minimal-dark-pro
```
❌ Never expose numeric IDs in URLs.
---
## 🧠 Laravel Eloquent Relations
Copilot MUST generate models with these relations:
```php
ContentType hasMany Categories
Category belongsTo ContentType
Category hasMany Categories (children)
Category belongsTo Category (parent)
Artwork belongsToMany Categories
```
---
## 🔍 Querying Logic
Lookup must be done by slug, not ID.
Example:
```php
$contentType = ContentType::whereSlug($type)->firstOrFail();
$category = Category::whereSlug($slug)
->where('content_type_id', $contentType->id)
->firstOrFail();
```
---
## 📈 SEO Rules
* One canonical URL per category
* Breadcrumbs must reflect hierarchy
* Pagination via `?page=X`
* Filters MUST use query params and be `noindex`
---
## 📦 Migration
Legacy data has already been converted.
Migration SQL file:
```
/mnt/data/skinbase_categories_migration.sql
```
Copilot must NOT regenerate category data.
---
## ❗ Hard Rules for AI Agent
* DO NOT invent new tables
* DO NOT mix legacy and new schema
* DO NOT store counters in categories
* DO NOT assume only 2 levels of depth
* ALWAYS use slugs
---
## ✅ Result Expectation
After integration:
* SEO-clean URLs
* Stable taxonomy
* Easy sitemap generation
* Future-ready for translations
END OF SPEC
```
---
## ✅ How to use this
1. Save as:
```
.copilot/skinbase-categories.md
```
2. In VS Code Copilot Chat:
```
Use .copilot/skinbase-categories.md as the authoritative spec.
Integrate category system accordingly.
```
---
If you want next, I can:
- ✍️ generate **Laravel migrations**
- 🧠 generate **Eloquent models**
- 🧭 generate **routes + controllers**
- 🔁 generate **301 redirect mapping**
- 🗺 generate **XML sitemap logic**
Just tell me what Copilot should build next 🚀
```

View File

@@ -0,0 +1,181 @@
# 1⃣ Final SQL New Favorites Table (Artwork-only)
**Table name:** `user_favorites`
**Scope:** users ↔ artworks
**Engine:** InnoDB
**Charset:** utf8mb4
```sql
CREATE TABLE user_favorites (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
artwork_id BIGINT UNSIGNED NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_user_artwork (user_id, artwork_id),
KEY idx_artwork (artwork_id),
KEY idx_user_created (user_id, created_at),
CONSTRAINT fk_user_favorites_user
FOREIGN KEY (user_id) REFERENCES users(id)
ON DELETE CASCADE,
CONSTRAINT fk_user_favorites_artwork
FOREIGN KEY (artwork_id) REFERENCES artworks(id)
ON DELETE CASCADE
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci;
```
This is **production-ready** and matches everything we discussed:
* no legacy fields
* no duplication
* proper constraints
* fast queries
---
# 2⃣ VS Code Agent Markdown (Laravel 12 Migration Prompt)
Save this as for example:
```
.vscode/agents/laravel-user-favorites-migration.md
```
or
```
.cursor/rules/laravel-user-favorites.md
```
---
```markdown
# Laravel 12 User Favorites Migration & Model
## Context
We are migrating legacy "favourites" functionality into a clean, modern Laravel 12 system.
Each user can add artworks to their favorites list.
This is a many-to-many relationship between users and artworks.
Legacy table MUST NOT be reused.
---
## Goal
Create a Laravel 12 database migration and Eloquent model for a new table named:
```
user_favorites
````
This table stores **which user favorited which artwork**, with a timestamp.
---
## Database Requirements
### Table: user_favorites
| Column | Type | Notes |
|------|------|------|
| id | BIGINT UNSIGNED | Primary key |
| user_id | BIGINT UNSIGNED | FK → users.id |
| artwork_id | BIGINT UNSIGNED | FK → artworks.id |
| created_at | TIMESTAMP | When artwork was favorited |
### Constraints & Indexes
- UNIQUE (user_id, artwork_id)
→ prevents duplicate favorites
- INDEX artwork_id
→ fast favorite count per artwork
- INDEX (user_id, created_at)
→ fast "my favorites" queries
### Foreign Keys
- user_id → users.id (ON DELETE CASCADE)
- artwork_id → artworks.id (ON DELETE CASCADE)
### Engine & Charset
- Engine: InnoDB
- Charset: utf8mb4
- Collation: utf8mb4_unicode_ci
---
## Laravel Migration Requirements
- Use `Schema::create`
- Use `foreignId()->constrained()->cascadeOnDelete()`
- Use `timestamps()` **ONLY if created_at is needed**
(do NOT add updated_at)
- Add explicit indexes and unique constraints
---
## Laravel Model Requirements
### Model: UserFavorite
- Table: `user_favorites`
- `$timestamps = false` (created_at handled manually or via DB default)
- Fillable:
- user_id
- artwork_id
- created_at
### Relationships
```php
UserFavorite belongsTo User
UserFavorite belongsTo Artwork
````
---
## Additional Notes
* This table is interaction-based, NOT content-based
* Do NOT store favorite counts here
* Favorite counts will be aggregated separately (Redis or statistics table)
* This table must be lean and write-optimized
---
## Deliverables
* Migration file for Laravel 12
* Eloquent model `UserFavorite`
* Proper naming and clean schema
* No legacy fields, no polymorphic logic
Generate clean, production-quality code.
````
---
## 3⃣ How to Use This in VS Code (Quick Steps)
1. Paste markdown into `.vscode/agents/` or `.cursor/rules/`
2. Open VS Code
3. Ask your AI agent:
> “Create Laravel 12 migration and model based on this document”
4. Review generated migration
5. Run:
```bash
php artisan migrate
````

View File

@@ -0,0 +1,293 @@
# Copilot Instructions Artworks Module (SkinBase)
> **This file defines HOW Copilot must generate code.**
> It is a strict instruction set.
> If there is a conflict between generated code and these rules,
> **these rules override everything.**
---
## 1. Global Rules (MANDATORY)
Copilot MUST:
* Target **Laravel 12**
* Use **PHP 8.3+ syntax**
* Follow **Eloquent best practices**
* Respect **SoftDeletes**
* Respect **FK relationships**
* Generate **clean, readable, maintainable code**
Copilot MUST NOT:
* Reference legacy tables (`wallz`, old categories, old views)
* Generate MyISAM tables
* Generate hard deletes for user content
* Put counters on hot tables
* Use IDs in URLs
* Mix responsibilities across models
---
## 2. Authoritative Schema Reference
The canonical schema for artworks is defined in:
```
.copilot/artworks.md
```
Copilot MUST:
* Read this file before generating any code
* Match table names, columns, relations exactly
* Never invent fields or tables unless explicitly allowed
---
## 3. Models Generation Rules
When generating Eloquent models:
### Required models
Copilot MUST generate:
* `App\Models\Artwork`
* `App\Models\ArtworkTranslation`
* `App\Models\ArtworkStats`
* `App\Models\ArtworkComment`
* `App\Models\ArtworkDownload`
### Model requirements
Each model MUST:
* Declare `$fillable`
* Define all relationships
* Use `SoftDeletes` **only when allowed**
* Include PHPDoc blocks for relations
* Use type-hinted return values
### Forbidden
Copilot MUST NOT:
* Add business logic into models
* Perform stat mutations in models
* Use observers unless explicitly requested
---
## 4. Relationships (STRICT)
Copilot MUST implement these exact relations:
### Artwork
```php
belongsTo(User::class)
hasMany(ArtworkTranslation::class)
hasOne(ArtworkStats::class)
belongsToMany(Category::class)
hasMany(ArtworkComment::class)
hasMany(ArtworkDownload::class)
```
### ArtworkTranslation
```php
belongsTo(Artwork::class)
```
### ArtworkStats
```php
belongsTo(Artwork::class)
```
### ArtworkComment
```php
belongsTo(Artwork::class)
belongsTo(User::class)
```
### ArtworkDownload
```php
belongsTo(Artwork::class)
```
---
## 5. Query & Scope Rules
Copilot MUST define these scopes on `Artwork`:
```php
scopePublic($query)
scopeApproved($query)
scopePublished($query)
```
Public queries MUST always include:
```php
deleted_at IS NULL
is_public = true
is_approved = true
```
Copilot MUST NOT:
* Eager-load stats in list views
* Use `offset` pagination for feeds
* Load unnecessary relations by default
---
## 6. Controller Generation Rules
When generating controllers:
Copilot MUST:
* Use **thin controllers**
* Delegate logic to services/actions if needed
* Validate input using Form Requests
* Use route-model binding with `slug`
* Handle soft-deleted content properly
Copilot MUST NOT:
* Query raw DB tables directly
* Bypass scopes
* Return unfiltered content
---
## 7. Routing Rules
Routes MUST:
* Use **slug-based routing**
* Never expose numeric IDs
* Respect category hierarchy
* Be SEO-friendly
Example (valid):
```
/photography/abstract/dark/night-city
```
Invalid:
```
/artwork/123
```
---
## 8. Soft Delete Rules
Copilot MUST:
* Use `delete()` (soft delete) for user content
* Use `restore()` for recovery
* Use `forceDelete()` **only** when explicitly requested
When content is soft-deleted:
* It must disappear from public browsing
* It must remain accessible to admins
---
## 9. Stats & High-Load Rules
Copilot MUST:
* Treat stats as **derived data**
* Update stats via jobs / services
* Never increment counters inline in controllers
* Assume Redis may be present
Copilot MUST NOT:
* Store counters on `artworks`
* Use `increment()` directly on hot rows
---
## 10. Search Rules
Copilot MAY:
* Use MySQL FULLTEXT
* Use external search engines (if requested)
Copilot MUST NOT:
* Search file paths
* Search binary metadata
* Assume Elasticsearch exists unless specified
---
## 11. Forbidden Patterns (NEVER GENERATE)
❌ Hard deletes on artworks
❌ Legacy column names
❌ Polymorphic abuse
❌ Fat controllers
❌ Magic numbers
❌ Inline SQL in controllers
❌ Business logic in migrations
---
## 12. Extension Rules
Copilot MAY generate new features ONLY if:
* They do not modify core tables
* They follow the same architectural principles
* They are isolated in new tables/services
Allowed examples:
* tags
* EXIF metadata
* versioning
* reporting/flagging
* API resources
---
## 13. Error Handling
Copilot MUST:
* Throw meaningful exceptions
* Return proper HTTP codes
* Use 404 for missing public content
* Use 410 or 301 for deleted content (if requested)
---
## 14. Final Instruction (ABSOLUTE)
> **Copilot must treat this file and `artworks.md`
> as non-negotiable contracts.**
If unsure:
* ask for clarification
* do NOT guess
* do NOT invent schema
---
### ✅ End of Copilot Instructions

View File

@@ -0,0 +1,816 @@
# SkinBase Legacy Users Migration to Laravel Auth (Authoritative Spec)
This document is the **single source of truth** for migrating users from the legacy SkinBase database (`projekti_old_skinbase`) into the new Laravel (SkinBase 2026) application using modern authentication.
It covers:
- ✅ Target data model (Laravel-friendly)
- ✅ What to keep / what to drop from legacy
- ✅ Exact Laravel migrations
- ✅ Import SQL + migration order
- ✅ Old password compatibility (if possible)
- ✅ First-login password reset flow (recommended)
- ✅ Validation SQL & sanity checks
- ✅ Role-based access control (RBAC)
> **Copilot AI Agent instructions:**
> Follow this document strictly. Do not invent additional fields/tables unless explicitly allowed here.
---
## 0) Context: Legacy tables (what were migrating)
Legacy schema relevant for users:
### `users` (MyISAM, mixed responsibilities)
- `user_id` (PK)
- `uname` (username)
- `password` (varchar 80) legacy hash or plaintext (unknown)
- `password2` (varchar 255) sometimes present
- `email`
- `real_name`
- `web`
- `birth`, `gender`, `country`, `country_code`, `lang`
- `picture`, `cover_art`
- `signature`, `about_me`, `description`
- `LastVisit`, `joinDate`
- `user_type` (membership / level)
- `active`, `authorized`
- many legacy preferences and obsolete fields (ICQ etc.)
### `users_data` (mostly duplicate/overlap)
This is redundant and will NOT be kept as-is.
### `users_statistics`
Useful but not auth-related; will migrate to `user_statistics`.
### `users_types`
Legacy user “levels”. Well map to modern roles.
---
## 1) Migration Goals
### Authentication goals
- Use **Laravel default authentication** (Breeze/Fortify/Jetstream-compatible).
- Allow login via:
- username OR email
- Preserve user accounts with minimal friction.
- Handle legacy password format safely:
- Prefer secure migration with password reset
- Optionally support legacy hash verification if algorithm is known
### Data goals
- Keep IDs stable where reasonable (`user_id``users.id`) to simplify future migrations.
- Move non-auth profile data into a dedicated profile table.
- Remove obsolete fields (ICQ etc.) and replace with modern social links.
### Security goals
- Do not store weak hashes long-term.
- If legacy password verification is implemented, rehash to bcrypt/argon immediately upon successful login.
- Default to forcing password reset if legacy hash format is unknown.
---
## 2) Target Database Design (New System)
### 2.1 `users` (Auth + identity only)
**Keep it clean.** This table should contain only identity/auth/security-critical fields.
Fields:
- `id` (BIGINT)
- `username` (unique)
- `name`
- `email` (unique)
- `password` (bcrypt/argon hash)
- `email_verified_at`
- `remember_token`
- `is_active` (legacy `active`)
- `needs_password_reset` (new)
- `role` (simple RBAC) OR use roles table/spatie later
- timestamps
### 2.2 `user_profiles` (Profile data)
- bio/about, avatar, cover image
- country + language + birthdate + gender
- website
- timestamps
### 2.3 `user_social_links` (modern social replacement)
Instead of ICQ, store dynamic social platforms:
- github, twitter/x, instagram, youtube, discord, website, etc.
### 2.4 `user_statistics` (optional but useful)
Migrated from legacy `users_statistics`.
---
## 3) What to Remove / Replace
### Remove (obsolete / not used / legacy UI junk)
- `icq` (obsolete)
- `zone`
- `numboard`, `NumStats`, `numskin`, `section_style`
- `menu`
- `eicon`
- `mlist`
- various “board/menu” preferences that no longer exist
### Keep / migrate
- username, email, name
- last visit (optional)
- active/authorized → `is_active` + `email_verified_at` strategy
- about/bio
- avatar/cover
- country/language/gender/birthdate
- website
- statistics (optional)
### Replace ICQ with social links
Add `user_social_links` table.
---
## 4) Role Mapping (Legacy `users_types` → Modern RBAC)
Legacy:
- `users.user_type` references `users_types.id`
New (simple approach):
- store a string `role` directly in `users.role`:
- `user`
- `moderator`
- `admin`
Mapping recommendation (adjust if your legacy meaning differs):
- `user_type` <= 0 → `user`
- `user_type` in [1..X] with “moderator” meaning → `moderator`
- special admin IDs → `admin`
> If you later need granular permissions, adopt **spatie/laravel-permission**.
> For now, keep it simple.
---
## 5) Exact Laravel Migrations (Copy/Paste)
> These migrations are authoritative. Put them in `database/migrations/` in this order.
### 5.1 Create/extend `users` table
If you already have Laravels default `users` migration, create a new migration to **modify** it.
**Migration: `2026_02_01_000010_update_users_table_for_skinbase.php`**
```php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
// Ensure big integer id is used in your app; Laravel default is bigIncrements already.
// Add username for legacy uname
if (!Schema::hasColumn('users', 'username')) {
$table->string('username', 80)->nullable()->unique()->after('id');
}
// If name exists, keep it. Ensure nullable for legacy.
if (!Schema::hasColumn('users', 'name')) {
$table->string('name')->nullable();
} else {
$table->string('name')->nullable()->change();
}
// Email is important; legacy might have duplicates/NULLs -> handle in import script carefully.
if (Schema::hasColumn('users', 'email')) {
$table->string('email')->nullable()->change();
}
if (!Schema::hasColumn('users', 'is_active')) {
$table->boolean('is_active')->default(true)->after('remember_token');
}
if (!Schema::hasColumn('users', 'needs_password_reset')) {
$table->boolean('needs_password_reset')->default(true)->after('is_active');
}
if (!Schema::hasColumn('users', 'role')) {
$table->string('role', 32)->default('user')->after('needs_password_reset');
}
// Optional: store legacy hash algorithm marker (only if doing compat)
if (!Schema::hasColumn('users', 'legacy_password_algo')) {
$table->string('legacy_password_algo', 32)->nullable()->after('role');
}
// Optional: store legacy last visit
if (!Schema::hasColumn('users', 'last_visit_at')) {
$table->timestamp('last_visit_at')->nullable()->after('legacy_password_algo');
}
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
if (Schema::hasColumn('users', 'username')) $table->dropColumn('username');
if (Schema::hasColumn('users', 'is_active')) $table->dropColumn('is_active');
if (Schema::hasColumn('users', 'needs_password_reset')) $table->dropColumn('needs_password_reset');
if (Schema::hasColumn('users', 'role')) $table->dropColumn('role');
if (Schema::hasColumn('users', 'legacy_password_algo')) $table->dropColumn('legacy_password_algo');
if (Schema::hasColumn('users', 'last_visit_at')) $table->dropColumn('last_visit_at');
});
}
};
````
---
### 5.2 Create `user_profiles`
**Migration: `2026_02_01_000020_create_user_profiles_table.php`**
```php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('user_profiles', function (Blueprint $table) {
$table->unsignedBigInteger('user_id')->primary();
$table->text('bio')->nullable();
$table->string('avatar', 255)->nullable();
$table->string('cover_image', 255)->nullable();
$table->string('country', 80)->nullable();
$table->char('country_code', 2)->nullable(); // normalize to ISO-3166-1 alpha-2
$table->string('language', 10)->nullable();
$table->date('birthdate')->nullable();
$table->enum('gender', ['M','F','X'])->default('X');
$table->string('website', 255)->nullable();
$table->timestamps();
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
});
}
public function down(): void
{
Schema::dropIfExists('user_profiles');
}
};
```
---
### 5.3 Create `user_social_links`
**Migration: `2026_02_01_000030_create_user_social_links_table.php`**
```php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('user_social_links', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id');
$table->string('platform', 32); // e.g. github, twitter, instagram, youtube, discord, website
$table->string('url', 255);
$table->timestamps();
$table->unique(['user_id', 'platform']);
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
});
}
public function down(): void
{
Schema::dropIfExists('user_social_links');
}
};
```
---
### 5.4 Create `user_statistics` (optional)
**Migration: `2026_02_01_000040_create_user_statistics_table.php`**
```php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('user_statistics', function (Blueprint $table) {
$table->unsignedBigInteger('user_id')->primary();
$table->unsignedInteger('uploads')->default(0);
$table->unsignedInteger('downloads')->default(0);
$table->unsignedInteger('pageviews')->default(0);
$table->unsignedInteger('awards')->default(0);
$table->timestamps();
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
});
}
public function down(): void
{
Schema::dropIfExists('user_statistics');
}
};
```
---
## 6) Auth Logic: Old Password Compatibility (Two Approaches)
### Approach A (Recommended): Force Reset for Everyone
**Safest**, because legacy hashing is unknown.
* Import users with a random password hash (or keep legacy hash in `password` temporarily but do not allow it).
* Set `needs_password_reset = 1`
* On login attempt, require reset.
This avoids accepting weak hashes forever.
### Approach B (Optional): Support legacy hash on first login, then rehash
Only do this if you can identify the algorithm used in legacy `password` / `password2`.
Common old hashes:
* MD5: 32 hex chars
* SHA1: 40 hex chars
* bcrypt: starts with `$2y$` or `$2a$`
* phpBB style or custom salts: unknown
#### Detection hints (informational)
* If legacy `password` length is 32 and is hex → probably MD5
* If length is 40 hex → probably SHA1
* If starts with `$2y$` → already bcrypt
> **Action requirement:** Before implementing compat, confirm by checking a known user password against a sample hash.
---
## 7) Implementation: Login via Username OR Email
### 7.1 Breeze/Fortify login request logic
In your login controller (or Fortify authentication callback), accept a single input field:
* `login` (username or email)
* `password`
Example lookup:
```php
$user = User::query()
->where('email', $login)
->orWhere('username', $login)
->first();
```
---
## 8) Implementation: Legacy Password Compatibility (If enabled)
### 8.1 Add a service: `app/Support/LegacyPassword.php`
```php
<?php
namespace App\Support;
class LegacyPassword
{
public static function detectAlgo(?string $hash): ?string
{
if (!$hash) return null;
if (str_starts_with($hash, '$2y$') || str_starts_with($hash, '$2a$')) return 'bcrypt';
if (preg_match('/^[a-f0-9]{32}$/i', $hash)) return 'md5';
if (preg_match('/^[a-f0-9]{40}$/i', $hash)) return 'sha1';
return null; // unknown
}
public static function verify(string $plain, string $legacyHash, string $algo): bool
{
return match ($algo) {
'md5' => md5($plain) === $legacyHash,
'sha1' => sha1($plain) === $legacyHash,
'bcrypt' => password_verify($plain, $legacyHash),
default => false
};
}
}
```
### 8.2 Modify authentication (pseudo-code)
On login:
1. Try normal Laravel hash check (`Hash::check`)
2. If fails AND `legacy_password_algo` is present (or detected), try legacy verify
3. If legacy verify passes:
* set new password using `Hash::make($plain)`
* set `needs_password_reset = 0`
* clear `legacy_password_algo`
Example snippet:
```php
use Illuminate\Support\Facades\Hash;
use App\Support\LegacyPassword;
if (Hash::check($password, $user->password)) {
// ok
} else {
$algo = $user->legacy_password_algo ?: LegacyPassword::detectAlgo($user->password);
if ($algo && LegacyPassword::verify($password, $user->password, $algo)) {
$user->password = Hash::make($password);
$user->needs_password_reset = false;
$user->legacy_password_algo = null;
$user->save();
} else {
// invalid credentials
}
}
```
> If `detectAlgo()` returns null, do NOT allow login: require password reset via email.
---
## 9) First-login Password Reset Flow (Recommended & Secure)
### Requirements
* If `needs_password_reset = 1`, user must reset password before accessing account.
* This can be enforced via middleware.
### 9.1 Middleware: `EnsurePasswordResetCompleted`
Create: `app/Http/Middleware/EnsurePasswordResetCompleted.php`
```php
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class EnsurePasswordResetCompleted
{
public function handle(Request $request, Closure $next)
{
$user = $request->user();
if ($user && $user->needs_password_reset) {
if (!$request->routeIs('password.reset.*')) {
return redirect()->route('password.reset.notice');
}
}
return $next($request);
}
}
```
### 9.2 Routes for reset notice & flow
Add routes:
* `password.reset.notice` → show "You must reset your password"
* Use Laravels standard password reset email flow
### 9.3 UX recommendation
* When migrating, send optional mass email campaign:
* “SkinBase upgraded set your new password”
* But dont require sending all at once; users can request reset when needed.
---
## 10) Migration / Import Process (Recommended Order)
### Step 1: Ensure old DB is accessible
Options:
* Import old DB into same MySQL server
* Or create a read-only connection in Laravel (`config/database.php`) to `projekti_old_skinbase`
### Step 2: Run migrations for new schema
```bash
php artisan migrate
```
### Step 3: Import users
We keep `user_id` as `users.id` to preserve identity mapping.
---
## 11) Import SQL (Base migration)
> These SQL examples assume both databases are on the same MySQL server.
> Adjust database names as needed.
### 11.1 Insert into `users`
**Important rules:**
* Some users may have NULL/duplicate emails → handle safely.
* Username should be unique. If duplicates exist, add suffix.
Recommended initial import (conservative):
* Keep legacy hash in `password` temporarily
* Mark `needs_password_reset = 1`
* Set `legacy_password_algo` if detectable
```sql
INSERT INTO users (id, username, name, email, password, is_active, needs_password_reset, role, legacy_password_algo, last_visit_at, created_at, updated_at)
SELECT
u.user_id AS id,
NULLIF(u.uname, '') AS username,
NULLIF(u.real_name, '') AS name,
NULLIF(u.email, '') AS email,
COALESCE(NULLIF(u.password2, ''), NULLIF(u.password, ''), '') AS password,
CASE WHEN u.active = 1 THEN 1 ELSE 0 END AS is_active,
1 AS needs_password_reset,
'user' AS role,
NULL AS legacy_password_algo,
u.LastVisit AS last_visit_at,
u.joinDate AS created_at,
NOW() AS updated_at
FROM projekti_old_skinbase.users u;
```
> After import, you can populate `legacy_password_algo` using detection rules if you want compat.
Example:
```sql
UPDATE users
SET legacy_password_algo =
CASE
WHEN password LIKE '$2y$%' THEN 'bcrypt'
WHEN password REGEXP '^[a-f0-9]{32}$' THEN 'md5'
WHEN password REGEXP '^[a-f0-9]{40}$' THEN 'sha1'
ELSE NULL
END
WHERE legacy_password_algo IS NULL;
```
---
### 11.2 Insert into `user_profiles`
```sql
INSERT INTO user_profiles (user_id, bio, avatar, cover_image, country, country_code, language, birthdate, gender, website, created_at, updated_at)
SELECT
u.user_id,
NULLIF(u.about_me, '') AS bio,
NULLIF(u.picture, '') AS avatar,
NULLIF(u.cover_art, '') AS cover_image,
NULLIF(u.country, '') AS country,
NULLIF(LEFT(u.country_code, 2), '') AS country_code,
NULLIF(u.lang, '') AS language,
u.birth AS birthdate,
COALESCE(u.gender, 'X') AS gender,
NULLIF(u.web, '') AS website,
NOW(),
NOW()
FROM projekti_old_skinbase.users u
WHERE u.user_id IS NOT NULL;
```
---
### 11.3 Social links (only website initially)
Optionally insert website into social links (if you prefer everything in one place):
```sql
INSERT INTO user_social_links (user_id, platform, url, created_at, updated_at)
SELECT
u.user_id,
'website',
u.web,
NOW(),
NOW()
FROM projekti_old_skinbase.users u
WHERE u.web IS NOT NULL AND u.web <> '';
```
---
### 11.4 Statistics
```sql
INSERT INTO user_statistics (user_id, uploads, downloads, pageviews, awards, created_at, updated_at)
SELECT
s.user_id,
s.uploads,
s.downloads,
s.pageviews,
s.awards,
NOW(),
NOW()
FROM projekti_old_skinbase.users_statistics s;
```
---
## 12) Migration Validation SQL (Sanity Checks)
### 12.1 Count parity
```sql
SELECT
(SELECT COUNT(*) FROM projekti_old_skinbase.users) AS old_users,
(SELECT COUNT(*) FROM users) AS new_users;
```
### 12.2 Missing usernames
```sql
SELECT id, email
FROM users
WHERE username IS NULL OR username = '';
```
### 12.3 Duplicate usernames
```sql
SELECT username, COUNT(*) c
FROM users
WHERE username IS NOT NULL AND username <> ''
GROUP BY username
HAVING c > 1;
```
### 12.4 Duplicate emails
```sql
SELECT email, COUNT(*) c
FROM users
WHERE email IS NOT NULL AND email <> ''
GROUP BY email
HAVING c > 1;
```
### 12.5 Orphaned profiles
```sql
SELECT p.user_id
FROM user_profiles p
LEFT JOIN users u ON u.id = p.user_id
WHERE u.id IS NULL;
```
### 12.6 Users inactive / unauthorized review
Legacy had `authorized`. If you want to incorporate:
```sql
SELECT user_id, active, authorized
FROM projekti_old_skinbase.users
WHERE active = 0 OR authorized = 0
LIMIT 200;
```
---
## 13) Role-based Access Control (RBAC)
### Option 1 (Simple, recommended now): string role on users
* `users.role` = `user|moderator|admin`
* Add middleware checks:
* admin-only panels
* moderator actions (approve uploads, etc.)
Example middleware:
```php
public function handle($request, Closure $next, string $role)
{
$user = $request->user();
if (!$user || $user->role !== $role) abort(403);
return $next($request);
}
```
### Option 2 (Advanced later): spatie/laravel-permission
Adopt if you need granular permissions:
* `approve_artwork`
* `ban_user`
* `edit_categories`
* etc.
Not required for v1 migration.
---
## 14) Implementation Notes (Important)
### Email issues
Legacy allows NULL/duplicate emails. Laravel password reset requires unique emails.
Strategy:
* If email missing: user must login with username and request support or add email.
* If duplicate emails: resolve manually or append `+id` style (not recommended) or enforce unique by cleanup.
### Username issues
If duplicates exist, your import must resolve them.
Recommended rule:
* if username duplicate, append `-<id>`
### MyISAM note
Legacy tables are MyISAM; importing into InnoDB is fine.
Do not try to preserve MyISAM.
---
## 15) Recommended Laravel Auth Starter Kit
Use **Laravel Breeze** for simplest modern auth:
* login/register
* password resets
* email verification (optional)
Then customize:
* login field: username OR email
* middleware to enforce password reset
---
## 16) Deliverables Checklist (What Copilot must implement)
1. ✅ Migrations (sections 5.15.4)
2. ✅ Import strategy + SQL (section 11)
3. ✅ Validation SQL queries (section 12)
4. ✅ Login supports username/email (section 7)
5. ✅ Password reset enforcement (section 9)
6. ✅ Optional legacy password compatibility (section 8)
7. ✅ RBAC (section 13 option 1)
---
## 17) Final Security Policy
* Default `needs_password_reset = 1` for all migrated users.
* If legacy hash compatibility is used:
* accept legacy hash **only once**
* rehash immediately to Laravel hash
* clear legacy markers
* Do not keep MD5/SHA1 hashes long-term.
---
END OF DOCUMENT

View File

@@ -0,0 +1,19 @@
Generate all Eloquent models for the Artworks module.
Requirements:
- Follow .copilot/artworks.md exactly
- Follow .copilot/instructions.md strictly
- Laravel 12, PHP 8.3+
- Use SoftDeletes only where allowed
- Define all relationships
- Add fillable arrays
- Add PHPDoc blocks for relations
- Do NOT add business logic
- Do NOT add observers
Models:
- Artwork
- ArtworkTranslation
- ArtworkStats
- ArtworkComment
- ArtworkDownload

View File

@@ -0,0 +1,240 @@
# SkinBase Category Link Building (AUTHORITATIVE SPEC)
This document defines the **ONLY valid way** to build public category and artwork URLs in SkinBase.
Copilot AI Agent MUST follow this specification exactly.
---
## 🎯 Goal
- SEO-friendly URLs
- No numeric IDs in public routes
- Unlimited category depth
- Predictable and deterministic link building
- One canonical URL per resource
---
## 🚫 Forbidden Concepts
Copilot MUST NOT:
- expose numeric IDs in URLs
- use legacy paths (`/Photography/3`)
- infer hierarchy from URL text
- mix `content_type_id` and `parent_id`
- create alternative URL formats
- generate uppercase URLs
---
## 🧱 Data Model (Authoritative)
### content_types
- `id`
- `slug` → FIRST URL segment
Examples:
```
photography
wallpapers
skins
other
```
---
### categories
- `id`
- `content_type_id`
- `parent_id`
- `slug`
Rules:
- `parent_id = NULL` → root category
- `parent_id != NULL` → child category
- `parent_id` MUST reference `categories.id`
- `content_type_id` MUST reference `content_types.id`
---
## 🧭 URL Structure (MANDATORY)
### Category URLs
```
/{content_type.slug}/{category-path}
```
Where:
- `category-path` is built from category slugs in hierarchy order
Examples:
```
/photography
/photography/abstract
/photography/abstract/dark
/skins/media-players
/other/art
```
---
### Artwork URLs
```
/{content_type.slug}/{category-path}/{artwork.slug}
```
Examples:
```
/photography/abstract/dark/night-city
/skins/media-players/zoom-player-dark
```
Rules:
- Artwork MUST belong to the last category in the path
- Artwork slug is ALWAYS the final segment
---
## 🧠 Category Path Construction (STRICT RULE)
Category paths MUST be constructed by walking parents.
Algorithm (conceptual):
1. Start with current category
2. Collect its `slug`
3. Move to `parent`
4. Repeat until `parent_id = NULL`
5. Reverse collected slugs
6. Join with `/`
Example:
```
Photography
└── Abstract
└── Dark
```
Produces:
```
abstract/dark
```
Final URL:
```
/photography/abstract/dark
````
---
## 🧩 Laravel Helper Contract
Category model MUST expose:
```php
$category->full_slug_path
````
Which returns:
```
abstract/dark
```
Final URL generation:
```php
'/' . $category->contentType->slug . '/' . $category->full_slug_path
```
---
## 🧭 Breadcrumb Rules
Breadcrumbs MUST reflect hierarchy exactly:
Example:
```
Home → Photography → Abstract → Dark
```
Each breadcrumb link MUST use the same slug-based URL logic.
---
## 🔐 Canonical URL RULE (SEO)
Every category and artwork page MUST include:
```html
<link rel="canonical" href="https://skinbase.org/{full-slug-url}">
```
Canonical URL MUST be:
* lowercase
* slug-based
* without IDs
* without query parameters
---
## 🧨 Legacy URL Handling
Legacy URLs MUST be handled ONLY via **301 redirects**.
Examples:
```
/Photography/3
/Photography/Business/564
```
Redirect to:
```
/photography/business
```
Copilot MUST NOT generate new legacy URLs.
---
## ✅ Validation Rules
Copilot MUST ensure:
* all URLs are lowercase
* slugs are used exclusively
* depth is unlimited
* parent relationships are respected
* only ONE URL exists per resource
---
## 🏁 FINAL STATEMENT
This document is the **single source of truth** for SkinBase category link building.
If any instruction conflicts with older code, documentation, or assumptions,
THIS DOCUMENT WINS.
END OF SPEC

View File

@@ -0,0 +1,309 @@
# Skinbase Thumbnails Generation Rules
## Project
Skinbase.org Artwork / Wallpapers / Skins CDN
CDN Base URL (Public):
https://files.skinbase.org
All generated thumbnails must be publicly accessible under this domain.
---
## 1. Goals
- Generate fast-loading, high-quality thumbnails
- Optimize for CDN delivery (Cloudflare + Apache)
- Preserve visual quality
- Keep consistent sizes
- Use immutable filenames (hash-based)
---
## 2. Supported Input Formats
Source images may be:
- JPG / JPEG
- PNG
- WEBP
- TIFF
- PSD (flattened first)
Before thumbnail generation:
- Strip EXIF metadata
- Convert to RGB
- Normalize orientation
---
## 3. Output Formats
Primary output:
- WEBP (preferred)
- AVIF (optional, future use)
- JPG (fallback only if WebP fails)
Default quality:
| Format | Quality |
|--------|---------|
| WebP | 82 |
| AVIF | 45 |
| JPG | 85 |
---
## 4. Thumbnail Sizes
Generate the following sizes for every image:
| Type | Width | Height | Crop |
|------|--------|--------|------|
| xs | 150px | auto | no |
| sm | 300px | auto | no |
| md | 600px | auto | no |
| lg | 1200px | auto | no |
| sq | 400px | 400px | yes |
Rules:
- Keep aspect ratio for non-square
- Never upscale
- Use center crop for sq
---
## 5. File Naming Convention
All thumbnails must use hash-based paths.
Format:
/lg/ff/2e/ff2e9ba2277b6b8296a0011c618ebf20c8c334a2.webp
Public URL example:
https://files.skinbase.org/lg/ff/2e/ff2e9ba2277b6b8296a0011c618ebf20c8c334a2.webp
Rules:
- Hash = SHA1(original_file + size + timestamp)
- First 2 bytes = dir1
- Next 2 bytes = dir2
- Next 2 bytes = dir3
- Filename = full hash
---
## 6. Directory Structure
Base directory (server):
/opt/www/virtual/skinbase/files/
Public mapping:
/opt/www/virtual/skinbase/files/lg/...
→ https://files.skinbase.org/lg/...
Structure:
/xs/
/sm/
/md/
/lg/
/sq/
Each contains hashed subfolders.
Do not store flat files.
---
## 7. Image Processing Rules
When generating thumbnails:
1. Load source image
2. Auto-orient
3. Strip metadata
4. Resize
5. Apply mild sharpening
6. Encode WebP
7. Save to CDN path
Sharpening:
- Radius: 0.5
- Amount: 0.3
- Threshold: 0
No heavy filters allowed.
---
## 8. Background Handling
For transparent images:
- Preserve alpha channel
- Do NOT add background
- Keep transparent WebP
For JPG fallback:
- Background: #000000
---
## 9. Performance Constraints
Target limits:
| Metric | Value |
|--------|-------|
| Max size (lg) | 400 KB |
| Max size (md) | 180 KB |
| Max size (sm) | 80 KB |
| Max size (xs) | 30 KB |
| Max size (sq) | 150 KB |
If exceeded:
- Lower quality by 5
- Re-encode
---
## 10. Security Rules
- Never execute embedded scripts
- Reject SVG with scripts
- Reject malformed images
- Validate MIME type
- Validate dimensions
Max source size: 100 MB
---
## 11. Cache Compatibility
All outputs must be CDN-ready.
Headers expected:
Cache-Control: public, max-age=31536000, immutable
Never generate filenames that change.
---
## 12. Regeneration Rules
Thumbnails must be regenerated when:
- Source image changes
- Processing rules change
- Quality profile updated
Old thumbnails must remain (cache-safe).
---
## 13. Laravel Integration
When thumbnail is created:
1. Save metadata to DB
2. Store hash
3. Store size
4. Store extension
5. Store public URL
Public URL format:
https://files.skinbase.org/{size}/{dir1}/{dir2}/{hash}.webp
Where:
{size} ∈ { xs, sm, md, lg, sq }
Model fields:
- thumb_hash
- thumb_ext
- thumb_size
- thumb_width
- thumb_height
- thumb_url
---
## 14. Logging
Every generation must log:
- Source path
- Output path
- Public URL
- Size
- Time
- Result
Format: JSON
Example:
{
"source": "upload/a.jpg",
"target": "lg/ff/2e/...",
"url": "https://files.skinbase.org/lg/ff/2e/...",
"size": "lg",
"time_ms": 120,
"status": "ok"
}
---
## 15. Error Handling
If generation fails:
- Log error
- Mark record as failed
- Do not retry more than 3x
- Alert admin
---
## 16. Forbidden Actions
AI agents must NOT:
- Overwrite existing thumbnails
- Change naming rules
- Change directory layout
- Serve via PHP
- Store in public uploads
- Generate relative URLs
All URLs must be absolute and use https://files.skinbase.org
---
## 17. Future Extensions
Planned:
- AVIF support
- DPR variants (2x, 3x)
- Smart cropping
- Face detection
- AI upscaling (optional)
Do not implement without approval.
---
## End of Rules

View File

@@ -0,0 +1,312 @@
# Skinbase User Schema Review & Upgrade Plan
**Database:** MySQL / Percona 8.x
**Project:** Skinbase (new system, no legacy dependencies)
**Reviewed tables:** users, user_profiles, user_social_links, user_statistics
---
## 1. Overview
The current user-related database schema is **well designed**, modern, and suitable
for long-term growth.
Key strengths include:
- Clear separation of concerns
- Proper use of foreign keys and cascading deletes
- BigInt primary keys
- Soft deletes on users
- Migration-friendly legacy password handling
This document summarizes:
- what is already good
- recommended optimizations
- future-proofing steps (non-breaking)
- performance considerations for scale
---
## 2. users Table
### 2.1 Whats Good
- Unique `username` and `email`
- `legacy_password_algo` allows smooth migration from old systems
- `needs_password_reset` improves security posture
- Role stored as string allows flexibility in early stages
- Soft deletes enabled
### 2.2 Recommended Indexes
Add indexes for common query patterns:
```sql
CREATE INDEX idx_users_active ON users (is_active);
CREATE INDEX idx_users_role ON users (role);
CREATE INDEX idx_users_last_visit ON users (last_visit_at);
````
These improve:
* active user filtering
* admin queries
* “last seen” or online user features
### 2.3 Future Role Normalization (Planned)
Current approach is fine short-term:
```text
role = 'user' | 'admin' | 'moderator'
```
Planned future upgrade:
* `roles` table
* `user_roles` pivot table (many-to-many)
This allows:
* multiple roles per user
* temporary or scoped roles
* better permission modeling
⚠️ No immediate action required — just avoid hard-coding role logic.
### 2.4 Optional Security Enhancements
Recommended additions (optional but advised):
```sql
last_password_change_at TIMESTAMP NULL,
failed_login_attempts INT UNSIGNED DEFAULT 0,
locked_until TIMESTAMP NULL
```
Enables:
* rate limiting
* temporary account locking
* better auditability
---
## 3. user_profiles Table
### 3.1 Strengths
* Clean one-to-one relationship with `users`
* Public profile data separated from auth data
* Nullable fields for progressive profile completion
* Inclusive gender enum with safe default
* Localization-ready (`language`, `country_code`)
### 3.2 Country Handling Recommendation
Prefer using only:
```text
country_code (ISO 3166-1 alpha-2)
```
Benefits:
* language-independent
* avoids inconsistent country naming
* easier frontend mapping
`country` text field can be deprecated later if needed.
### 3.3 Avatar & Media Metadata (Future)
Current:
```text
avatar VARCHAR(255)
```
Recommended future approach:
```text
avatar_hash CHAR(64)
avatar_ext VARCHAR(10)
avatar_updated_at TIMESTAMP
```
This aligns with:
* hash-based file storage
* CDN-friendly URLs
* cache invalidation control
---
## 4. user_social_links Table
### 4.1 Current Design
* One row per platform per user
* Unique constraint on `(user_id, platform)`
* Cascade delete enabled
This is solid.
### 4.2 Platform Normalization (Recommended)
Current:
```text
platform VARCHAR(32)
```
Risk:
* inconsistent values (`twitter`, `x`, `Twitter`, etc.)
Options:
**Option A Enum (simple):**
```sql
ENUM('twitter','x','instagram','deviantart','artstation','github','website')
```
**Option B Reference Table (best long-term):**
* `social_platforms`
* foreign key `platform_id`
Option B is preferred for Skinbase as platforms evolve.
---
## 5. user_statistics Table
### 5.1 Strengths
* Isolated counters
* One row per user
* Minimal row size
* Clean FK relationship
### 5.2 Performance Warning (Important)
Avoid frequent direct updates like:
```sql
UPDATE user_statistics SET downloads = downloads + 1;
```
At scale, this causes:
* row locking
* write contention
* degraded performance
### 5.3 Recommended Strategy
* Use **Redis** (or in-memory cache) for real-time increments
* Periodically flush aggregated values to MySQL
* Use jobs / cron for batch updates
This ensures:
* fast user interactions
* scalable statistics tracking
---
## 6. Charset & Collation
Current:
```text
utf8mb4_unicode_ci
```
This is correct and safe.
Optional upgrade (MySQL 8+):
```text
utf8mb4_0900_ai_ci
```
Benefits:
* newer Unicode rules
* slightly better performance
Not required immediately.
---
## 7. Tables Planned for Future Expansion
Not required now, but expected as Skinbase grows:
### 7.1 user_activity_log
* logins
* uploads
* profile edits
* moderation actions
### 7.2 user_followers
* artist-to-artist following
* social graph features
### 7.3 user_settings
* privacy preferences
* notification settings
* email subscriptions
These should remain **separate tables** to avoid bloating `users`.
---
## 8. Laravel Implementation Notes
Recommended model casting:
```php
protected $casts = [
'is_active' => 'boolean',
'needs_password_reset' => 'boolean',
'email_verified_at' => 'datetime',
'last_visit_at' => 'datetime',
];
```
Best practices:
* Eager load profiles (`with('profile')`)
* Cache public profile + statistics
* Keep statistics out of main user queries
---
## 9. Final Verdict
**Schema quality:** Excellent
**Scalability:** Very good
**Migration readiness:** Excellent
**Long-term Skinbase fit:** Excellent
The current schema is production-ready and significantly better than typical legacy
user databases. Most future improvements can be introduced **without breaking changes**.
Primary future wins:
* Redis-backed statistics
* normalized roles and social platforms
* hash-based media storage
---
**Status:** Approved for production
**Next steps:** API design, public profile queries, legacy user migration
```

View File

@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[compose.yaml]
indent_size = 4

View File

@@ -0,0 +1,8 @@
# cPad Configuration
# Template: custom
CPAD_DEBUG=false
CPAD_CACHE_ENABLED=true
CPAD_LOG_LEVEL=WARNING
CPAD_SECURITY_LEVEL=MAXIMUM
CPAD_BACKUP_ENABLED=true

View File

@@ -0,0 +1,364 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=skinbase26
DB_USERNAME=root
DB_PASSWORD=
# Legacy database connection (projekti_old_skinbase)
LEGACY_DB_CONNECTION=mysql
LEGACY_DB_HOST=127.0.0.1
LEGACY_DB_PORT=3306
LEGACY_DB_DATABASE=projekti_old_skinbase
LEGACY_DB_USERNAME=root
LEGACY_DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
# Skinbase Nova conditional public sessions
SKINBASE_CONDITIONAL_SESSIONS_ENABLED=true
SKINBASE_SKIP_ANONYMOUS_PUBLIC_GET_SESSIONS=true
SKINBASE_SKIP_BOT_PUBLIC_GET_SESSIONS=true
# Debug only; do not enable permanently in production
SKINBASE_SESSION_DEBUG_HEADER=false
BROADCAST_CONNECTION=reverb
FILESYSTEM_DISK=local
QUEUE_CONNECTION=redis
MESSAGING_REALTIME=true
MESSAGING_BROADCAST_QUEUE=broadcasts
MESSAGING_TYPING_TTL=8
MESSAGING_TYPING_CACHE_STORE=redis
MESSAGING_PRESENCE_TTL=90
MESSAGING_CONVERSATION_PRESENCE_TTL=45
MESSAGING_PRESENCE_CACHE_STORE=redis
MESSAGING_RECOVERY_MAX_MESSAGES=100
MESSAGING_OFFLINE_FALLBACK_ONLY=true
HORIZON_NAME=skinbase-nova
HORIZON_PATH=horizon
HORIZON_PREFIX=skinbase_nova_horizon:
REVERB_APP_ID=skinbase-local
REVERB_APP_KEY=skinbase-local-key
REVERB_APP_SECRET=skinbase-local-secret
REVERB_HOST=127.0.0.1
REVERB_PORT=8080
REVERB_SCHEME=http
REVERB_SERVER_HOST=0.0.0.0
REVERB_SERVER_PORT=8080
REVERB_SERVER_PATH=
REVERB_SCALING_ENABLED=false
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
# Upload UI feature flag (legacy upload remains default unless explicitly enabled)
SKINBASE_UPLOADS_V2=false
# Upload transport tuning
UPLOAD_CHUNK_MAX_BYTES=5242880
UPLOAD_CHUNK_REQUEST_TIMEOUT_MS=45000
UPLOAD_RATE_CHUNK_USER=180
UPLOAD_RATE_CHUNK_IP=360
# Draft abuse prevention controls
SKINBASE_MAX_DRAFTS=10
SKINBASE_MAX_DRAFT_STORAGE_MB=1024
SKINBASE_DUPLICATE_HASH_POLICY=block
# Vision / AI auto-tagging (local defaults)
VISION_ENABLED=true
VISION_QUEUE=default
VISION_IMAGE_VARIANT=md
VISION_GATEWAY_URL=
VISION_GATEWAY_TIMEOUT=10
VISION_GATEWAY_CONNECT_TIMEOUT=3
VISION_VECTOR_GATEWAY_ENABLED=true
VISION_VECTOR_GATEWAY_URL=
VISION_VECTOR_GATEWAY_API_KEY=
VISION_VECTOR_GATEWAY_COLLECTION=images
VISION_VECTOR_GATEWAY_TIMEOUT=20
VISION_VECTOR_GATEWAY_CONNECT_TIMEOUT=5
VISION_VECTOR_GATEWAY_RETRIES=1
VISION_VECTOR_GATEWAY_RETRY_DELAY_MS=250
VISION_VECTOR_GATEWAY_UPSERT_ENDPOINT=/vectors/upsert
VISION_VECTOR_GATEWAY_SEARCH_ENDPOINT=/vectors/search
VISION_VECTOR_GATEWAY_DELETE_ENDPOINT=/vectors/delete
VISION_VECTOR_GATEWAY_COLLECTIONS_ENDPOINT=/vectors/collections
# CLIP service (set base URL to enable CLIP calls)
CLIP_BASE_URL=
CLIP_ANALYZE_ENDPOINT=/analyze
CLIP_TIMEOUT_SECONDS=8
CLIP_CONNECT_TIMEOUT_SECONDS=2
CLIP_HTTP_RETRIES=1
CLIP_HTTP_RETRY_DELAY_MS=200
CLIP_EMBED_ENDPOINT=/embed
CLIP_EMBED_TIMEOUT_SECONDS=8
CLIP_EMBED_CONNECT_TIMEOUT_SECONDS=2
CLIP_EMBED_HTTP_RETRIES=1
CLIP_EMBED_HTTP_RETRY_DELAY_MS=200
# Similar artworks / embedding pipeline
RECOMMENDATIONS_QUEUE=${VISION_QUEUE}
RECOMMENDATIONS_EMBEDDING_ENABLED=true
RECOMMENDATIONS_EMBEDDING_MODEL=clip
RECOMMENDATIONS_EMBEDDING_MODEL_VERSION=v1
RECOMMENDATIONS_ALGO_VERSION=clip-cosine-v1
RECOMMENDATIONS_AB_ALGO_VERSIONS=clip-cosine-v1
RECOMMENDATIONS_MIN_DIM=64
RECOMMENDATIONS_MAX_DIM=4096
RECOMMENDATIONS_BACKFILL_BATCH=200
SIMILARITY_VECTOR_ENABLED=false
SIMILARITY_VECTOR_ADAPTER=pgvector
# Personalized discovery foundation (Phase 8)
DISCOVERY_QUEUE=${RECOMMENDATIONS_QUEUE}
DISCOVERY_PROFILE_VERSION=profile-v1
DISCOVERY_EVENT_VERSION=event-v1
DISCOVERY_ALGO_VERSION=${RECOMMENDATIONS_ALGO_VERSION}
DISCOVERY_CACHE_VERSION=cache-v1
DISCOVERY_DECAY_HALF_LIFE_HOURS=72
DISCOVERY_WEIGHT_VIEW=1
DISCOVERY_WEIGHT_CLICK=2
DISCOVERY_WEIGHT_FAVORITE=4
DISCOVERY_WEIGHT_DOWNLOAD=3
DISCOVERY_CACHE_TTL_MINUTES=60
DISCOVERY_V3_ENABLED=false
DISCOVERY_V3_CACHE_VERSION=cache-v3
DISCOVERY_V3_CACHE_TTL_MINUTES=5
DISCOVERY_V3_VECTOR_SIMILARITY_WEIGHT=0.8
DISCOVERY_V3_VECTOR_BASE_SCORE=0.75
DISCOVERY_V3_MAX_SEED_ARTWORKS=3
DISCOVERY_V3_VECTOR_CANDIDATE_POOL=60
DISCOVERY_V3_SECTION_SIMILAR_STYLE_LIMIT=3
DISCOVERY_V3_SECTION_YOU_MAY_ALSO_LIKE_LIMIT=6
DISCOVERY_V3_SECTION_VISUALLY_RELATED_LIMIT=6
DISCOVERY_RANKING_WEIGHTS_VERSION=rank-w-v1
DISCOVERY_RANKING_W1=0.65
DISCOVERY_RANKING_W2=0.20
DISCOVERY_RANKING_W3=0.10
DISCOVERY_RANKING_W4=0.05
DISCOVERY_RANKING_WEIGHTS_VERSION_CLIP_COSINE_V1=rank-w-v1
DISCOVERY_RANKING_W1_CLIP_COSINE_V1=0.65
DISCOVERY_RANKING_W2_CLIP_COSINE_V1=0.20
DISCOVERY_RANKING_W3_CLIP_COSINE_V1=0.10
DISCOVERY_RANKING_W4_CLIP_COSINE_V1=0.05
DISCOVERY_RANKING_WEIGHTS_VERSION_CLIP_COSINE_V2=rank-w-v2-prod-1
DISCOVERY_RANKING_W1_CLIP_COSINE_V2=0.52
DISCOVERY_RANKING_W2_CLIP_COSINE_V2=0.23
DISCOVERY_RANKING_W3_CLIP_COSINE_V2=0.15
DISCOVERY_RANKING_W4_CLIP_COSINE_V2=0.10
DISCOVERY_ROLLOUT_ENABLED=false
DISCOVERY_ROLLOUT_BASELINE_ALGO_VERSION=clip-cosine-v1
DISCOVERY_ROLLOUT_CANDIDATE_ALGO_VERSION=clip-cosine-v2
DISCOVERY_ROLLOUT_ACTIVE_GATE=g10
DISCOVERY_ROLLOUT_GATE_10_PERCENT=10
DISCOVERY_ROLLOUT_GATE_50_PERCENT=50
DISCOVERY_ROLLOUT_GATE_100_PERCENT=100
DISCOVERY_FORCE_ALGO_VERSION=
DISCOVERY_ROLLOUT_WARN_CTR_DROP_PCT=3
DISCOVERY_ROLLOUT_ROLLBACK_CTR_DROP_PCT=5
DISCOVERY_ROLLOUT_WARN_LONG_DWELL_DROP_PCT=4
DISCOVERY_ROLLOUT_ROLLBACK_LONG_DWELL_DROP_PCT=8
DISCOVERY_ROLLOUT_WARN_DIVERSITY_CONCENTRATION_RISE_PCT=10
DISCOVERY_ROLLOUT_ROLLBACK_DIVERSITY_CONCENTRATION_RISE_PCT=15
DISCOVERY_EVAL_WEIGHT_CTR=0.45
DISCOVERY_EVAL_WEIGHT_SAVE_RATE=0.35
DISCOVERY_EVAL_WEIGHT_LONG_DWELL=0.25
DISCOVERY_EVAL_WEIGHT_BOUNCE_PENALTY=0.15
DISCOVERY_EVAL_SAVE_RATE_INFORMATIONAL=true
# YOLO service (optional)
YOLO_ENABLED=true
YOLO_BASE_URL=
YOLO_ANALYZE_ENDPOINT=/analyze
YOLO_TIMEOUT_SECONDS=8
YOLO_CONNECT_TIMEOUT_SECONDS=2
YOLO_HTTP_RETRIES=1
YOLO_HTTP_RETRY_DELAY_MS=200
YOLO_PHOTOGRAPHY_ONLY=true
# -----------------------------------------------------------------------------
# Production examples (uncomment and adjust)
# -----------------------------------------------------------------------------
# VISION_ENABLED=true
# VISION_QUEUE=vision
# VISION_IMAGE_VARIANT=md
# VISION_GATEWAY_URL=https://vision.internal
# VISION_GATEWAY_TIMEOUT=8
# VISION_GATEWAY_CONNECT_TIMEOUT=2
#
# CLIP_BASE_URL=https://clip.internal
# CLIP_ANALYZE_ENDPOINT=/analyze
# CLIP_TIMEOUT_SECONDS=5
# CLIP_CONNECT_TIMEOUT_SECONDS=1
# CLIP_HTTP_RETRIES=1
# CLIP_HTTP_RETRY_DELAY_MS=150
# CLIP_EMBED_ENDPOINT=/embed
# CLIP_EMBED_TIMEOUT_SECONDS=5
# CLIP_EMBED_CONNECT_TIMEOUT_SECONDS=1
# CLIP_EMBED_HTTP_RETRIES=1
# CLIP_EMBED_HTTP_RETRY_DELAY_MS=150
# RECOMMENDATIONS_QUEUE=vision
# RECOMMENDATIONS_EMBEDDING_ENABLED=true
# RECOMMENDATIONS_EMBEDDING_MODEL=clip
# RECOMMENDATIONS_EMBEDDING_MODEL_VERSION=v1
# RECOMMENDATIONS_ALGO_VERSION=clip-cosine-v1
# RECOMMENDATIONS_AB_ALGO_VERSIONS=clip-cosine-v1,clip-cosine-v2
# RECOMMENDATIONS_BACKFILL_BATCH=250
# DISCOVERY_QUEUE=vision
# DISCOVERY_PROFILE_VERSION=profile-v1
# DISCOVERY_EVENT_VERSION=event-v1
# DISCOVERY_ALGO_VERSION=clip-cosine-v1
# DISCOVERY_CACHE_VERSION=cache-v1
# DISCOVERY_DECAY_HALF_LIFE_HOURS=72
# DISCOVERY_WEIGHT_VIEW=1
# DISCOVERY_WEIGHT_CLICK=2
# DISCOVERY_WEIGHT_FAVORITE=4
# DISCOVERY_WEIGHT_DOWNLOAD=3
# DISCOVERY_V3_ENABLED=true
# DISCOVERY_V3_CACHE_VERSION=cache-v3
# DISCOVERY_V3_CACHE_TTL_MINUTES=5
# DISCOVERY_V3_VECTOR_SIMILARITY_WEIGHT=0.8
# DISCOVERY_V3_VECTOR_BASE_SCORE=0.75
# DISCOVERY_V3_MAX_SEED_ARTWORKS=3
# DISCOVERY_V3_VECTOR_CANDIDATE_POOL=60
# DISCOVERY_V3_SECTION_SIMILAR_STYLE_LIMIT=3
# DISCOVERY_V3_SECTION_YOU_MAY_ALSO_LIKE_LIMIT=6
# DISCOVERY_V3_SECTION_VISUALLY_RELATED_LIMIT=6
# DISCOVERY_RANKING_WEIGHTS_VERSION=rank-w-v1
# DISCOVERY_RANKING_W1=0.65
# DISCOVERY_RANKING_W2=0.20
# DISCOVERY_RANKING_W3=0.10
# DISCOVERY_RANKING_W4=0.05
#
# YOLO_ENABLED=true
# YOLO_BASE_URL=https://yolo.internal
# YOLO_ANALYZE_ENDPOINT=/analyze
# YOLO_TIMEOUT_SECONDS=5
# YOLO_CONNECT_TIMEOUT_SECONDS=1
# YOLO_HTTP_RETRIES=1
# YOLO_HTTP_RETRY_DELAY_MS=150
# YOLO_PHOTOGRAPHY_ONLY=true
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
# Registration anti-spam
REGISTRATION_IP_PER_MINUTE_LIMIT=3
REGISTRATION_IP_PER_DAY_LIMIT=20
REGISTRATION_EMAIL_PER_MINUTE_LIMIT=6
REGISTRATION_EMAIL_COOLDOWN_MINUTES=30
REGISTRATION_VERIFY_TOKEN_TTL_HOURS=24
REGISTRATION_ENABLE_TURNSTILE=true
REGISTRATION_DISPOSABLE_DOMAINS_ENABLED=true
REGISTRATION_TURNSTILE_SUSPICIOUS_ATTEMPTS=2
REGISTRATION_TURNSTILE_ATTEMPT_WINDOW_MINUTES=30
REGISTRATION_EMAIL_GLOBAL_SEND_PER_MINUTE=30
REGISTRATION_MONTHLY_EMAIL_LIMIT=10000
TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY=
TURNSTILE_VERIFY_URL=https://challenges.cloudflare.com/turnstile/v0/siteverify
TURNSTILE_TIMEOUT=5
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# ─── Early-Stage Growth System ───────────────────────────────────────────────
# Set NOVA_EARLY_GROWTH_ENABLED=false to instantly revert to normal behaviour.
# NOVA_EARLY_GROWTH_MODE: off | light | aggressive
NOVA_EARLY_GROWTH_ENABLED=false
NOVA_EARLY_GROWTH_MODE=off
# Module toggles (only active when NOVA_EARLY_GROWTH_ENABLED=true)
NOVA_EGS_ADAPTIVE_WINDOW=true
NOVA_EGS_GRID_FILLER=true
NOVA_EGS_SPOTLIGHT=true
NOVA_EGS_ACTIVITY_LAYER=false
# AdaptiveTimeWindow thresholds
NOVA_EGS_UPLOADS_PER_DAY_NARROW=10
NOVA_EGS_UPLOADS_PER_DAY_WIDE=3
NOVA_EGS_WINDOW_NARROW_DAYS=7
NOVA_EGS_WINDOW_MEDIUM_DAYS=30
NOVA_EGS_WINDOW_WIDE_DAYS=90
# GridFiller minimum items per page
NOVA_EGS_GRID_MIN_RESULTS=12
# Auto-disable when site reaches organic scale
NOVA_EGS_AUTO_DISABLE=false
NOVA_EGS_AUTO_DISABLE_UPLOADS=50
NOVA_EGS_AUTO_DISABLE_USERS=500
# Cache TTLs (seconds)
NOVA_EGS_SPOTLIGHT_TTL=3600
NOVA_EGS_BLEND_TTL=300
NOVA_EGS_WINDOW_TTL=600
NOVA_EGS_ACTIVITY_TTL=1800
# ─── OAuth / Social Login ─────────────────────────────────────────────────────
# Google — https://console.cloud.google.com/apis/credentials
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=/auth/google/callback
# Discord — https://discord.com/developers/applications
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
DISCORD_REDIRECT_URI=/auth/discord/callback
# Apple — https://developer.apple.com/account/resources/identifiers/list/serviceId
# Apple sign in removed

View File

@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore

View File

@@ -0,0 +1,55 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.fleet
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/hot
/public/storage
/public/files
/storage/*.key
/storage/pail
/storage/app
/storage/framework/cache
/storage/framework/sessions
/storage/framework/views
/storage/logs
/storage/testing
/storage/*.log
/storage/*.key
/storage/*.sqlite
/storage/*.sqlite3
/storage/*.zip
/storage/*.tar.gz
/storage/*.tar.gz
/storage/*.tar.bz2
/storage/*.tar.xz
/storage/*.tar
/storage/*.tgz
/storage/*.tbz2
/storage/*.txz
/storage/*.zip
/storage/*.tar.gz
/storage/*.tar.bz2
/storage/*.tar.xz
/storage/*.tar
/storage/*.tgz
/vendor
Homestead.json
Homestead.yaml
Thumbs.db
/oldSite/*
oldSite
packages
/packages/*
/public/admin/*

View File

@@ -0,0 +1,441 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications.
## Learning Laravel
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
## Laravel Sponsors
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
### Premium Partners
- **[Vehikl](https://vehikl.com)**
- **[Tighten Co.](https://tighten.co)**
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
- **[64 Robots](https://64robots.com)**
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
- **[Redberry](https://redberry.international/laravel-development)**
- **[Active Logic](https://activelogic.com)**
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## Vision & AI Auto-Tagging Integration
## Upload UI Feature Flag (`uploads.v2`)
The new React upload wizard is behind a feature flag and is **disabled by default**.
- Flag env var: `SKINBASE_UPLOADS_V2`
- Config key: `features.uploads_v2`
- Client flags source: `window.SKINBASE_FLAGS`
### Default behavior
- `SKINBASE_UPLOADS_V2=false` → legacy upload UI is rendered.
- `SKINBASE_UPLOADS_V2=true``UploadWizard` is rendered.
### Setup
In `.env` (or `.env.example` for project defaults):
```dotenv
SKINBASE_UPLOADS_V2=false
```
Enable explicitly when ready:
```dotenv
SKINBASE_UPLOADS_V2=true
```
After changing env values, clear/reload config as usual:
```bash
php artisan config:clear
```
The system intentionally keeps legacy upload as the default until the flag is explicitly turned on.
## Upload Moderation UI Flow
Admin moderation for draft uploads is available through a dedicated queue page.
- Page route: `/admin/uploads/moderation`
- Access: authenticated users with `role=admin` or `role=moderator`
- Data source: `GET /api/admin/uploads/pending`
### Queue behavior
1. The page loads pending draft uploads (`moderation_status=pending`).
2. Moderators can enter an optional note per upload.
3. Approve action calls:
- `POST /api/admin/uploads/{id}/approve`
- Sets moderation to approved and records moderator + timestamp.
4. Reject action calls:
- `POST /api/admin/uploads/{id}/reject`
- Sets upload status/processing state to rejected and stores note.
### Publish gate
- Normal users can publish only when `moderation_status=approved`.
- Admin users can publish with override behavior.
## Similar Artworks Analytics (A/B Evaluation)
The artwork page similar-items block emits two event types:
- `impression` (block rendered)
- `click` (item clicked)
Events are stored in `similar_artwork_events` and aggregated daily into `similar_artwork_daily_metrics` by `algo_version`.
- Ingest endpoint: `POST /api/analytics/similar-artworks`
- Aggregation command: `php artisan analytics:aggregate-similar-artworks --date=YYYY-MM-DD`
- Scheduler: runs daily at `03:10`
## Personalized Discovery Foundation (Phase 8)
This foundation adds versioned, async-only ingestion and profile normalization for personalized discovery.
- Tables:
- `user_interest_profiles`
- `user_discovery_events`
- `user_recommendation_cache`
- Ingest endpoint: `POST /api/discovery/events` (auth required)
- Supported event types: `view`, `click`, `favorite`, `download`
- Processing model: non-blocking queue job (`IngestUserDiscoveryEventJob`)
- Normalization: recency-decay + score normalization in `UserInterestProfileService`
No feed ranking/UI behavior is introduced in this foundation step.
### Feed Endpoint Skeleton
The backend now exposes a personalized feed API skeleton:
- Endpoint: `GET /api/v1/feed` (auth required)
- Query params:
- `limit` (1-50, default 24)
- `cursor` (opaque cursor token for pagination)
- `algo_version` (optional override)
- Response includes `data` items and `meta.next_cursor` for cursor pagination.
Behavior:
- Reads `user_recommendation_cache` by `user_id + algo_version`.
- On cache miss/stale, returns immediate fallback results and dispatches async regeneration job.
- Regeneration runs in queue (`RegenerateUserRecommendationCacheJob`) and writes refreshed cache.
- Includes cold-start fallback (`popular + similar`) and a diversity guard to avoid near-duplicates.
## Feed Analytics Instrumentation
Feed analytics now track:
- `feed_impression`
- `feed_click`
Payload dimensions:
- `user_id` (derived from auth session)
- `artwork_id`
- `position`
- `algo_version`
- `source` (`personalized`, `cold_start`, `fallback`)
Optional:
- `dwell_seconds` (for click dwell bucket metrics)
Endpoints:
- Ingest: `POST /api/analytics/feed` (auth required)
- Daily aggregation: `php artisan analytics:aggregate-feed --date=YYYY-MM-DD`
- Admin report: `GET /api/admin/reports/feed-performance`
Daily metrics include CTR, save-rate, and dwell buckets.
For non-blocking client transport, use `navigator.sendBeacon` with `fetch(..., { keepalive: true })` fallback.
Reference helper: `resources/js/lib/feedAnalytics.js`.
## Phase 8B: Ranking Weight Tuning (Manual + Data-Driven)
Discovery ranking now supports versioned blend weights per `algo_version` in `config/discovery.php`.
- Blend terms: `w1` interest, `w2` recency, `w3` popularity, `w4` novelty
- Per-algo sets: `discovery.ranking.algo_weight_sets`
- Safe rollout: deterministic traffic split by `algo_version` with config gates (`g10`, `g50`, `g100`)
- Emergency rollback: `DISCOVERY_FORCE_ALGO_VERSION=clip-cosine-v1`
Offline evaluator and A/B helper:
- Evaluate objective across one/all algos:
- `php artisan analytics:evaluate-feed-weights --from=YYYY-MM-DD --to=YYYY-MM-DD`
- Optional: `--algo=clip-cosine-v1`
- Baseline vs candidate comparison:
- `php artisan analytics:compare-feed-ab clip-cosine-v1 clip-cosine-v2 --from=YYYY-MM-DD --to=YYYY-MM-DD`
Objective score uses `feed_daily_metrics` and configurable objective weights in `discovery.evaluation.objective_weights`.
Temporary production policy: set `DISCOVERY_EVAL_SAVE_RATE_INFORMATIONAL=true` to keep `save_rate` visible but excluded from objective score until save-event ingestion is verified.
Operational runbook: `docs/feed-rollout-runbook.md`.
## Operations / Runbooks
- Upload UI v2 rollout, post-deploy monitoring, and rollback: `docs/ui/upload-v2-rollout-runbook.md`
- Feed rollout and rollback: `docs/feed-rollout-runbook.md`
- Registration anti-spam and email quota protection: `docs/registration-antispam.md`
No automatic tuning is enabled in this phase.
Skinbase uses asynchronous AI tagging via `AutoTagArtworkJob`.
The job calls external vision services (CLIP and optional YOLO), normalizes tags, and attaches them through `TagService` as AI tags with confidence values.
### Critical Safety Rule
⚠️ **Publish must never depend on vision services.**
- Upload/publish flow dispatches AI tagging to queue after publish work.
- Vision failures, timeouts, or service outages must not block artwork publish.
- If AI tagging fails, artwork remains published and can be tagged later (retry/manual/batch).
### Environment Variables (Vision)
Set these in `.env` (all are optional; defaults are in `config/vision.php`):
#### Global
- `VISION_ENABLED` (default: `true`)
- Master switch for all AI auto-tagging.
- `VISION_QUEUE` (default: `default`)
- Queue name used by `AutoTagArtworkJob`.
- `VISION_IMAGE_VARIANT` (default: `md`)
- Derivative variant sent to vision services (e.g. `md`, `lg`).
#### CLIP
- `CLIP_BASE_URL` (default: empty)
- Base URL for CLIP service (example: `https://clip.internal`).
- If empty, CLIP call is skipped.
- `CLIP_ANALYZE_ENDPOINT` (default: `/analyze`)
- Path appended to `CLIP_BASE_URL`.
- `CLIP_TIMEOUT_SECONDS` (default: `8`)
- Request timeout for CLIP calls.
- `CLIP_CONNECT_TIMEOUT_SECONDS` (default: `2`)
- Connection timeout for CLIP calls.
- `CLIP_HTTP_RETRIES` (default: `1`)
- HTTP retry attempts for CLIP requests.
- `CLIP_HTTP_RETRY_DELAY_MS` (default: `200`)
- Delay between CLIP retries.
#### YOLO (optional)
- `YOLO_ENABLED` (default: `true`)
- Enables YOLO integration.
- `YOLO_BASE_URL` (default: empty)
- Base URL for YOLO service. If empty, YOLO call is skipped.
- `YOLO_ANALYZE_ENDPOINT` (default: `/analyze`)
- Path appended to `YOLO_BASE_URL`.
- `YOLO_TIMEOUT_SECONDS` (default: `8`)
- Request timeout for YOLO calls.
- `YOLO_CONNECT_TIMEOUT_SECONDS` (default: `2`)
- Connection timeout for YOLO calls.
- `YOLO_HTTP_RETRIES` (default: `1`)
- HTTP retry attempts for YOLO requests.
- `YOLO_HTTP_RETRY_DELAY_MS` (default: `200`)
- Delay between YOLO retries.
- `YOLO_PHOTOGRAPHY_ONLY` (default: `true`)
- When `true`, YOLO is called only for artworks in photography content type.
### Expected CLIP Response Format
CLIP `/analyze` should return tags as either a direct list or under `tags` / `data`:
```json
[
{ "tag": "cyberpunk", "confidence": 0.42 },
{ "tag": "city", "confidence": 0.31 }
]
```
Also accepted:
```json
{
"tags": [
{ "tag": "cyberpunk", "confidence": 0.42 }
]
}
```
or
```json
{
"data": [
{ "tag": "cyberpunk", "confidence": 0.42 }
]
}
```
### Expected YOLO Response Format
YOLO may return the same tag list format as CLIP, or object detections:
```json
{
"objects": [
{ "label": "person", "confidence": 0.91 },
{ "label": "camera", "confidence": 0.67 }
]
}
```
`label` values are converted to tags, confidence is preserved when present.
### AutoTagArtworkJob Behavior
- Calls CLIP `/analyze` when `VISION_ENABLED=true` and `CLIP_BASE_URL` is set.
- Optionally calls YOLO based on `YOLO_ENABLED` and `YOLO_PHOTOGRAPHY_ONLY`.
- Merges CLIP + YOLO tags and keeps highest confidence for duplicates.
- Normalizes tags before attach (lowercase, cleanup, slug-safe format).
- Uses `TagService::attachAiTags()` to store pivot data:
- `source = ai`
- `confidence = <float|null>`
- Runs with queue retry + timeout safety (`tries`, `backoff`, `timeout`).
- Logs failures with reference/context for troubleshooting.
- On non-retriable response scenarios (e.g. 4xx), job exits safely without blocking publish.
### Queue / Worker Requirements (`VISION_QUEUE`)
- Ensure a worker is running for the configured queue.
- Example worker command:
```bash
php artisan queue:work --queue=default
```
- If `VISION_QUEUE=vision`, run worker for that queue:
```bash
php artisan queue:work --queue=vision
```
- In production, use Supervisor/systemd/Horizon to keep workers alive.
- Without an active worker, auto-tagging jobs remain queued and will not execute.
### Local vs Production Notes
#### Local development
- For fully offline local work, set `VISION_ENABLED=false`.
- Or set only `CLIP_BASE_URL`/`YOLO_BASE_URL` you can reach locally.
- Prefer short timeouts to avoid slow dev feedback loops.
#### Production
- Use internal/private service endpoints for CLIP/YOLO when possible.
- Keep conservative timeouts and low retry counts to prevent queue congestion.
- Monitor failed jobs and logs for vision service reliability.
- Scale queue workers based on upload volume and service latency.
### Verify Setup (Health + Test Call)
After configuring env vars and restarting workers, verify in this order:
Quick helper (PowerShell):
```powershell
pwsh -File ./scripts/vision-smoke.ps1
```
Optional flags:
```powershell
pwsh -File ./scripts/vision-smoke.ps1 -EnvFile ".env" -SampleImageUrl "https://files.skinbase.org/img/aa/bb/cc/md.webp"
pwsh -File ./scripts/vision-smoke.ps1 -SkipAnalyze
```
1. Confirm queue worker is consuming `VISION_QUEUE`.
```bash
php artisan queue:work --queue=default
```
1. Check CLIP/YOLO health endpoints (replace host/port as needed):
```bash
curl -fsS "$CLIP_BASE_URL/health"
curl -fsS "$YOLO_BASE_URL/health"
```
1. Make a direct analyze test call (CLIP example):
```bash
curl -X POST "$CLIP_BASE_URL$CLIP_ANALYZE_ENDPOINT" \
-H "Content-Type: application/json" \
-d '{"image_url":"https://files.skinbase.org/img/aa/bb/cc/md.webp"}'
```
1. Trigger an upload/publish and confirm:
- Publish response succeeds even if CLIP/YOLO is down.
- `AutoTagArtworkJob` is queued/executed asynchronously.
- AI tags appear on the artwork when services are healthy.
- Failures are logged, but publish is unaffected.
## Queue workers
The contact form mails are queued. To process them you need a worker. Locally you can run a foreground worker:
```
php artisan queue:work --sleep=3 --tries=3
```
For production we provide example configs under `deploy/`:
- `deploy/supervisor/skinbase-queue.conf` — Supervisor config
- `deploy/systemd/skinbase-queue.service` — systemd unit file
See `docs/QUEUE.md` for full setup steps and commands.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).

View File

@@ -0,0 +1,35 @@
<?php
namespace App;
class Banner
{
public static function ShowResponsiveAd()
{
#echo '<div class="responsive_ad">';
#echo '<script async src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>';
#echo '<ins class="adsbygoogle" style="display:block" data-ad-client="ca-pub-6457864535683080" data-ad-slot="9918154676" data-ad-format="auto"></ins>';
#echo '<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>';
#echo '</div>';
}
public static function ShowBanner300x250()
{
echo '<script async src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>';
echo '<ins class="adsbygoogle" style="display:inline-block;width:300px;height:250px" data-ad-client="ca-pub-6457864535683080" data-ad-slot="7579263359"></ins>';
echo '<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>';
}
public static function ShowBanner728x90()
{
echo '<script async src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>';
echo '<ins class="adsbygoogle" style="display:inline-block;width:728px;height:90px" data-ad-client="ca-pub-6457864535683080" data-ad-slot="1234567890"></ins>';
echo '<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>';
}
public static function ShowBannerGoogle300x250()
{
// alias to ShowBanner300x250 for compatibility
self::ShowBanner300x250();
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace App;
use Illuminate\Support\Facades\DB;
class Chat
{
public $username = "";
public $nickname = "";
public function Authenticate()
{
}
public function StoreMessage($tekst)
{
$userId = $_SESSION['web_login']['user_id'] ?? null;
$username = $_SESSION['web_login']['username'] ?? null;
if (empty($userId) || empty($username) || empty($tekst)) {
return;
}
$last = DB::table('chat')
->select('message')
->where('user_id', $userId)
->orderByDesc('chat_id')
->limit(1)
->first();
if (!$last || ($last->message ?? '') !== $tekst) {
DB::table('chat')->insert([
'time' => now(),
'sender' => $username,
'user_id' => $userId,
'message' => $tekst,
]);
}
}
public function UpdateChatFile($chat_file, $num_rows)
{
$output = "<ul>";
$chats = DB::table('chat')
->select('time', 'sender', 'message')
->orderByDesc('chat_id')
->limit((int)$num_rows ?: 8)
->get();
$x = 0;
foreach ($chats as $chat) {
$x++;
$add = ($x % 2 === 0) ? ' class="odd" ' : '';
$datetime = date("F jS @ H:i", strtotime($chat->time));
$message = wordwrap($chat->message, 20, " ", true);
$ime = wordwrap($chat->sender, 12, " ", true);
$output .= '<li ' . $add . '>&lt;';
$output .= '<a href="/profile.php?uname=' . rawurlencode($chat->sender) . '" title="' . htmlspecialchars($datetime, ENT_QUOTES, 'UTF-8') . ' GMT+1">';
$output .= htmlspecialchars($ime, ENT_QUOTES, 'UTF-8');
$output .= '</a>&gt; ';
$output .= htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
$output .= '</li>';
}
$output .= '</ul>';
@file_put_contents(base_path($chat_file), $output);
}
public function ShowOnline()
{
echo '<div id="oboks" name="boks">Loading...</div>';
}
public function ShowChat($num_rows = 10, $username = null)
{
echo '<div id="chat_box" name="chat_box">Loading...</div>';
echo '<div class="row well">';
if (!empty($_SESSION['web_login']['status'])) {
echo '<form action="' . htmlspecialchars(route('community.chat'), ENT_QUOTES, 'UTF-8') . '" method="post">';
echo csrf_field();
echo '<div class="col-sm-10">';
echo '<input type="text" class="form-control" id="chat_txt" name="chat_txt" value="">';
echo '</div>';
echo '<div class="col-sm-2">';
echo '<button type="submit" class="btn btn-success">Say</button>';
echo '</div>';
echo '<input type="hidden" name="store_chat" value="true">';
echo '</form>';
} else {
echo '<div class="clear alert alert-danger">You should be logged in to join a chat!</div>';
}
echo '</div>';
}
}

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
final class AggregateDiscoveryFeedbackCommand extends Command
{
protected $signature = 'analytics:aggregate-discovery-feedback {--date= : Date (Y-m-d), defaults to yesterday}';
protected $description = 'Aggregate discovery feedback events into daily metrics by algorithm and surface';
public function handle(): int
{
if (! Schema::hasTable('user_discovery_events') || ! Schema::hasTable('discovery_feedback_daily_metrics')) {
$this->warn('Required discovery feedback tables are missing.');
return self::SUCCESS;
}
$date = $this->option('date')
? (string) $this->option('date')
: now()->subDay()->toDateString();
$surfaceExpression = $this->surfaceExpression();
$rows = DB::table('user_discovery_events')
->selectRaw('algo_version')
->selectRaw($surfaceExpression . ' AS surface')
->selectRaw("SUM(CASE WHEN event_type = 'view' THEN 1 ELSE 0 END) AS views")
->selectRaw("SUM(CASE WHEN event_type = 'click' THEN 1 ELSE 0 END) AS clicks")
->selectRaw("SUM(CASE WHEN event_type = 'favorite' THEN 1 ELSE 0 END) AS favorites")
->selectRaw("SUM(CASE WHEN event_type = 'download' THEN 1 ELSE 0 END) AS downloads")
->selectRaw("SUM(CASE WHEN event_type = 'hide_artwork' THEN 1 ELSE 0 END) AS hidden_artworks")
->selectRaw("SUM(CASE WHEN event_type = 'dislike_tag' THEN 1 ELSE 0 END) AS disliked_tags")
->selectRaw("SUM(CASE WHEN event_type = 'unhide_artwork' THEN 1 ELSE 0 END) AS undo_hidden_artworks")
->selectRaw("SUM(CASE WHEN event_type = 'undo_dislike_tag' THEN 1 ELSE 0 END) AS undo_disliked_tags")
->selectRaw('COUNT(DISTINCT user_id) AS unique_users')
->selectRaw('COUNT(DISTINCT artwork_id) AS unique_artworks')
->whereDate('event_date', $date)
->groupBy('algo_version', DB::raw($surfaceExpression))
->get();
foreach ($rows as $row) {
$views = (int) ($row->views ?? 0);
$clicks = (int) ($row->clicks ?? 0);
$favorites = (int) ($row->favorites ?? 0);
$downloads = (int) ($row->downloads ?? 0);
$feedbackActions = $favorites + $downloads;
$hiddenArtworks = (int) ($row->hidden_artworks ?? 0);
$dislikedTags = (int) ($row->disliked_tags ?? 0);
$undoHiddenArtworks = (int) ($row->undo_hidden_artworks ?? 0);
$undoDislikedTags = (int) ($row->undo_disliked_tags ?? 0);
$negativeFeedbackActions = $hiddenArtworks + $dislikedTags;
$undoActions = $undoHiddenArtworks + $undoDislikedTags;
DB::table('discovery_feedback_daily_metrics')->updateOrInsert(
[
'metric_date' => $date,
'algo_version' => (string) ($row->algo_version ?? ''),
'surface' => (string) ($row->surface ?? 'unknown'),
],
[
'views' => $views,
'clicks' => $clicks,
'favorites' => $favorites,
'downloads' => $downloads,
'hidden_artworks' => $hiddenArtworks,
'disliked_tags' => $dislikedTags,
'undo_hidden_artworks' => $undoHiddenArtworks,
'undo_disliked_tags' => $undoDislikedTags,
'feedback_actions' => $feedbackActions,
'negative_feedback_actions' => $negativeFeedbackActions,
'undo_actions' => $undoActions,
'unique_users' => (int) ($row->unique_users ?? 0),
'unique_artworks' => (int) ($row->unique_artworks ?? 0),
'ctr' => $views > 0 ? $clicks / $views : 0.0,
'favorite_rate_per_click' => $clicks > 0 ? $favorites / $clicks : 0.0,
'download_rate_per_click' => $clicks > 0 ? $downloads / $clicks : 0.0,
'feedback_rate_per_click' => $clicks > 0 ? $feedbackActions / $clicks : 0.0,
'updated_at' => now(),
'created_at' => now(),
]
);
}
$this->info("Aggregated discovery feedback for {$date}.");
return self::SUCCESS;
}
private function surfaceExpression(): string
{
if (DB::connection()->getDriverName() === 'sqlite') {
return "COALESCE(NULLIF(JSON_EXTRACT(meta, '$.gallery_type'), ''), NULLIF(JSON_EXTRACT(meta, '$.surface'), ''), 'unknown')";
}
return "COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(meta, '$.gallery_type')), ''), NULLIF(JSON_UNQUOTE(JSON_EXTRACT(meta, '$.surface')), ''), 'unknown')";
}
}

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
final class AggregateFeedAnalyticsCommand extends Command
{
protected $signature = 'analytics:aggregate-feed {--date= : Date (Y-m-d), defaults to yesterday}';
protected $description = 'Aggregate feed analytics into daily metrics by algo version and source';
public function handle(): int
{
$date = $this->option('date')
? (string) $this->option('date')
: now()->subDay()->toDateString();
$rows = DB::table('feed_events')
->selectRaw('algo_version, source')
->selectRaw("SUM(CASE WHEN event_type = 'feed_impression' THEN 1 ELSE 0 END) AS impressions")
->selectRaw("SUM(CASE WHEN event_type = 'feed_click' THEN 1 ELSE 0 END) AS clicks")
->selectRaw("SUM(CASE WHEN event_type = 'feed_click' AND dwell_seconds IS NOT NULL AND dwell_seconds < 5 THEN 1 ELSE 0 END) AS dwell_0_5")
->selectRaw("SUM(CASE WHEN event_type = 'feed_click' AND dwell_seconds >= 5 AND dwell_seconds < 30 THEN 1 ELSE 0 END) AS dwell_5_30")
->selectRaw("SUM(CASE WHEN event_type = 'feed_click' AND dwell_seconds >= 30 AND dwell_seconds < 120 THEN 1 ELSE 0 END) AS dwell_30_120")
->selectRaw("SUM(CASE WHEN event_type = 'feed_click' AND dwell_seconds >= 120 THEN 1 ELSE 0 END) AS dwell_120_plus")
->whereDate('event_date', $date)
->groupBy('algo_version', 'source')
->get();
foreach ($rows as $row) {
$algoVersion = (string) $row->algo_version;
$source = (string) $row->source;
$impressions = (int) ($row->impressions ?? 0);
$clicks = (int) ($row->clicks ?? 0);
$saves = $this->countSavesForGroup($date, $algoVersion, $source);
$ctr = $impressions > 0 ? $clicks / $impressions : 0.0;
$saveRate = $clicks > 0 ? $saves / $clicks : 0.0;
DB::table('feed_daily_metrics')->updateOrInsert(
[
'metric_date' => $date,
'algo_version' => $algoVersion,
'source' => $source,
],
[
'impressions' => $impressions,
'clicks' => $clicks,
'saves' => $saves,
'ctr' => $ctr,
'save_rate' => $saveRate,
'dwell_0_5' => (int) ($row->dwell_0_5 ?? 0),
'dwell_5_30' => (int) ($row->dwell_5_30 ?? 0),
'dwell_30_120' => (int) ($row->dwell_30_120 ?? 0),
'dwell_120_plus' => (int) ($row->dwell_120_plus ?? 0),
'updated_at' => now(),
'created_at' => now(),
]
);
}
$this->info("Aggregated feed analytics for {$date}.");
return self::SUCCESS;
}
private function countSavesForGroup(string $date, string $algoVersion, string $source): int
{
/** @var Collection<int, object{user_id:int,artwork_id:int}> $clickedPairs */
$clickedPairs = DB::table('feed_events')
->select('user_id', 'artwork_id')
->whereDate('event_date', $date)
->where('event_type', 'feed_click')
->where('algo_version', $algoVersion)
->where('source', $source)
->groupBy('user_id', 'artwork_id')
->get();
if ($clickedPairs->isEmpty()) {
return 0;
}
$saves = 0;
foreach ($clickedPairs as $pair) {
$hasSave = DB::table('user_discovery_events')
->whereDate('event_date', $date)
->where('user_id', (int) $pair->user_id)
->where('artwork_id', (int) $pair->artwork_id)
->where('algo_version', $algoVersion)
->whereIn('event_type', ['favorite', 'download'])
->exists();
if ($hasSave) {
$saves++;
}
}
return $saves;
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
final class AggregateSimilarArtworkAnalyticsCommand extends Command
{
protected $signature = 'analytics:aggregate-similar-artworks {--date= : Date (Y-m-d), defaults to yesterday}';
protected $description = 'Aggregate similar artwork analytics into daily counts by algo version';
public function handle(): int
{
$date = $this->option('date')
? (string) $this->option('date')
: now()->subDay()->toDateString();
$rows = DB::table('similar_artwork_events')
->selectRaw('algo_version')
->selectRaw("SUM(CASE WHEN event_type = 'impression' THEN 1 ELSE 0 END) AS impressions")
->selectRaw("SUM(CASE WHEN event_type = 'click' THEN 1 ELSE 0 END) AS clicks")
->whereDate('event_date', $date)
->groupBy('algo_version')
->get();
foreach ($rows as $row) {
$impressions = (int) ($row->impressions ?? 0);
$clicks = (int) ($row->clicks ?? 0);
$ctr = $impressions > 0 ? $clicks / $impressions : 0.0;
DB::table('similar_artwork_daily_metrics')->updateOrInsert(
[
'metric_date' => $date,
'algo_version' => (string) $row->algo_version,
],
[
'impressions' => $impressions,
'clicks' => $clicks,
'ctr' => $ctr,
'updated_at' => now(),
'created_at' => now(),
]
);
}
$this->info("Aggregated similar artwork analytics for {$date}.");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
final class AggregateTagInteractionAnalyticsCommand extends Command
{
protected $signature = 'analytics:aggregate-tag-interactions {--date= : Date (Y-m-d), defaults to yesterday}';
protected $description = 'Aggregate tag interaction analytics into daily metrics by surface, tag, source tag, and query';
public function handle(): int
{
$date = $this->option('date')
? (string) $this->option('date')
: now()->subDay()->toDateString();
$normalizedTag = "COALESCE(tag_slug, '')";
$normalizedSourceTag = "COALESCE(source_tag_slug, '')";
$normalizedQuery = "LOWER(TRIM(COALESCE(query, '')))";
$rows = DB::table('tag_interaction_events')
->selectRaw('surface')
->selectRaw("{$normalizedTag} AS tag_slug")
->selectRaw("{$normalizedSourceTag} AS source_tag_slug")
->selectRaw("{$normalizedQuery} AS query")
->selectRaw('COUNT(*) AS clicks')
->selectRaw('COUNT(DISTINCT user_id) AS unique_users')
->selectRaw("COUNT(DISTINCT CASE WHEN session_key IS NOT NULL AND session_key <> '' THEN session_key END) AS unique_sessions")
->selectRaw('AVG(position) AS avg_position')
->whereDate('event_date', $date)
->where('event_type', 'click')
->groupBy('surface', DB::raw($normalizedTag), DB::raw($normalizedSourceTag), DB::raw($normalizedQuery))
->get();
DB::transaction(function () use ($date, $rows): void {
DB::table('tag_interaction_daily_metrics')
->where('metric_date', $date)
->delete();
$payload = $rows->map(static function ($row) use ($date): array {
return [
'metric_date' => $date,
'surface' => (string) $row->surface,
'tag_slug' => trim((string) ($row->tag_slug ?? '')),
'source_tag_slug' => trim((string) ($row->source_tag_slug ?? '')),
'query' => trim((string) ($row->query ?? '')),
'clicks' => (int) ($row->clicks ?? 0),
'unique_users' => (int) ($row->unique_users ?? 0),
'unique_sessions' => (int) ($row->unique_sessions ?? 0),
'avg_position' => round((float) ($row->avg_position ?? 0), 2),
'created_at' => now(),
'updated_at' => now(),
];
})->all();
foreach (array_chunk($payload, 500) as $chunk) {
if ($chunk !== []) {
DB::table('tag_interaction_daily_metrics')->insert($chunk);
}
}
});
$this->info("Aggregated tag interaction analytics for {$date}.");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,488 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use App\Services\TagService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Generate AI tags for artworks using a local LM Studio vision model.
*
* Usage:
* php artisan artworks:ai-tag
* php artisan artworks:ai-tag --after-id=1000 --chunk=20 --dry-run
* php artisan artworks:ai-tag --limit=100 --skip-tagged
* php artisan artworks:ai-tag --artwork-id=242 # process a single artwork by ID
* php artisan artworks:ai-tag --artwork-id=242 --dump-curl # print equivalent curl command (no API call made)
* php artisan artworks:ai-tag --artwork-id=242 --debug # print CDN URL, file size, magic bytes and data-URI prefix
* php artisan artworks:ai-tag --url=http://192.168.1.5:8200 --model=google/gemma-3-4b
*/
final class AiTagArtworksCommand extends Command
{
protected $signature = 'artworks:ai-tag
{--artwork-id= : Process only this single artwork ID (bypasses public/approved scope)}
{--after-id=0 : Skip artworks with ID this value (useful for resuming)}
{--limit= : Stop after processing this many artworks}
{--chunk=50 : DB chunk size}
{--dry-run : Print tags but do not persist them}
{--skip-tagged : Skip artworks that already have at least one AI tag}
{--url-only : Send CDN URL instead of base64 (only works if LM Studio can reach the CDN)}
{--dump-curl : Print the equivalent curl command for the API call and skip the actual request}
{--debug : Print CDN URL, file size, magic bytes and data-URI prefix for each image}
{--url= : LM Studio base URL (overrides config/env)}
{--model= : Model identifier (overrides config/env)}
{--clear-ai-tags : Delete existing AI tags for each artwork before re-tagging}
';
protected $description = 'Generate tags for artworks via a local LM Studio vision model';
// -------------------------------------------------------------------------
// Prompt
// -------------------------------------------------------------------------
private const SYSTEM_PROMPT = <<<'PROMPT'
You are a precise visual-art tagging engine for an artwork gallery.
Your task is to analyse an artwork image and generate high-quality search tags that are useful for discovery, filtering, and categorisation.
Prioritise tags that are:
- visually evident in the image
- concise and specific
- useful for gallery search
Prefer concrete visual concepts over vague opinions.
Do not invent details that are not clearly visible.
Do not include artist names, brands, watermarks, or assumptions about intent unless directly visible.
Return tags that describe:
- subject or scene
- art style or genre
- mood or atmosphere
- colour palette
- technique or medium if visually apparent
- composition or notable visual elements if relevant
Avoid:
- generic filler tags like "beautiful", "nice", "art", "image"
- duplicate or near-duplicate tags
- full sentences
- overly broad tags when a more specific one is visible
Output must be deterministic, compact, and consistent.
PROMPT;
private const USER_PROMPT = <<<'PROMPT'
Analyse this artwork image and return a JSON array of relevant tags.
Requirements:
- Return ONLY a valid JSON array of lowercase strings.
- No markdown, no explanation, no extra text.
- Output between 8 and 14 tags.
- Each tag must be 1 to 3 words.
- Use only letters, numbers, spaces, and hyphens.
- Do not end tags with punctuation.
- Do not include duplicate or near-duplicate tags.
- Order tags from most important to least important.
Focus on tags from these groups when visible:
1. main subject or scene
2. style or genre
3. mood or atmosphere
4. dominant colours
5. medium or technique
6. notable visual elements or composition
Tagging guidelines:
- Prefer specific tags over generic ones.
- Use searchable gallery-style tags.
- Include only what is clearly visible or strongly implied by the image.
- If the artwork is abstract, prioritise style, colour, mood, and composition.
- If the artwork is representational, prioritise subject, setting, style, and mood.
- If a detail is uncertain, leave it out.
Good output example:
["fantasy portrait","digital painting","female warrior","blue tones","dramatic lighting","glowing eyes","cinematic mood","detailed armor"]
Bad output example:
["art","beautiful image","very cool fantasy woman","amazing colors","masterpiece"]
Now return only the JSON array.
PROMPT;
// -------------------------------------------------------------------------
public function __construct(private readonly TagService $tagService)
{
parent::__construct();
}
public function handle(): int
{
$artworkId = $this->option('artwork-id') !== null ? (int) $this->option('artwork-id') : null;
$afterId = max(0, (int) $this->option('after-id'));
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
$chunk = max(1, min((int) $this->option('chunk'), 200));
$dryRun = (bool) $this->option('dry-run');
$skipTagged = (bool) $this->option('skip-tagged');
$dumpCurl = (bool) $this->option('dump-curl');
$verbose = (bool) $this->option('debug');
$useBase64 = !(bool) $this->option('url-only');
$clearAiTags = (bool) $this->option('clear-ai-tags');
$baseUrl = rtrim((string) ($this->option('url') ?: config('vision.lm_studio.base_url')), '/');
$model = (string) ($this->option('model') ?: config('vision.lm_studio.model'));
$maxTags = (int) config('vision.lm_studio.max_tags', 12);
$this->info("LM Studio : {$baseUrl}");
$this->info("Model : {$model}");
$this->info("Image mode : " . ($useBase64 ? 'base64 (default)' : 'CDN URL (--url-only)'));
$this->info("Dry run : " . ($dryRun ? 'YES' : 'no'));
$this->info("Clear AI : " . ($clearAiTags ? 'YES — existing AI tags deleted first' : 'no'));
if ($artworkId !== null) {
$this->info("Artwork ID : {$artworkId} (single-artwork mode)");
}
$this->line('');
// Single-artwork mode: bypass public/approved scope so any artwork can be tested.
if ($artworkId !== null) {
$artwork = Artwork::withTrashed()->find($artworkId);
if ($artwork === null) {
$this->error("Artwork #{$artworkId} not found.");
return self::FAILURE;
}
$limit = 1;
$query = Artwork::withTrashed()->where('id', $artworkId);
} else {
$query = Artwork::query()
->public()
->where('id', '>', $afterId)
->whereNotNull('hash')
->whereNotNull('thumb_ext')
->orderBy('id');
if ($skipTagged) {
// Exclude artworks that already have an AI-sourced tag in the pivot.
$query->whereDoesntHave('tags', fn ($q) => $q->where('artwork_tag.source', 'ai'));
}
}
$processed = 0;
$tagged = 0;
$skipped = 0;
$errors = 0;
$query->chunkById($chunk, function ($artworks) use (
&$processed, &$tagged, &$skipped, &$errors,
$limit, $dryRun, $dumpCurl, $verbose, $useBase64, $baseUrl, $model, $maxTags, $clearAiTags,
) {
foreach ($artworks as $artwork) {
if ($limit !== null && $processed >= $limit) {
return false; // stop iteration
}
$processed++;
$imageUrl = $artwork->thumbUrl('md');
if ($imageUrl === null) {
$this->warn(" [#{$artwork->id}] No thumb URL — skip");
$skipped++;
continue;
}
$this->line(" [#{$artwork->id}] {$artwork->title}");
// Remove AI tags first if requested.
if ($clearAiTags) {
$aiTagIds = DB::table('artwork_tag')
->where('artwork_id', $artwork->id)
->where('source', 'ai')
->pluck('tag_id')
->all();
if ($aiTagIds !== []) {
if (!$dryRun) {
$this->tagService->detachTags($artwork, $aiTagIds);
}
$this->line(' ✂ Cleared ' . count($aiTagIds) . ' existing AI tag(s)' . ($dryRun ? ' (dry-run)' : ''));
}
}
if ($verbose) {
$this->line(" CDN URL : {$imageUrl}");
}
try {
$tags = $this->fetchTags($baseUrl, $model, $imageUrl, $useBase64, $maxTags, $dumpCurl, $verbose);
} catch (Throwable $e) {
$this->error(" ✗ API error: " . $e->getMessage());
// Show first 120 chars of the response body for easier debugging.
if (str_contains($e->getMessage(), 'status code')) {
$this->line(" (use --dry-run to test without saving)");
}
Log::error('artworks:ai-tag API error', [
'artwork_id' => $artwork->id,
'error' => $e->getMessage(),
]);
$errors++;
continue;
}
if ($tags === []) {
$this->warn(" ✗ No tags returned");
$skipped++;
continue;
}
$tagList = implode(', ', $tags);
$this->line("{$tagList}");
if (!$dryRun) {
$aiTagPayload = array_map(fn (string $t) => ['tag' => $t, 'confidence' => null], $tags);
try {
$this->tagService->attachAiTags($artwork, $aiTagPayload);
$tagged++;
} catch (Throwable $e) {
$this->error(" ✗ Save error: " . $e->getMessage());
Log::error('artworks:ai-tag save error', [
'artwork_id' => $artwork->id,
'error' => $e->getMessage(),
]);
$errors++;
}
} else {
$tagged++;
}
}
});
$this->line('');
$this->info("Done. processed={$processed} tagged={$tagged} skipped={$skipped} errors={$errors}");
return $errors > 0 ? self::FAILURE : self::SUCCESS;
}
// -------------------------------------------------------------------------
// LM Studio API call
// -------------------------------------------------------------------------
/**
* @return list<string>
*/
private function fetchTags(
string $baseUrl,
string $model,
string $imageUrl,
bool $useBase64,
int $maxTags,
bool $dumpCurl = false,
bool $verbose = false,
): array {
$imageContent = $useBase64
? $this->buildBase64ImageContent($imageUrl, $verbose)
: ['type' => 'image_url', 'image_url' => ['url' => $imageUrl]];
$payload = [
'model' => $model,
'temperature' => (float) config('vision.lm_studio.temperature', 0.3),
'max_tokens' => (int) config('vision.lm_studio.max_tokens', 300),
'messages' => [
[
'role' => 'system',
'content' => self::SYSTEM_PROMPT,
],
[
'role' => 'user',
'content' => [
$imageContent,
['type' => 'text', 'text' => self::USER_PROMPT],
],
],
],
];
$timeout = (int) config('vision.lm_studio.timeout', 60);
$connectTimeout = (int) config('vision.lm_studio.connect_timeout', 5);
$endpoint = "{$baseUrl}/v1/chat/completions";
// --dump-curl: write payload to a temp file and print the equivalent curl command.
if ($dumpCurl) {
$jsonPayload = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
// Truncate any base64 data URIs in the printed output so the terminal stays readable.
$printable = preg_replace(
'/("data:[^;]+;base64,)([A-Za-z0-9+\/=]{60})[A-Za-z0-9+\/=]+(")/',
'$1$2...[base64 truncated]$3',
$jsonPayload,
) ?? $jsonPayload;
// Write the full (untruncated) payload to a temp file for use with curl --data.
$tmpJson = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'sbtag_payload_' . uniqid() . '.json';
file_put_contents($tmpJson, $jsonPayload);
$this->line('');
$this->line('<fg=yellow>--- Payload (base64 truncated for display) ---</>');
$this->line($printable);
$this->line('');
$this->line('<fg=yellow>--- curl command (full payload in temp file) ---</>');
$this->line(
'curl -s -X POST ' . escapeshellarg($endpoint)
. ' -H ' . escapeshellarg('Content-Type: application/json')
. ' --data @' . escapeshellarg($tmpJson)
. ' | python -m json.tool'
);
$this->line('');
$this->info("Full JSON payload written to: {$tmpJson}");
// Return empty — no real API call is made.
return [];
}
$response = Http::timeout($timeout)
->connectTimeout($connectTimeout)
->post($endpoint, $payload)
->throw();
$body = $response->json();
$content = $body['choices'][0]['message']['content'] ?? '';
return $this->parseTags((string) $content, $maxTags);
}
/**
* Download the image using the system curl binary (raw bytes, no encoding surprises),
* base64-encode from the local file, then delete it.
*
* Using curl directly is more reliable than the Laravel Http client here because it
* avoids gzip/deflate decoding issues, chunked-transfer quirks, and header parsing
* edge cases that could corrupt the image bytes before encoding.
*
* @return array<string, mixed>
* @throws \RuntimeException if curl fails or the file is empty
*/
private function buildBase64ImageContent(string $imageUrl, bool $verbose = false): array
{
$ext = strtolower(pathinfo(parse_url($imageUrl, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION));
$mime = match ($ext) {
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
default => 'image/jpeg',
};
$tmpPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'sbtag_' . uniqid() . '.' . ($ext ?: 'jpg');
try {
exec(
'curl -s -f -L --max-time 30 -o ' . escapeshellarg($tmpPath) . ' ' . escapeshellarg($imageUrl),
$output,
$exitCode,
);
if ($exitCode !== 0 || !file_exists($tmpPath) || filesize($tmpPath) === 0) {
throw new \RuntimeException("curl failed to download image (exit={$exitCode}, size=" . (file_exists($tmpPath) ? filesize($tmpPath) : 'N/A') . "): {$imageUrl}");
}
$rawBytes = file_get_contents($tmpPath);
if ($rawBytes === false || $rawBytes === '') {
throw new \RuntimeException("file_get_contents returned empty after curl download: {$tmpPath}");
}
// LM Studio does not support WebP. Convert to JPEG via GD if needed.
if ($mime === 'image/webp') {
$convertedPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'sbtag_conv_' . uniqid() . '.jpg';
try {
if (!function_exists('imagecreatefromwebp')) {
throw new \RuntimeException('GD extension with WebP support is required to convert WebP images. Enable ext-gd with WebP support in php.ini.');
}
$img = imagecreatefromwebp($tmpPath);
if ($img === false) {
throw new \RuntimeException("GD failed to load WebP: {$tmpPath}");
}
imagejpeg($img, $convertedPath, 92);
imagedestroy($img);
$rawBytes = file_get_contents($convertedPath);
$mime = 'image/jpeg';
if ($verbose) {
$this->line(' Convert : WebP → JPEG (LM Studio does not accept WebP)');
}
} finally {
@unlink($convertedPath);
}
}
if ($verbose) {
$fileSize = filesize($tmpPath);
// Show first 8 bytes as hex to confirm it's a real image, not an HTML error page.
$magicHex = strtoupper(bin2hex(substr($rawBytes, 0, 8)));
$this->line(" File : {$tmpPath}");
$this->line(" Size : {$fileSize} bytes");
$this->line(" Magic : {$magicHex} (JPEG=FFD8FF, PNG=89504E47, WEBP=52494646)");
}
$base64 = base64_encode($rawBytes);
$dataUri = "data:{$mime};base64,{$base64}";
if ($verbose) {
$this->line(" MIME : {$mime}");
$this->line(" URI pfx : " . substr($dataUri, 0, 60) . '...');
}
} finally {
@unlink($tmpPath);
}
return ['type' => 'image_url', 'image_url' => ['url' => $dataUri]];
}
// -------------------------------------------------------------------------
// Response parsing
// -------------------------------------------------------------------------
/**
* Extract a JSON array from the model's response text.
*
* The model should return just the array, but may include surrounding text
* or markdown code fences, so we search for the first `[…]` block.
*
* @return list<string>
*/
private function parseTags(string $content, int $maxTags): array
{
$content = trim($content);
// Strip markdown code fences if present (```json … ```)
$content = preg_replace('/^```(?:json)?\s*/i', '', $content) ?? $content;
$content = preg_replace('/\s*```$/', '', $content) ?? $content;
// Extract the first JSON array from the text.
if (!preg_match('/(\[.*?\])/s', $content, $matches)) {
return [];
}
$decoded = json_decode($matches[1], true);
if (!is_array($decoded)) {
return [];
}
$tags = [];
foreach ($decoded as $item) {
if (!is_string($item)) {
continue;
}
$clean = trim(strtolower((string) $item));
if ($clean !== '') {
$tags[] = $clean;
}
}
// Respect the configured max-tags ceiling.
return array_slice(array_unique($tags), 0, $maxTags);
}
}

View File

@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Maturity\ArtworkMaturityAuditService;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\Vision\VisionService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Throwable;
final class AuditArtworkMaturityThumbnailsCommand extends Command
{
protected $signature = 'artworks:audit-thumbnail-maturity
{--id= : Audit only this artwork ID}
{--after-id=0 : Skip artworks with ID less than or equal to this value}
{--limit= : Stop after processing this many artworks}
{--chunk=25 : Number of artworks to scan per batch}
{--variant= : Thumbnail variant to analyze (defaults to vision.image_variant)}
{--refresh : Re-scan artworks that already have an open audit finding}
{--dry-run : Report candidates without writing audit findings}';
protected $description = 'Scan artwork thumbnails for possible mature content without mutating artwork maturity fields.';
public function handle(VisionService $vision, ArtworkMaturityAuditService $audit): int
{
$artworkId = $this->option('id') !== null ? max(1, (int) $this->option('id')) : null;
$afterId = max(0, (int) $this->option('after-id'));
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
$chunkSize = max(1, min((int) $this->option('chunk'), 200));
$dryRun = (bool) $this->option('dry-run');
$refresh = (bool) $this->option('refresh');
$variant = trim((string) ($this->option('variant') ?: config('vision.image_variant', 'md')));
if (! $vision->isEnabled()) {
$this->error('Vision maturity analysis is disabled.');
return self::FAILURE;
}
if (! $dryRun && ! Schema::hasTable('artwork_maturity_audit_findings')) {
$this->error('Artwork maturity audit findings table is missing. Run the latest database migrations first.');
return self::FAILURE;
}
$this->info(sprintf(
'Starting artwork maturity thumbnail audit. order=id_desc variant=%s chunk=%d limit=%s refresh=%s dry_run=%s',
$variant !== '' ? $variant : 'md',
$chunkSize,
$limit !== null ? (string) $limit : 'all',
$refresh ? 'yes' : 'no',
$dryRun ? 'yes' : 'no',
));
$query = $audit->eligibleArtworkQuery($refresh)
->orderByDesc('id');
if ($artworkId !== null) {
$query->whereKey($artworkId);
}
if ($afterId > 0) {
$query->where('id', '>', $afterId);
}
$processed = 0;
$flagged = 0;
$safe = 0;
$written = 0;
$failed = 0;
$query->chunkByIdDesc($chunkSize, function ($artworks) use ($vision, $audit, $variant, $limit, $dryRun, $refresh, &$processed, &$flagged, &$safe, &$written, &$failed) {
foreach ($artworks as $artwork) {
if ($limit !== null && $processed >= $limit) {
return false;
}
try {
$assessment = (array) ($vision->analyzeArtworkMaturityDetailed($artwork, (string) $artwork->hash, $variant)['assessment'] ?? []);
$processed++;
if ($audit->shouldOpenFinding($assessment)) {
$flagged++;
$message = sprintf(
'Artwork %d flagged for moderator review. action=%s confidence=%s label=%s',
(int) $artwork->id,
(string) ($assessment['action_hint'] ?? 'unknown'),
is_numeric($assessment['confidence'] ?? null) ? number_format((float) $assessment['confidence'], 4, '.', '') : 'n/a',
(string) ($assessment['maturity_label'] ?? 'unknown'),
);
$this->warn($message);
Log::warning('artworks:audit-thumbnail-maturity candidate detected', [
'artwork_id' => (int) $artwork->id,
'title' => (string) $artwork->title,
'assessment' => $assessment,
'variant' => $variant,
]);
if (! $dryRun) {
$audit->recordFinding($artwork, $assessment, $variant !== '' ? $variant : 'md');
$written++;
}
continue;
}
if (($assessment['status'] ?? ArtworkMaturityService::AI_STATUS_FAILED) === ArtworkMaturityService::AI_STATUS_SUCCEEDED) {
$safe++;
$this->line(sprintf('Artwork %d scanned safe for audit purposes.', (int) $artwork->id));
if (! $dryRun && $refresh) {
$audit->markFindingCleared($artwork, 'Thumbnail maturity rescan no longer indicates moderator review.');
}
continue;
}
$failed++;
$this->warn(sprintf(
'Artwork %d maturity audit failed: %s',
(int) $artwork->id,
(string) ($assessment['advisory'] ?? $assessment['status'] ?? 'unknown failure'),
));
} catch (Throwable $exception) {
$processed++;
$failed++;
$this->warn(sprintf('Artwork %d audit failed: %s', (int) $artwork->id, $exception->getMessage()));
Log::warning('artworks:audit-thumbnail-maturity failed', [
'artwork_id' => (int) $artwork->id,
'title' => (string) $artwork->title,
'variant' => $variant,
'error' => $exception->getMessage(),
]);
}
}
return true;
});
$this->info(sprintf(
'Artwork maturity thumbnail audit complete. processed=%d flagged=%d safe=%d written=%d failed=%d',
$processed,
$flagged,
$safe,
$written,
$failed,
));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
}

View File

@@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use App\Services\Uploads\UploadStorageService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Throwable;
final class AuditArtworkThumbnailsCommand extends Command
{
protected $signature = 'artworks:audit-thumbnails
{--id= : Audit only this artwork ID}
{--limit= : Stop after processing this many artworks}
{--chunk=200 : Number of artworks to scan per batch}
{--variant=* : Specific thumbnail variants to check (defaults to all configured derivatives)}
{--dry-run : Report missing thumbnails without updating the artworks table}';
protected $description = 'Check artwork thumbnails on the configured object storage disk and mark artworks with missing thumbnails.';
public function handle(UploadStorageService $storage): int
{
$artworkId = $this->option('id') !== null ? max(1, (int) $this->option('id')) : null;
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
$chunkSize = max(1, min((int) $this->option('chunk'), 1000));
$dryRun = (bool) $this->option('dry-run');
$variants = $this->resolveVariants();
if ($variants === []) {
$this->error('No thumbnail variants are configured. Check uploads.derivatives.');
return self::FAILURE;
}
if (! $dryRun && ! Schema::hasColumns('artworks', [
'has_missing_thumbnails',
'missing_thumbnail_variants_json',
'thumbnails_checked_at',
])) {
$this->error('Artwork thumbnail audit columns are missing. Run the latest database migrations first.');
return self::FAILURE;
}
$diskName = $storage->objectDiskName();
$diskConfig = config("filesystems.disks.{$diskName}");
if (! is_array($diskConfig)) {
$this->error("Filesystem disk [{$diskName}] is not configured.");
return self::FAILURE;
}
$disk = Storage::disk($diskName);
$this->info(sprintf(
'Starting thumbnail audit. disk=%s variants=%s chunk=%d limit=%s dry_run=%s',
$diskName,
implode(',', $variants),
$chunkSize,
$limit !== null ? (string) $limit : 'all',
$dryRun ? 'yes' : 'no',
));
$query = Artwork::query()
->select(['id', 'hash', 'thumb_ext'])
->orderBy('id');
if ($artworkId !== null) {
$query->whereKey($artworkId);
}
$processed = 0;
$healthy = 0;
$missing = 0;
$written = 0;
$failed = 0;
$query->chunkById($chunkSize, function ($artworks) use ($storage, $disk, $variants, $limit, $dryRun, &$processed, &$healthy, &$missing, &$written, &$failed) {
foreach ($artworks as $artwork) {
if ($limit !== null && $processed >= $limit) {
return false;
}
try {
$missingVariants = $this->resolveMissingVariants($artwork, $variants, $storage, $disk);
$hasMissing = $missingVariants !== [];
if ($hasMissing) {
$missing++;
$this->warn(sprintf(
'Artwork %d missing thumbnails: %s',
(int) $artwork->id,
implode(',', $missingVariants),
));
} else {
$healthy++;
}
if (! $dryRun) {
$this->persistAuditResult((int) $artwork->id, $hasMissing, $missingVariants);
$written++;
}
} catch (Throwable $exception) {
$failed++;
$this->warn(sprintf('Artwork %d audit failed: %s', (int) $artwork->id, $exception->getMessage()));
}
$processed++;
}
return true;
});
$this->info(sprintf(
'Thumbnail audit complete. processed=%d healthy=%d missing=%d written=%d failed=%d',
$processed,
$healthy,
$missing,
$written,
$failed,
));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* @return list<string>
*/
private function resolveVariants(): array
{
$configured = array_keys((array) config('uploads.derivatives', []));
$configured = array_values(array_filter(array_map(
static fn ($variant): string => strtolower(trim((string) $variant)),
$configured,
)));
$requested = (array) $this->option('variant');
if ($requested === []) {
return $configured;
}
$normalizedRequested = array_values(array_unique(array_filter(array_map(
static fn ($variant): string => strtolower(trim((string) $variant)),
$requested,
))));
$invalid = array_values(array_diff($normalizedRequested, $configured));
if ($invalid !== []) {
$this->error('Unknown thumbnail variants: ' . implode(', ', $invalid));
$this->line('Configured variants: ' . implode(', ', $configured));
return [];
}
return $normalizedRequested;
}
/**
* @param list<string> $variants
* @return list<string>
*/
private function resolveMissingVariants(Artwork $artwork, array $variants, UploadStorageService $storage, mixed $disk): array
{
$hash = strtolower((string) preg_replace('/[^a-z0-9]/', '', (string) ($artwork->hash ?? '')));
$thumbExt = strtolower(ltrim((string) ($artwork->thumb_ext ?? ''), '.'));
if ($hash === '' || $thumbExt === '') {
return $variants;
}
$filename = $hash . '.' . $thumbExt;
$missing = [];
foreach ($variants as $variant) {
$objectPath = $storage->objectPathForVariant($variant, $hash, $filename);
if (! $disk->exists($objectPath)) {
$missing[] = $variant;
}
}
return $missing;
}
/**
* @param list<string> $missingVariants
*/
private function persistAuditResult(int $artworkId, bool $hasMissing, array $missingVariants): void
{
DB::table('artworks')
->where('id', $artworkId)
->update([
'has_missing_thumbnails' => $hasMissing,
'missing_thumbnail_variants_json' => $missingVariants === []
? null
: json_encode(array_values($missingVariants), JSON_UNESCAPED_SLASHES),
'thumbnails_checked_at' => now(),
]);
}
}

View File

@@ -0,0 +1,519 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\Finder\Finder;
class AuditMigrationSchemaCommand extends Command
{
protected $signature = 'schema:audit-migrations
{--all-files : Audit all discovered migration files, not only migrations marked as ran}
{--json : Output the report as JSON}
{--base-path=* : Additional base paths to scan for migrations, relative to project root}';
protected $description = 'Compare the live database schema against executed migration files and report missing tables or columns';
private const NO_ARG_COLUMN_METHODS = [
'id' => ['id'],
'timestamps' => ['created_at', 'updated_at'],
'timestampsTz' => ['created_at', 'updated_at'],
'softDeletes' => ['deleted_at'],
'softDeletesTz' => ['deleted_at'],
'rememberToken' => ['remember_token'],
];
private const NON_COLUMN_METHODS = [
'index',
'unique',
'primary',
'foreign',
'foreignIdFor',
'dropColumn',
'dropColumns',
'dropIndex',
'dropUnique',
'dropPrimary',
'dropForeign',
'dropConstrainedForeignId',
'renameColumn',
'renameIndex',
'constrained',
'cascadeOnDelete',
'restrictOnDelete',
'nullOnDelete',
'cascadeOnUpdate',
'restrictOnUpdate',
'nullOnUpdate',
'after',
'nullable',
'default',
'useCurrent',
'useCurrentOnUpdate',
'comment',
'charset',
'collation',
'storedAs',
'virtualAs',
'generatedAs',
'always',
'invisible',
'first',
];
public function handle(): int
{
$migrationFiles = $this->discoverMigrationFiles();
$ranMigrations = collect(DB::table('migrations')->pluck('migration')->all())
->mapWithKeys(fn (string $migration): array => [$migration => true])
->all();
$expected = [];
$parsedFiles = 0;
foreach ($migrationFiles as $migrationName => $path) {
if (! $this->option('all-files') && ! isset($ranMigrations[$migrationName])) {
continue;
}
$parsedFiles++;
$operations = $this->parseMigrationFile($path);
foreach ($operations as $operation) {
$table = $operation['table'];
if ($operation['type'] === 'create-table' && isset($expected[$table])) {
$expected[$table]['sources'][$migrationName] = true;
if (Schema::hasTable($table)) {
$actualColumns = array_fill_keys(
array_map('strtolower', Schema::getColumnListing($table)),
true
);
$existingColumns = array_fill_keys(array_keys($expected[$table]['columns']), true);
$replacementColumns = [];
foreach ($operation['add'] as $column) {
if (! isset($existingColumns[$column]) && isset($actualColumns[$column])) {
$replacementColumns[$column] = true;
}
}
if ($replacementColumns !== []) {
foreach ($replacementColumns as $column => $_) {
$expected[$table]['columns'][$column] = true;
}
foreach (array_keys($expected[$table]['columns']) as $column) {
if (! isset($actualColumns[$column]) && ! isset($replacementColumns[$column])) {
unset($expected[$table]['columns'][$column]);
}
}
}
}
continue;
}
if ($operation['type'] === 'alter-table' && ! isset($expected[$table]) && ! Schema::hasTable($table)) {
continue;
}
$expected[$table] ??= [
'columns' => [],
'sources' => [],
];
$expected[$table]['sources'][$migrationName] = true;
if ($operation['type'] === 'drop-table') {
unset($expected[$table]);
continue;
}
foreach ($operation['add'] as $column) {
$expected[$table]['columns'][$column] = true;
}
foreach ($operation['drop'] as $column) {
unset($expected[$table]['columns'][$column]);
}
}
}
ksort($expected);
$report = [
'parsed_files' => $parsedFiles,
'expected_tables' => count($expected),
'missing_tables' => [],
'missing_columns' => [],
];
foreach ($expected as $table => $spec) {
$sources = array_keys($spec['sources']);
sort($sources);
if (! Schema::hasTable($table)) {
$report['missing_tables'][] = [
'table' => $table,
'sources' => $sources,
];
continue;
}
$actualColumns = array_map('strtolower', Schema::getColumnListing($table));
$expectedColumns = array_keys($spec['columns']);
sort($expectedColumns);
$missing = array_values(array_diff($expectedColumns, $actualColumns));
if ($missing !== []) {
$report['missing_columns'][] = [
'table' => $table,
'columns' => $missing,
'sources' => $sources,
];
}
}
if ((bool) $this->option('json')) {
$this->line(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
} else {
$this->renderReport($report);
}
return ($report['missing_tables'] === [] && $report['missing_columns'] === [])
? self::SUCCESS
: self::FAILURE;
}
/**
* @return array<string, string>
*/
private function discoverMigrationFiles(): array
{
$paths = [
database_path('migrations'),
base_path('packages/klevze'),
];
foreach ((array) $this->option('base-path') as $relativePath) {
$resolved = base_path((string) $relativePath);
if (is_dir($resolved)) {
$paths[] = $resolved;
}
}
$finder = new Finder();
$finder->files()->name('*.php');
foreach ($paths as $path) {
if (is_dir($path)) {
$finder->in($path);
}
}
$files = [];
foreach ($finder as $file) {
$realPath = $file->getRealPath();
if (! $realPath) {
continue;
}
$normalized = str_replace('\\', '/', $realPath);
if (! str_contains($normalized, '/database/migrations/') && ! str_contains($normalized, '/Migrations/')) {
continue;
}
$files[pathinfo($realPath, PATHINFO_FILENAME)] = $realPath;
}
ksort($files);
return $files;
}
/**
* @return array<int, array{type:string, table:string, add:array<int,string>, drop:array<int,string>}>
*/
private function parseMigrationFile(string $path): array
{
$content = File::get($path);
$upBody = $this->extractMethodBody($content, 'up');
if ($upBody === null) {
return [];
}
$operations = [];
foreach ($this->extractSchemaClosures($upBody) as $closure) {
$operations[] = [
'type' => $closure['operation'],
'table' => $closure['table'],
'add' => $this->extractAddedColumns($closure['body']),
'drop' => $this->extractDroppedColumns($closure['body']),
];
}
if (preg_match_all("/Schema::drop(?:IfExists)?\(\s*['\"]([^'\"]+)['\"]\s*\)/", $upBody, $matches)) {
foreach ($matches[1] as $table) {
$operations[] = [
'type' => 'drop-table',
'table' => strtolower((string) $table),
'add' => [],
'drop' => [],
];
}
}
foreach ($this->extractRawAlterTableChanges($upBody) as $change) {
$operations[] = [
'type' => 'alter-table',
'table' => $change['table'],
'add' => [$change['new_column']],
'drop' => [$change['old_column']],
];
}
return $operations;
}
/**
* @return array<int, array{table:string, old_column:string, new_column:string}>
*/
private function extractRawAlterTableChanges(string $upBody): array
{
$changes = [];
if (preg_match_all(
'/ALTER\s+TABLE\s+[`"]?([^`"\s]+)[`"]?\s+CHANGE(?:\s+COLUMN)?\s+[`"]?([^`"\s]+)[`"]?\s+[`"]?([^`"\s]+)[`"]?/i',
$upBody,
$matches,
PREG_SET_ORDER
)) {
foreach ($matches as $match) {
$oldColumn = strtolower((string) $match[2]);
$newColumn = strtolower((string) $match[3]);
if ($oldColumn === $newColumn) {
continue;
}
$changes[] = [
'table' => strtolower((string) $match[1]),
'old_column' => $oldColumn,
'new_column' => $newColumn,
];
}
}
return $changes;
}
private function extractMethodBody(string $content, string $method): ?string
{
if (! preg_match('/function\s+' . preg_quote($method, '/') . '\s*\([^)]*\)\s*(?::\s*[^{]+)?\s*\{/m', $content, $match, PREG_OFFSET_CAPTURE)) {
return null;
}
$start = $match[0][1] + strlen($match[0][0]) - 1;
$end = $this->findMatchingBrace($content, $start);
if ($end === null) {
return null;
}
return substr($content, $start + 1, $end - $start - 1);
}
private function findMatchingBrace(string $content, int $openingBracePos): ?int
{
$length = strlen($content);
$depth = 0;
$inSingle = false;
$inDouble = false;
for ($index = $openingBracePos; $index < $length; $index++) {
$char = $content[$index];
$prev = $index > 0 ? $content[$index - 1] : '';
if ($char === "'" && ! $inDouble && $prev !== '\\') {
$inSingle = ! $inSingle;
continue;
}
if ($char === '"' && ! $inSingle && $prev !== '\\') {
$inDouble = ! $inDouble;
continue;
}
if ($inSingle || $inDouble) {
continue;
}
if ($char === '{') {
$depth++;
continue;
}
if ($char === '}') {
$depth--;
if ($depth === 0) {
return $index;
}
}
}
return null;
}
/**
* @return array<int, array{operation:string, table:string, body:string}>
*/
private function extractSchemaClosures(string $upBody): array
{
preg_match_all('/Schema::(create|table)\(\s*[\'\"]([^\'\"]+)[\'\"]\s*,\s*function/s', $upBody, $matches, PREG_OFFSET_CAPTURE);
$closures = [];
foreach ($matches[0] as $index => $fullMatch) {
$offset = (int) $fullMatch[1];
$operation = strtolower((string) $matches[1][$index][0]) === 'create' ? 'create-table' : 'alter-table';
$table = strtolower((string) $matches[2][$index][0]);
$bracePos = strpos($upBody, '{', $offset);
if ($bracePos === false) {
continue;
}
$closing = $this->findMatchingBrace($upBody, $bracePos);
if ($closing === null) {
continue;
}
$closures[] = [
'operation' => $operation,
'table' => $table,
'body' => substr($upBody, $bracePos + 1, $closing - $bracePos - 1),
];
}
return $closures;
}
/**
* @return array<int, string>
*/
private function extractAddedColumns(string $body): array
{
$columns = [];
if (preg_match_all('/\$table->([A-Za-z_][A-Za-z0-9_]*)\(\s*[\'\"]([^\'\"]+)[\'\"][^;]*\);/s', $body, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$method = (string) $match[1];
$column = strtolower((string) $match[2]);
if (in_array($method, self::NON_COLUMN_METHODS, true)) {
continue;
}
$columns[$column] = true;
}
}
if (preg_match_all('/\$table->([A-Za-z_][A-Za-z0-9_]*)\(\s*\)(?:[^;]*)?;/s', $body, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$method = (string) $match[1];
foreach (self::NO_ARG_COLUMN_METHODS[$method] ?? [] as $column) {
$columns[$column] = true;
}
}
}
if (preg_match_all('/\$table->(nullableMorphs|morphs|uuidMorphs|nullableUuidMorphs|ulidMorphs|nullableUlidMorphs)\(\s*[\'\"]([^\'\"]+)[\'\"][^;]*\);/s', $body, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$prefix = strtolower((string) $match[2]);
$columns[$prefix . '_type'] = true;
$columns[$prefix . '_id'] = true;
}
}
ksort($columns);
return array_keys($columns);
}
/**
* @return array<int, string>
*/
private function extractDroppedColumns(string $body): array
{
$columns = [];
if (preg_match_all('/\$table->dropColumn\(\s*[\'\"]([^\'\"]+)[\'\"][^;]*\);/s', $body, $matches)) {
foreach ($matches[1] as $column) {
$columns[strtolower((string) $column)] = true;
}
}
if (preg_match_all('/\$table->dropColumn\(\s*\[(.*?)\]\s*\);/s', $body, $matches)) {
foreach ($matches[1] as $arrayBody) {
if (preg_match_all('/[\'\"]([^\'\"]+)[\'\"]/', $arrayBody, $columnMatches)) {
foreach ($columnMatches[1] as $column) {
$columns[strtolower((string) $column)] = true;
}
}
}
}
if (preg_match_all('/\$table->renameColumn\(\s*[\'\"]([^\'\"]+)[\'\"]\s*,\s*[\'\"]([^\'\"]+)[\'\"][^;]*\);/s', $body, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$columns[strtolower((string) $match[1])] = true;
}
}
ksort($columns);
return array_keys($columns);
}
/**
* @param array{parsed_files:int, expected_tables:int, missing_tables:array<int,array{table:string,sources:array<int,string>}>, missing_columns:array<int,array{table:string,columns:array<int,string>,sources:array<int,string>}>} $report
*/
private function renderReport(array $report): void
{
$this->info(sprintf(
'Parsed %d migration file(s). Expected schema covers %d table(s).',
$report['parsed_files'],
$report['expected_tables']
));
if ($report['missing_tables'] === [] && $report['missing_columns'] === []) {
$this->info('Schema audit passed. No missing tables or columns detected.');
return;
}
if ($report['missing_tables'] !== []) {
$this->newLine();
$this->error('Missing tables:');
foreach ($report['missing_tables'] as $item) {
$this->line(sprintf(' - %s', $item['table']));
$this->line(sprintf(' sources: %s', implode(', ', $item['sources'])));
}
}
if ($report['missing_columns'] !== []) {
$this->newLine();
$this->error('Missing columns:');
foreach ($report['missing_columns'] as $item) {
$this->line(sprintf(' - %s: %s', $item['table'], implode(', ', $item['columns'])));
$this->line(sprintf(' sources: %s', implode(', ', $item['sources'])));
}
}
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class AvatarsBulkUpdate extends Command
{
protected $signature = 'avatars:bulk-update
{path=./user_profiles_avatar.csv : CSV file path (user_id,avatar_hash)}
{--dry-run : Do not write to database}
';
protected $description = 'Bulk update user_profiles.avatar_hash from CSV (user_id,avatar_hash)';
public function handle(): int
{
$path = $this->argument('path');
$dry = $this->option('dry-run');
if (!file_exists($path)) {
$this->error("CSV file not found: {$path}");
return 1;
}
$this->info('Reading CSV: ' . $path);
if (($handle = fopen($path, 'r')) === false) {
$this->error('Unable to open CSV file');
return 1;
}
$row = 0;
$updates = 0;
while (($data = fgetcsv($handle)) !== false) {
$row++;
// Skip empty rows
if (count($data) === 0) {
continue;
}
// Expect at least two columns: user_id, avatar_hash
$userId = isset($data[0]) ? trim($data[0]) : null;
$hash = isset($data[1]) ? trim($data[1]) : null;
// If first row looks like a header, skip it
if ($row === 1 && (!is_numeric($userId) || $userId === 'user_id')) {
continue;
}
if ($userId === '' || $hash === '') {
$this->line("[skip] row={$row} invalid data");
continue;
}
$userId = (int) $userId;
if ($dry) {
$this->line("[dry] user={$userId} would set avatar_hash={$hash}");
$updates++;
continue;
}
try {
$affected = DB::table('user_profiles')
->where('user_id', $userId)
->update([ 'avatar_hash' => $hash, 'avatar_updated_at' => now() ]);
if ($affected) {
$this->line("[ok] user={$userId} avatar_hash updated");
$updates++;
} else {
$this->line("[noop] user={$userId} no row updated (missing profile?)");
}
} catch (\Throwable $e) {
$this->error("[error] user={$userId} {$e->getMessage()}");
continue;
}
}
fclose($handle);
$this->info("Done. Processed rows={$row} updates={$updates}");
return 0;
}
}

View File

@@ -0,0 +1,415 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\DB;
use App\Models\User;
use App\Models\UserProfile;
use Intervention\Image\ImageManagerStatic as Image;
use Carbon\Carbon;
class AvatarsMigrate extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'avatars:migrate
{--dry-run : Do not write files or update database}
{--force : Overwrite existing migrated avatars}
{--remove-legacy : Remove legacy files after successful migration}
{--path=public/files/usericons : Legacy path to scan}
{--user-id= : Only migrate a single user by ID}
';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Migrate legacy avatars from public/files/usericons to storage/app/public/avatars and generate sizes (WebP)';
/**
* Allowed MIME types for source images.
*
* @var array
*/
protected $allowed = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
];
/**
* Target sizes to generate.
*
* @var int[]
*/
protected $sizes = [32, 40, 64, 80, 96, 128, 256, 512];
public function handle(): int
{
$dry = $this->option('dry-run');
$force = $this->option('force');
$removeLegacy = $this->option('remove-legacy');
$legacyPath = base_path($this->option('path'));
$userId = $this->option('user-id') ? (int) $this->option('user-id') : null;
$verbose = $this->output->isVerbose();
$this->info('Starting avatar migration' . ($dry ? ' (dry-run)' : '') . ($userId ? " for user={$userId}" : ''));
// Detect processing backend: Intervention preferred, GD fallback
$useIntervention = class_exists('Intervention\\Image\\ImageManagerStatic');
if ($useIntervention) {
Image::configure(['driver' => extension_loaded('imagick') ? 'imagick' : 'gd']);
}
$bar = null;
$query = User::with('profile');
if ($userId) {
$query->where('id', $userId);
}
$query->chunk(100, function ($users) use ($dry, $force, $removeLegacy, $legacyPath, &$bar, $useIntervention, $verbose) {
foreach ($users as $user) {
/** @var UserProfile|null $profile */
$profile = $user->profile;
if (!$profile) {
continue;
}
// Skip if already migrated unless --force
if (!$force && !empty($profile->avatar_hash)) {
$this->line("[skip] user={$user->id} already migrated");
continue;
}
$source = $this->findLegacyFile($profile, $user->id, $legacyPath, 'legacy');
//dd($source);
if (!$source) {
if ($verbose) {
$this->line("[noop] user={$user->id} no legacy file found");
}
continue;
}
try {
$this->line("[proc] user={$user->id} file={$source}");
if ($useIntervention) {
$img = Image::make($source);
$mime = $img->mime();
} else {
$info = @getimagesize($source);
$mime = $info['mime'] ?? null;
}
if (!in_array($mime, $this->allowed, true)) {
$this->line("[reject] user={$user->id} unsupported mime={$mime}");
continue;
}
// Re-encode full original to webp (strip metadata)
if ($useIntervention) {
$originalBlob = (string) $img->encode('webp', 82);
} else {
$originalBlob = $this->gdEncodeWebp($source, 82);
}
// Hybrid hash: deterministic user-id fingerprint + short content fingerprint
// idPart = sha1(zero-padded user id), contentPart = first 12 chars of sha1(original webp blob)
$idPart = sha1(sprintf('%08d', $user->id));
$contentPart = substr(sha1($originalBlob), 0, 12);
$hash = sprintf('%s_%s', $idPart, $contentPart);
// Precompute storage dir for dry-run and real run
$hashPrefix1 = substr($hash, 0, 2);
$hashPrefix2 = substr($hash, 2, 2);
$dir = "avatars/{$hashPrefix1}/{$hashPrefix2}/{$hash}";
// CDN base for public URLs
$cdnBase = rtrim((string) config('cdn.avatar_url', 'https://files.skinbase.org'), '/');
if ($dry) {
$absPathDry = Storage::disk('public')->path("{$dir}/original.webp");
$publicUrlDry = sprintf('%s/%s/original.webp?v=%s', $cdnBase, $dir, $hash);
$this->line("[dry] user={$user->id} would write avatars for hash={$hash} path={$absPathDry} url={$publicUrlDry}");
} else {
// Save original.webp
Storage::disk('public')->put("{$dir}/original.webp", $originalBlob);
// Generate sizes
foreach ($this->sizes as $size) {
if ($useIntervention) {
$thumb = Image::make($source)->fit($size, $size, function ($constraint) {
$constraint->upsize();
});
$thumbBlob = (string) $thumb->encode('webp', 82);
} else {
$thumbBlob = $this->gdCreateThumbnailWebp($source, $size, 82);
}
Storage::disk('public')->put("{$dir}/{$size}.webp", $thumbBlob);
}
// Update DB
$profile->avatar_hash = $hash;
$profile->avatar_mime = 'image/webp';
$profile->avatar_updated_at = Carbon::now();
$profile->save();
$absPath = Storage::disk('public')->path("{$dir}/original.webp");
$publicUrl = sprintf('%s/%s/original.webp?v=%s', $cdnBase, $dir, $hash);
$this->line("[ok] user={$user->id} migrated hash={$hash} path={$absPath} url={$publicUrl}");
if ($removeLegacy && !empty($profile->avatar_legacy)) {
$legacyFile = base_path("public/files/usericons/{$profile->avatar_legacy}");
if (file_exists($legacyFile)) {
@unlink($legacyFile);
$this->line("[rm] removed legacy file {$legacyFile}");
}
}
}
} catch (\Exception $e) {
$this->error("[error] user={$user->id} {$e->getMessage()}");
continue;
}
}
});
$this->info('Avatar migration complete');
return 0;
}
/**
* Try to find a legacy avatar file for a user/profile.
*
* @param UserProfile $profile
* @param int $userId
* @param string $legacyBase
* @return string|null
*/
protected function findLegacyFile(UserProfile $profile, int $userId, string $legacyBase, ?string $legacyConnection = null): ?string
{
$avatar = DB::connection('legacy')->table('users')->where('user_id', $userId)->value('icon');
if (!empty($profile->avatar_legacy)) {
$p = $legacyBase . DIRECTORY_SEPARATOR . $avatar;
if (file_exists($p)) {
return $p;
}
}
// 1) If profile->avatar_legacy looks like a filename, try it
if (!empty($profile->avatar_legacy)) {
$p = $legacyBase . DIRECTORY_SEPARATOR . $profile->avatar_legacy;
if (file_exists($p)) {
return $p;
}
}
// 2) Try files named by user id with common extensions
$exts = ['png','jpg','jpeg','webp','gif'];
foreach ($exts as $ext) {
$p = $legacyBase . DIRECTORY_SEPARATOR . "{$userId}.{$ext}";
if (file_exists($p)) {
return $p;
}
}
// 3) Try any file under legacy dir that contains the user id in name
if (is_dir($legacyBase)) {
$files = glob($legacyBase . DIRECTORY_SEPARATOR . "*{$userId}*.*");
if (!empty($files)) {
return $files[0];
}
}
// 4) Fallback: try legacy database connection (connection name 'legacy')
// If a legacy DB connection is configured, query `users.icon` for avatar filename.
try {
$conn = $legacyConnection ?: (config('database.connections.legacy') ? 'legacy' : null);
if ($conn) {
$icon = DB::connection($conn)->table('users')->where('id', $userId)->value('icon');
if (!empty($icon)) {
// If icon looks like an absolute path, use it directly; otherwise resolve under legacy base path
$p = $icon;
if (!file_exists($p)) {
$p = $legacyBase . DIRECTORY_SEPARATOR . ltrim($icon, '\/');
}
if (file_exists($p)) {
if ($this->output->isVerbose()) {
$this->line("[legacy-db] user={$userId} icon={$icon} resolved={$p}");
}
return $p;
}
if ($this->output->isVerbose()) {
$this->line("[legacy-db] user={$userId} icon={$icon} not found at resolved path {$p}");
}
}
}
} catch (\Throwable $e) {
// Non-fatal: just skip legacy DB if query fails or connection missing
}
return null;
}
/**
* GD-based encode to WebP binary blob.
*
* @param string $path
* @param int $quality
* @return string
*/
protected function gdEncodeWebp(string $path, int $quality = 82): string
{
if (!function_exists('imagewebp')) {
throw new \RuntimeException('GD imagewebp function is not available. Install Intervention Image or enable GD WebP support.');
}
$src = $this->gdCreateResource($path);
if (!$src) {
throw new \RuntimeException('Unable to read image for GD processing: ' . $path);
}
ob_start();
imagewebp($src, null, $quality);
$data = ob_get_clean();
imagedestroy($src);
return $data;
}
/**
* Create a center-cropped square thumbnail and return WebP binary.
*
* @param string $path
* @param int $size
* @param int $quality
* @return string
*/
protected function gdCreateThumbnailWebp(string $path, int $size, int $quality = 82): string
{
if (!function_exists('imagewebp')) {
throw new \RuntimeException('GD imagewebp function is not available. Install Intervention Image or enable GD WebP support.');
}
$src = $this->gdCreateResource($path);
if (!$src) {
throw new \RuntimeException('Unable to read image for GD processing: ' . $path);
}
$w = imagesx($src);
$h = imagesy($src);
$min = min($w, $h);
$srcX = (int) floor(($w - $min) / 2);
$srcY = (int) floor(($h - $min) / 2);
$dst = imagecreatetruecolor($size, $size);
// preserve transparency
imagealphablending($dst, false);
imagesavealpha($dst, true);
imagecopyresampled($dst, $src, 0, 0, $srcX, $srcY, $size, $size, $min, $min);
ob_start();
imagewebp($dst, null, $quality);
$data = ob_get_clean();
imagedestroy($src);
imagedestroy($dst);
return $data;
}
/**
* Create GD image resource from file path.
*
* @param string $path
* @return resource|false
*/
protected function gdCreateResource(string $path)
{
$info = @getimagesize($path);
if (!$info) {
return false;
}
$mime = $info['mime'] ?? '';
switch ($mime) {
case 'image/jpeg':
return imagecreatefromjpeg($path);
case 'image/png':
return imagecreatefrompng($path);
case 'image/webp':
if (function_exists('imagecreatefromwebp')) {
return imagecreatefromwebp($path);
}
return false;
case 'image/gif':
if (function_exists('imagecreatefromgif')) {
$res = imagecreatefromgif($path);
if (!$res) {
return false;
}
// Ensure returned resource is truecolor (WebP requires truecolor)
if (!imageistruecolor($res)) {
$w = imagesx($res);
$h = imagesy($res);
$true = imagecreatetruecolor($w, $h);
// Preserve transparency where possible
imagealphablending($true, false);
imagesavealpha($true, true);
// Fill with fully transparent color
$transparent = imagecolorallocatealpha($true, 0, 0, 0, 127);
imagefilledrectangle($true, 0, 0, $w, $h, $transparent);
// If the source has an indexed transparent color, try to preserve it
$transIndex = imagecolortransparent($res);
if ($transIndex >= 0) {
try {
$colorTotal = imagecolorstotal($res);
if ($transIndex >= 0 && $transIndex < $colorTotal) {
$colors = imagecolorsforindex($res, $transIndex);
if (is_array($colors)) {
$alphaColor = imagecolorallocatealpha($true, $colors['red'], $colors['green'], $colors['blue'], 127);
imagefilledrectangle($true, 0, 0, $w, $h, $alphaColor);
}
}
} catch (\Throwable $e) {
// Non-fatal: skip preserving indexed transparent color
}
}
// Copy pixels
imagecopy($true, $res, 0, 0, 0, 0, $w, $h);
imagedestroy($res);
return $true;
}
return $res;
}
return false;
default:
return false;
}
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\BackfillArtworkEmbeddingsJob;
use Illuminate\Console\Command;
final class BackfillArtworkEmbeddingsCommand extends Command
{
protected $signature = 'artworks:embeddings-backfill {--after-id=0 : Resume after this artwork id} {--batch=200 : Batch size for resumable fan-out} {--force : Regenerate even when source hash matches}';
protected $description = 'Queue resumable CLIP embedding backfill for artworks';
public function handle(): int
{
$afterId = max(0, (int) $this->option('after-id'));
$batch = max(1, min((int) $this->option('batch'), 1000));
$force = (bool) $this->option('force');
BackfillArtworkEmbeddingsJob::dispatch($afterId, $batch, $force);
$this->info("Queued artwork embedding backfill (after_id={$afterId}, batch={$batch}, force=" . ($force ? 'yes' : 'no') . ').');
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\BackfillArtworkVectorIndexJob;
use Illuminate\Console\Command;
final class BackfillArtworkVectorIndexCommand extends Command
{
protected $signature = 'artworks:vectors-repair {--after-id=0 : Resume after this artwork id} {--batch=200 : Batch size for resumable fan-out} {--public-only : Repair only public, approved, published artworks} {--stale-hours=0 : Repair only artworks never indexed or older than this many hours}';
protected $description = 'Queue resumable vector gateway repair for artworks that already have local embeddings';
public function handle(): int
{
$afterId = max(0, (int) $this->option('after-id'));
$batch = max(1, min((int) $this->option('batch'), 1000));
$publicOnly = (bool) $this->option('public-only');
$staleHours = max(0, (int) $this->option('stale-hours'));
BackfillArtworkVectorIndexJob::dispatch($afterId, $batch, $publicOnly, $staleHours);
$this->info('Queued artwork vector repair (after_id=' . $afterId . ', batch=' . $batch . ', public_only=' . ($publicOnly ? 'yes' : 'no') . ', stale_hours=' . $staleHours . ').');
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,571 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\User;
use App\Models\UserActivity;
use App\Services\Activity\UserActivityService;
use Illuminate\Console\Command;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class BackfillUserActivitiesCommand extends Command
{
protected $signature = 'skinbase:backfill-user-activities
{--chunk=1000 : Number of source records to process per batch}
{--user-id= : Backfill only one actor user id}
{--types=all : Comma-separated groups: all, uploads, comments, likes, follows, achievements, forum}
{--dry-run : Preview inserts without writing changes}';
protected $description = 'Backfill historical profile activity into user_activities for existing users.';
public function __construct(private readonly UserActivityService $activities)
{
parent::__construct();
}
public function handle(): int
{
if (! Schema::hasTable('user_activities')) {
$this->error('The user_activities table does not exist. Run migrations first.');
return self::FAILURE;
}
$chunk = max(1, (int) $this->option('chunk'));
$userId = $this->option('user-id') !== null ? max(1, (int) $this->option('user-id')) : null;
$dryRun = (bool) $this->option('dry-run');
$groups = $this->parseGroups((string) $this->option('types'));
if ($groups === null) {
$this->error('Invalid --types value. Use one or more of: all, uploads, comments, likes, follows, achievements, forum.');
return self::FAILURE;
}
if ($userId !== null && ! User::query()->whereKey($userId)->exists()) {
$this->error("User id={$userId} was not found.");
return self::FAILURE;
}
if ($dryRun) {
$this->warn('[DRY RUN] No activity rows will be inserted.');
}
$this->info('Backfilling historical profile activity.');
$summary = [];
foreach ($groups as $group) {
$groupSummary = match ($group) {
'uploads' => [
'uploads' => $this->backfillUploads($chunk, $userId, $dryRun),
],
'comments' => [
'comments' => $this->backfillArtworkComments($chunk, $userId, $dryRun),
],
'likes' => [
'likes' => $this->backfillArtworkLikes($chunk, $userId, $dryRun),
'favourites' => $this->backfillArtworkFavourites($chunk, $userId, $dryRun),
],
'follows' => [
'follows' => $this->backfillFollows($chunk, $userId, $dryRun),
],
'achievements' => [
'achievements' => $this->backfillAchievements($chunk, $userId, $dryRun),
],
'forum' => [
'forum_posts' => $this->backfillForumThreads($chunk, $userId, $dryRun),
'forum_replies' => $this->backfillForumReplies($chunk, $userId, $dryRun),
],
default => [],
};
$summary = [...$summary, ...$groupSummary];
}
foreach ($summary as $label => $stats) {
$this->line(sprintf(
'%s: processed=%d inserted=%d existing=%d skipped=%d',
$label,
(int) ($stats['processed'] ?? 0),
(int) ($stats['inserted'] ?? 0),
(int) ($stats['existing'] ?? 0),
(int) ($stats['skipped'] ?? 0),
));
}
$totalProcessed = array_sum(array_map(static fn (array $stats): int => (int) ($stats['processed'] ?? 0), $summary));
$totalInserted = array_sum(array_map(static fn (array $stats): int => (int) ($stats['inserted'] ?? 0), $summary));
$totalExisting = array_sum(array_map(static fn (array $stats): int => (int) ($stats['existing'] ?? 0), $summary));
$totalSkipped = array_sum(array_map(static fn (array $stats): int => (int) ($stats['skipped'] ?? 0), $summary));
$this->info(sprintf(
'Finished. processed=%d inserted=%d existing=%d skipped=%d',
$totalProcessed,
$totalInserted,
$totalExisting,
$totalSkipped,
));
return self::SUCCESS;
}
/**
* @return array<int, string>|null
*/
private function parseGroups(string $value): ?array
{
$items = collect(explode(',', strtolower(trim($value))))
->map(static fn (string $item): string => trim($item))
->filter()
->values();
if ($items->isEmpty() || $items->contains('all')) {
return ['uploads', 'comments', 'likes', 'follows', 'achievements', 'forum'];
}
$allowed = ['uploads', 'comments', 'likes', 'follows', 'achievements', 'forum'];
if ($items->contains(static fn (string $item): bool => ! in_array($item, $allowed, true))) {
return null;
}
return $items->unique()->values()->all();
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillUploads(int $chunk, ?int $userId, bool $dryRun): array
{
if (! Schema::hasTable('artworks')) {
return $this->emptyStats();
}
$query = DB::table('artworks')
->select(['id', 'user_id', 'created_at'])
->where('user_id', '>', 0)
->whereExists($this->existingUserSubquery('artworks.user_id'))
->where('is_public', true)
->where('is_approved', true)
->whereNotNull('published_at')
->whereNull('deleted_at')
->when($userId !== null, fn (Builder $builder) => $builder->where('user_id', $userId));
return $this->backfillRows(
label: 'uploads',
query: $query,
chunk: $chunk,
chunkColumn: 'id',
mapper: static fn (object $row): ?array => [
'user_id' => (int) $row->user_id,
'type' => UserActivity::TYPE_UPLOAD,
'entity_type' => UserActivity::ENTITY_ARTWORK,
'entity_id' => (int) $row->id,
'meta' => null,
'created_at' => $row->created_at,
],
dryRun: $dryRun,
);
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillArtworkComments(int $chunk, ?int $userId, bool $dryRun): array
{
if (! Schema::hasTable('artwork_comments') || ! Schema::hasTable('artworks')) {
return $this->emptyStats();
}
$query = DB::table('artwork_comments')
->select(['id', 'user_id', 'parent_id', 'created_at'])
->where('user_id', '>', 0)
->whereExists($this->existingUserSubquery('artwork_comments.user_id'))
->where('is_approved', true)
->whereNull('deleted_at')
->whereExists(function ($subquery): void {
$subquery->selectRaw('1')
->from('artworks')
->whereColumn('artworks.id', 'artwork_comments.artwork_id')
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNotNull('artworks.published_at')
->whereNull('artworks.deleted_at');
})
->when($userId !== null, fn (Builder $builder) => $builder->where('user_id', $userId));
return $this->backfillRows(
label: 'comments',
query: $query,
chunk: $chunk,
chunkColumn: 'id',
mapper: static fn (object $row): ?array => [
'user_id' => (int) $row->user_id,
'type' => $row->parent_id ? UserActivity::TYPE_REPLY : UserActivity::TYPE_COMMENT,
'entity_type' => UserActivity::ENTITY_ARTWORK_COMMENT,
'entity_id' => (int) $row->id,
'meta' => null,
'created_at' => $row->created_at,
],
dryRun: $dryRun,
);
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillArtworkLikes(int $chunk, ?int $userId, bool $dryRun): array
{
if (! Schema::hasTable('artwork_likes') || ! Schema::hasTable('artworks')) {
return $this->emptyStats();
}
$query = DB::table('artwork_likes')
->select(['id', 'user_id', 'artwork_id', 'created_at'])
->where('user_id', '>', 0)
->whereExists($this->existingUserSubquery('artwork_likes.user_id'))
->whereExists(function ($subquery): void {
$subquery->selectRaw('1')
->from('artworks')
->whereColumn('artworks.id', 'artwork_likes.artwork_id')
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNotNull('artworks.published_at')
->whereNull('artworks.deleted_at');
})
->when($userId !== null, fn (Builder $builder) => $builder->where('user_id', $userId));
return $this->backfillRows(
label: 'likes',
query: $query,
chunk: $chunk,
chunkColumn: 'id',
mapper: static fn (object $row): ?array => [
'user_id' => (int) $row->user_id,
'type' => UserActivity::TYPE_LIKE,
'entity_type' => UserActivity::ENTITY_ARTWORK,
'entity_id' => (int) $row->artwork_id,
'meta' => null,
'created_at' => $row->created_at,
],
dryRun: $dryRun,
);
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillArtworkFavourites(int $chunk, ?int $userId, bool $dryRun): array
{
if (! Schema::hasTable('artwork_favourites') || ! Schema::hasTable('artworks')) {
return $this->emptyStats();
}
$query = DB::table('artwork_favourites')
->select(['id', 'user_id', 'artwork_id', 'created_at'])
->where('user_id', '>', 0)
->whereExists($this->existingUserSubquery('artwork_favourites.user_id'))
->whereExists(function ($subquery): void {
$subquery->selectRaw('1')
->from('artworks')
->whereColumn('artworks.id', 'artwork_favourites.artwork_id')
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNotNull('artworks.published_at')
->whereNull('artworks.deleted_at');
})
->when($userId !== null, fn (Builder $builder) => $builder->where('user_id', $userId));
return $this->backfillRows(
label: 'favourites',
query: $query,
chunk: $chunk,
chunkColumn: 'id',
mapper: static fn (object $row): ?array => [
'user_id' => (int) $row->user_id,
'type' => UserActivity::TYPE_FAVOURITE,
'entity_type' => UserActivity::ENTITY_ARTWORK,
'entity_id' => (int) $row->artwork_id,
'meta' => null,
'created_at' => $row->created_at,
],
dryRun: $dryRun,
);
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillFollows(int $chunk, ?int $userId, bool $dryRun): array
{
if (! Schema::hasTable('user_followers')) {
return $this->emptyStats();
}
$query = DB::table('user_followers')
->select(['id', 'follower_id', 'user_id', 'created_at'])
->where('follower_id', '>', 0)
->where('user_id', '>', 0)
->whereExists($this->existingUserSubquery('user_followers.follower_id'))
->whereExists($this->existingUserSubquery('user_followers.user_id'))
->when($userId !== null, fn (Builder $builder) => $builder->where('follower_id', $userId));
return $this->backfillRows(
label: 'follows',
query: $query,
chunk: $chunk,
chunkColumn: 'id',
mapper: static fn (object $row): ?array => [
'user_id' => (int) $row->follower_id,
'type' => UserActivity::TYPE_FOLLOW,
'entity_type' => UserActivity::ENTITY_USER,
'entity_id' => (int) $row->user_id,
'meta' => null,
'created_at' => $row->created_at,
],
dryRun: $dryRun,
);
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillAchievements(int $chunk, ?int $userId, bool $dryRun): array
{
if (! Schema::hasTable('user_achievements')) {
return $this->emptyStats();
}
$query = DB::table('user_achievements')
->select(['id', 'user_id', 'achievement_id', 'unlocked_at'])
->where('user_id', '>', 0)
->whereExists($this->existingUserSubquery('user_achievements.user_id'))
->when($userId !== null, fn (Builder $builder) => $builder->where('user_id', $userId));
return $this->backfillRows(
label: 'achievements',
query: $query,
chunk: $chunk,
chunkColumn: 'id',
mapper: static fn (object $row): ?array => [
'user_id' => (int) $row->user_id,
'type' => UserActivity::TYPE_ACHIEVEMENT,
'entity_type' => UserActivity::ENTITY_ACHIEVEMENT,
'entity_id' => (int) $row->achievement_id,
'meta' => null,
'created_at' => $row->unlocked_at,
],
dryRun: $dryRun,
);
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillForumThreads(int $chunk, ?int $userId, bool $dryRun): array
{
if (! Schema::hasTable('forum_threads')) {
return $this->emptyStats();
}
$query = DB::table('forum_threads')
->select(['id', 'user_id', 'created_at'])
->where('user_id', '>', 0)
->whereExists($this->existingUserSubquery('forum_threads.user_id'))
->where('visibility', 'public')
->whereNull('deleted_at')
->when($userId !== null, fn (Builder $builder) => $builder->where('user_id', $userId));
return $this->backfillRows(
label: 'forum_posts',
query: $query,
chunk: $chunk,
chunkColumn: 'id',
mapper: static fn (object $row): ?array => [
'user_id' => (int) $row->user_id,
'type' => UserActivity::TYPE_FORUM_POST,
'entity_type' => UserActivity::ENTITY_FORUM_THREAD,
'entity_id' => (int) $row->id,
'meta' => null,
'created_at' => $row->created_at,
],
dryRun: $dryRun,
);
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillForumReplies(int $chunk, ?int $userId, bool $dryRun): array
{
if (! Schema::hasTable('forum_posts') || ! Schema::hasTable('forum_threads')) {
return $this->emptyStats();
}
$query = DB::table('forum_posts')
->select(['forum_posts.id', 'forum_posts.user_id', 'forum_posts.created_at'])
->join('forum_threads', 'forum_threads.id', '=', 'forum_posts.thread_id')
->where('forum_posts.user_id', '>', 0)
->whereExists($this->existingUserSubquery('forum_posts.user_id'))
->whereNull('forum_posts.deleted_at')
->where('forum_threads.visibility', 'public')
->whereNull('forum_threads.deleted_at')
->whereRaw('forum_posts.id <> (SELECT MIN(fp2.id) FROM forum_posts as fp2 WHERE fp2.thread_id = forum_posts.thread_id)')
->when(Schema::hasColumn('forum_posts', 'flagged'), fn (Builder $builder) => $builder->where('forum_posts.flagged', false))
->when($userId !== null, fn (Builder $builder) => $builder->where('forum_posts.user_id', $userId));
return $this->backfillRows(
label: 'forum_replies',
query: $query,
chunk: $chunk,
chunkColumn: 'forum_posts.id',
mapper: static fn (object $row): ?array => [
'user_id' => (int) $row->user_id,
'type' => UserActivity::TYPE_FORUM_REPLY,
'entity_type' => UserActivity::ENTITY_FORUM_POST,
'entity_id' => (int) $row->id,
'meta' => null,
'created_at' => $row->created_at,
],
dryRun: $dryRun,
chunkAlias: 'id',
);
}
/**
* @param callable(object): ?array{user_id:int,type:string,entity_type:string,entity_id:int,meta:?array,created_at:mixed} $mapper
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillRows(
string $label,
Builder $query,
int $chunk,
string $chunkColumn,
callable $mapper,
bool $dryRun,
?string $chunkAlias = null,
): array {
$stats = $this->emptyStats();
$query->chunkById($chunk, function (Collection $rows) use (&$stats, $mapper, $dryRun): void {
$stats['processed'] += $rows->count();
$entries = $rows
->map($mapper)
->filter(static fn (?array $entry): bool => $entry !== null && (int) ($entry['user_id'] ?? 0) > 0 && (int) ($entry['entity_id'] ?? 0) > 0 && ! empty($entry['created_at']))
->values();
if ($entries->isEmpty()) {
$stats['skipped'] += $rows->count();
return;
}
$existing = $this->existingKeysForEntries($entries);
$pending = [];
foreach ($entries as $entry) {
$key = $this->entryKey($entry['user_id'], $entry['type'], $entry['entity_type'], $entry['entity_id']);
if (isset($existing[$key])) {
$stats['existing']++;
continue;
}
$pending[] = [
'user_id' => (int) $entry['user_id'],
'type' => (string) $entry['type'],
'entity_type' => (string) $entry['entity_type'],
'entity_id' => (int) $entry['entity_id'],
'meta' => $entry['meta'] !== null
? json_encode($entry['meta'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)
: null,
'created_at' => $entry['created_at'],
];
}
if ($pending === []) {
return;
}
if ($dryRun) {
$stats['inserted'] += count($pending);
return;
}
DB::table('user_activities')->insert($pending);
$stats['inserted'] += count($pending);
collect($pending)
->pluck('user_id')
->unique()
->each(fn (int $userId): bool => tap(true, fn () => $this->activities->invalidateUserFeed($userId)));
}, $chunkColumn, $chunkAlias);
$this->line(sprintf('%s backfill complete.', $label));
return $stats;
}
/**
* @param Collection<int, array{user_id:int,type:string,entity_type:string,entity_id:int,meta:?array,created_at:mixed}> $entries
* @return array<string, true>
*/
private function existingKeysForEntries(Collection $entries): array
{
$existing = [];
$entries
->groupBy(fn (array $entry): string => $entry['type'] . '|' . $entry['entity_type'])
->each(function (Collection $groupedEntries, string $groupKey) use (&$existing): void {
[$type, $entityType] = explode('|', $groupKey, 2);
$userIds = $groupedEntries->pluck('user_id')->unique()->values()->all();
$entityIds = $groupedEntries->pluck('entity_id')->unique()->values()->all();
DB::table('user_activities')
->select(['user_id', 'entity_id'])
->where('type', $type)
->where('entity_type', $entityType)
->whereIn('user_id', $userIds)
->whereIn('entity_id', $entityIds)
->get()
->each(function (object $row) use (&$existing, $type, $entityType): void {
$existing[$this->entryKey((int) $row->user_id, $type, $entityType, (int) $row->entity_id)] = true;
});
});
return $existing;
}
private function entryKey(int $userId, string $type, string $entityType, int $entityId): string
{
return $userId . ':' . $type . ':' . $entityType . ':' . $entityId;
}
private function existingUserSubquery(string $column): \Closure
{
return static function ($subquery) use ($column): void {
$subquery->selectRaw('1')
->from('users')
->whereColumn('users.id', $column);
};
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function emptyStats(): array
{
return [
'processed' => 0,
'inserted' => 0,
'existing' => 0,
'skipped' => 0,
];
}
}

View File

@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\Sitemaps\BuildSitemapReleaseJob;
use App\Services\Sitemaps\SitemapBuildService;
use App\Services\Sitemaps\SitemapPublishService;
use Illuminate\Console\Command;
final class BuildSitemapsCommand extends Command
{
protected $signature = 'skinbase:sitemaps:build
{--only=* : Limit the build to one or more sitemap families}
{--release= : Override the generated release id}
{--shards : Show per-shard output in the command report}
{--queue : Dispatch the release build to the queue}
{--force : Accepted for backward compatibility; release builds are always fresh}
{--clear : Accepted for backward compatibility; release builds are isolated}
{--dry-run : Build a release artifact set without activating it}';
protected $description = 'Build a versioned sitemap release artifact set.';
public function handle(SitemapBuildService $build, SitemapPublishService $publish): int
{
$startedAt = microtime(true);
$families = $this->selectedFamilies($build);
$releaseId = ($value = $this->option('release')) !== null && trim((string) $value) !== '' ? trim((string) $value) : null;
if ($families === []) {
$this->error('No valid sitemap families were selected.');
return self::INVALID;
}
$showShards = (bool) $this->option('shards');
if ((bool) $this->option('queue')) {
BuildSitemapReleaseJob::dispatch($families, $releaseId);
$this->info('Queued sitemap release build' . ($releaseId !== null ? ' for [' . $releaseId . '].' : '.'));
return self::SUCCESS;
}
try {
$manifest = $publish->buildRelease($families, $releaseId);
} catch (\Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$totalUrls = 0;
$totalDocuments = 0;
foreach ($families as $family) {
$names = (array) data_get($manifest, 'families.' . $family . '.documents', []);
$familyUrls = 0;
if (! $showShards) {
$this->line('Building family [' . $family . '] with ' . count($names) . ' document(s).');
}
foreach ($names as $name) {
$documentType = str_ends_with((string) $name, '-index') ? 'index' : ((string) $family === (string) config('sitemaps.news.google_variant_name', 'news-google') ? 'google-news' : 'urlset');
$familyUrls += (int) data_get($manifest, 'families.' . $family . '.url_count', 0);
$totalUrls += (int) data_get($manifest, 'families.' . $family . '.url_count', 0);
$totalDocuments++;
if ($showShards || ! str_contains((string) $name, '-000')) {
$this->line(sprintf(
' - %s [%s]',
$name,
$documentType,
));
}
}
$this->info(sprintf('Family [%s] complete: urls=%d documents=%d', $family, (int) data_get($manifest, 'families.' . $family . '.url_count', 0), count($names)));
}
$totalDocuments++;
$this->info(sprintf(
'Sitemap release [%s] complete: families=%d documents=%d urls=%d status=%s duration=%.2fs',
(string) $manifest['release_id'],
(int) data_get($manifest, 'totals.families', 0),
(int) data_get($manifest, 'totals.documents', 0),
(int) data_get($manifest, 'totals.urls', 0),
(string) ($manifest['status'] ?? 'built'),
microtime(true) - $startedAt,
));
$this->line('Sitemap index complete');
return self::SUCCESS;
}
/**
* @return list<string>
*/
private function selectedFamilies(SitemapBuildService $build): array
{
$only = [];
foreach ((array) $this->option('only') as $value) {
foreach (explode(',', (string) $value) as $family) {
$normalized = trim($family);
if ($normalized !== '') {
$only[] = $normalized;
}
}
}
$enabled = $build->enabledFamilies();
if ($only === []) {
return $enabled;
}
return array_values(array_filter($enabled, fn (string $family): bool => in_array($family, $only, true)));
}
}

View File

@@ -0,0 +1,626 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Support\UsernamePolicy;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use RuntimeException;
use Symfony\Component\Console\Output\OutputInterface;
final class CheckArtworkUserReferencesCommand extends Command
{
protected $signature = 'artworks:check-user-refs
{--chunk=1000 : Number of artworks to process per chunk}
{--show-missing=25 : Maximum number of missing references to print}
{--artwork-id= : Only check/copy the user referenced by this specific artwork ID}
{--copy-missing-from-legacy : Copy missing referenced users from the legacy users table into the new users table using the same id}
{--create-placeholder : Create a placeholder tmpu{id} stub user when the legacy user cannot be found}
{--dry-run-copy : Preview legacy user copies without writing them}
{--legacy-connection=legacy : Legacy database connection name}
{--legacy-users-table=users : Legacy users table name}
{--json : Output the summary as JSON}';
protected $description = 'Check that every artworks.user_id points to an existing users.id row.';
public function handle(): int
{
$chunkSize = max(1, (int) $this->option('chunk'));
$showMissing = max(0, (int) $this->option('show-missing'));
$copyMissingFromLegacy = (bool) $this->option('copy-missing-from-legacy');
$createPlaceholder = (bool) $this->option('create-placeholder');
$dryRunCopy = (bool) $this->option('dry-run-copy');
$legacyConnection = (string) $this->option('legacy-connection');
$legacyUsersTable = (string) $this->option('legacy-users-table');
$artworkId = $this->option('artwork-id') !== null ? (int) $this->option('artwork-id') : null;
$this->line(sprintf('Auditing artworks.user_id references in chunks of %d...', $chunkSize));
$audit = $this->auditArtworkUserReferences($chunkSize, $showMissing, $artworkId);
$copySummary = null;
if ($copyMissingFromLegacy) {
$this->newLine();
$this->line(sprintf(
'%s missing referenced users from legacy connection "%s" table "%s".',
$dryRunCopy ? 'Previewing copy of' : 'Copying',
$legacyConnection,
$legacyUsersTable,
));
try {
$copySummary = $this->copyMissingUsersFromLegacy(
array_keys($audit['missing_user_ids']),
$legacyConnection,
$legacyUsersTable,
$dryRunCopy,
$createPlaceholder,
);
} catch (\Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
if (! $dryRunCopy && ($copySummary['copied'] ?? 0) > 0) {
$audit = $this->auditArtworkUserReferences($chunkSize, $showMissing, $artworkId);
}
}
if ((bool) $this->option('json')) {
$payload = [
'summary' => $audit['summary'],
'sample_missing' => $audit['sample_missing'],
];
if ($copySummary !== null) {
$payload['copy_summary'] = $copySummary;
}
$this->line(json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return ((int) ($audit['summary']['missing'] ?? 0)) === 0 ? self::SUCCESS : self::FAILURE;
}
$this->renderAuditSummary($audit['summary'], $audit['sample_missing']);
if ($copySummary !== null) {
$this->renderCopySummary($copySummary);
}
return ((int) ($audit['summary']['missing'] ?? 0)) === 0 ? self::SUCCESS : self::FAILURE;
}
/**
* @return array{
* summary: array{checked:int, valid:int, missing:int, null_user_ids:int},
* sample_missing: array<int, array{artwork_id:int, user_id:string, title:string}>,
* missing_user_ids: array<int, true>
* }
*/
private function auditArtworkUserReferences(int $chunkSize, int $showMissing, ?int $artworkId = null): array
{
$checked = 0;
$valid = 0;
$missing = 0;
$nullUserIds = 0;
$sampleRows = [];
$missingUserIds = [];
DB::table('artworks')
->leftJoin('users', 'users.id', '=', 'artworks.user_id')
->select([
'artworks.id',
'artworks.user_id',
'artworks.title',
DB::raw('users.id as matched_user_id'),
])
->when($artworkId !== null, fn ($q) => $q->where('artworks.id', $artworkId))
->orderBy('artworks.id')
->chunkById($chunkSize, function ($artworks) use (&$checked, &$valid, &$missing, &$nullUserIds, &$sampleRows, &$missingUserIds, $showMissing): void {
foreach ($artworks as $artwork) {
$checked++;
if ($artwork->matched_user_id !== null) {
$valid++;
continue;
}
$missing++;
if ($artwork->user_id === null) {
$nullUserIds++;
} else {
$missingUserIds[(int) $artwork->user_id] = true;
}
if (count($sampleRows) < $showMissing) {
$sampleRows[] = [
'artwork_id' => (int) $artwork->id,
'user_id' => $artwork->user_id === null ? '[null]' : (string) $artwork->user_id,
'title' => (string) ($artwork->title ?? ''),
];
}
}
if ($this->isVerboseOutput()) {
$this->line(sprintf(
' audited %d artworks so far; missing=%d, null_user_id=%d.',
$checked,
$missing,
$nullUserIds,
));
}
}, 'artworks.id', 'id');
return [
'summary' => [
'checked' => $checked,
'valid' => $valid,
'missing' => $missing,
'null_user_ids' => $nullUserIds,
],
'sample_missing' => $sampleRows,
'missing_user_ids' => $missingUserIds,
];
}
/**
* @param array<int, int|string> $legacyIds
* @return array<string, mixed>
*/
private function copyMissingUsersFromLegacy(array $legacyIds, string $legacyConnection, string $legacyUsersTable, bool $dryRun, bool $createPlaceholder = false): array
{
$result = [
'requested_users' => count($legacyIds),
'copied' => 0,
'placeholders_created' => 0,
'would_copy' => 0,
'conflicts' => 0,
'not_found_in_legacy' => 0,
'errors' => 0,
'dry_run' => $dryRun,
'sample_copied_ids' => [],
'sample_placeholder_ids' => [],
'sample_conflict_ids' => [],
'sample_not_found_ids' => [],
'sample_error_messages' => [],
];
if ($legacyIds === []) {
if ($this->isVerboseOutput()) {
$this->line('No missing non-null user ids were found to copy from the legacy users table.');
}
return $result;
}
$this->ensureLegacyConnectionIsUsable($legacyConnection, $legacyUsersTable);
$normalizedLegacyIds = array_values(array_unique(array_map('intval', $legacyIds)));
foreach (array_chunk($normalizedLegacyIds, 200) as $chunkIndex => $chunk) {
$legacyRows = DB::connection($legacyConnection)
->table($legacyUsersTable)
->whereIn('user_id', $chunk)
->get()
->keyBy(fn (object $row): int => (int) $row->user_id);
if ($this->isVerboseOutput()) {
$this->line(sprintf(
' processing legacy chunk %d with %d requested ids; found %d legacy rows.',
$chunkIndex + 1,
count($chunk),
$legacyRows->count(),
));
}
foreach ($chunk as $legacyId) {
if (DB::table('users')->where('id', $legacyId)->exists()) {
$result['conflicts']++;
if (count($result['sample_conflict_ids']) < 10) {
$result['sample_conflict_ids'][] = $legacyId;
}
if ($this->isVerboseOutput()) {
$this->warn(sprintf('[skip-conflict] user #%d already exists in the new users table.', $legacyId));
}
continue;
}
$legacyUser = $legacyRows->get($legacyId);
if (! $legacyUser) {
$result['not_found_in_legacy']++;
if (count($result['sample_not_found_ids']) < 10) {
$result['sample_not_found_ids'][] = $legacyId;
}
if ($this->isVerboseOutput()) {
$this->warn(sprintf(
'[missing-legacy] user #%d was not found in %s.%s.',
$legacyId,
$legacyConnection,
$legacyUsersTable,
));
}
if ($createPlaceholder) {
if ($dryRun) {
$result['would_copy']++;
$this->line(sprintf('[dry-run] would create placeholder tmpu%d for user #%d', $legacyId, $legacyId));
} else {
try {
$this->createPlaceholderUser($legacyId);
$result['placeholders_created']++;
if (count($result['sample_placeholder_ids']) < 10) {
$result['sample_placeholder_ids'][] = $legacyId;
}
$this->info(sprintf('[placeholder] created tmpu%d for user #%d', $legacyId, $legacyId));
} catch (\Throwable $exception) {
$result['errors']++;
$message = sprintf('#%d (placeholder): %s', $legacyId, $exception->getMessage());
if (count($result['sample_error_messages']) < 10) {
$result['sample_error_messages'][] = $message;
}
$this->error('[placeholder-error] ' . $message);
}
}
}
continue;
}
if ($dryRun) {
$result['would_copy']++;
if (count($result['sample_copied_ids']) < 10) {
$result['sample_copied_ids'][] = $legacyId;
}
$this->line(sprintf('[dry-run] would import legacy user %s', $this->describeLegacyUser($legacyUser, $legacyId)));
continue;
}
try {
$this->importLegacyUserBySameId($legacyUser, $legacyId);
$result['copied']++;
if (count($result['sample_copied_ids']) < 10) {
$result['sample_copied_ids'][] = $legacyId;
}
$this->info(sprintf('[copied] imported legacy user %s', $this->describeLegacyUser($legacyUser, $legacyId)));
} catch (\Throwable $exception) {
$result['errors']++;
$message = sprintf('#%d: %s', $legacyId, $exception->getMessage());
if (count($result['sample_error_messages']) < 10) {
$result['sample_error_messages'][] = $message;
}
$this->error('[copy-error] ' . $message);
}
}
}
return $result;
}
private function importLegacyUserBySameId(object $legacyUser, int $legacyId): void
{
$now = now();
$username = $this->resolveImportUsername($legacyUser, $legacyId);
$email = $this->resolveImportEmail($legacyUser, $legacyId);
$name = (string) ($this->legacyField($legacyUser, 'real_name') ?: $username);
$createdAt = $this->parseLegacyDate($this->legacyField($legacyUser, 'joinDate')) ?? $now;
$lastVisitAt = $this->parseLegacyDate($this->legacyField($legacyUser, 'LastVisit'));
$countryCode = $this->legacyField($legacyUser, 'country_code');
DB::transaction(function () use ($legacyId, $legacyUser, $username, $email, $name, $createdAt, $lastVisitAt, $countryCode, $now): void {
if (DB::table('users')->where('id', $legacyId)->exists()) {
throw new RuntimeException(sprintf('Conflict: user id %d already exists in the new users table.', $legacyId));
}
DB::table('users')->insert([
'id' => $legacyId,
'username' => $username,
'username_changed_at' => $now,
'name' => $name,
'email' => $email,
'password' => Hash::make(Str::random(64)),
'is_active' => (int) ($this->legacyField($legacyUser, 'active') ?? 1) === 1,
'needs_password_reset' => true,
'role' => 'user',
'legacy_password_algo' => null,
'last_visit_at' => $lastVisitAt,
'created_at' => $createdAt,
'updated_at' => $now,
]);
if (Schema::hasTable('user_profiles')) {
DB::table('user_profiles')->updateOrInsert(
['user_id' => $legacyId],
[
'bio' => $this->legacyField($legacyUser, 'about_me') ?: $this->legacyField($legacyUser, 'description'),
'country' => $this->legacyField($legacyUser, 'country'),
'country_code' => is_string($countryCode) && $countryCode !== '' ? substr($countryCode, 0, 2) : null,
'website' => $this->legacyField($legacyUser, 'web'),
'gender' => $this->normalizeLegacyGender($this->legacyField($legacyUser, 'gender')),
'created_at' => $now,
'updated_at' => $now,
],
);
}
if (Schema::hasTable('user_statistics')) {
DB::table('user_statistics')->updateOrInsert(
['user_id' => $legacyId],
[
'uploads_count' => 0,
'downloads_received_count' => 0,
'artwork_views_received_count' => 0,
'awards_received_count' => 0,
'created_at' => $now,
'updated_at' => $now,
],
);
}
});
}
private function createPlaceholderUser(int $legacyId): void
{
$now = now();
$username = $this->uniquePlaceholderUsername($legacyId);
$email = $username . '@users.skinbase.org';
DB::transaction(function () use ($legacyId, $username, $email, $now): void {
if (DB::table('users')->where('id', $legacyId)->exists()) {
throw new RuntimeException(sprintf('Conflict: user id %d already exists in the new users table.', $legacyId));
}
DB::table('users')->insert([
'id' => $legacyId,
'username' => $username,
'username_changed_at' => $now,
'name' => $username,
'email' => $email,
'password' => Hash::make(Str::random(64)),
'is_active' => false,
'needs_password_reset' => true,
'role' => 'user',
'legacy_password_algo' => null,
'last_visit_at' => null,
'created_at' => $now,
'updated_at' => $now,
]);
if (Schema::hasTable('user_profiles')) {
DB::table('user_profiles')->updateOrInsert(
['user_id' => $legacyId],
['created_at' => $now, 'updated_at' => $now],
);
}
if (Schema::hasTable('user_statistics')) {
DB::table('user_statistics')->updateOrInsert(
['user_id' => $legacyId],
[
'uploads_count' => 0,
'downloads_received_count' => 0,
'artwork_views_received_count' => 0,
'awards_received_count' => 0,
'created_at' => $now,
'updated_at' => $now,
],
);
}
});
}
private function resolveImportUsername(object $legacyUser, int $legacyId): string
{
$rawUsername = (string) ($this->legacyField($legacyUser, 'uname') ?: ('user' . $legacyId));
$username = $this->sanitizeUsername($rawUsername);
if (! $this->usernameExists($username, $legacyId)) {
return $username;
}
return $this->uniquePlaceholderUsername($legacyId);
}
private function sanitizeUsername(string $username): string
{
return UsernamePolicy::sanitizeLegacy($username);
}
private function usernameExists(string $username, int $ignoreUserId): bool
{
return DB::table('users')
->whereRaw('LOWER(username) = ?', [strtolower($username)])
->where('id', '!=', $ignoreUserId)
->exists();
}
private function uniquePlaceholderUsername(int $legacyId): string
{
$base = 'tmpu' . $legacyId;
$candidate = $base;
$suffix = 1;
while ($this->usernameExists($candidate, $legacyId)) {
$suffixStr = (string) $suffix;
$candidate = substr($base, 0, max(1, 20 - strlen($suffixStr))) . $suffixStr;
$suffix++;
}
return $candidate;
}
private function renderAuditSummary(array $summary, array $sampleRows): void
{
$this->info(sprintf(
'Checked %d artworks: %d valid, %d missing user references, %d null user_id values.',
(int) ($summary['checked'] ?? 0),
(int) ($summary['valid'] ?? 0),
(int) ($summary['missing'] ?? 0),
(int) ($summary['null_user_ids'] ?? 0),
));
if ($sampleRows !== []) {
$this->newLine();
$this->warn('Sample missing references:');
$this->table(['Artwork ID', 'user_id', 'Title'], array_map(
static fn (array $row): array => [$row['artwork_id'], $row['user_id'], $row['title']],
$sampleRows,
));
}
if ((int) ($summary['missing'] ?? 0) === 0) {
$this->info('No missing user references found in artworks.user_id.');
} else {
$this->error('Found artworks with missing user references.');
}
}
private function renderCopySummary(array $copySummary): void
{
$this->newLine();
$this->info(sprintf(
'Legacy copy summary: requested %d users, copied %d, placeholders %d, would copy %d, conflicts %d, not found in legacy %d, errors %d.',
(int) ($copySummary['requested_users'] ?? 0),
(int) ($copySummary['copied'] ?? 0),
(int) ($copySummary['placeholders_created'] ?? 0),
(int) ($copySummary['would_copy'] ?? 0),
(int) ($copySummary['conflicts'] ?? 0),
(int) ($copySummary['not_found_in_legacy'] ?? 0),
(int) ($copySummary['errors'] ?? 0),
));
if (($copySummary['sample_copied_ids'] ?? []) !== []) {
$this->line('Copied or would-copy user ids: ' . implode(', ', $copySummary['sample_copied_ids']));
}
if (($copySummary['sample_placeholder_ids'] ?? []) !== []) {
$this->line('Placeholder users created for ids: ' . implode(', ', $copySummary['sample_placeholder_ids']));
}
if (($copySummary['sample_conflict_ids'] ?? []) !== []) {
$this->warn('Conflicts: user ids already present in new DB: ' . implode(', ', $copySummary['sample_conflict_ids']));
}
if (($copySummary['sample_not_found_ids'] ?? []) !== []) {
$this->warn('Not found in legacy: ' . implode(', ', $copySummary['sample_not_found_ids']));
}
if (($copySummary['sample_error_messages'] ?? []) !== []) {
foreach ($copySummary['sample_error_messages'] as $message) {
$this->warn($message);
}
}
}
private function ensureLegacyConnectionIsUsable(string $connection, string $table): void
{
try {
DB::connection($connection)->getPdo();
} catch (\Throwable $exception) {
throw new RuntimeException(sprintf('Legacy DB connection "%s" is not configured or reachable.', $connection), 0, $exception);
}
if (! DB::connection($connection)->getSchemaBuilder()->hasTable($table)) {
throw new RuntimeException(sprintf('Legacy users table "%s" was not found on connection "%s".', $table, $connection));
}
}
private function resolveImportEmail(object $legacyUser, int $legacyId): string
{
$rawEmail = strtolower(trim((string) ($this->legacyField($legacyUser, 'email') ?? '')));
$candidate = $rawEmail !== ''
? $rawEmail
: ($this->sanitizeEmailLocal((string) ($this->legacyField($legacyUser, 'uname') ?: ('user' . $legacyId))) . '@users.skinbase.org');
return $this->uniqueEmailCandidate($candidate, $legacyId);
}
private function uniqueEmailCandidate(string $email, int $legacyId): string
{
$candidate = strtolower(trim($email));
$suffix = 1;
while ($candidate === '' || DB::table('users')->whereRaw('LOWER(email) = ?', [$candidate])->where('id', '!=', $legacyId)->exists()) {
$parts = explode('@', $email, 2);
$local = $this->sanitizeEmailLocal($parts[0] ?? ('user' . $legacyId));
$domain = $parts[1] ?? 'users.skinbase.org';
$candidate = $local . '+' . $suffix . '@' . $domain;
$suffix++;
}
return $candidate;
}
private function sanitizeEmailLocal(string $value): string
{
$local = strtolower(trim(Str::ascii($value)));
$local = preg_replace('/[^a-z0-9._-]/', '-', $local) ?: 'user';
return trim($local, '.-') ?: 'user';
}
private function normalizeLegacyGender(mixed $value): ?string
{
$normalized = strtoupper(trim((string) ($value ?? '')));
return match ($normalized) {
'M', 'MALE', 'MAN', 'BOY' => 'M',
'F', 'FEMALE', 'WOMAN', 'GIRL' => 'F',
default => null,
};
}
private function isVerboseOutput(): bool
{
return $this->getOutput()->getVerbosity() >= OutputInterface::VERBOSITY_VERBOSE;
}
private function describeLegacyUser(object $legacyUser, int $legacyId): string
{
$username = trim((string) ($this->legacyField($legacyUser, 'uname') ?? ''));
$name = trim((string) ($this->legacyField($legacyUser, 'real_name') ?? ''));
$email = trim((string) ($this->legacyField($legacyUser, 'email') ?? ''));
return sprintf(
'#%d username=%s name=%s email=%s',
$legacyId,
$username !== '' ? '@' . $username : '[missing]',
$name !== '' ? '"' . $name . '"' : '[missing]',
$email !== '' ? '<' . $email . '>' : '[missing]',
);
}
private function parseLegacyDate(mixed $value): ?Carbon
{
if (! is_string($value) || trim($value) === '' || str_starts_with($value, '0000-00-00')) {
return null;
}
try {
return Carbon::parse($value);
} catch (\Throwable) {
return null;
}
}
private function legacyField(object $row, string $field): mixed
{
return property_exists($row, $field) ? $row->{$field} : null;
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Recommendations\FeedOfflineEvaluationService;
use Illuminate\Console\Command;
final class CompareFeedAbCommand extends Command
{
protected $signature = 'analytics:compare-feed-ab
{baseline : Baseline algo_version}
{candidate : Candidate algo_version}
{--from= : Start date (Y-m-d), defaults to last 30 days}
{--to= : End date (Y-m-d), defaults to today}
{--json : Output as JSON}';
protected $description = 'A/B helper for baseline vs candidate feed algo comparison';
public function __construct(private readonly FeedOfflineEvaluationService $evaluator)
{
parent::__construct();
}
public function handle(): int
{
$from = (string) ($this->option('from') ?: now()->subDays(29)->toDateString());
$to = (string) ($this->option('to') ?: now()->toDateString());
if ($from > $to) {
$this->error('Invalid range: --from must be <= --to');
return self::FAILURE;
}
$baseline = (string) $this->argument('baseline');
$candidate = (string) $this->argument('candidate');
$comparison = $this->evaluator->compareBaselineCandidate($baseline, $candidate, $from, $to);
if ((bool) $this->option('json')) {
$this->line((string) json_encode($comparison, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return self::SUCCESS;
}
$this->table(
['algo_version', 'ctr', 'save_rate', 'long_dwell_share', 'bounce_rate', 'objective_score'],
[[
(string) $comparison['baseline']['algo_version'],
(float) $comparison['baseline']['ctr'],
(float) $comparison['baseline']['save_rate'],
(float) $comparison['baseline']['long_dwell_share'],
(float) $comparison['baseline']['bounce_rate'],
(float) $comparison['baseline']['objective_score'],
], [
(string) $comparison['candidate']['algo_version'],
(float) $comparison['candidate']['ctr'],
(float) $comparison['candidate']['save_rate'],
(float) $comparison['candidate']['long_dwell_share'],
(float) $comparison['candidate']['bounce_rate'],
(float) $comparison['candidate']['objective_score'],
]]
);
$delta = (array) $comparison['delta'];
$this->line('Δ objective_score: ' . (string) $delta['objective_score']);
$this->line('Δ objective_lift_pct: ' . (string) ($delta['objective_lift_pct'] ?? 'n/a'));
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Meilisearch\Client as MeilisearchClient;
/**
* Configure the Meilisearch artworks index:
* sortable attributes (all fields used in category/discover sorts)
* filterable attributes (used in search filters)
*
* Run after any schema / toSearchableArray change:
* php artisan meilisearch:configure-index
*/
class ConfigureMeilisearchIndex extends Command
{
protected $signature = 'meilisearch:configure-index {--index=artworks : Meilisearch index name}';
protected $description = 'Push sortable and filterable attribute settings to the Meilisearch artworks index.';
/**
* Fields that can be used as sort targets in Artwork::search()->options(['sort' => ]).
* Must match keys in Artwork::toSearchableArray().
*/
private const SORTABLE_ATTRIBUTES = [
'created_at',
'published_at_ts',
'missing_thumbnail_rank',
'trending_score_24h',
'trending_score_7d',
'favorites_count',
'downloads_count',
'awards_received_count',
'awards_score_7d',
'awards_score_30d',
'views',
'likes',
'downloads',
'ranking_score',
'engagement_velocity',
'shares_count',
'comments_count',
'heat_score',
];
/**
* Fields used in filter expressions (AND category = "" etc.).
*/
private const FILTERABLE_ATTRIBUTES = [
'id',
'is_public',
'is_approved',
'is_mature',
'is_mature_effective',
'maturity_level',
'maturity_status',
'has_missing_thumbnails',
'category',
'content_type',
'tags',
'author_id',
'orientation',
'resolution',
];
public function handle(): int
{
$prefix = config('scout.prefix', '');
$indexName = $prefix . (string) $this->option('index');
/** @var MeilisearchClient $client */
$client = app(MeilisearchClient::class);
$index = $client->index($indexName);
$this->info("Configuring Meilisearch index: {$indexName}");
// ── Sortable attributes ───────────────────────────────────────────────
$this->line(' → Updating sortableAttributes…');
$task = $index->updateSortableAttributes(self::SORTABLE_ATTRIBUTES);
$this->line(" Task uid: {$task['taskUid']}");
// ── Filterable attributes ─────────────────────────────────────────────
$this->line(' → Updating filterableAttributes…');
$task2 = $index->updateFilterableAttributes(self::FILTERABLE_ATTRIBUTES);
$this->line(" Task uid: {$task2['taskUid']}");
$this->info('Done. Meilisearch will process these tasks asynchronously.');
$this->warn('Re-index artworks if sortable attributes changed: php artisan artworks:search-rebuild');
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\CollectionBackgroundJobService;
use Illuminate\Console\Command;
class DispatchCollectionMaintenanceCommand extends Command
{
protected $signature = 'collections:dispatch-maintenance
{--health : Dispatch health and eligibility refresh jobs}
{--recommendations : Dispatch recommendation refresh jobs}
{--duplicates : Dispatch duplicate scan jobs}';
protected $description = 'Dispatch queued collection maintenance jobs for health, recommendation, and duplicate workflows.';
public function handle(CollectionBackgroundJobService $jobs): int
{
$runHealth = (bool) $this->option('health');
$runRecommendations = (bool) $this->option('recommendations');
$runDuplicates = (bool) $this->option('duplicates');
if (! $runHealth && ! $runRecommendations && ! $runDuplicates) {
$runHealth = true;
$runRecommendations = true;
$runDuplicates = true;
}
$summary = $jobs->dispatchScheduledMaintenance($runHealth, $runRecommendations, $runDuplicates);
foreach ($summary as $key => $payload) {
$this->info(sprintf('%s: %d queued.', ucfirst((string) $key), (int) ($payload['count'] ?? 0)));
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\User;
use App\Support\UsernamePolicy;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class EnforceUsernamePolicy extends Command
{
protected $signature = 'skinbase:enforce-usernames {--dry-run : Report only, no writes}';
protected $description = 'Normalize and enforce username policy on existing users, with collision resolution and redirect logging.';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$logPath = storage_path('logs/username_migration.log');
@file_put_contents($logPath, '['.now()."] enforce-usernames dry_run=".($dryRun ? '1' : '0')."\n", FILE_APPEND);
$used = User::query()->whereNotNull('username')->pluck('id', 'username')->mapWithKeys(fn ($id, $username) => [strtolower((string) $username) => (int) $id])->all();
$updated = 0;
User::query()->orderBy('id')->chunkById(500, function ($users) use (&$used, &$updated, $dryRun, $logPath): void {
foreach ($users as $user) {
$current = strtolower(trim((string) ($user->username ?? '')));
$base = UsernamePolicy::sanitizeLegacy($current !== '' ? $current : ('user'.$user->id));
if (UsernamePolicy::isReserved($base) || UsernamePolicy::similarReserved($base) !== null) {
$base = 'user'.$user->id;
}
$candidate = substr($base, 0, UsernamePolicy::max());
$suffix = 1;
while ((isset($used[$candidate]) && (int) $used[$candidate] !== (int) $user->id) || UsernamePolicy::isReserved($candidate) || UsernamePolicy::similarReserved($candidate) !== null) {
$suffixStr = (string) $suffix;
$prefixLen = max(1, UsernamePolicy::max() - strlen($suffixStr));
$candidate = substr($base, 0, $prefixLen) . $suffixStr;
$suffix++;
}
$needsUpdate = $candidate !== $current;
if (! $needsUpdate) {
$used[$candidate] = (int) $user->id;
continue;
}
@file_put_contents($logPath, sprintf("[%s] user_id=%d old=%s new=%s\n", now()->toDateTimeString(), (int) $user->id, $current, $candidate), FILE_APPEND);
if (! $dryRun) {
DB::transaction(function () use ($user, $current, $candidate): void {
if ($current !== '' && Schema::hasTable('username_history')) {
DB::table('username_history')->insert([
'user_id' => (int) $user->id,
'old_username' => $current,
'changed_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
}
if ($current !== '' && Schema::hasTable('username_redirects')) {
DB::table('username_redirects')->updateOrInsert(
['old_username' => $current],
[
'new_username' => $candidate,
'user_id' => (int) $user->id,
'created_at' => now(),
'updated_at' => now(),
]
);
}
DB::table('users')->where('id', (int) $user->id)->update([
'username' => $candidate,
'username_changed_at' => now(),
'updated_at' => now(),
]);
});
}
$used[$candidate] = (int) $user->id;
$updated++;
}
});
$this->info("Username policy enforcement complete. Updated: {$updated}" . ($dryRun ? ' (dry run)' : ''));
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Recommendations\FeedOfflineEvaluationService;
use Illuminate\Console\Command;
final class EvaluateFeedWeightsCommand extends Command
{
protected $signature = 'analytics:evaluate-feed-weights
{--algo= : Optional algo_version to evaluate}
{--from= : Start date (Y-m-d), defaults to last 30 days}
{--to= : End date (Y-m-d), defaults to today}
{--json : Output as JSON}';
protected $description = 'Offline feed weight evaluation using feed_daily_metrics';
public function __construct(private readonly FeedOfflineEvaluationService $evaluator)
{
parent::__construct();
}
public function handle(): int
{
$from = (string) ($this->option('from') ?: now()->subDays(29)->toDateString());
$to = (string) ($this->option('to') ?: now()->toDateString());
$algo = $this->option('algo') ? (string) $this->option('algo') : null;
if ($from > $to) {
$this->error('Invalid range: --from must be <= --to');
return self::FAILURE;
}
if ($algo !== null && $algo !== '') {
$result = $this->evaluator->evaluateAlgo($algo, $from, $to);
if ((bool) $this->option('json')) {
$this->line((string) json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
} else {
$this->table(
['algo_version', 'ctr', 'save_rate', 'long_dwell_share', 'bounce_rate', 'objective_score'],
[[
(string) $result['algo_version'],
(float) $result['ctr'],
(float) $result['save_rate'],
(float) $result['long_dwell_share'],
(float) $result['bounce_rate'],
(float) $result['objective_score'],
]]
);
}
return self::SUCCESS;
}
$results = $this->evaluator->evaluateAll($from, $to);
if ((bool) $this->option('json')) {
$this->line((string) json_encode($results, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return self::SUCCESS;
}
$rows = array_map(static fn (array $row): array => [
(string) $row['algo_version'],
(float) $row['ctr'],
(float) $row['save_rate'],
(float) $row['long_dwell_share'],
(float) $row['bounce_rate'],
(float) $row['objective_score'],
], $results);
$this->table(
['algo_version', 'ctr', 'save_rate', 'long_dwell_share', 'bounce_rate', 'objective_score'],
$rows
);
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Klevze\ControlPanel\Facades\FileManager;
use Klevze\ControlPanel\Core\Utils\Translation as TranslationUtil;
class ExportMissingTranslations extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'translations:export-missing {file=admin} {--out=}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Export missing translations for a file (e.g. admin) into a CSV';
private $translationURL = "https://cPad.dev/api/translation/get/list";
private $token = 'Ddt06xvjYX1TK792H4jAtld8UhgVORYIpkB7nBX6';
public function handle(): int
{
$type = $this->argument('file') ?? 'admin';
$this->info('Exporting missing translations for: ' . $type);
// Gather files to scan
$files = [];
$files = array_merge(
FileManager::getFileList(app_path(), true),
FileManager::getFileList(base_path('packages'), true),
FileManager::getFileList(resource_path(), true)
);
$tempTranslations = [];
foreach ($files as $file) {
$res = TranslationUtil::findTranslations($file, $type);
if (!empty($res) && is_array($res)) {
$tempTranslations[] = $res;
}
}
$tempTranslations = collect($tempTranslations)->collapse();
$missing = [];
foreach ($tempTranslations as $keycode => $row) {
$exists = DB::table('translations')->where('keycode', $keycode)->where('file', $type)->exists();
if (! $exists) {
$missing[] = $keycode;
}
}
$this->info('Found ' . count($missing) . ' missing keys');
// Fetch suggested translations from external service for sl and en
$suggestions = [];
if (!empty($missing)) {
$payload = [
'keys' => $missing,
'languages' => ['sl', 'en'],
];
try {
$resp = Http::withToken($this->token)->post($this->translationURL, $payload);
if ($resp->successful()) {
$suggestions = $resp->json();
} else {
$this->warn('Translation suggestion service returned ' . $resp->status());
}
} catch (\Throwable $e) {
$this->warn('Failed to call suggestion service: ' . $e->getMessage());
}
}
// Build CSV
$out = $this->option('out') ?: storage_path('app/translations_missing_' . $type . '.csv');
$fh = fopen($out, 'w');
if (! $fh) {
$this->error('Failed to open output file: ' . $out);
return 1;
}
// Header
fputcsv($fh, ['file','keycode','suggested_sl','suggested_en','placeholder']);
foreach ($missing as $key) {
$s_sl = $suggestions[$key]['sl'] ?? '';
$s_en = $suggestions[$key]['en'] ?? '';
$placeholder = $type . '.' . $key;
fputcsv($fh, [$type, $key, $s_sl, $s_en, $placeholder]);
}
fclose($fh);
$this->info('CSV exported to: ' . $out);
return 0;
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\TagNormalizer;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* One-time (and idempotent) command to convert slug-style tag names to
* human-readable display names.
*
* A tag is considered "slug-style" when its name is identical to its slug
* (e.g. name="digital-art", slug="digital-art"). Tags that already have a
* custom name (user-edited) are left untouched.
*
* Usage:
* php artisan tags:fix-names
* php artisan tags:fix-names --dry-run
*/
final class FixTagNamesCommand extends Command
{
protected $signature = 'tags:fix-names
{--dry-run : Show what would change without writing to the database}
';
protected $description = 'Convert slug-style tag names (e.g. "digital-art") to readable names ("Digital Art")';
public function __construct(private readonly TagNormalizer $normalizer)
{
parent::__construct();
}
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->warn('DRY-RUN — no changes will be written.');
}
// Only fix rows where name === slug (those were created by the old code).
$rows = DB::table('tags')
->whereColumn('name', 'slug')
->orderBy('id')
->get(['id', 'name', 'slug']);
if ($rows->isEmpty()) {
$this->info('Nothing to fix — all tag names are already human-readable.');
return self::SUCCESS;
}
$this->info("Found {$rows->count()} tag(s) with slug-style names.");
$updated = 0;
$bar = $this->output->createProgressBar($rows->count());
$bar->start();
foreach ($rows as $row) {
$displayName = $this->normalizer->toDisplayName($row->slug);
if ($displayName === $row->name) {
$bar->advance();
continue; // Already correct (e.g. single-word tag "cars" → "Cars" — wait, that would differ)
}
if ($this->output->isVerbose()) {
$this->newLine();
$this->line(" {$row->slug}\"{$displayName}\"");
}
if (!$dryRun) {
DB::table('tags')
->where('id', $row->id)
->update(['name' => $displayName]);
}
$updated++;
$bar->advance();
}
$bar->finish();
$this->newLine(2);
$suffix = $dryRun ? ' (dry-run, nothing written)' : '';
$this->info("Updated {$updated} of {$rows->count()} tag(s){$suffix}.");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\ArtworkStatsService;
use Illuminate\Console\Command;
/**
* Drain the Redis artwork-stat delta queue into MySQL.
*
* The ArtworkStatsService::incrementViews/Downloads methods push compressed
* delta payloads to a Redis list (`artwork_stats:deltas`) when Redis is
* available. This command drains that queue by applying each delta to the
* artwork_stats table via applyDelta().
*
* Designed to run every 5 minutes so counters stay reasonably fresh while
* keeping MySQL write pressure low. If Redis is unavailable the command exits
* immediately without error the service already fell back to direct DB
* writes in that case.
*
* Usage:
* php artisan skinbase:flush-redis-stats
* php artisan skinbase:flush-redis-stats --max=500
*/
class FlushRedisStatsCommand extends Command
{
protected $signature = 'skinbase:flush-redis-stats {--max=1000 : Maximum deltas to process per run}';
protected $description = 'Drain Redis artwork stat delta queue into MySQL';
public function handle(ArtworkStatsService $service): int
{
$max = (int) $this->option('max');
$processed = $service->processPendingFromRedis($max);
if ($this->getOutput()->isVerbose()) {
$this->info("Processed {$processed} artwork-stat delta(s) from Redis.");
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\ForumPost;
use App\Services\BbcodeConverter;
class ForumConvertPosts extends Command
{
protected $signature = 'forum:convert-posts {--dry-run} {--chunk=500} {--limit=} {--report}';
protected $description = 'Convert migrated forum posts content from legacy BBCode to HTML in-place';
public function handle(): int
{
$dry = $this->option('dry-run');
$chunk = (int)$this->option('chunk');
$limit = $this->option('limit') ? (int)$this->option('limit') : null;
$query = ForumPost::query()->orderBy('id');
$total = $limit ? min($query->count(), $limit) : $query->count();
$this->info('Converting forum posts (dry-run='.($dry ? 'yes' : 'no').')');
$this->info("Total posts to consider: {$total}");
$bar = $this->output->createProgressBar($total);
$bar->start();
$converter = new BbcodeConverter();
$processed = 0;
$changed = 0;
try {
$query->chunkById($chunk, function ($posts) use (&$bar, &$processed, &$changed, $dry, $limit, $converter) {
foreach ($posts as $post) {
if ($limit !== null && $processed >= $limit) {
throw new \RuntimeException('limit_reached');
}
$bar->advance();
$processed++;
$old = $post->content ?? '';
$new = $converter->convert($old);
if ($old === $new) {
continue;
}
$changed++;
if ($dry) {
$this->line('[dry] would update post ' . $post->id);
continue;
}
$post->content = $new;
$post->save();
}
});
} catch (\RuntimeException $e) {
if ($e->getMessage() !== 'limit_reached') {
throw $e;
}
// intentionally stop chunking when limit reached
}
$bar->finish();
$this->line('');
$this->info("Processed: {$processed} posts. Changed: {$changed} posts.");
if ($this->option('report')) {
$this->info('Conversion complete');
}
return 0;
}
}

View File

@@ -0,0 +1,624 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use App\Models\ForumCategory;
use App\Models\User;
use App\Models\ForumThread;
use App\Models\ForumPost;
use Exception;
use App\Services\BbcodeConverter;
class ForumMigrateOld extends Command
{
protected $signature = 'forum:migrate-old {--dry-run} {--only=} {--limit=} {--chunk=500} {--report} {--repair-orphans}';
protected $description = 'Migrate legacy forum data from legacy DB into new forum tables';
protected string $logPath;
protected ?int $limit = null;
protected ?int $deletedUserId = null;
/** @var array<int,int> */
protected array $missingUserIds = [];
public function __construct()
{
parent::__construct();
$this->logPath = storage_path('logs/forum_migration.log');
}
public function handle(): int
{
$this->info('Starting forum migration');
$this->log('Starting forum migration');
$dry = $this->option('dry-run');
$only = $this->option('only');
$chunk = (int)$this->option('chunk');
$this->limit = $this->option('limit') !== null ? max(0, (int) $this->option('limit')) : null;
$only = $only === 'attachments' ? 'gallery' : $only;
if ($only && !in_array($only, ['categories', 'threads', 'posts', 'gallery', 'repair-orphans'], true)) {
$this->error('Invalid --only value. Allowed: categories, threads, posts, gallery (or attachments), repair-orphans.');
return 1;
}
if ($chunk < 1) {
$chunk = 500;
}
try {
if (!$only || $only === 'categories') {
$this->migrateCategories($dry);
}
if (!$only || $only === 'threads') {
$this->migrateThreads($dry, $chunk);
}
if (!$only || $only === 'posts') {
$this->migratePosts($dry, $chunk);
}
if (!$only || $only === 'gallery') {
$this->migrateGallery($dry, $chunk);
}
if ($this->option('repair-orphans') || $only === 'repair-orphans') {
$this->repairOrphanPosts($dry);
}
if ($this->option('report')) {
$this->generateReport();
}
$this->info('Forum migration finished');
$this->log('Forum migration finished');
return 0;
} catch (Exception $e) {
$this->error('Migration failed: ' . $e->getMessage());
$this->log('Migration failed: ' . $e->getMessage());
return 1;
}
}
protected function migrateCategories(bool $dry)
{
$this->info('Migrating categories');
$legacy = DB::connection('legacy');
$roots = $legacy->table('forum_topics')
->select('root_id')
->distinct()
->where('root_id', '>', 0)
->orderBy('root_id')
->pluck('root_id');
if ($this->limit !== null && $this->limit > 0) {
$roots = $roots->take($this->limit);
}
$this->info('Found ' . $roots->count() . ' legacy root ids');
foreach ($roots as $rootId) {
$row = $legacy->table('forum_topics')->where('topic_id', $rootId)->first();
$name = $row->topic ?? 'Category ' . $rootId;
$slug = Str::slug(substr($name, 0, 150));
$this->line("-> root {$rootId}: {$name}");
if ($dry) {
$this->log("[dry] create category {$name} ({$slug})");
continue;
}
DB::transaction(function () use ($rootId, $name, $slug) {
ForumCategory::updateOrCreate(
['id' => $rootId],
['name' => $name, 'slug' => $slug]
);
}, 3);
}
$this->info('Categories migrated');
}
protected function migrateThreads(bool $dry, int $chunk)
{
$this->info('Migrating threads');
$legacy = DB::connection('legacy');
$query = $legacy->table('forum_topics')->orderBy('topic_id');
$total = $query->count();
if ($this->limit !== null && $this->limit > 0) {
$total = min($total, $this->limit);
}
$this->info("Total threads to process: {$total}");
$bar = $this->output->createProgressBar($total);
$bar->start();
$processed = 0;
$limit = $this->limit;
// chunk by legacy primary key `topic_id`
$query->chunkById($chunk, function ($rows) use ($dry, $bar, &$processed, $limit) {
foreach ($rows as $r) {
if ($limit !== null && $limit > 0 && $processed >= $limit) {
return false;
}
$bar->advance();
$processed++;
$data = [
'id' => $r->topic_id,
'category_id' => $this->resolveCategoryId($r->root_id ?? null, $r->topic_id),
// resolve user id or assign to system user (1) when missing or not found
'user_id' => $this->resolveUserId($r->user_id ?? null),
'title' => $r->topic,
'slug' => $this->uniqueSlug(Str::slug(substr($r->topic,0,200)) ?: 'thread-'.$r->topic_id, $r->topic_id),
'content' => $r->preview ?? '',
'views' => $r->views ?? 0,
'is_locked' => isset($r->open) ? !((bool)$r->open) : false,
'is_pinned' => false,
'visibility' => $this->mapPrivilegeToVisibility($r->privilege ?? 0),
'last_post_at' => $this->normalizeDate($r->last_update ?? null),
];
if ($dry) {
$this->log('[dry] thread: ' . $r->topic_id . ' - ' . $r->topic);
continue;
}
DB::transaction(function () use ($data) {
ForumThread::updateOrCreate(['id' => $data['id']], $data);
}, 3);
}
}, 'topic_id');
$bar->finish();
$this->line('');
$this->info('Threads migrated');
}
protected function migratePosts(bool $dry, int $chunk)
{
$this->info('Migrating posts');
$legacy = DB::connection('legacy');
$query = $legacy->table('forum_posts')->orderBy('post_id');
$total = $query->count();
if ($this->limit !== null && $this->limit > 0) {
$total = min($total, $this->limit);
}
$this->info("Total posts to process: {$total}");
$bar = $this->output->createProgressBar($total);
$bar->start();
$processed = 0;
$limit = $this->limit;
// legacy forum_posts uses `post_id` as primary key
$query->chunkById($chunk, function ($rows) use ($dry, $bar, &$processed, $limit) {
foreach ($rows as $r) {
if ($limit !== null && $limit > 0 && $processed >= $limit) {
return false;
}
$bar->advance();
$processed++;
$data = [
'id' => $r->post_id,
'thread_id' => $r->topic_id,
'user_id' => $r->user_id ?? null,
'content' => $this->convertLegacyMessage($r->message ?? ''),
'is_edited' => isset($r->isupdated) ? (bool)$r->isupdated : false,
'edited_at' => $r->updated ?? null,
];
if ($dry) {
$this->log('[dry] post: ' . $r->post_id);
continue;
}
DB::transaction(function () use ($data) {
ForumPost::updateOrCreate(['id' => $data['id']], $data);
}, 3);
}
}, 'post_id');
$bar->finish();
$this->line('');
$this->info('Posts migrated');
}
protected function mapPrivilegeToVisibility($priv)
{
// legacy privilege: 0 public, 1 members, 4 staff? adjust mapping conservatively
if ($priv >= 4) return 'staff';
if ($priv >= 1) return 'members';
return 'public';
}
protected function normalizeDate($val)
{
if (empty($val)) return null;
$s = trim((string)$val);
// legacy sometimes contains sentinel invalid dates like -0001-11-30 or zero dates
if (strpos($s, '-0001') !== false) return null;
if (strpos($s, '0000-00-00') !== false) return null;
if (strtotime($s) === false) return null;
return date('Y-m-d H:i:s', strtotime($s));
}
protected function uniqueSlug(string $base, int $id)
{
$slug = $base;
$i = 0;
while (ForumThread::where('slug', $slug)->where('id', '<>', $id)->exists()) {
$i++;
$slug = $base . '-' . $id;
// if somehow still exists, append counter
if (ForumThread::where('slug', $slug)->where('id', '<>', $id)->exists()) {
$slug = $base . '-' . $id . '-' . $i;
}
}
return $slug;
}
protected function resolveCategoryId($rootId, $topicId)
{
// prefer explicit rootId
if (!empty($rootId)) {
// ensure category exists
if (ForumCategory::where('id', $rootId)->exists()) return $rootId;
}
// if this topic itself is a category
if (ForumCategory::where('id', $topicId)->exists()) return $topicId;
// fallback: use first available category
$first = ForumCategory::first();
if ($first) return $first->id;
// as last resort, create Uncategorized
$cat = ForumCategory::create(['name' => 'Uncategorized', 'slug' => 'uncategorized']);
return $cat->id;
}
protected function resolveUserId($userId)
{
if (empty($userId)) {
return $this->resolveDeletedUserId();
}
// check users table in default connection
if (DB::table('users')->where('id', $userId)->exists()) {
return $userId;
}
$uid = (int) $userId;
if ($uid > 0 && !in_array($uid, $this->missingUserIds, true)) {
$this->missingUserIds[] = $uid;
}
return $this->resolveDeletedUserId();
}
protected function resolveDeletedUserId(): int
{
if ($this->deletedUserId !== null) {
return $this->deletedUserId;
}
$userOne = User::query()->find(1);
if ($userOne) {
$this->deletedUserId = 1;
return $this->deletedUserId;
}
$fallback = User::query()->orderBy('id')->first();
if ($fallback) {
$this->deletedUserId = (int) $fallback->id;
return $this->deletedUserId;
}
$created = User::query()->create([
'name' => 'Deleted User',
'email' => 'deleted-user+forum@skinbase.local',
'password' => Hash::make(Str::random(64)),
'role' => 'user',
]);
$this->deletedUserId = (int) $created->id;
return $this->deletedUserId;
}
protected function convertLegacyMessage($msg)
{
$converter = new BbcodeConverter();
return $converter->convert($msg);
}
protected function repairOrphanPosts(bool $dry): void
{
$this->info('Repairing orphan posts');
$orphansQuery = ForumPost::query()->whereDoesntHave('thread')->orderBy('id');
$orphanCount = (clone $orphansQuery)->count();
if ($orphanCount === 0) {
$this->info('No orphan posts found.');
return;
}
$this->warn("Found {$orphanCount} orphan posts.");
$repairThread = $this->resolveOrCreateOrphanRepairThread($dry);
if ($repairThread === null) {
$this->warn('Unable to resolve/create repair thread in dry-run mode. Reporting only.');
(clone $orphansQuery)->limit(20)->get(['id', 'thread_id', 'user_id', 'created_at'])->each(function (ForumPost $post): void {
$this->line("- orphan post id={$post->id} thread_id={$post->thread_id} user_id={$post->user_id}");
});
return;
}
$this->line("Repair target thread: {$repairThread->id} ({$repairThread->slug})");
if ($dry) {
$this->info("[dry] Would reassign {$orphanCount} orphan posts to thread {$repairThread->id}");
(clone $orphansQuery)->limit(20)->get(['id', 'thread_id', 'user_id', 'created_at'])->each(function (ForumPost $post) use ($repairThread): void {
$this->log("[dry] orphan post {$post->id}: {$post->thread_id} -> {$repairThread->id}");
});
return;
}
$updated = 0;
(clone $orphansQuery)->chunkById(500, function ($posts) use ($repairThread, &$updated): void {
DB::transaction(function () use ($posts, $repairThread, &$updated): void {
/** @var ForumPost $post */
foreach ($posts as $post) {
$post->thread_id = $repairThread->id;
$post->is_edited = true;
$post->edited_at = $post->edited_at ?: now();
$post->save();
$updated++;
}
}, 3);
}, 'id');
$latestPostAt = ForumPost::query()
->where('thread_id', $repairThread->id)
->max('created_at');
if ($latestPostAt) {
$repairThread->last_post_at = $latestPostAt;
$repairThread->save();
}
$this->info("Repaired orphan posts: {$updated}");
$this->log("Repaired orphan posts: {$updated} -> thread {$repairThread->id}");
}
protected function resolveOrCreateOrphanRepairThread(bool $dry): ?ForumThread
{
$slug = 'migration-orphaned-posts';
$existing = ForumThread::query()->where('slug', $slug)->first();
if ($existing) {
return $existing;
}
$category = ForumCategory::query()->ordered()->first();
if (!$category && !$dry) {
$category = ForumCategory::query()->create([
'name' => 'Migration Repairs',
'slug' => 'migration-repairs',
'parent_id' => null,
'position' => 9999,
]);
}
if (!$category) {
return null;
}
if ($dry) {
return new ForumThread([
'id' => 0,
'slug' => $slug,
'category_id' => $category->id,
]);
}
return ForumThread::query()->create([
'category_id' => $category->id,
'user_id' => $this->resolveDeletedUserId(),
'title' => 'Migration: Orphaned Posts Recovery',
'slug' => $slug,
'content' => 'Automatic recovery thread for legacy posts whose source thread no longer exists after migration.',
'views' => 0,
'is_locked' => false,
'is_pinned' => false,
'visibility' => 'staff',
'last_post_at' => now(),
]);
}
protected function generateReport()
{
$this->info('Generating migration report');
$legacy = DB::connection('legacy');
$legacyCounts = [
'categories' => $legacy->table('forum_topics')->where('root_id','>',0)->distinct('root_id')->count('root_id'),
'threads' => $legacy->table('forum_topics')->count(),
'posts' => $legacy->table('forum_posts')->count(),
];
$newCounts = [
'categories' => ForumCategory::count(),
'threads' => ForumThread::count(),
'posts' => ForumPost::count(),
'attachments' => DB::table('forum_attachments')->count(),
];
$orphans = ForumPost::query()
->whereDoesntHave('thread')
->count();
$legacyThreadsWithLastUpdate = $legacy->table('forum_topics')->whereNotNull('last_update')->count();
$newThreadsWithLastPost = ForumThread::query()->whereNotNull('last_post_at')->count();
$legacyPostsWithPostDate = $legacy->table('forum_posts')->whereNotNull('post_date')->count();
$newPostsWithCreatedAt = ForumPost::query()->whereNotNull('created_at')->count();
$report = [
'missing_users_count' => count($this->missingUserIds),
'missing_users' => $this->missingUserIds,
'orphan_posts' => $orphans,
'timestamp_mismatches' => [
'threads_last_post_gap' => max(0, $legacyThreadsWithLastUpdate - $newThreadsWithLastPost),
'posts_created_at_gap' => max(0, $legacyPostsWithPostDate - $newPostsWithCreatedAt),
],
];
$this->info('Legacy counts: ' . json_encode($legacyCounts));
$this->info('New counts: ' . json_encode($newCounts));
$this->info('Report: ' . json_encode($report));
$this->log('Report: legacy=' . json_encode($legacyCounts) . ' new=' . json_encode($newCounts) . ' extra=' . json_encode($report));
}
protected function log(string $msg)
{
$line = '[' . date('c') . '] ' . $msg . "\n";
file_put_contents($this->logPath, $line, FILE_APPEND | LOCK_EX);
}
protected function migrateGallery(bool $dry, int $chunk)
{
$this->info('Migrating gallery (forum_topics_gallery → forum_attachments)');
$legacy = DB::connection('legacy');
if (!$legacy->getSchemaBuilder()->hasTable('forum_topics_gallery')) {
$this->info('No legacy forum_topics_gallery table found, skipping');
return;
}
$query = $legacy->table('forum_topics_gallery')->orderBy('id');
$total = $query->count();
if ($this->limit !== null && $this->limit > 0) {
$total = min($total, $this->limit);
}
$this->info("Total gallery items to process: {$total}");
$bar = $this->output->createProgressBar($total);
$bar->start();
$processed = 0;
$limit = $this->limit;
$query->chunkById($chunk, function ($rows) use ($dry, $bar, &$processed, $limit) {
foreach ($rows as $r) {
if ($limit !== null && $limit > 0 && $processed >= $limit) {
return false;
}
$bar->advance();
$processed++;
// expected legacy fields: id, name, category (topic id), folder, datum, description
$topicId = $r->category ?? ($r->topic_id ?? null);
$fileName = $r->name ?? null;
if (empty($topicId) || empty($fileName)) {
$this->log('Skipping gallery row with missing topic or name: ' . json_encode($r));
continue;
}
$nid = floor($topicId / 100);
$relativePath = "files/news/{$nid}/{$topicId}/{$fileName}";
$publicPath = public_path($relativePath);
$fileSize = null;
$mimeType = null;
$width = null;
$height = null;
if (file_exists($publicPath)) {
$fileSize = filesize($publicPath);
$img = @getimagesize($publicPath);
if ($img !== false) {
$width = $img[0];
$height = $img[1];
$mimeType = $img['mime'] ?? null;
} else {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $publicPath);
finfo_close($finfo);
}
}
// find legacy first post id for this topic
$legacy = DB::connection('legacy');
$firstPostId = $legacy->table('forum_posts')
->where('topic_id', $topicId)
->orderBy('post_date')
->value('post_id');
// map to new forum_posts id (we preserved ids when migrating)
$postId = null;
if ($firstPostId && \App\Models\ForumPost::where('id', $firstPostId)->exists()) {
$postId = $firstPostId;
} else {
// fallback: find any post in new DB for thread
$post = \App\Models\ForumPost::where('thread_id', $topicId)->orderBy('created_at')->first();
if ($post) $postId = $post->id;
}
if (empty($postId)) {
$this->log('No target post found for gallery item: topic ' . $topicId . ' file ' . $fileName);
continue;
}
if ($dry) {
$this->log("[dry] attach {$relativePath} -> post {$postId}");
continue;
}
DB::transaction(function () use ($postId, $relativePath, $fileSize, $mimeType, $width, $height) {
\App\Models\ForumAttachment::query()->updateOrCreate(
[
'post_id' => $postId,
'file_path' => $relativePath,
],
[
'file_size' => $fileSize ?? 0,
'mime_type' => $mimeType,
'width' => $width,
'height' => $height,
'updated_at' => now(),
]
);
}, 3);
}
}, 'id');
$bar->finish();
$this->line('');
$this->info('Gallery migrated');
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use App\Services\Images\ArtworkSquareThumbnailBackfillService;
use Illuminate\Console\Command;
use Throwable;
final class GenerateMissingSquareThumbnailsCommand extends Command
{
protected $signature = 'artworks:generate-missing-sq-thumbs
{--id= : Generate only for this artwork ID}
{--limit= : Stop after processing this many artworks}
{--force : Regenerate even if an sq variant row already exists}
{--dry-run : Report what would be generated without writing files}';
protected $description = 'Generate missing smart square artwork thumbnails';
public function handle(ArtworkSquareThumbnailBackfillService $backfill): int
{
$artworkId = $this->option('id') !== null ? max(1, (int) $this->option('id')) : null;
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
$force = (bool) $this->option('force');
$dryRun = (bool) $this->option('dry-run');
$query = Artwork::query()
->whereNotNull('hash')
->where('hash', '!=', '')
->orderBy('id');
if ($artworkId !== null) {
$query->whereKey($artworkId);
}
$processed = 0;
$generated = 0;
$planned = 0;
$skipped = 0;
$failed = 0;
$query->chunkById(100, function ($artworks) use ($backfill, $force, $dryRun, $limit, &$processed, &$generated, &$planned, &$skipped, &$failed) {
foreach ($artworks as $artwork) {
if ($limit !== null && $processed >= $limit) {
return false;
}
try {
$result = $backfill->ensureSquareThumbnail($artwork, $force, $dryRun);
$status = (string) ($result['status'] ?? 'skipped');
if ($status === 'generated') {
$generated++;
} elseif ($status === 'dry_run') {
$planned++;
} else {
$skipped++;
}
} catch (Throwable $e) {
$failed++;
$this->warn(sprintf('Artwork %d failed: %s', (int) $artwork->getKey(), $e->getMessage()));
}
$processed++;
}
return true;
});
$this->info(sprintf(
'Square thumbnail backfill complete. processed=%d generated=%d planned=%d skipped=%d failed=%d',
$processed,
$generated,
$planned,
$skipped,
$failed,
));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
}

View File

@@ -0,0 +1,419 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
use Meilisearch\Client as MeilisearchClient;
use Throwable;
/**
* Comprehensive service health check.
*
* Usage:
* php artisan health:check # all checks
* php artisan health:check --only=meili # single service
* php artisan health:check --json # machine-readable JSON output
*/
class HealthCheckCommand extends Command
{
protected $signature = 'health:check
{--only= : Run only a named check (mysql|redis|cache|meilisearch|reverb|vision|horizon|app)}
{--json : Output results as JSON}';
protected $description = 'Check health of all critical services (MySQL, Redis, Cache, Meilisearch, Reverb, Vision, Horizon, App).';
/** Collected results: [name => [status, message, details]] */
private array $results = [];
public function handle(): int
{
$only = $this->option('only') ? strtolower((string) $this->option('only')) : null;
$checks = [
'mysql' => fn () => $this->checkMysql(),
'redis' => fn () => $this->checkRedis(),
'cache' => fn () => $this->checkCache(),
'meilisearch' => fn () => $this->checkMeilisearch(),
'reverb' => fn () => $this->checkReverb(),
'vision' => fn () => $this->checkVision(),
'horizon' => fn () => $this->checkHorizon(),
'app' => fn () => $this->checkApp(),
];
if ($only !== null) {
if (! array_key_exists($only, $checks)) {
$this->error("Unknown check '{$only}'. Available: " . implode(', ', array_keys($checks)));
return self::FAILURE;
}
$checks = [$only => $checks[$only]];
}
foreach ($checks as $name => $check) {
$check();
}
if ($this->option('json')) {
$this->line(json_encode($this->results, JSON_PRETTY_PRINT));
return $this->hasFailures() ? self::FAILURE : self::SUCCESS;
}
$this->renderTable();
$failed = $this->countByStatus('fail');
$warned = $this->countByStatus('warn');
$this->newLine();
if ($failed > 0) {
$this->error("{$failed} check(s) FAILED" . ($warned > 0 ? ", {$warned} warning(s)" : '') . '.');
return self::FAILURE;
}
if ($warned > 0) {
$this->warn("⚠️ All checks passed with {$warned} warning(s).");
return self::SUCCESS;
}
$this->info('✅ All checks passed.');
return self::SUCCESS;
}
// ── Individual checks ──────────────────────────────────────────────────────
private function checkMysql(): void
{
try {
DB::select('SELECT 1');
$db = config('database.connections.' . config('database.default') . '.database');
$artworkCount = DB::table('artworks')->whereNull('deleted_at')->count();
$this->pass('mysql', "Connected to `{$db}`. Artworks in DB: {$artworkCount}.", ['artwork_count' => $artworkCount]);
} catch (Throwable $e) {
$this->failCheck('mysql', 'Connection failed: ' . $e->getMessage());
}
}
private function checkRedis(): void
{
try {
$pong = Redis::ping();
// ping returns "+PONG\r\n" string or true depending on driver
$ok = $pong === true || str_contains((string) $pong, 'PONG');
if ($ok) {
$info = Redis::info('server');
$version = $info['redis_version'] ?? ($info['Server']['redis_version'] ?? 'unknown');
$this->pass('redis', "Reachable. Redis version: {$version}.", ['version' => $version]);
} else {
$this->failCheck('redis', 'Unexpected ping response: ' . var_export($pong, true));
}
} catch (Throwable $e) {
$this->failCheck('redis', 'Connection failed: ' . $e->getMessage());
}
}
private function checkCache(): void
{
try {
$key = '_healthcheck_' . uniqid('', true);
$value = 'ok_' . time();
Cache::put($key, $value, 10);
$got = Cache::get($key);
Cache::forget($key);
$driver = config('cache.default');
if ($got === $value) {
$this->pass('cache', "Driver `{$driver}` read/write OK.", ['driver' => $driver]);
} else {
$this->failCheck('cache', "Driver `{$driver}`: wrote '{$value}' but read back " . var_export($got, true));
}
} catch (Throwable $e) {
$this->failCheck('cache', 'Cache test failed: ' . $e->getMessage());
}
}
private function checkMeilisearch(): void
{
try {
/** @var MeilisearchClient $client */
$client = app(MeilisearchClient::class);
$health = $client->health();
if (($health['status'] ?? '') !== 'available') {
$this->failCheck('meilisearch', 'Meilisearch reports unhealthy status: ' . json_encode($health));
return;
}
$version = $client->version()['pkgVersion'] ?? 'unknown';
$indexName = (new Artwork())->searchableAs();
$index = $client->index($indexName);
$stats = $index->stats();
$docCount = (int) ($stats['numberOfDocuments'] ?? 0);
$isIndexing = (bool) ($stats['isIndexing'] ?? false);
// Expected: ≥ 50% of DB artworks should be indexed
$dbCount = DB::table('artworks')
->whereNull('deleted_at')
->where('is_public', 1)
->where('is_approved', 1)
->count();
$status = 'pass';
$message = "v{$version}. Index `{$indexName}`: {$docCount} docs (DB public+approved: {$dbCount}).";
if ($isIndexing) {
$message .= ' [currently re-indexing]';
}
if ($docCount === 0) {
$status = 'fail';
$message = "Index `{$indexName}` is EMPTY (DB has {$dbCount} public+approved artworks). Run: php artisan scout:import \"App\\\\Models\\\\Artwork\"";
} elseif ($dbCount > 0 && $docCount < (int) ($dbCount * 0.5)) {
$status = 'warn';
$message .= " — indexed count is < 50% of DB count. Index may be stale. Run: php artisan artworks:search-rebuild";
}
// Check pending Meilisearch tasks
try {
$tasks = $client->getTasks(['statuses' => 'enqueued,processing']);
$pendingCount = $tasks->getTotal();
if ($pendingCount > 0) {
$message .= " ({$pendingCount} tasks still pending)";
}
} catch (Throwable) {
// non-fatal
}
$this->result('meilisearch', $status, $message, [
'index' => $indexName,
'indexed_docs' => $docCount,
'db_eligible' => $dbCount,
'is_indexing' => $isIndexing,
'meili_version' => $version,
]);
} catch (Throwable $e) {
$this->failCheck('meilisearch', 'Unreachable or error: ' . $e->getMessage());
}
}
private function checkReverb(): void
{
$host = config('reverb.servers.reverb.options.host') ?? env('REVERB_HOST', '');
$port = (int) (config('reverb.servers.reverb.options.port') ?? env('REVERB_PORT', 443));
$scheme = config('reverb.servers.reverb.options.scheme') ?? env('REVERB_SCHEME', 'https');
if (empty($host)) {
$this->warn_check('reverb', 'REVERB_HOST not configured — skipping.');
return;
}
// Reverb exposes an HTTP health endpoint at /apps/{appId}
// We do a plain TCP connect as the minimal check; a refused connection means down.
$timeout = 5;
try {
$errno = 0;
$errstr = '';
$proto = $scheme === 'https' ? 'ssl' : 'tcp';
$fp = @fsockopen("{$proto}://{$host}", $port, $errno, $errstr, $timeout);
if ($fp === false) {
$this->failCheck('reverb', "Cannot connect to {$host}:{$port} ({$scheme}) — {$errstr} [{$errno}]");
return;
}
fclose($fp);
// Try the HTTP health probe (Reverb answers 200 on /)
$url = "{$scheme}://{$host}:{$port}/";
$response = $this->httpGet($url, 3);
$code = $response['code'] ?? 0;
// Reverb returns 200 or 400 on the root — both mean it's alive
if ($code >= 200 && $code < 500) {
$this->pass('reverb', "Reachable at {$host}:{$port} (HTTP {$code}).", ['host' => $host, 'port' => $port]);
} else {
$this->warn_check('reverb', "TCP open but HTTP returned {$code}.", ['host' => $host, 'port' => $port]);
}
} catch (Throwable $e) {
$this->failCheck('reverb', 'Check failed: ' . $e->getMessage());
}
}
private function checkVision(): void
{
$services = [
'CLIP / Gateway' => rtrim((string) config('vision.gateway.base_url', ''), '/') . '/health',
'Vector Gateway' => rtrim((string) config('vision.vector_gateway.base_url', ''), '/') . '/health',
];
$allPassed = true;
$messages = [];
foreach ($services as $label => $url) {
if ($url === '/health' || $url === '') {
$messages[] = "{$label}: not configured";
continue;
}
$response = $this->httpGet($url, 5);
$code = $response['code'] ?? 0;
if ($code >= 200 && $code < 300) {
$messages[] = "{$label}: OK (HTTP {$code})";
} elseif ($code === 0) {
$allPassed = false;
$messages[] = "{$label}: UNREACHABLE ({$url})";
} else {
$allPassed = false;
$messages[] = "{$label}: HTTP {$code} ({$url})";
}
}
$summary = implode(' | ', $messages);
if ($allPassed) {
$this->pass('vision', $summary);
} else {
$this->warn_check('vision', $summary);
}
}
private function checkHorizon(): void
{
try {
// Horizon stores its status in Redis under the horizon:master-supervisor key prefix.
// A simpler cross-version check: look for any horizon-related Redis key.
$status = Cache::store('redis')->get('horizon:status');
if ($status === null) {
// Try reading directly from Redis
$status = Redis::get('horizon:status');
}
if ($status === null) {
$this->warn_check('horizon', 'No Horizon status key in Redis — Horizon may not be running or has never started.');
return;
}
$status = is_string($status) ? strtolower(trim($status)) : strtolower((string) $status);
if ($status === 'running') {
$this->pass('horizon', "Horizon status: running.");
} elseif ($status === 'paused') {
$this->warn_check('horizon', "Horizon is PAUSED. Resume with: php artisan horizon:continue");
} else {
$this->failCheck('horizon', "Horizon status: {$status}. Start with: php artisan horizon");
}
} catch (Throwable $e) {
$this->warn_check('horizon', 'Could not read Horizon status: ' . $e->getMessage());
}
}
private function checkApp(): void
{
$appUrl = rtrim((string) config('app.url', ''), '/');
if (empty($appUrl) || str_contains($appUrl, '.test') || str_contains($appUrl, 'localhost')) {
$this->warn_check('app', "APP_URL is `{$appUrl}` — looks like a local/dev URL, skipping HTTP probe.");
return;
}
// Probe the app homepage
$response = $this->httpGet($appUrl . '/', 10);
$code = $response['code'] ?? 0;
if ($code === 200) {
$ttfb = $response['ttfb'] ?? 0;
$this->pass('app', "Homepage responded HTTP 200. TTFB: {$ttfb}ms.", ['url' => $appUrl, 'ttfb_ms' => $ttfb]);
} elseif ($code > 0) {
$this->warn_check('app', "Homepage returned HTTP {$code}. URL: {$appUrl}", ['url' => $appUrl, 'http_code' => $code]);
} else {
$this->failCheck('app', "Homepage unreachable. URL: {$appUrl}");
}
}
// ── Helpers ────────────────────────────────────────────────────────────────
private function httpGet(string $url, int $timeout = 5): array
{
$start = microtime(true);
try {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_USERAGENT => 'SkinbaseHealthCheck/1.0',
CURLOPT_HTTPHEADER => ['Accept: application/json, text/html'],
]);
$body = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$ttfb = (int) round((microtime(true) - $start) * 1000);
return ['code' => $code, 'body' => $body ?: '', 'ttfb' => $ttfb];
} catch (Throwable) {
return ['code' => 0, 'body' => '', 'ttfb' => 0];
}
}
private function pass(string $name, string $message, array $details = []): void
{
$this->result($name, 'pass', $message, $details);
}
private function failCheck(string $name, string $message, array $details = []): void
{
$this->result($name, 'fail', $message, $details);
}
private function warn_check(string $name, string $message, array $details = []): void
{
$this->result($name, 'warn', $message, $details);
}
private function result(string $name, string $status, string $message, array $details = []): void
{
$this->results[$name] = [
'status' => $status,
'message' => $message,
'details' => $details,
];
}
private function renderTable(): void
{
$this->newLine();
$this->line(' <fg=white;options=bold>SERVICE STATUS MESSAGE</>');
$this->line(' ' . str_repeat('─', 90));
foreach ($this->results as $name => $r) {
[$icon, $color] = match ($r['status']) {
'pass' => ['✅', 'green'],
'warn' => ['⚠️ ', 'yellow'],
default => ['❌', 'red'],
};
$label = str_pad(strtoupper($name), 15);
$status = str_pad(strtoupper($r['status']), 7);
$message = $r['message'];
$this->line(" {$icon} <fg={$color}>{$label} {$status}</> {$message}");
}
$this->line(' ' . str_repeat('─', 90));
}
private function hasFailures(): bool
{
return $this->countByStatus('fail') > 0;
}
private function countByStatus(string $status): int
{
return count(array_filter($this->results, fn ($r) => $r['status'] === $status));
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class ImportArtworkHashes extends Command
{
protected $signature = 'artworks:import-hashes {file=artworks_hash_skinbase.csv} {--start=0} {--limit=0}';
protected $description = 'Import artwork hash, file_ext and thumb_ext from CSV (id,hash,file_ext,thumb_ext)';
public function handle(): int
{
$file = $this->argument('file');
$path = base_path($file);
if (!is_readable($path)) {
$this->error("CSV file not readable: {$path}");
return 1;
}
$handle = fopen($path, 'r');
if (!$handle) {
$this->error('Unable to open CSV file');
return 1;
}
// Read header
$header = fgetcsv($handle);
if ($header === false) {
$this->error('CSV appears empty');
fclose($handle);
return 1;
}
$start = (int) $this->option('start');
$limit = (int) $this->option('limit');
$rowIndex = 0;
$bar = null;
// Optionally count lines for progress bar
if ($limit === 0) {
// We'll not determine total to keep memory low
}
while (($row = fgetcsv($handle)) !== false) {
$rowIndex++;
if ($rowIndex <= $start) {
continue;
}
if ($limit > 0 && ($rowIndex - $start) > $limit) {
break;
}
// Expecting columns: id,hash,file_ext,thumb_ext
$id = isset($row[0]) ? trim($row[0], "\" ") : null;
$hash = isset($row[1]) ? trim($row[1], "\" ") : null;
$file_ext = isset($row[2]) ? trim($row[2], "\" ") : null;
$thumb_ext = isset($row[3]) ? trim($row[3], "\" ") : null;
if (empty($id)) {
continue;
}
// Normalize null strings
if (strtoupper($hash) === 'NULL') $hash = null;
if (strtoupper($file_ext) === 'NULL') $file_ext = null;
if (strtoupper($thumb_ext) === 'NULL') $thumb_ext = null;
try {
DB::table('artworks')->where('id', $id)->limit(1)->update([
'hash' => $hash,
'file_ext' => $file_ext,
'thumb_ext' => $thumb_ext,
]);
} catch (\Throwable $e) {
// Log and continue on duplicate / other DB errors
$this->error("Row {$rowIndex} (id={$id}) failed: " . $e->getMessage());
continue;
}
if ($rowIndex % 500 === 0) {
$this->info("Processed {$rowIndex} rows");
}
}
fclose($handle);
$this->info('Import complete.');
return 0;
}
}

View File

@@ -0,0 +1,193 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
class ImportCategories extends Command
{
protected $signature = 'import:categories {path=database/artworks_categories.csv}';
protected $description = 'Import categories from legacy CSV into categories table';
public function handle(): int
{
$path = base_path($this->argument('path'));
if (!file_exists($path)) {
$this->error("CSV file not found: {$path}");
return 1;
}
$file = new \SplFileObject($path);
$file->setFlags(\SplFileObject::READ_CSV | \SplFileObject::SKIP_EMPTY);
$header = null;
$rows = [];
foreach ($file as $index => $row) {
if ($row === [null] || $row === false) {
continue;
}
if ($index === 0) {
$header = $row;
continue;
}
// normalize row length
if (!is_array($row)) {
continue;
}
$rows[] = $row;
}
// build a map of rows by category id to allow ancestor lookups
$map = [];
foreach ($rows as $r) {
$rid = isset($r[0]) ? (int)$r[0] : null;
if ($rid) {
$map[$rid] = $r;
}
}
// Temporarily disable foreign key checks to allow inserting parents out of order
DB::statement('SET FOREIGN_KEY_CHECKS=0');
// truncate categories table to start fresh
DB::table('categories')->truncate();
DB::beginTransaction();
try {
$this->info('Inserting categories...');
// helper to walk ancestor chain and find a valid content_type_id
$findAncestorContentType = function ($startRoot) use ($map) {
$seen = [];
$cur = $startRoot;
while ($cur && isset($map[$cur]) && !in_array($cur, $seen, true)) {
$seen[] = $cur;
$ancestor = $map[$cur];
$candidate = isset($ancestor[7]) ? (int)$ancestor[7] : null;
if (!empty($candidate)) {
// verify the content type exists in DB
if (DB::table('content_types')->where('id', $candidate)->exists()) {
return $candidate;
}
}
$cur = isset($ancestor[8]) ? (int)$ancestor[8] : null;
}
return null;
};
// helper to collect ancestor chain rows for debugging dump
$collectAncestorChain = function ($startRoot) use ($map) {
$chain = [];
$seen = [];
$cur = $startRoot;
while ($cur && isset($map[$cur]) && !in_array($cur, $seen, true)) {
$seen[] = $cur;
$r = $map[$cur];
$chain[] = [
'id' => $cur,
'name' => $r[1] ?? null,
'section_id' => isset($r[7]) ? (int)$r[7] : null,
'rootid' => isset($r[8]) ? (int)$r[8] : null,
'raw' => $r,
];
$cur = isset($r[8]) ? (int)$r[8] : null;
}
return $chain;
};
foreach ($rows as $row) {
// CSV format: category_id,category_name,num_artworks,description,official_webpage,picture,views,section_id,rootid
$origId = isset($row[0]) ? (int)$row[0] : null;
$name = isset($row[1]) ? trim($row[1]) : '';
$description = isset($row[3]) && $row[3] !== '' ? $row[3] : null;
$image = isset($row[5]) && $row[5] !== '' ? $row[5] : null;
$contentTypeId = isset($row[7]) ? (int)$row[7] : null;
$rootId = isset($row[8]) ? (int)$row[8] : null;
// If the CSV `rootid` corresponds to a content type, it's a root for that content type => parent_id NULL
if (!empty($rootId) && DB::table('content_types')->where('id', $rootId)->exists()) {
$parentId = null;
} else {
// If content_type_id == parent_id then parent_id should be NULL
$parentId = ($contentTypeId === $rootId || $rootId === 0) ? null : $rootId;
}
// resolve missing/invalid content_type_id from ancestor chain (parent/root)
$ctExists = !empty($contentTypeId) && DB::table('content_types')->where('id', $contentTypeId)->exists();
if (!$ctExists) {
if (!empty($rootId)) {
$resolved = $findAncestorContentType($rootId);
if ($resolved) {
$contentTypeId = $resolved;
$this->info("Resolved content_type_id for category {$origId} from ancestor: {$contentTypeId}");
}
}
}
// after resolution, verify content_type exists
if (empty($contentTypeId) || !DB::table('content_types')->where('id', $contentTypeId)->exists()) {
$this->error("Missing content_type_id for category {$origId}. Dumping data and aborting import.");
$dump = [
'row' => $row,
'resolved_root' => $rootId,
'ancestor_chain' => $rootId ? $collectAncestorChain($rootId) : [],
];
$this->line(json_encode($dump, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
throw new \RuntimeException('Aborting import: unresolved content_type_id for category ' . $origId);
}
$slug = Str::slug($name) ?: 'cat-' . $origId;
// ensure uniqueness for slug within content_type
$existing = DB::table('categories')
->where('content_type_id', $contentTypeId)
->where('slug', $slug)
->exists();
if ($existing) {
$slug = $slug . '-' . $origId;
}
$now = now();
$data = [
'id' => $origId,
'content_type_id' => $contentTypeId,
'parent_id' => $parentId,
'name' => $name,
'slug' => $slug,
'description' => $description,
'image' => $image,
'is_active' => true,
'sort_order' => 0,
'created_at' => $now,
'updated_at' => $now,
];
DB::table('categories')->insert($data);
$this->line("Inserted category {$origId}: {$name}");
}
DB::commit();
$this->info('Import completed.');
return 0;
} catch (\Throwable $e) {
DB::rollBack();
$this->error('Import failed: ' . $e->getMessage());
return 1;
} finally {
// re-enable foreign key checks
DB::statement('SET FOREIGN_KEY_CHECKS=1');
}
}
}

View File

@@ -0,0 +1,305 @@
<?php
namespace App\Console\Commands;
use App\Models\Artwork;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;
/**
* Import artworks from legacy `wallz` table and attach categories via `connected` table.
*
* Usage:
* php artisan skinbase:import-legacy-artworks --chunk=500 --dry-run
* php artisan skinbase:import-legacy-artworks --artwork-id=69527
*/
class ImportLegacyArtworks extends Command
{
protected $signature = 'skinbase:import-legacy-artworks
{--chunk=500 : chunk size for processing}
{--limit= : maximum number of legacy rows to import}
{--artwork-id= : import only one legacy wallz row by id}
{--dry-run : do not persist any changes}
{--legacy-connection=legacy : name of legacy DB connection}
{--legacy-table=wallz : legacy artworks table name}
{--connected-table=connected : legacy artwork->category table}
';
protected $description = 'Import artworks from legacy DB (wallz) into new artworks table';
private function coerceUnsignedInt(mixed $value, int $default = 0): int
{
if ($value === null) {
return $default;
}
if (is_bool($value)) {
return $value ? 1 : 0;
}
if (is_int($value)) {
return max(0, $value);
}
if (is_float($value)) {
return max(0, (int) $value);
}
if (is_string($value)) {
$trimmed = trim($value);
if ($trimmed === '') {
return $default;
}
if (is_numeric($trimmed)) {
return max(0, (int) $trimmed);
}
}
return $default;
}
private function coerceString(mixed $value, string $default = ''): string
{
if ($value === null) {
return $default;
}
$stringValue = trim((string) $value);
return $stringValue !== '' ? $stringValue : $default;
}
public function handle(): int
{
$chunk = (int) $this->option('chunk');
$limit = $this->option('limit') ? (int) $this->option('limit') : null;
$artworkId = $this->option('artwork-id') ? (int) $this->option('artwork-id') : null;
$dryRun = (bool) $this->option('dry-run');
$legacyConn = $this->option('legacy-connection');
$legacyTable = $this->option('legacy-table');
$connectedTable = $this->option('connected-table');
if ($artworkId !== null && $artworkId <= 0) {
$this->error('The --artwork-id option must be a positive integer.');
return self::FAILURE;
}
$this->info("Starting import from {$legacyConn}.{$legacyTable} (chunk={$chunk})");
$query = DB::connection($legacyConn)->table($legacyTable)->orderBy('id');
if ($artworkId !== null) {
$this->info("Scoping import to legacy artwork id={$artworkId}");
$query->where('id', $artworkId);
$limit = 1;
}
$processed = 0;
$query->chunkById($chunk, function ($rows) use (&$processed, $limit, $dryRun, $legacyConn, $connectedTable) {
foreach ($rows as $row) {
if ($limit !== null && $processed >= $limit) {
return false; // stop chunking
}
$legacyId = $row->id ?? null;
$title = $row->name ?? $row->title ?? ($row->headline ?? ('legacy-' . ($legacyId ?? Str::random(6))));
$description = $row->description ?? $row->desc ?? null;
$slugBase = Str::slug(substr((string) $title, 0, 120));
// Use cleaned title slug directly. If no title, fallback to artwork-<id|random>.
$slug = $slugBase ? $slugBase : 'artwork-' . ($legacyId ?? Str::random(8));
$publishedAt = null;
if (! empty($row->datum)) {
$publishedAt = date('Y-m-d H:i:s', strtotime($row->datum));
} elseif (! empty($row->created_at)) {
$publishedAt = $row->created_at;
}
// File mapping — try common legacy fields. Normalize and ensure file_path is not null.
$rawFileName = $row->pic ?? $row->picture ?? $row->file ?? $row->fname ?? null;
$fileName = null;
$filePath = '';
if (! empty($rawFileName) && trim((string) $rawFileName) !== '') {
$fileName = trim((string) $rawFileName);
// store legacy path under legacy/ folder, but do not move files here — admin can handle file migration
$filePath = 'legacy/uploads/' . ltrim($fileName, '/');
}
// derive mime type if missing (use extension mapping), fallback to application/octet-stream
$mime = $row->mimetype ?? $row->mime ?? null;
if (empty($mime) && $fileName) {
$ext = strtolower(pathinfo((string) $fileName, PATHINFO_EXTENSION));
$map = [
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'bmp' => 'image/bmp',
'webp' => 'image/webp',
'svg' => 'image/svg+xml',
'ico' => 'image/x-icon',
'zip' => 'application/zip',
'pdf' => 'application/pdf',
];
$mime = $map[$ext] ?? null;
}
if (empty($mime)) {
$mime = 'application/octet-stream';
}
$data = [
'id' => $row->id ?? null,
// NOTE: artworks.user_id is NOT NULL (no FK constraint, but column cannot be null)
'user_id' => $row->user_id ?? 1,
'title' => (string) $title,
'slug' => (string) $slug,
'description' => $description,
'file_name' => $fileName,
// ensure non-null file_path to satisfy NOT NULL DB constraints
'file_path' => $filePath ?? '',
// legacy DB sometimes has no filesize; default to 0 to satisfy NOT NULL
'file_size' => isset($row->filesize) && $row->filesize !== null ? (int) $row->filesize : (isset($row->size) && $row->size !== null ? (int) $row->size : 0),
'mime_type' => $mime,
'width' => $row->width ?? null,
'height' => $row->height ?? null,
'is_public' => isset($row->visible) ? (bool) $row->visible : true,
'is_approved' => isset($row->approved) ? (bool) $row->approved : true,
'published_at' => $publishedAt,
];
// Coerce required NOT NULL columns to safe defaults (legacy data can be messy)
$data['user_id'] = $this->coerceUnsignedInt($data['user_id'], 1);
$data['file_name'] = $this->coerceString($data['file_name'], 'legacy-' . ($legacyId ?? Str::random(8)));
$data['file_path'] = $this->coerceString($data['file_path'], 'legacy/uploads/' . $data['file_name']);
$data['mime_type'] = $this->coerceString($data['mime_type'], 'application/octet-stream');
$data['file_size'] = $this->coerceUnsignedInt($data['file_size'], 0);
$data['width'] = $this->coerceUnsignedInt($data['width'], 0);
$data['height'] = $this->coerceUnsignedInt($data['height'], 0);
$this->line('Importing legacy id=' . ($legacyId ?? 'unknown') . ' title=' . $data['title']);
if ($dryRun) {
$processed++;
continue;
}
try {
$art = null;
DB::connection()->transaction(function () use (&$art, $data, $legacyId, $legacyConn, $connectedTable) {
// Preserve the imported slug verbatim. Public artwork URLs include the artwork id.
$data['slug'] = Str::limit((string) ($data['slug'] ?: 'artwork'), 160, '');
// Preserve legacy primary ID if available and safe to do so.
if (! empty($legacyId) && is_numeric($legacyId) && (int) $legacyId > 0) {
$preserveId = (int) $legacyId;
if (Artwork::where('id', $preserveId)->exists()) {
// Avoid overwriting an existing artwork with the same id.
throw new \RuntimeException("Artwork with id {$preserveId} already exists; skipping import for this legacy id.");
}
$data['id'] = $preserveId;
}
// If we need to preserve the legacy primary id, perform a raw insert
// so auto-increment doesn't assign a different id. Otherwise use Eloquent.
if (! empty($data['id'])) {
$insert = $data;
$ts = date('Y-m-d H:i:s');
if (! array_key_exists('created_at', $insert)) {
$insert['created_at'] = $ts;
}
if (! array_key_exists('updated_at', $insert)) {
$insert['updated_at'] = $ts;
}
DB::table('artworks')->insert($insert);
$art = Artwork::find($insert['id']);
} else {
$art = Artwork::create($data);
}
// attach categories if connected table exists
if (DB::connection($legacyConn)->getSchemaBuilder()->hasTable($connectedTable)) {
// attempt to find category ids from connected table; common column names: wallz_id, art_id, connected_id
$rows = DB::connection($legacyConn)->table($connectedTable)
->where(function ($q) use ($legacyId) {
$q->where('wallz_id', $legacyId)
->orWhere('art_id', $legacyId)
->orWhere('item_id', $legacyId);
})->get();
$categoryIds = [];
foreach ($rows as $r) {
$cid = $r->category_id ?? $r->cat_id ?? $r->category ?? null;
if ($cid) {
// try to find matching Category in new DB by id or slug
if (is_numeric($cid) && \App\Models\Category::where('id', $cid)->exists()) {
$categoryIds[] = (int) $cid;
} else {
// maybe legacy stores slug
$cat = \App\Models\Category::where('slug', $cid)->first();
if ($cat) {
$categoryIds[] = $cat->id;
}
}
}
}
if (! empty($categoryIds)) {
$art->categories()->syncWithoutDetaching(array_values(array_unique($categoryIds)));
}
}
});
// Post-insert verification: if we attempted to preserve the legacy id,
// confirm the row exists with that id. Log mapping if preservation failed.
if (! empty($legacyId) && is_numeric($legacyId) && (int) $legacyId > 0) {
$preserveId = (int) $legacyId;
$exists = Artwork::where('id', $preserveId)->exists();
if (! $exists) {
// If $art was created but with a different id, log mapping for manual reconciliation
if ($art instanceof Artwork) {
Log::warning('Imported legacy artwork but failed to preserve id', [
'legacy_id' => $preserveId,
'created_id' => $art->id,
'slug' => $art->slug ?? null,
]);
} else {
Log::warning('Legacy artwork not found after import', ['legacy_id' => $preserveId]);
}
}
}
$processed++;
} catch (Throwable $e) {
$this->error('Failed to import legacy id=' . ($legacyId ?? 'unknown') . ': ' . $e->getMessage());
Log::error('ImportLegacyArtworks error', [
'legacy_id' => $legacyId,
'error' => $e->getMessage(),
'data' => $data ?? null,
'trace' => $e->getTraceAsString(),
]);
}
}
return null;
}, 'id');
if ($artworkId !== null && $processed === 0) {
$this->warn("Legacy artwork id={$artworkId} was not found in {$legacyConn}.{$legacyTable}.");
return self::FAILURE;
}
$this->info('Import complete. Processed: ' . $processed);
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,288 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\ArtworkAward;
use App\Models\ArtworkAwardStat;
use App\Services\ArtworkAwardService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Migrates legacy `users_opinions` (projekti_old_skinbase) into `artwork_medals`.
*
* Score mapping (legacy score new medal):
* 4 gold (weight 3)
* 3 silver (weight 2)
* 2 bronze (weight 1)
* 1 skipped (too low to map meaningfully)
*
* Usage:
* php artisan awards:import-legacy
* php artisan awards:import-legacy --dry-run
* php artisan awards:import-legacy --chunk=500
* php artisan awards:import-legacy --skip-stats (skip final stats recalc)
*/
class ImportLegacyAwards extends Command
{
protected $signature = 'awards:import-legacy
{--dry-run : Preview only no writes to DB}
{--chunk=250 : Rows to process per batch}
{--skip-stats : Skip per-artwork stats recalculation at the end}
{--force : Overwrite existing awards instead of skipping duplicates}';
protected $description = 'Import legacy users_opinions into artwork_medals';
/** Maps legacy score value → medal string */
private const SCORE_MAP = [
4 => 'gold',
3 => 'silver',
2 => 'bronze',
];
public function handle(ArtworkAwardService $service): int
{
$dryRun = (bool) $this->option('dry-run');
$chunk = max(1, (int) $this->option('chunk'));
$skipStats = (bool) $this->option('skip-stats');
$force = (bool) $this->option('force');
if ($dryRun) {
$this->warn('[DRY-RUN] No data will be written.');
}
// Verify legacy connection is reachable
try {
DB::connection('legacy')->getPdo();
} catch (\Throwable $e) {
$this->error('Cannot connect to legacy database: ' . $e->getMessage());
return self::FAILURE;
}
if (! DB::connection('legacy')->getSchemaBuilder()->hasTable('users_opinions')) {
$this->error('Legacy table `users_opinions` not found.');
return self::FAILURE;
}
// Pre-load sets of valid artwork IDs and user IDs from the new DB
$this->info('Loading new-DB artwork and user ID sets…');
$validArtworkIds = DB::table('artworks')
->whereNull('deleted_at')
->pluck('id')
->flip() // flip so we can use isset() for O(1) lookup
->all();
$validUserIds = DB::table('users')
->whereNull('deleted_at')
->pluck('id')
->flip()
->all();
$this->info(sprintf(
'Found %d artworks and %d users in new DB.',
count($validArtworkIds),
count($validUserIds)
));
// Count legacy rows for progress bar
$total = DB::connection('legacy')
->table('users_opinions')
->count();
$this->info("Legacy rows to process: {$total}");
if ($total === 0) {
$this->warn('No legacy rows found. Nothing to do.');
return self::SUCCESS;
}
$stats = [
'imported' => 0,
'skipped_score' => 0,
'skipped_artwork' => 0,
'skipped_user' => 0,
'skipped_duplicate'=> 0,
'updated_force' => 0,
'errors' => 0,
];
$affectedArtworkIds = [];
$bar = $this->output->createProgressBar($total);
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% | imported: %imported% | skipped: %skipped%');
$bar->setMessage('0', 'imported');
$bar->setMessage('0', 'skipped');
$bar->start();
DB::connection('legacy')
->table('users_opinions')
->orderBy('opinion_id')
->chunk($chunk, function ($rows) use (
&$stats,
&$affectedArtworkIds,
$validArtworkIds,
$validUserIds,
$dryRun,
$force,
$bar
) {
$inserts = [];
$now = now();
foreach ($rows as $row) {
$artworkId = (int) $row->artwork_id;
$userId = (int) $row->author_id; // author_id = the voter
$score = (int) $row->score;
$postedAt = $row->post_date ?? $now;
// --- score → medal ---
$medal = self::SCORE_MAP[$score] ?? null;
if ($medal === null) {
$stats['skipped_score']++;
$bar->advance();
continue;
}
// --- Artwork must exist in new DB ---
if (! isset($validArtworkIds[$artworkId])) {
$stats['skipped_artwork']++;
$bar->advance();
continue;
}
// --- User must exist in new DB ---
if (! isset($validUserIds[$userId])) {
$stats['skipped_user']++;
$bar->advance();
continue;
}
if (! $dryRun) {
if ($force) {
// Upsert: update medal if row already exists
$affected = DB::table('artwork_medals')
->where('artwork_id', $artworkId)
->where('user_id', $userId)
->update([
'medal_type' => $medal,
'weight' => ArtworkAward::WEIGHTS[$medal],
'updated_at' => $now,
]);
if ($affected > 0) {
$stats['updated_force']++;
$affectedArtworkIds[$artworkId] = true;
$bar->advance();
continue;
}
} else {
// Skip if already exists
if (
DB::table('artwork_medals')
->where('artwork_id', $artworkId)
->where('user_id', $userId)
->exists()
) {
$stats['skipped_duplicate']++;
$bar->advance();
continue;
}
}
$inserts[] = [
'artwork_id' => $artworkId,
'user_id' => $userId,
'medal_type' => $medal,
'weight' => ArtworkAward::WEIGHTS[$medal],
'created_at' => $postedAt,
'updated_at' => $postedAt,
];
$affectedArtworkIds[$artworkId] = true;
}
$stats['imported']++;
$bar->advance();
}
// Bulk insert the batch (DB::table bypasses the observer intentionally;
// stats are recalculated in bulk at the end for performance)
if (! $dryRun && ! empty($inserts)) {
try {
DB::table('artwork_medals')->insert($inserts);
} catch (\Throwable $e) {
// Fallback: insert one-by-one to isolate constraint violations
foreach ($inserts as $row) {
try {
DB::table('artwork_medals')->insertOrIgnore([$row]);
} catch (\Throwable) {
$stats['errors']++;
}
}
}
}
$skippedTotal = $stats['skipped_score']
+ $stats['skipped_artwork']
+ $stats['skipped_user']
+ $stats['skipped_duplicate'];
$bar->setMessage((string) $stats['imported'], 'imported');
$bar->setMessage((string) $skippedTotal, 'skipped');
});
$bar->finish();
$this->newLine(2);
// -------------------------------------------------------------------------
// Recalculate stats for every affected artwork
// -------------------------------------------------------------------------
if (! $dryRun && ! $skipStats && ! empty($affectedArtworkIds)) {
$artworkCount = count($affectedArtworkIds);
$this->info("Recalculating award stats for {$artworkCount} artworks…");
$statsBar = $this->output->createProgressBar($artworkCount);
$statsBar->start();
foreach (array_keys($affectedArtworkIds) as $artworkId) {
try {
$service->recalcStats($artworkId);
} catch (\Throwable $e) {
$this->newLine();
$this->warn("Stats recalc failed for artwork #{$artworkId}: {$e->getMessage()}");
}
$statsBar->advance();
}
$statsBar->finish();
$this->newLine(2);
}
// -------------------------------------------------------------------------
// Summary
// -------------------------------------------------------------------------
$this->table(
['Result', 'Count'],
[
['Imported (new rows)', $stats['imported']],
['Forced updates', $stats['updated_force']],
['Skipped bad score', $stats['skipped_score']],
['Skipped artwork gone', $stats['skipped_artwork']],
['Skipped user gone', $stats['skipped_user']],
['Skipped duplicate', $stats['skipped_duplicate']],
['Errors', $stats['errors']],
]
);
if ($dryRun) {
$this->warn('[DRY-RUN] Nothing was written. Re-run without --dry-run to apply.');
} else {
$this->info('Migration complete.');
}
return $stats['errors'] > 0 ? self::FAILURE : self::SUCCESS;
}
}

View File

@@ -0,0 +1,266 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Migrates legacy `artworks_comments` (projekti_old_skinbase) into `artwork_comments`.
*
* Column mapping:
* legacy.comment_id artwork_comments.legacy_id (idempotency key)
* legacy.artwork_id artwork_comments.artwork_id
* legacy.user_id artwork_comments.user_id
* legacy.description artwork_comments.content
* legacy.date + .time artwork_comments.created_at / updated_at
*
* Ignored legacy columns: owner, author (username strings), owner_user_id
*
* Usage:
* php artisan comments:import-legacy
* php artisan comments:import-legacy --dry-run
* php artisan comments:import-legacy --chunk=1000
* php artisan comments:import-legacy --allow-guest-user=0 (import rows where user_id maps to 0 / not found, assigning a fallback user_id)
*/
class ImportLegacyComments extends Command
{
protected $signature = 'comments:import-legacy
{--dry-run : Preview only no writes to DB}
{--chunk=500 : Rows to process per batch}
{--skip-empty : Skip comments with empty/whitespace-only content}';
protected $description = 'Import legacy artworks_comments into artwork_comments';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$chunk = max(1, (int) $this->option('chunk'));
$skipEmpty = (bool) $this->option('skip-empty');
if ($dryRun) {
$this->warn('[DRY-RUN] No data will be written.');
}
// Verify legacy connection
try {
DB::connection('legacy')->getPdo();
} catch (\Throwable $e) {
$this->error('Cannot connect to legacy database: ' . $e->getMessage());
return self::FAILURE;
}
if (! DB::connection('legacy')->getSchemaBuilder()->hasTable('artworks_comments')) {
$this->error('Legacy table `artworks_comments` not found.');
return self::FAILURE;
}
if (! DB::getSchemaBuilder()->hasColumn('artwork_comments', 'legacy_id')) {
$this->error('Column `legacy_id` missing from `artwork_comments`. Run: php artisan migrate');
return self::FAILURE;
}
// Pre-load valid artwork IDs and user IDs from new DB for O(1) lookup
$this->info('Loading new-DB artwork and user ID sets…');
$validArtworkIds = DB::table('artworks')
->whereNull('deleted_at')
->pluck('id')
->flip()
->all();
$validUserIds = DB::table('users')
->whereNull('deleted_at')
->pluck('id')
->flip()
->all();
$this->info(sprintf(
'Found %d artworks and %d users in new DB.',
count($validArtworkIds),
count($validUserIds)
));
// Already-imported legacy IDs (to resume safely)
$this->info('Loading already-imported legacy_ids…');
$alreadyImported = DB::table('artwork_comments')
->whereNotNull('legacy_id')
->pluck('legacy_id')
->flip()
->all();
$this->info(sprintf('%d comments already imported (will be skipped).', count($alreadyImported)));
$total = DB::connection('legacy')->table('artworks_comments')->count();
$this->info("Legacy rows to process: {$total}");
if ($total === 0) {
$this->warn('No legacy rows found. Nothing to do.');
return self::SUCCESS;
}
$stats = [
'imported' => 0,
'skipped_duplicate' => 0,
'skipped_artwork' => 0,
'skipped_user' => 0,
'skipped_empty' => 0,
'errors' => 0,
];
$bar = $this->output->createProgressBar($total);
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% | imported: %imported% | skipped: %skipped%');
$bar->setMessage('0', 'imported');
$bar->setMessage('0', 'skipped');
$bar->start();
DB::connection('legacy')
->table('artworks_comments')
->orderBy('comment_id')
->chunk($chunk, function ($rows) use (
&$stats,
&$alreadyImported,
$validArtworkIds,
$validUserIds,
$dryRun,
$skipEmpty,
$bar
) {
$inserts = [];
$now = now();
foreach ($rows as $row) {
$legacyId = (int) $row->comment_id;
$artworkId = (int) $row->artwork_id;
$userId = (int) $row->user_id;
$content = trim((string) ($row->description ?? ''));
// --- Already imported ---
if (isset($alreadyImported[$legacyId])) {
$stats['skipped_duplicate']++;
$bar->advance();
continue;
}
// --- Content ---
if ($skipEmpty && $content === '') {
$stats['skipped_empty']++;
$bar->advance();
continue;
}
// Replace empty content with a placeholder so NOT NULL is satisfied
if ($content === '') {
$content = '[no content]';
}
// --- Artwork must exist ---
if (! isset($validArtworkIds[$artworkId])) {
$stats['skipped_artwork']++;
$bar->advance();
continue;
}
// --- User must exist ---
if (! isset($validUserIds[$userId])) {
$stats['skipped_user']++;
$bar->advance();
continue;
}
// --- Build timestamp from separate date + time columns ---
$createdAt = $this->buildTimestamp($row->date, $row->time, $now);
if (! $dryRun) {
$inserts[] = [
'legacy_id' => $legacyId,
'artwork_id' => $artworkId,
'user_id' => $userId,
'content' => $content,
'is_approved' => 1,
'created_at' => $createdAt,
'updated_at' => $createdAt,
'deleted_at' => null,
];
$alreadyImported[$legacyId] = true;
}
$stats['imported']++;
$bar->advance();
}
if (! $dryRun && ! empty($inserts)) {
try {
DB::table('artwork_comments')->insert($inserts);
} catch (\Throwable $e) {
// Fallback: row-by-row with ignore on unique violations
foreach ($inserts as $row) {
try {
DB::table('artwork_comments')->insertOrIgnore([$row]);
} catch (\Throwable) {
$stats['errors']++;
}
}
}
}
$skippedTotal = $stats['skipped_duplicate']
+ $stats['skipped_artwork']
+ $stats['skipped_user']
+ $stats['skipped_empty'];
$bar->setMessage((string) $stats['imported'], 'imported');
$bar->setMessage((string) $skippedTotal, 'skipped');
});
$bar->finish();
$this->newLine(2);
// -------------------------------------------------------------------------
// Summary
// -------------------------------------------------------------------------
$this->table(
['Result', 'Count'],
[
['Imported', $stats['imported']],
['Skipped already imported', $stats['skipped_duplicate']],
['Skipped artwork gone', $stats['skipped_artwork']],
['Skipped user gone', $stats['skipped_user']],
['Skipped empty content', $stats['skipped_empty']],
['Errors', $stats['errors']],
]
);
if ($dryRun) {
$this->warn('[DRY-RUN] Nothing was written. Re-run without --dry-run to apply.');
} else {
$this->info('Migration complete.');
}
return $stats['errors'] > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* Combine a legacy `date` (DATE) and `time` (TIME) column into a single datetime string.
* Falls back to $fallback when both are null.
*/
private function buildTimestamp(mixed $date, mixed $time, \Illuminate\Support\Carbon $fallback): string
{
if (! $date) {
return $fallback->toDateTimeString();
}
$datePart = substr((string) $date, 0, 10); // '2000-09-13'
$timePart = $time ? substr((string) $time, 0, 8) : '00:00:00'; // '09:34:27'
// Sanity-check: MySQL TIME can be negative or > 24h for intervals — clamp to midnight
if (! preg_match('/^\d{2}:\d{2}:\d{2}$/', $timePart) || $timePart < '00:00:00') {
$timePart = '00:00:00';
}
return $datePart . ' ' . $timePart;
}
}

View File

@@ -0,0 +1,137 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class ImportLegacyFavourites extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'import:legacy-favourites
{--connection=legacy : Legacy DB connection name}
{--table=favourites : Legacy favourites table name}
{--id-column=id : ID column to use for chunking}
{--map-user=user_id : Column name for user id}
{--map-artwork=artwork_id : Column name for artwork id}
{--map-created=datum : Column name for created timestamp}
{--chunk=500 : Chunk size}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Copy legacy favourites (from another DB connection) into user_favorites';
public function handle(): int
{
$connection = $this->option('connection');
$table = $this->option('table');
$idColumn = $this->option('id-column');
$mapUser = $this->option('map-user');
$mapArtwork = $this->option('map-artwork');
$mapCreated = $this->option('map-created');
$chunk = (int) $this->option('chunk');
$this->info("Using connection='{$connection}', table='{$table}', idColumn='{$idColumn}'");
try {
$legacy = DB::connection($connection);
} catch (\Throwable $e) {
$this->error('Cannot connect to legacy connection: '.$e->getMessage());
return 1;
}
try {
$schema = $legacy->getSchemaBuilder();
} catch (\Throwable $e) {
$this->error('Failed to get schema builder for legacy connection: '.$e->getMessage());
return 1;
}
if (! $schema->hasTable($table)) {
$this->error("Table '{$table}' does not exist on connection '{$connection}'");
return 1;
}
$this->info('Starting import...');
$attempted = 0;
$inserted = 0;
// Try chunkById for efficient processing; fallback to cursor if id column missing
try {
$legacy->table($table)
->select([$idColumn, $mapUser, $mapArtwork, $mapCreated])
->orderBy($idColumn)
->chunkById($chunk, function ($rows) use (&$attempted, &$inserted, $mapUser, $mapArtwork, $mapCreated) {
$batch = [];
foreach ($rows as $r) {
$attempted++;
$batch[] = [
'user_id' => $r->{$mapUser},
'artwork_id' => $r->{$mapArtwork},
'created_at' => $r->{$mapCreated} ?? now(),
];
}
if (count($batch) > 0) {
$res = DB::table('user_favorites')->insertOrIgnore($batch);
// insertOrIgnore may return number inserted on some drivers; approximate otherwise
if (is_int($res)) {
$inserted += $res;
} else {
$inserted += count($batch);
}
}
$this->info("Processed {$attempted} rows, approx inserted {$inserted}");
});
} catch (\Throwable $e) {
$this->warn('chunkById failed, falling back to cursor: '.$e->getMessage());
$cursor = $legacy->table($table)
->select([$mapUser, $mapArtwork, $mapCreated])
->orderBy($mapCreated)
->cursor();
$batch = [];
foreach ($cursor as $r) {
$attempted++;
$batch[] = [
'user_id' => $r->{$mapUser},
'artwork_id' => $r->{$mapArtwork},
'created_at' => $r->{$mapCreated} ?? now(),
];
if (count($batch) >= $chunk) {
$res = DB::table('user_favorites')->insertOrIgnore($batch);
if (is_int($res)) {
$inserted += $res;
} else {
$inserted += count($batch);
}
$this->info("Processed {$attempted} rows, approx inserted {$inserted}");
$batch = [];
}
}
if (count($batch) > 0) {
$res = DB::table('user_favorites')->insertOrIgnore($batch);
if (is_int($res)) {
$inserted += $res;
} else {
$inserted += count($batch);
}
}
}
$this->info("Import complete. Attempted {$attempted}, approx inserted {$inserted}.");
return 0;
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use cPad\Plugins\News\Models\NewsArticle;
class ImportLegacyNewsCommand extends Command
{
protected $signature = 'news:import-legacy {--dry-run} {--limit=500} {--start=0}';
protected $description = 'Import News articles from legacy DB into the current Skinbase news_articles table.';
public function handle()
{
$dryRun = $this->option('dry-run');
$limit = (int) $this->option('limit');
$start = (int) $this->option('start');
// Verify legacy DB connection exists and is reachable
try {
DB::connection('legacy')->getPdo();
} catch (\Throwable $e) {
$this->error('Cannot connect to legacy database via connection "legacy": ' . $e->getMessage());
Log::error('Legacy import failed - cannot connect to legacy', ['exception' => $e]);
return 2;
}
if (! DB::connection('legacy')->getSchemaBuilder()->hasTable('news')) {
$this->error('Legacy table `news` not found on legacy connection.');
return 2;
}
$this->info(sprintf('Fetching up to %d legacy rows starting at %d...', $limit, $start));
try {
$rows = DB::connection('legacy')->table('news')
->orderBy('news_id')
->skip($start)
->take($limit)
->get();
} catch (\Throwable $e) {
$this->error('Failed to query legacy DB: ' . $e->getMessage());
Log::error('Legacy import failed', ['exception' => $e]);
return 2;
}
if ($rows->isEmpty()) {
$this->info('No rows found in legacy news table.');
return 0;
}
$this->info('Processing ' . $rows->count() . ' rows...');
$created = 0;
foreach ($rows as $row) {
// Map fields conservatively — adjust mapping as needed for your legacy schema
$title = $row->headline ?? ($row->title ?? '');
$content = $row->content ?? ($row->message ?? '');
$excerpt = $row->preview ?? null;
$publishedAt = $row->create_date ?? ($row->published_at ?? null);
// Best-effort author mapping: try username/uname then fallback to user id 1
$authorId = 1;
if (!empty($row->uname)) {
$uid = DB::table('users')->where('username', $row->uname)->orWhere('uname', $row->uname)->value('id');
if ($uid) {
$authorId = $uid;
}
}
$payload = [
'title' => $title,
'slug' => NewsArticle::generateUniqueSlug($title),
'excerpt' => $excerpt,
'content' => $content,
'cover_image' => $row->picture ?? null,
'type' => 'announcement',
'author_id' => $authorId,
'category_id' => null,
'editorial_status' => isset($row->type) && (int)$row->type === 0 ? NewsArticle::EDITORIAL_STATUS_DRAFT : NewsArticle::EDITORIAL_STATUS_PUBLISHED,
'published_at' => $publishedAt ? date('Y-m-d H:i:s', strtotime($publishedAt)) : null,
'is_featured' => ($row->frontpage ?? 0) == 1,
'is_pinned' => ($row->type ?? 0) == 2,
'views' => $row->views ?? 0,
'canonical_url' => '/legacy/news/' . ($row->news_id ?? ''),
];
if ($dryRun) {
$this->line('[dry-run] Would insert: ' . $payload['title'] . ' (' . ($payload['published_at'] ?? 'no-date') . ')');
continue;
}
try {
NewsArticle::create($payload);
$created++;
} catch (\Throwable $e) {
$this->error('Failed to insert legacy article ' . ($row->news_id ?? '?') . ': ' . $e->getMessage());
Log::error('import-legacy: insert failed', ['exception' => $e, 'row' => $row]);
}
}
$this->info(sprintf('Done. Created %d articles (dry-run=%s).', $created, $dryRun ? 'yes' : 'no'));
return 0;
}
}

View File

@@ -0,0 +1,343 @@
<?php
namespace App\Console\Commands;
use App\Support\UsernamePolicy;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
class ImportLegacyUsers extends Command
{
protected $signature = 'skinbase:import-legacy-users {--chunk=200 : Chunk size for processing} {--force-reset-all : Force reset passwords for all imported users} {--restore-temp-usernames : Restore legacy usernames for existing users still using tmpu12345-style placeholders} {--dry-run : Preview which users would be skipped/deleted without making changes}';
protected $description = 'Import legacy users into the new auth schema per legacy_users_migration spec';
protected string $migrationLogPath;
/** @var array<int,true> Legacy user IDs that qualify for import */
protected array $activeUserIds = [];
public function handle(): int
{
$this->migrationLogPath = (string) storage_path('logs/username_migration.log');
@file_put_contents($this->migrationLogPath, '['.now()."] Starting legacy username policy migration\n", FILE_APPEND);
// Build the set of legacy user IDs that have any meaningful activity.
// Users outside this set will be skipped (or deleted from the new DB if already imported).
$this->activeUserIds = $this->buildActiveUserIds();
$this->info('Active legacy users (uploads / comments / forum): ' . count($this->activeUserIds));
$chunk = (int) $this->option('chunk');
$dryRun = (bool) $this->option('dry-run');
$imported = 0;
$skipped = 0;
$purged = 0;
if (! DB::connection('legacy')->getPdo()) {
$this->error('Legacy DB connection "legacy" is not configured or reachable.');
return self::FAILURE;
}
DB::connection('legacy')->table('users')
->chunkById($chunk, function ($rows) use (&$imported, &$skipped, &$purged, $dryRun) {
$ids = $rows->pluck('user_id')->all();
$stats = DB::connection('legacy')->table('users_statistics')
->whereIn('user_id', $ids)
->get()
->keyBy('user_id');
foreach ($rows as $row) {
$legacyId = (int) $row->user_id;
// ── Inactive user: no uploads, no comments, no forum activity ──
if (! isset($this->activeUserIds[$legacyId])) {
// If already imported into the new DB, purge it.
$existsInNew = DB::table('users')->where('id', $legacyId)->exists();
if ($existsInNew) {
if ($dryRun) {
$this->warn("[dry] Would DELETE inactive user_id={$legacyId} from new DB");
} else {
$this->purgeNewUser($legacyId);
$this->warn("[purge] Deleted inactive user_id={$legacyId} from new DB");
$purged++;
}
} else {
$this->line("[skip] user_id={$legacyId} no activity — skipping");
}
$skipped++;
continue;
}
if ($dryRun) {
$this->line("[dry] Would import user_id={$legacyId}");
$imported++;
continue;
}
try {
$this->importRow($row, $stats[$row->user_id] ?? null);
$imported++;
} catch (\Throwable $e) {
$skipped++;
$this->warn("Skip user_id {$row->user_id}: {$e->getMessage()}");
}
}
}, 'user_id');
$this->info("Imported: {$imported}, Skipped: {$skipped}, Purged: {$purged}");
return self::SUCCESS;
}
/**
* Build a lookup array of legacy user IDs that qualify for import:
* uploaded at least one artwork (users_statistics.uploads > 0)
* posted at least one artwork comment (artworks_comments.user_id)
* created or posted to a forum thread (forum_topics / forum_posts)
*
* @return array<int,true>
*/
protected function buildActiveUserIds(): array
{
$rows = DB::connection('legacy')->select("
SELECT DISTINCT user_id FROM users_statistics WHERE uploads > 0
UNION
SELECT DISTINCT user_id FROM artworks_comments WHERE user_id > 0
UNION
SELECT DISTINCT user_id FROM forum_posts WHERE user_id > 0
UNION
SELECT DISTINCT user_id FROM forum_topics WHERE user_id > 0
");
$map = [];
foreach ($rows as $r) {
$map[(int) $r->user_id] = true;
}
return $map;
}
/**
* Remove all new-DB records for a given legacy user ID.
* Covers: users, user_profiles, user_statistics, username_redirects.
*/
protected function purgeNewUser(int $userId): void
{
DB::transaction(function () use ($userId) {
DB::table('username_redirects')->where('user_id', $userId)->delete();
DB::table('user_statistics')->where('user_id', $userId)->delete();
DB::table('user_profiles')->where('user_id', $userId)->delete();
DB::table('users')->where('id', $userId)->delete();
});
}
protected function importRow($row, $statRow = null): void
{
$legacyId = (int) $row->user_id;
// Use legacy username as-is by default. Placeholder tmp usernames can be
// restored explicitly with --restore-temp-usernames using safe uniqueness rules.
$existingUser = DB::table('users')
->select(['id', 'username'])
->where('id', $legacyId)
->first();
$username = $this->resolveImportUsername($row, $legacyId, $existingUser?->username ?? null);
$normalizedLegacy = UsernamePolicy::normalize((string) ($row->uname ?? ''));
if ($normalizedLegacy !== $username) {
@file_put_contents(
$this->migrationLogPath,
sprintf("[%s] user_id=%d old=%s new=%s\n", now()->toDateTimeString(), $legacyId, $normalizedLegacy, $username),
FILE_APPEND
);
}
// Use the real legacy email; only synthesise a placeholder when missing.
$rawEmail = $row->email ? strtolower(trim($row->email)) : null;
$email = $rawEmail ?: ($this->sanitizeEmailLocal($username) . '@users.skinbase.org');
$legacyPassword = $row->password2 ?: $row->password ?: null;
// Optionally force-reset every imported user's password to a secure random value.
if ($this->option('force-reset-all')) {
$this->warn("Force-reset-all enabled: generating secure password for user_id {$row->user_id}.");
$passwordHash = Hash::make(Str::random(64));
} else {
// Force-reset known weak default passwords (e.g. "abc123").
if ($legacyPassword !== null && trim($legacyPassword) === 'abc123') {
$this->warn("Weak password 'abc123' detected for user_id {$row->user_id}; forcing reset.");
$passwordHash = Hash::make(Str::random(64));
} else {
$passwordHash = Hash::make($legacyPassword ?: Str::random(32));
}
}
$uploads = $this->sanitizeStatValue($statRow->uploads ?? 0);
$downloads = $this->sanitizeStatValue($statRow->downloads ?? 0);
$pageviews = $this->sanitizeStatValue($statRow->pageviews ?? 0);
$awards = $this->sanitizeStatValue($statRow->awards ?? 0);
DB::transaction(function () use ($legacyId, $username, $email, $passwordHash, $row, $uploads, $downloads, $pageviews, $awards) {
$now = now();
$existingUser = DB::table('users')
->select(['id', 'username'])
->where('id', $legacyId)
->first();
$alreadyExists = $existingUser !== null;
$previousUsername = (string) ($existingUser?->username ?? '');
// All fields synced from legacy on every run
$sharedFields = [
'username' => $username,
'username_changed_at' => $now,
'name' => $row->real_name ?: $username,
'email' => $email,
'is_active' => (int) ($row->active ?? 1) === 1,
'needs_password_reset' => true,
'role' => 'user',
'legacy_password_algo' => null,
'last_visit_at' => $row->LastVisit ?: null,
'updated_at' => $now,
];
if ($alreadyExists) {
// Sync all fields from legacy — password is never overwritten on re-runs
// (unless --force-reset-all was passed, in which case the caller handles it
// separately outside this transaction).
DB::table('users')->where('id', $legacyId)->update($sharedFields);
} else {
DB::table('users')->insert(array_merge($sharedFields, [
'id' => $legacyId,
'password' => $passwordHash,
'created_at' => $row->joinDate ?: $now,
]));
}
DB::table('user_profiles')->updateOrInsert(
['user_id' => $legacyId],
[
'about' => $row->about_me ?: $row->description ?: null,
'avatar_legacy' => $row->picture ?: null,
'cover_image' => $row->cover_art ?: null,
'country' => $row->country ?: null,
'country_code' => $row->country_code ? substr($row->country_code, 0, 2) : null,
'language' => $row->lang ?: null,
'birthdate' => $row->birth ?: null,
'gender' => $this->normalizeLegacyGender($row->gender ?? null),
'website' => $row->web ?: null,
'updated_at' => $now,
]
);
// Do not duplicate `website` into `user_social_links` — keep canonical site in `user_profiles.website`.
DB::table('user_statistics')->updateOrInsert(
['user_id' => $legacyId],
[
'uploads_count' => $uploads,
'downloads_received_count' => $downloads,
'artwork_views_received_count' => $pageviews,
'awards_received_count' => $awards,
'updated_at' => $now,
]
);
if (Schema::hasTable('username_redirects')) {
$old = $this->usernameRedirectKey((string) ($row->uname ?? ''));
if ($old !== '' && $old !== $username) {
DB::table('username_redirects')->updateOrInsert(
['old_username' => $old],
[
'new_username' => $username,
'user_id' => $legacyId,
'created_at' => $now,
'updated_at' => $now,
]
);
}
if ($this->shouldRestoreTemporaryUsername($previousUsername) && $previousUsername !== $username) {
DB::table('username_redirects')->updateOrInsert(
['old_username' => $this->usernameRedirectKey($previousUsername)],
[
'new_username' => $username,
'user_id' => $legacyId,
'created_at' => $now,
'updated_at' => $now,
]
);
}
}
});
}
protected function resolveImportUsername(object $row, int $legacyId, ?string $existingUsername = null): string
{
$legacyUsername = $this->sanitizeUsername((string) ($row->uname ?: ('user' . $legacyId)));
if (! $this->option('restore-temp-usernames')) {
return $legacyUsername;
}
if ($existingUsername === null || $existingUsername === '') {
return $legacyUsername;
}
if (! $this->shouldRestoreTemporaryUsername($existingUsername)) {
return $existingUsername;
}
return UsernamePolicy::uniqueCandidate((string) ($row->uname ?: ('user' . $legacyId)), $legacyId);
}
protected function shouldRestoreTemporaryUsername(?string $username): bool
{
if (! is_string($username) || trim($username) === '') {
return false;
}
return preg_match('/^tmpu\d+$/i', trim($username)) === 1;
}
/**
* Ensure statistic values are safe for unsigned DB columns.
*/
protected function sanitizeStatValue($value): int
{
$n = is_numeric($value) ? (int) $value : 0;
if ($n < 0) {
return 0;
}
return $n;
}
protected function sanitizeUsername(string $username): string
{
return UsernamePolicy::sanitizeLegacy($username);
}
protected function usernameRedirectKey(?string $username): string
{
$value = $this->sanitizeUsername((string) ($username ?? ''));
return $value === 'user' && trim((string) ($username ?? '')) === '' ? '' : $value;
}
protected function normalizeLegacyGender(mixed $value): ?string
{
$normalized = strtoupper(trim((string) ($value ?? '')));
return match ($normalized) {
'M', 'MALE', 'MAN', 'BOY' => 'M',
'F', 'FEMALE', 'WOMAN', 'GIRL' => 'F',
default => null,
};
}
protected function sanitizeEmailLocal(string $value): string
{
$local = strtolower(trim($value));
$local = preg_replace('/[^a-z0-9._-]/', '-', $local) ?: 'user';
return trim($local, '.-') ?: 'user';
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class ImportWallzCategories extends Command
{
/**
* The name and signature of the console command.
*
* --connection: database connection name for legacy DB (default: legacy)
* --table: legacy table name (default: wallz)
* --chunk: rows per chunk (default: 500)
*/
protected $signature = 'import:wallz-categories {--connection=legacy} {--table=wallz} {--chunk=500}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Import artwork -> category mappings from legacy wallz table into artwork_category';
public function handle()
{
$conn = $this->option('connection');
$table = $this->option('table');
$chunk = (int) $this->option('chunk');
$this->info("Importing from connection=[{$conn}] table=[{$table}]");
// Validate connection exists in config
if (! config()->has('database.connections.' . $conn)) {
$this->error("Database connection '{$conn}' not configured. Available connections: " . implode(', ', array_keys(config('database.connections'))));
return 1;
}
// Check legacy table exists
try {
if (! Schema::connection($conn)->hasTable($table)) {
$this->error("Table '{$table}' does not exist on connection '{$conn}'.");
return 1;
}
} catch (\Throwable $e) {
$this->error('Error checking legacy table: ' . $e->getMessage());
return 1;
}
$total = DB::connection($conn)->table($table)->count();
$this->info("Found {$total} rows in legacy table.");
$bar = $this->output->createProgressBar($total ?: 0);
$bar->start();
try {
DB::connection($conn)->table($table)
->select('id', 'category')
->orderBy('id')
->chunk($chunk, function ($rows) use ($bar) {
$inserts = [];
foreach ($rows as $r) {
// Skip empty category
if (empty($r->category)) continue;
$inserts[] = [
'artwork_id' => (int) $r->id,
'category_id' => (int) $r->category,
];
}
if (! empty($inserts)) {
// Use insertOrIgnore to avoid duplicates
DB::table('artwork_category')->insertOrIgnore($inserts);
}
$bar->advance(count($rows));
});
$bar->finish();
$this->newLine(2);
$this->info('Import complete.');
return 0;
} catch (\Throwable $e) {
$bar->finish();
$this->newLine(2);
$this->error('Import failed: ' . $e->getMessage());
return 1;
}
}
}

View File

@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use App\Services\Vision\VectorService;
use Carbon\CarbonImmutable;
use InvalidArgumentException;
use Illuminate\Console\Command;
final class IndexArtworkVectorsCommand extends Command
{
protected $signature = 'artworks:vectors-index
{--order=updated-desc : Ordering mode: updated-desc or id-asc}
{--start-id=0 : Start from this artwork id (inclusive)}
{--after-id=0 : Resume after this artwork id}
{--after-updated-at= : Resume updated-desc mode after this ISO-8601 timestamp}
{--batch=100 : Batch size per iteration}
{--limit=0 : Maximum artworks to process in this run}
{--embedded-only : Re-upsert only artworks that already have local embeddings}
{--public-only : Index only public, approved, published artworks}
{--dry-run : Preview requests without sending them}';
protected $description = 'Send artwork image URLs to the vector gateway for indexing';
public function handle(VectorService $vectors): int
{
$dryRun = (bool) $this->option('dry-run');
if (! $dryRun && ! $vectors->isConfigured()) {
$this->error('Vision vector gateway is not configured. Set VISION_VECTOR_GATEWAY_URL and VISION_VECTOR_GATEWAY_API_KEY.');
return self::FAILURE;
}
$order = $this->normalizeOrder((string) $this->option('order'));
$startId = max(0, (int) $this->option('start-id'));
$afterId = max(0, (int) $this->option('after-id'));
try {
$afterUpdatedAt = $this->resolveAfterUpdatedAt($order, (string) $this->option('after-updated-at'));
} catch (InvalidArgumentException $exception) {
$this->error($exception->getMessage());
return self::INVALID;
}
$batch = max(1, min((int) $this->option('batch'), 1000));
$limit = max(0, (int) $this->option('limit'));
$publicOnly = (bool) $this->option('public-only');
$nextId = $startId > 0 ? $startId : max(1, $afterId + 1);
$embeddedOnly = (bool) $this->option('embedded-only');
$processed = 0;
$indexed = 0;
$skipped = 0;
$failed = 0;
$lastId = $afterId;
$nextUpdatedAt = $afterUpdatedAt;
if ($startId > 0 && $afterId > 0) {
$this->warn(sprintf(
'Both --start-id=%d and --after-id=%d were provided. Using --start-id and ignoring --after-id.',
$startId,
$afterId
));
}
if ($order === 'updated-desc' && ($startId > 0 || $afterId > 0) && $afterUpdatedAt === null) {
$this->warn('The --start-id/--after-id options are legacy id-asc cursors. They are ignored unless --order=id-asc is used, or unless --after-updated-at is also provided for updated-desc mode.');
}
$this->info(sprintf(
'Starting vector index: order=%s start_id=%d after_id=%d after_updated_at=%s next_id=%d batch=%d limit=%s embedded_only=%s public_only=%s dry_run=%s',
$order,
$startId,
$afterId,
$afterUpdatedAt?->toIso8601String() ?? 'none',
$nextId,
$batch,
$limit > 0 ? (string) $limit : 'all',
$embeddedOnly ? 'yes' : 'no',
$publicOnly ? 'yes' : 'no',
$dryRun ? 'yes' : 'no'
));
while (true) {
$remaining = $limit > 0 ? max(0, $limit - $processed) : $batch;
if ($limit > 0 && $remaining === 0) {
break;
}
$take = $limit > 0 ? min($batch, $remaining) : $batch;
$query = Artwork::query()
->with(['categories' => fn ($categories) => $categories->with('contentType')->orderBy('sort_order')->orderBy('name')])
->whereNotNull('hash');
if ($order === 'updated-desc') {
$query->orderByDesc('updated_at')
->orderByDesc('id')
->limit($take);
if ($nextUpdatedAt !== null) {
$query->where(function ($cursorQuery) use ($nextUpdatedAt, $afterId): void {
$cursorQuery->where('updated_at', '<', $nextUpdatedAt)
->orWhere(function ($sameTimestampQuery) use ($nextUpdatedAt, $afterId): void {
$sameTimestampQuery->where('updated_at', '=', $nextUpdatedAt)
->where('id', '<', $afterId);
});
});
}
} else {
$query->where('id', '>=', $nextId)
->orderBy('id')
->limit($take);
}
if ($embeddedOnly) {
$query->whereHas('embeddings');
}
if ($publicOnly) {
$query->public()->published();
}
$artworks = $query->get();
if ($artworks->isEmpty()) {
$this->line('No more artworks matched the current query window.');
break;
}
$this->line(sprintf(
'Fetched batch: count=%d first_id=%d last_id=%d first_updated_at=%s last_updated_at=%s',
$artworks->count(),
(int) $artworks->first()->id,
(int) $artworks->last()->id,
optional($artworks->first()->updated_at)->toIso8601String() ?? 'null',
optional($artworks->last()->updated_at)->toIso8601String() ?? 'null'
));
foreach ($artworks as $artwork) {
$processed++;
$lastId = (int) $artwork->id;
if ($order === 'updated-desc') {
$nextUpdatedAt = $artwork->updated_at !== null
? CarbonImmutable::instance($artwork->updated_at)
: null;
$afterId = $lastId;
} else {
$nextId = $lastId + 1;
}
try {
$payload = $vectors->payloadForArtwork($artwork);
} catch (\Throwable $e) {
$skipped++;
$this->warn("Skipped artwork {$artwork->id}: {$e->getMessage()}");
continue;
}
$this->line(sprintf(
'Processing artwork=%d hash=%s thumb_ext=%s url=%s metadata=%s',
(int) $artwork->id,
(string) ($artwork->hash ?? ''),
(string) ($artwork->thumb_ext ?? ''),
$payload['url'],
$this->json($payload['metadata'])
));
if ($dryRun) {
$indexed++;
$this->line(sprintf(
'[dry] artwork=%d indexed=%d/%d',
(int) $artwork->id,
$indexed,
$processed
));
continue;
}
try {
$vectors->upsertArtwork($artwork);
$indexed++;
$this->info(sprintf(
'Indexed artwork %d successfully. totals: processed=%d indexed=%d skipped=%d failed=%d',
(int) $artwork->id,
$processed,
$indexed,
$skipped,
$failed
));
} catch (\Throwable $e) {
$failed++;
$this->warn("Failed artwork {$artwork->id}: {$e->getMessage()}");
}
}
}
$this->info(sprintf(
'Vector index finished. processed=%d indexed=%d skipped=%d failed=%d last_id=%d next_id=%d next_updated_at=%s',
$processed,
$indexed,
$skipped,
$failed,
$lastId,
$nextId,
$nextUpdatedAt?->toIso8601String() ?? 'none'
));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
private function normalizeOrder(string $order): string
{
$normalized = strtolower(trim($order));
return match ($normalized) {
'updated-desc', 'updated', 'latest', 'latest-updated' => 'updated-desc',
'id-asc', 'id', 'legacy' => 'id-asc',
default => 'updated-desc',
};
}
private function resolveAfterUpdatedAt(string $order, string $afterUpdatedAt): ?CarbonImmutable
{
if ($order !== 'updated-desc') {
return null;
}
$value = trim($afterUpdatedAt);
if ($value === '') {
return null;
}
try {
return CarbonImmutable::parse($value);
} catch (\Throwable) {
throw new InvalidArgumentException(sprintf('Invalid --after-updated-at value [%s]. Use an ISO-8601 timestamp.', $afterUpdatedAt));
}
}
/**
* @param array<string, string> $payload
*/
private function json(array $payload): string
{
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return is_string($json) ? $json : '{}';
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Sitemaps\SitemapReleaseManager;
use Illuminate\Console\Command;
final class ListSitemapReleasesCommand extends Command
{
protected $signature = 'skinbase:sitemaps:releases';
protected $description = 'List recent sitemap releases and the active release.';
public function handle(SitemapReleaseManager $releases): int
{
$active = $releases->activeReleaseId();
foreach ($releases->listReleases() as $release) {
$this->line(sprintf(
'%s status=%s families=%d published_at=%s%s',
(string) ($release['release_id'] ?? 'unknown'),
(string) ($release['status'] ?? 'unknown'),
(int) data_get($release, 'totals.families', 0),
(string) ($release['published_at'] ?? 'n/a'),
(string) ($release['release_id'] ?? '') === $active ? ' [active]' : '',
));
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,113 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Collect hourly metric snapshots for artworks.
*
* Runs on cron every hour. Inserts a row per artwork into
* artwork_metric_snapshots_hourly with the current totals.
* Deltas are computed by the heat recalculation command.
*
* Usage: php artisan nova:metrics-snapshot-hourly
* php artisan nova:metrics-snapshot-hourly --days=30 --chunk=500 --dry-run
*/
class MetricsSnapshotHourlyCommand extends Command
{
protected $signature = 'nova:metrics-snapshot-hourly
{--days=60 : Only snapshot artworks created within this many days}
{--chunk=1000 : Chunk size for DB queries}
{--dry-run : Log what would be written without persisting}';
protected $description = 'Collect hourly metric snapshots for rising/heat calculation';
public function handle(): int
{
$days = (int) $this->option('days');
$chunk = (int) $this->option('chunk');
$dryRun = (bool) $this->option('dry-run');
$bucketHour = now()->startOfHour();
$this->info("[nova:metrics-snapshot-hourly] bucket={$bucketHour->toDateTimeString()} days={$days} chunk={$chunk}" . ($dryRun ? ' (dry-run)' : ''));
$snapshotCount = 0;
$skipCount = 0;
// Query artworks eligible for snapshotting:
// - created within $days OR has a ranking_score above 0
// First collect eligible IDs, then process in chunks
$eligibleIds = DB::table('artworks')
->leftJoin('artwork_stats as s', 's.artwork_id', '=', 'artworks.id')
->where(function ($q) use ($days) {
$q->where('artworks.created_at', '>=', now()->subDays($days))
->orWhere(function ($q2) {
$q2->whereNotNull('s.ranking_score')
->where('s.ranking_score', '>', 0);
});
})
->whereNull('artworks.deleted_at')
->where('artworks.is_approved', true)
->pluck('artworks.id');
if ($eligibleIds->isEmpty()) {
$this->info('No eligible artworks found.');
return self::SUCCESS;
}
foreach ($eligibleIds->chunk($chunk) as $chunkIds) {
$artworkIds = $chunkIds->values()->all();
$stats = DB::table('artwork_stats')
->whereIn('artwork_id', $artworkIds)
->get()
->keyBy('artwork_id');
$rows = [];
foreach ($artworkIds as $artworkId) {
$stat = $stats->get($artworkId);
$rows[] = [
'artwork_id' => $artworkId,
'bucket_hour' => $bucketHour,
'views_count' => (int) ($stat?->views ?? 0),
'downloads_count' => (int) ($stat?->downloads ?? 0),
'favourites_count' => (int) ($stat?->favorites ?? 0),
'comments_count' => (int) ($stat?->comments_count ?? 0),
'shares_count' => (int) ($stat?->shares_count ?? 0),
'created_at' => now(),
];
}
if ($dryRun) {
$snapshotCount += count($rows);
continue;
}
if (!empty($rows)) {
// Upsert: if (artwork_id, bucket_hour) already exists, update totals
DB::table('artwork_metric_snapshots_hourly')->upsert(
$rows,
['artwork_id', 'bucket_hour'],
['views_count', 'downloads_count', 'favourites_count', 'comments_count', 'shares_count']
);
$snapshotCount += count($rows);
}
}
$this->info("Snapshots written: {$snapshotCount} | Skipped: {$skipCount}");
Log::info('[nova:metrics-snapshot-hourly] completed', [
'bucket' => $bucketHour->toDateTimeString(),
'written' => $snapshotCount,
'skipped' => $skipCount,
'dry_run' => $dryRun,
]);
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,325 @@
<?php
namespace App\Console\Commands;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* php artisan skinbase:migrate-favourites
*
* Migrates rows from the legacy `favourites` table (projekti_old_skinbase)
* into the new `artwork_favourites` table on the default connection.
*
* Skipped rows (logged as warnings):
* - artwork_id not found in new artworks table
* - user_id not found in new OR legacy users table (unless --import-missing-users)
* - row already imported (duplicate legacy_id)
* - would create a duplicate (user_id, artwork_id) pair
*
* Dropped legacy columns (not migrated):
* - user_type membership tier, not relevant to the relationship
* - author_id always derivable via artworks.user_id
*
* Options:
* --dry-run Preview without writing
* --chunk=500 Rows per batch
* --start-id=0 Resume from this favourite_id
* --limit=0 Stop after N inserts (0 = no limit)
* --import-missing-users Auto-create a stub user from legacy data when the
* user is missing from the new DB (needs_password_reset=true)
* --legacy-connection Override legacy DB connection name (default: legacy)
* --legacy-table Override legacy favourites table name (default: favourites)
* --legacy-users-table Override legacy users table name (default: users)
*/
class MigrateFavourites extends Command
{
protected $signature = 'skinbase:migrate-favourites
{--dry-run : Preview changes without writing to the database}
{--chunk=500 : Number of rows to process per batch}
{--start-id=0 : Resume processing from this favourite_id}
{--limit=0 : Stop after inserting this many rows (0 = unlimited)}
{--import-missing-users : Auto-create stub users from legacy data when missing from new DB}
{--legacy-connection=legacy : Name of the legacy DB connection}
{--legacy-table=favourites : Name of the legacy favourites table}
{--legacy-users-table=users : Name of the legacy users table}';
protected $description = 'Migrate legacy favourites into artwork_favourites.';
// ── Counters ─────────────────────────────────────────────────────────────
private int $inserted = 0;
private int $skipped = 0;
private int $total = 0;
private int $usersImported = 0;
// ── Runtime config (set in handle()) ─────────────────────────────────────
private bool $importMissingUsers = false;
private string $legacyConn = 'legacy';
private string $legacyUsersTable = 'users';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$chunk = max(1, (int) $this->option('chunk'));
$startId = max(0, (int) $this->option('start-id'));
$limit = max(0, (int) $this->option('limit'));
$this->importMissingUsers = (bool) $this->option('import-missing-users');
$this->legacyConn = (string) $this->option('legacy-connection');
$this->legacyUsersTable = (string) $this->option('legacy-users-table');
$legacyTable = (string) $this->option('legacy-table');
$this->info("Migrating <comment>{$this->legacyConn}.{$legacyTable}</comment> → <info>artwork_favourites</info>");
if ($this->importMissingUsers) {
$this->warn('--import-missing-users: stub users will be created with needs_password_reset=true.');
}
if ($dryRun) {
$this->warn('DRY-RUN mode — no rows will be written.');
}
if ($startId > 0) {
$this->line("Resuming from favourite_id >= {$startId}");
}
if ($limit > 0) {
$this->line("Will stop after {$limit} inserts.");
}
$query = DB::connection($this->legacyConn)
->table($legacyTable)
->orderBy('favourite_id');
if ($startId > 0) {
$query->where('favourite_id', '>=', $startId);
}
$query->chunkById(
$chunk,
function ($rows) use ($dryRun, $limit): bool {
foreach ($rows as $row) {
$this->total++;
if ($limit > 0 && $this->inserted >= $limit) {
return false; // stop chunking
}
if ($this->processRow($row, $dryRun) === false) {
$this->skipped++;
}
}
return true;
},
'favourite_id',
);
$this->newLine();
$this->info(sprintf(
'Done. %d scanned, %d %s, %d skipped%s.',
$this->total,
$this->inserted,
$dryRun ? 'would be inserted' : 'inserted',
$this->skipped,
$this->usersImported > 0
? ", {$this->usersImported} stub users " . ($dryRun ? 'would be ' : '') . 'created'
: '',
));
return self::SUCCESS;
}
// ── Row processing ────────────────────────────────────────────────────────
/**
* Process a single legacy row. Returns true on success, false when skipped.
*/
private function processRow(object $row, bool $dryRun): bool
{
$legacyId = (int) ($row->favourite_id ?? 0);
$artworkId = (int) ($row->artwork_id ?? 0);
$userId = (int) ($row->user_id ?? 0);
$datum = $row->datum ?? null;
// ── Validate IDs ────────────────────────────────────────────────────
if ($artworkId <= 0 || $userId <= 0) {
$this->skip($legacyId, "invalid artwork_id={$artworkId} or user_id={$userId}");
return false;
}
if (! DB::table('artworks')->where('id', $artworkId)->exists()) {
$this->skip($legacyId, "artwork #{$artworkId} not found in new DB");
return false;
}
if (! DB::table('users')->where('id', $userId)->exists()) {
if ($this->importMissingUsers) {
if (! $this->importUserStub($userId, $dryRun)) {
$this->skip($legacyId, "user #{$userId} not found in legacy DB either — skipped");
return false;
}
} else {
$this->skip($legacyId, "user #{$userId} not found in new DB (use --import-missing-users to auto-create)");
return false;
}
}
// ── Idempotency guards ───────────────────────────────────────────────
if (DB::table('artwork_favourites')->where('legacy_id', $legacyId)->exists()) {
// Already imported — silently skip (not counted as "skipped" error)
return true;
}
if (DB::table('artwork_favourites')
->where('user_id', $userId)
->where('artwork_id', $artworkId)
->exists()
) {
$this->skip($legacyId, "duplicate (user={$userId}, artwork={$artworkId}) already exists");
return false;
}
// ── Map timestamp ────────────────────────────────────────────────────
$createdAt = $this->parseDate($datum);
// ── Insert ───────────────────────────────────────────────────────────
if (! $dryRun) {
DB::table('artwork_favourites')->insert([
'user_id' => $userId,
'artwork_id' => $artworkId,
'legacy_id' => $legacyId,
'created_at' => $createdAt,
'updated_at' => $createdAt,
]);
}
$this->inserted++;
if ($this->inserted % 500 === 0) {
$this->line(" {$this->inserted} inserted, {$this->skipped} skipped…");
}
return true;
}
// ── Helpers ───────────────────────────────────────────────────────────────
/**
* Look up $userId in the legacy users table and create a stub record in
* the new users table preserving the same primary key.
*
* The stub has:
* - needs_password_reset = true (user must reset before logging in)
* - legacy_password_algo = 'legacy' (marks imported credential)
* - is_active determined from legacy `active` flag
* - email placeholder if original email is null or already taken
*
* @return bool true = stub created (or already existed), false = not in legacy DB
*/
private function importUserStub(int $userId, bool $dryRun): bool
{
// Already exists — nothing to do.
if (DB::table('users')->where('id', $userId)->exists()) {
return true;
}
$legacyUser = DB::connection($this->legacyConn)
->table($this->legacyUsersTable)
->where('user_id', $userId)
->first();
if (! $legacyUser) {
return false;
}
// ── Map fields ──────────────────────────────────────────────────────
$username = trim((string) ($legacyUser->uname ?? '')) ?: "user_{$userId}";
// Ensure username is unique in the new DB.
if (DB::table('users')->where('username', $username)->exists()) {
$username = $username . '_' . $userId;
}
$name = trim((string) ($legacyUser->real_name ?? '')) ?: $username;
$email = trim((string) ($legacyUser->email ?? ''));
// Resolve email: use placeholder when blank or already taken.
if ($email === '' || DB::table('users')->where('email', $email)->exists()) {
$email = "legacy_{$userId}@legacy.skinbase.org";
}
$isActive = ((int) ($legacyUser->active ?? 0)) === 1;
$createdAt = $this->parseDate($legacyUser->joinDate ?? null);
$lastVisit = $this->parseDate($legacyUser->LastVisit ?? null);
$stub = [
'id' => $userId,
'username' => $username,
'name' => $name,
'email' => $email,
'password' => bcrypt(Str::random(48)), // unusable random password
'needs_password_reset' => true,
'legacy_password_algo' => 'legacy',
'is_active' => $isActive,
'role' => 'user',
'last_visit_at' => $lastVisit !== $createdAt ? $lastVisit : null,
'created_at' => $createdAt,
'updated_at' => $createdAt,
];
$msg = "Stub user created: #{$userId} ({$username}, {$email})";
if ($dryRun) {
$this->line(" [dry] {$msg}");
$this->usersImported++;
return true;
}
try {
// Force explicit ID insert — MySQL respects it even with auto_increment.
DB::table('users')->insert($stub);
$this->usersImported++;
$this->line(" <info>{$msg}</info>");
Log::info("skinbase:migrate-favourites {$msg}");
} catch (\Throwable $e) {
$err = "Failed to create stub user #{$userId}: {$e->getMessage()}";
$this->warn(" {$err}");
Log::error("skinbase:migrate-favourites {$err}");
return false;
}
return true;
}
/**
* Parse a legacy date value (DATE string / null / zero-date) to a
* full datetime string safe for MySQL.
*/
private function parseDate(mixed $value): string
{
if (empty($value) || $value === '0000-00-00' || $value === '0000-00-00 00:00:00') {
return Carbon::now()->toDateTimeString();
}
try {
return Carbon::parse((string) $value)->toDateTimeString();
} catch (\Throwable) {
return Carbon::now()->toDateTimeString();
}
}
private function skip(int $legacyId, string $reason): void
{
$msg = "SKIP favourite#{$legacyId}: {$reason}";
$this->warn(" {$msg}");
Log::warning("skinbase:migrate-favourites {$msg}");
}
}

View File

@@ -0,0 +1,152 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class MigrateFeaturedWorks extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'migrate:featured-works {--dry-run : Do not write any rows} {--limit=0 : Stop after inserting this many rows} {--legacy-connection=legacy : name of legacy DB connection} {--legacy-table=featured_works : legacy table name} {--start-id=0 : Start processing from this legacy featured_id} {--chunk=500 : Chunk size for processing}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Migrate rows from legacy featured_works into artwork_features safely';
public function handle()
{
$dryRun = $this->option('dry-run');
$limit = (int) $this->option('limit');
$this->info('Starting migration from `featured_works` to `artwork_features`');
if ($dryRun) {
$this->info('Running in dry-run mode; no inserts will be performed.');
}
$inserted = 0;
$skipped = 0;
$total = 0;
$startId = (int) $this->option('start-id');
$chunk = (int) $this->option('chunk');
$mapping = [
3 => 10, // Gold -> high priority
2 => 20, // Silver
1 => 30, // Bronze
4 => 50, // Featured
0 => 100 // default
];
$legacyConn = $this->option('legacy-connection');
$legacyTable = $this->option('legacy-table');
$this->info("Reading from legacy connection '{$legacyConn}' table '{$legacyTable}'");
$query = DB::connection($legacyConn)->table($legacyTable)->orderBy('featured_id');
if ($startId > 0) {
$this->info("Resuming from featured_id >= {$startId}");
$query = $query->where('featured_id', '>=', $startId);
}
$query->chunkById($chunk, function ($rows) use (&$inserted, &$skipped, &$total, $dryRun, $limit, $mapping) {
foreach ($rows as $row) {
$total++;
if ($limit > 0 && $inserted >= $limit) {
return false; // stop chunking
}
$artworkId = isset($row->artwork_id) ? (int) $row->artwork_id : 0;
if ($artworkId <= 0) {
$skipped++;
continue;
}
// Verify artwork exists
$exists = DB::table('artworks')->where('id', $artworkId)->exists();
if (! $exists) {
$skipped++;
continue;
}
// Avoid duplicate active feature for same artwork
$already = DB::table('artwork_features')
->where('artwork_id', $artworkId)
->where('is_active', true)
->exists();
if ($already) {
$skipped++;
continue;
}
// Determine featured_at
$postDate = $row->post_date ?? null;
if (empty($postDate) || $postDate === '0000-00-00' || $postDate === '0000-00-00 00:00:00') {
$featuredAt = Carbon::now();
} else {
try {
$featuredAt = Carbon::parse($postDate);
} catch (\Throwable $e) {
$featuredAt = Carbon::now();
}
}
// Map priority from legacy 'type'
$type = isset($row->type) ? (int) $row->type : 0;
$priority = $mapping[$type] ?? 100;
// Validate created_by: only set if a valid user id exists in new users table
$createdBy = isset($row->user_id) ? (int) $row->user_id : null;
if ($createdBy <= 0 || ! DB::table('users')->where('id', $createdBy)->exists()) {
$createdBy = null;
}
$record = [
'artwork_id' => $artworkId,
'featured_at' => $featuredAt->toDateTimeString(),
'expires_at' => null,
'priority' => $priority,
'label' => null,
'note' => $row->description ?? null,
'is_active' => 1,
'created_by' => $createdBy,
'created_at' => Carbon::now()->toDateTimeString(),
'updated_at' => Carbon::now()->toDateTimeString(),
];
if ($dryRun) {
$this->line('[dry] Insert: artwork_id=' . $artworkId . ' featured_at=' . $record['featured_at'] . ' priority=' . $priority);
$inserted++;
continue;
}
try {
DB::table('artwork_features')->insert($record);
$inserted++;
} catch (\Throwable $e) {
$this->error('Failed to insert artwork_id=' . $artworkId . ' : ' . $e->getMessage());
$skipped++;
}
}
// Return true to continue, false to stop chunking
return ($limit > 0 && $inserted >= $limit) ? false : true;
}, 'featured_id');
$this->info("Done. Processed: {$total}, Inserted: {$inserted}, Skipped: {$skipped}");
return 0;
}
}

View File

@@ -0,0 +1,351 @@
<?php
namespace App\Console\Commands;
use App\Support\UsernamePolicy;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* Migrates legacy friends_list (from the legacy DB connection) into user_followers.
*
* Usage:
* php artisan skinbase:migrate-follows [--dry-run] [--chunk=1000] [--import-missing-users]
*
* Legacy table: friends_list
* user_id -> follower_id (the user who added the friend = someone who follows)
* friend_id -> user_id (the user being followed)
*
* With --import-missing-users: any user referenced in friends_list that does not
* exist in the new DB will be fetched from the legacy `users` table and created
* as a stub before the follow row is inserted.
*/
class MigrateFollows extends Command
{
protected $signature = 'skinbase:migrate-follows
{--dry-run : Simulate without writing to the database}
{--chunk=1000 : Number of rows to process per batch}
{--import-missing-users : Import unknown users from legacy DB instead of skipping them}';
protected $description = 'Migrate legacy friends_list into user_followers';
/** Cache per-run: id => true (resolved) | null (not in legacy DB) | false (import error) */
private array $legacyUserCache = [];
public function handle(): int
{
$isDryRun = (bool) $this->option('dry-run');
$chunkSize = max(1, (int) $this->option('chunk'));
$importMissing = (bool) $this->option('import-missing-users');
$this->info($isDryRun
? '🔍 Dry-run mode nothing will be written.'
: '🚀 Live mode writing to user_followers.'
);
if ($importMissing) {
$this->info('👤 --import-missing-users: orphan users will be fetched from legacy DB.');
}
try {
$totalLegacy = DB::connection('legacy')->table('friends_list')->count();
} catch (\Throwable $e) {
$this->error('Cannot read legacy friends_list: ' . $e->getMessage());
return self::FAILURE;
}
$this->info("Total rows in legacy friends_list: {$totalLegacy}");
$validUserIds = DB::table('users')->pluck('id')->flip()->all();
$stats = [
'processed' => 0,
'inserted' => 0,
'duplicates' => 0,
'self_follows' => 0,
'invalid' => 0, // total orphan rows skipped
'invalid_zero_id' => 0, // follower_id or friend_id was 0
'invalid_not_in_new' => 0, // not in new DB (--import-missing-users not used)
'invalid_not_in_legacy' => 0, // not in new DB AND not in legacy DB
'invalid_import_error' => 0, // in legacy DB but stub import failed
'users_imported' => 0,
'errors' => 0,
];
$logPath = storage_path('logs/migrate_follows.log');
$logFile = fopen($logPath, 'a');
$this->logLine($logFile, '=== migrate-follows started at ' . now()->toISOString()
. " (dry_run={$isDryRun}, import_missing={$importMissing}) ===");
$chunkNum = 0;
$reportEvery = max(1, (int) ceil($totalLegacy / $chunkSize / 10));
DB::connection('legacy')
->table('friends_list')
->orderBy('id')
->chunk($chunkSize, function ($rows) use (
$isDryRun,
$importMissing,
&$validUserIds,
&$stats,
&$chunkNum,
$reportEvery,
$totalLegacy,
$logFile
) {
$toInsert = [];
foreach ($rows as $row) {
$stats['processed']++;
$followerId = (int) ($row->user_id ?? 0);
$followedId = (int) ($row->friend_id ?? 0);
$createdAt = $row->date_added ?? now();
if ($followerId === $followedId) {
$stats['self_follows']++;
$this->logLine($logFile, "SKIP self-follow: user_id={$followerId}");
continue;
}
// Try to resolve any user_id that isn't in the new DB yet
$skipReasons = [];
$sides = ['follower' => $followerId, 'followed' => $followedId];
foreach ($sides as $role => $uid) {
if (isset($validUserIds[$uid])) {
continue; // already valid
}
if ($uid === 0) {
$skipReasons[] = "{$role}_id is 0/null";
$stats['invalid_zero_id']++;
continue;
}
if (! $importMissing) {
$skipReasons[] = "{$role}={$uid} not in users table (use --import-missing-users to auto-import)";
$stats['invalid_not_in_new']++;
continue;
}
// ensureLegacyUser returns: true = resolved, null = not in legacy, false = import error
$result = $this->ensureLegacyUser($uid, $isDryRun, $logFile);
if ($result === true) {
$validUserIds[$uid] = true;
$stats['users_imported']++;
} elseif ($result === null) {
$skipReasons[] = "{$role}={$uid} not found in legacy DB";
$stats['invalid_not_in_legacy']++;
} else {
$skipReasons[] = "{$role}={$uid} found in legacy DB but import failed";
$stats['invalid_import_error']++;
}
}
if (! isset($validUserIds[$followerId]) || ! isset($validUserIds[$followedId])) {
$stats['invalid']++;
$reason = implode('; ', $skipReasons) ?: 'unknown';
$this->logLine($logFile, "SKIP orphan [row_id={$row->id}] follower={$followerId} followed={$followedId}{$reason}");
continue;
}
$toInsert[] = [
'follower_id' => $followerId,
'user_id' => $followedId,
'created_at' => $createdAt,
];
}
if (! $isDryRun && ! empty($toInsert)) {
try {
$inserted = DB::table('user_followers')->insertOrIgnore($toInsert);
$stats['inserted'] += $inserted;
$stats['duplicates'] += count($toInsert) - $inserted;
} catch (\Throwable $e) {
$stats['errors']++;
$this->logLine($logFile, 'ERROR batch insert: ' . $e->getMessage());
}
} elseif ($isDryRun) {
$stats['inserted'] += count($toInsert);
}
$chunkNum++;
if ($chunkNum % $reportEvery === 0 || $stats['processed'] >= $totalLegacy) {
$pct = $totalLegacy > 0 ? round($stats['processed'] / $totalLegacy * 100) : 100;
$this->line(" {$stats['processed']} / {$totalLegacy} rows ({$pct}%)"
. " inserted: {$stats['inserted']}"
. " imported: {$stats['users_imported']}"
. " skipped: " . ($stats['self_follows'] + $stats['invalid']));
}
});
$this->newLine();
if (! $isDryRun) {
$this->info('Backfilling user_statistics counters...');
$this->backfillCounters();
}
$this->table(
['Metric', 'Count'],
[
['Processed', $stats['processed']],
['Inserted', $stats['inserted']],
['Duplicates (already exist)', $stats['duplicates']],
['Self-follows skipped', $stats['self_follows']],
['Users stub-imported from legacy', $stats['users_imported']],
['Invalid (orphan) — total', $stats['invalid']],
[' ↳ zero/null user_id', $stats['invalid_zero_id']],
[' ↳ not in new DB (not imported)', $stats['invalid_not_in_new']],
[' ↳ not in legacy DB either', $stats['invalid_not_in_legacy']],
[' ↳ legacy import error', $stats['invalid_import_error']],
['Errors', $stats['errors']],
]
);
$summary = "Processed={$stats['processed']} Inserted={$stats['inserted']} "
. "Duplicates={$stats['duplicates']} SelfFollows={$stats['self_follows']} "
. "UsersImported={$stats['users_imported']} Invalid={$stats['invalid']} "
. "(ZeroId={$stats['invalid_zero_id']} NotInNew={$stats['invalid_not_in_new']} "
. "NotInLegacy={$stats['invalid_not_in_legacy']} ImportError={$stats['invalid_import_error']}) "
. "Errors={$stats['errors']}";
$this->logLine($logFile, "=== DONE: {$summary} ===");
fclose($logFile);
$this->info("Log written to: {$logPath}");
return self::SUCCESS;
}
// -------------------------------------------------------------------------
/**
* Ensure a legacy user_id exists in the new `users` table.
*
* Returns:
* true user is valid (was already there, or was just imported / dry-run pretend-imported)
* null user not found in the legacy DB either cannot be imported
* false user found in legacy DB but the stub-import threw an exception
*
* Results are cached per command run to avoid redundant DB queries.
*/
private function ensureLegacyUser(int $legacyId, bool $isDryRun, $logFile): ?bool
{
if (array_key_exists($legacyId, $this->legacyUserCache)) {
return $this->legacyUserCache[$legacyId];
}
if (DB::table('users')->where('id', $legacyId)->exists()) {
return $this->legacyUserCache[$legacyId] = true;
}
$legacyUser = DB::connection('legacy')
->table('users')
->where('user_id', $legacyId)
->first();
if (! $legacyUser) {
$this->logLine($logFile, "IMPORT FAIL: user_id={$legacyId} not found in legacy DB");
return $this->legacyUserCache[$legacyId] = null;
}
if ($isDryRun) {
$this->logLine($logFile, "DRY-RUN IMPORT: would create user_id={$legacyId} uname={$legacyUser->uname}");
return $this->legacyUserCache[$legacyId] = true;
}
try {
$this->importLegacyUserStub($legacyUser);
$this->logLine($logFile, "IMPORTED user_id={$legacyId} uname={$legacyUser->uname}");
return $this->legacyUserCache[$legacyId] = true;
} catch (\Throwable $e) {
$this->logLine($logFile, "IMPORT ERROR user_id={$legacyId}: " . $e->getMessage());
return $this->legacyUserCache[$legacyId] = false;
}
}
private function importLegacyUserStub(object $row): void
{
$legacyId = (int) $row->user_id;
$now = now();
$username = UsernamePolicy::sanitizeLegacy((string) ($row->uname ?: ('user' . $legacyId)));
if (! $username) {
$username = 'user' . $legacyId;
}
if (DB::table('users')->whereRaw('LOWER(username) = ?', [strtolower($username)])->exists()) {
$username = $username . $legacyId;
}
$email = ($row->email ? strtolower(trim($row->email)) : null)
?: ('user' . $legacyId . '@users.skinbase.org');
DB::transaction(function () use ($legacyId, $username, $email, $row, $now) {
DB::table('users')->insertOrIgnore([
'id' => $legacyId,
'username' => $username,
'name' => $row->real_name ?: $username,
'email' => $email,
'password' => Hash::make(Str::random(32)),
'is_active' => (int) ($row->active ?? 1) === 1,
'needs_password_reset' => true,
'role' => 'user',
'created_at' => $row->joinDate ?? $now,
'updated_at' => $now,
]);
DB::table('user_profiles')->updateOrInsert(
['user_id' => $legacyId],
[
'country' => $row->country ?? null,
'country_code' => $row->country_code ? substr((string) $row->country_code, 0, 2) : null,
'website' => $row->web ?? null,
'updated_at' => $now,
]
);
DB::table('user_statistics')->updateOrInsert(
['user_id' => $legacyId],
['updated_at' => $now, 'created_at' => $now]
);
});
}
// -------------------------------------------------------------------------
private function backfillCounters(): void
{
DB::statement('
UPDATE user_statistics us
JOIN (
SELECT user_id, COUNT(*) AS cnt
FROM user_followers
GROUP BY user_id
) AS f ON f.user_id = us.user_id
SET us.followers_count = f.cnt, us.updated_at = NOW()
');
DB::statement('
UPDATE user_statistics us
JOIN (
SELECT follower_id, COUNT(*) AS cnt
FROM user_followers
GROUP BY follower_id
) AS f ON f.follower_id = us.user_id
SET us.following_count = f.cnt, us.updated_at = NOW()
');
$this->info('Counters backfilled.');
}
private function logLine($handle, string $message): void
{
if (is_resource($handle)) {
fwrite($handle, '[' . now()->toISOString() . '] ' . $message . PHP_EOL);
}
}
}

View File

@@ -0,0 +1,246 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Message;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Migrates legacy `chat` / `messages` tables into the modern conversation-based system.
*
* Strategy:
* 1. Load all legacy rows from the `chat` table via the 'legacy' DB connection.
* 2. Group by (sender_user_id, receiver_user_id) pair (canonical: min first).
* 3. For each pair, find or create a `direct` conversation.
* 4. Insert each message in chronological order.
* 5. Set last_read_at based on the legacy read_date column (if present).
* 6. Skip deleted / inactive rows.
* 7. Convert smileys to emoji placeholders.
*
* Usage:
* php artisan skinbase:migrate-messages
* php artisan skinbase:migrate-messages --dry-run
* php artisan skinbase:migrate-messages --chunk=1000
*/
class MigrateMessagesCommand extends Command
{
protected $signature = 'skinbase:migrate-messages
{--dry-run : Preview only no writes to DB}
{--chunk=500 : Rows to process per batch}';
protected $description = 'Migrate legacy chat/messages into the modern conversation system';
/** Columns we attempt to read; gracefully degrade if missing. */
private array $skipped = [];
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$chunk = max(1, (int) $this->option('chunk'));
if ($dryRun) {
$this->warn('[DRY-RUN] No data will be written.');
}
// ── Check legacy connection ───────────────────────────────────────────
try {
DB::connection('legacy')->getPdo();
} catch (Throwable $e) {
$this->error('Cannot connect to legacy database: ' . $e->getMessage());
return self::FAILURE;
}
$legacySchema = DB::connection('legacy')->getSchemaBuilder();
if (! $legacySchema->hasTable('chat')) {
$this->error('Legacy table `chat` not found on the legacy connection.');
return self::FAILURE;
}
$columns = $legacySchema->getColumnListing('chat');
$this->info('Legacy chat columns: ' . implode(', ', $columns));
// Map expected legacy columns (adapt if your legacy schema differs)
$hasReadDate = in_array('read_date', $columns, true);
$hasSoftDelete = in_array('deleted', $columns, true);
// ── Count total rows ──────────────────────────────────────────────────
$query = DB::connection('legacy')->table('chat');
if ($hasSoftDelete) {
$query->where('deleted', 0);
}
$total = $query->count();
$this->info("Total legacy rows to process: {$total}");
if ($total === 0) {
$this->info('Nothing to migrate.');
return self::SUCCESS;
}
$bar = $this->output->createProgressBar($total);
$inserted = 0;
$skipped = 0;
$offset = 0;
// ── Chunk processing ──────────────────────────────────────────────────
while (true) {
$rows = DB::connection('legacy')
->table('chat')
->when($hasSoftDelete, fn ($q) => $q->where('deleted', 0))
->orderBy('id')
->offset($offset)
->limit($chunk)
->get();
if ($rows->isEmpty()) {
break;
}
foreach ($rows as $row) {
$senderId = (int) ($row->sender_user_id ?? $row->from_user_id ?? $row->user_id ?? 0);
$receiverId = (int) ($row->receiver_user_id ?? $row->to_user_id ?? $row->recipient_id ?? 0);
$body = trim((string) ($row->message ?? $row->body ?? $row->content ?? ''));
$createdAt = $row->created_at ?? $row->date ?? $row->timestamp ?? now();
$readDate = $hasReadDate ? $row->read_date : null;
if ($senderId === 0 || $receiverId === 0 || $body === '') {
$skipped++;
$this->skipped[] = ['id' => $row->id ?? '?', 'reason' => 'missing sender/receiver/body'];
$bar->advance();
continue;
}
// Skip self-messages
if ($senderId === $receiverId) {
$skipped++;
$this->skipped[] = ['id' => $row->id ?? '?', 'reason' => 'self-message'];
$bar->advance();
continue;
}
// Sanitize: strip HTML, convert smileys to emoji
$body = $this->sanitize($body);
if ($dryRun) {
$inserted++;
$bar->advance();
continue;
}
try {
DB::transaction(function () use ($senderId, $receiverId, $body, $createdAt, $readDate, &$inserted) {
// Find or create direct conversation
$conv = Conversation::findDirect($senderId, $receiverId);
if (! $conv) {
$conv = Conversation::create([
'type' => 'direct',
'created_by' => $senderId,
'last_message_at' => $createdAt,
]);
ConversationParticipant::insert([
[
'conversation_id' => $conv->id,
'user_id' => $senderId,
'role' => 'admin',
'joined_at' => $createdAt,
'last_read_at' => $readDate,
],
[
'conversation_id' => $conv->id,
'user_id' => $receiverId,
'role' => 'member',
'joined_at' => $createdAt,
'last_read_at' => $readDate,
],
]);
} else {
// Update last_read_at on existing participants when available
if ($readDate) {
ConversationParticipant::where('conversation_id', $conv->id)
->where('user_id', $receiverId)
->whereNull('last_read_at')
->update(['last_read_at' => $readDate]);
}
}
Message::create([
'conversation_id' => $conv->id,
'sender_id' => $senderId,
'body' => $body,
'created_at' => $createdAt,
'updated_at' => $createdAt,
]);
// Keep last_message_at up to date
if ($conv->last_message_at < $createdAt) {
$conv->update(['last_message_at' => $createdAt]);
}
$inserted++;
});
} catch (Throwable $e) {
$skipped++;
$this->skipped[] = ['id' => $row->id ?? '?', 'reason' => $e->getMessage()];
Log::warning('MigrateMessages: skipped row', [
'id' => $row->id ?? '?',
'reason' => $e->getMessage(),
]);
}
$bar->advance();
}
$offset += $chunk;
}
$bar->finish();
$this->newLine();
$this->info("Done. Inserted: {$inserted} | Skipped: {$skipped}");
if ($skipped > 0 && $this->option('verbose')) {
$this->table(['ID', 'Reason'], $this->skipped);
}
return self::SUCCESS;
}
/**
* Strip HTML tags and convert common legacy smileys to emoji.
*/
private function sanitize(string $body): string
{
// Strip raw HTML
$body = strip_tags($body);
// Decode HTML entities
$body = html_entity_decode($body, ENT_QUOTES | ENT_HTML5, 'UTF-8');
// Common smiley → emoji mapping
$smileys = [
':)' => '🙂', ':-)' => '🙂',
':(' => '🙁', ':-(' => '🙁',
':D' => '😀', ':-D' => '😀',
':P' => '😛', ':-P' => '😛',
';)' => '😉', ';-)' => '😉',
':o' => '😮', ':O' => '😮',
':|' => '😐', ':-|' => '😐',
':/' => '😕', ':-/' => '😕',
'<3' => '❤️',
'xD' => '😂', 'XD' => '😂',
];
return str_replace(array_keys($smileys), array_values($smileys), $body);
}
}

View File

@@ -0,0 +1,143 @@
<?php
namespace App\Console\Commands;
use App\Services\LegacySmileyMapper;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* php artisan skinbase:migrate-smileys
*
* Scans artworks.description, artwork_comments.content, and forum_posts.content,
* replaces legacy smiley codes (:beer, :lol, etc.) with Unicode emoji.
*
* Options:
* --dry-run Show what would change without writing to DB
* --chunk=200 Rows processed per batch (default 200)
* --table=artworks Limit scan to one table
*/
class MigrateSmileys extends Command
{
protected $signature = 'skinbase:migrate-smileys
{--dry-run : Preview changes without writing to the database}
{--chunk=200 : Number of rows to process per batch}
{--table= : Limit scan to a single table (artworks|artwork_comments|forum_posts)}';
protected $description = 'Convert legacy :smiley: codes to Unicode emoji in content fields.';
/** Tables and their content columns to scan. */
private const TARGETS = [
'artworks' => 'description',
'artwork_comments' => 'content',
'forum_posts' => 'content',
];
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$chunk = max(1, (int) $this->option('chunk'));
$tableOpt = $this->option('table');
$targets = self::TARGETS;
if ($tableOpt) {
if (! isset($targets[$tableOpt])) {
$this->error("Unknown table: {$tableOpt}. Allowed: " . implode(', ', array_keys($targets)));
return self::FAILURE;
}
$targets = [$tableOpt => $targets[$tableOpt]];
}
if ($dryRun) {
$this->warn('DRY-RUN mode — no changes will be written.');
}
$totalChanged = 0;
$totalRows = 0;
foreach ($targets as $table => $column) {
$this->line("Scanning <info>{$table}.{$column}</info>…");
[$changed, $rows] = $this->processTable($table, $column, $chunk, $dryRun);
$totalChanged += $changed;
$totalRows += $rows;
$this->line("{$rows} rows scanned, {$changed} updated.");
}
$this->newLine();
$this->info("Summary: {$totalRows} rows scanned, {$totalChanged} rows " . ($dryRun ? 'would be ' : '') . 'updated.');
return self::SUCCESS;
}
private function processTable(
string $table,
string $column,
int $chunk,
bool $dryRun
): array {
$totalChanged = 0;
$totalRows = 0;
DB::table($table)
->whereNotNull($column)
->orderBy('id')
->chunk($chunk, function ($rows) use ($table, $column, $dryRun, &$totalChanged, &$totalRows) {
foreach ($rows as $row) {
$original = $row->$column ?? '';
$converted = LegacySmileyMapper::convert($original);
// Collapse emoji flood runs BEFORE size/DB checks so that
// rows like ":beer :beer :beer …" (×500) don't exceed MEDIUMTEXT.
$collapsed = LegacySmileyMapper::collapseFlood($converted);
if ($collapsed !== $converted) {
$beforeBytes = mb_strlen($converted, '8bit');
$afterBytes = mb_strlen($collapsed, '8bit');
$floodMsg = "[{$table}#{$row->id}] Emoji flood collapsed "
. "({$beforeBytes} bytes \u{2192} {$afterBytes} bytes).";
$this->warn(" {$floodMsg}");
Log::warning($floodMsg);
$converted = $collapsed;
}
$totalRows++;
if ($converted === $original) {
continue;
}
$totalChanged++;
$codes = LegacySmileyMapper::detect($original);
$msg = "[{$table}#{$row->id}] Converting: " . implode(', ', $codes);
$this->line(" {$msg}");
Log::info($msg);
if (! $dryRun) {
// Guard: MEDIUMTEXT max is 16,777,215 bytes.
if (mb_strlen($converted, '8bit') > 16_777_215) {
$warn = "[{$table}#{$row->id}] SKIP — converted content exceeds MEDIUMTEXT limit (" . mb_strlen($converted, '8bit') . " bytes). Row left unchanged.";
$this->warn(" {$warn}");
Log::warning($warn);
continue;
}
try {
DB::table($table)
->where('id', $row->id)
->update([$column => $converted]);
} catch (\Throwable $e) {
$err = "[{$table}#{$row->id}] DB error: {$e->getMessage()}";
$this->warn(" {$err}");
Log::error($err);
}
}
}
});
return [$totalChanged, $totalRows];
}
}

View File

@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Story;
use App\Models\StoryAuthor;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;
/**
* Migrate legacy interview records into the new Stories system.
*
* Usage:
* php artisan stories:migrate-legacy
* php artisan stories:migrate-legacy --dry-run
* php artisan stories:migrate-legacy --legacy-connection=legacy --chunk=100
*
* Idempotent: running multiple times will not duplicate records.
* Legacy records are identified via `legacy_interview_id` column on stories table.
*/
final class MigrateStoriesCommand extends Command
{
protected $signature = 'stories:migrate-legacy
{--chunk=50 : number of records to process per batch}
{--dry-run : preview migration without persisting changes}
{--legacy-connection= : DB connection name for legacy database (default: uses default connection)}
{--legacy-table=interviews : legacy interviews table name}
';
protected $description = 'Migrate legacy interview records into the new nova Stories system (idempotent)';
public function handle(): int
{
$chunk = max(1, (int) $this->option('chunk'));
$dryRun = (bool) $this->option('dry-run');
$legacyConn = $this->option('legacy-connection') ?: null;
$table = (string) $this->option('legacy-table');
$this->info('Nova Stories — legacy interview migration');
$this->info("Table: {$table} | Chunk: {$chunk} | Dry-run: " . ($dryRun ? 'YES' : 'NO'));
$this->newLine();
try {
$db = $legacyConn ? DB::connection($legacyConn) : DB::connection();
// Quick existence check
$db->table($table)->limit(1)->get();
} catch (Throwable $e) {
$this->error("Cannot access table `{$table}`: " . $e->getMessage());
return self::FAILURE;
}
$inserted = 0;
$skipped = 0;
$failed = 0;
$db->table($table)->orderBy('id')->chunkById($chunk, function ($rows) use (
$dryRun, &$inserted, &$skipped, &$failed
) {
foreach ($rows as $row) {
$legacyId = (int) ($row->id ?? 0);
if (! $legacyId) {
$skipped++;
continue;
}
// Idempotency: skip if already migrated
if (Story::where('legacy_interview_id', $legacyId)->exists()) {
$skipped++;
continue;
}
try {
// ── Resolve / create author ──────────────────────────────
$authorName = $this->coerceString($row->username ?? $row->author ?? $row->uname ?? '');
$authorAvatar = $this->coerceString($row->icon ?? $row->avatar ?? '');
$author = null;
if ($authorName) {
$author = StoryAuthor::firstOrCreate(
['name' => $authorName],
['avatar' => $authorAvatar ?: null]
);
}
// ── Build slug ───────────────────────────────────────────
$rawTitle = $this->coerceString(
$row->headline ?? $row->title ?? $row->subject ?? ''
) ?: 'interview-' . $legacyId;
$slugBase = Str::slug(Str::limit($rawTitle, 180));
$slug = $slugBase ?: 'interview-' . $legacyId;
// Ensure uniqueness
$slug = $this->uniqueSlug($slug);
// ── Excerpt ──────────────────────────────────────────────
$fullContent = $this->coerceString(
$row->content ?? $row->tekst ?? $row->body ?? $row->text ?? ''
);
$excerpt = $this->coerceString($row->excerpt ?? $row->intro ?? $row->lead ?? '');
if (! $excerpt && $fullContent) {
$excerpt = Str::limit(strip_tags($fullContent), 200);
}
// ── Cover image ──────────────────────────────────────────
$coverRaw = $this->coerceString($row->pic ?? $row->image ?? $row->cover ?? $row->photo ?? '');
$coverImage = $coverRaw ? 'legacy/interviews/' . ltrim($coverRaw, '/') : null;
// ── Published date ───────────────────────────────────────
$publishedAt = null;
foreach (['datum', 'published_at', 'date', 'created_at'] as $field) {
$val = $row->{$field} ?? null;
if ($val) {
$ts = strtotime((string) $val);
if ($ts) {
$publishedAt = date('Y-m-d H:i:s', $ts);
break;
}
}
}
if ($dryRun) {
$this->line(" [DRY-RUN] Would import: #{$legacyId}{$slug}");
$inserted++;
continue;
}
Story::create([
'slug' => $slug,
'title' => Str::limit($rawTitle, 255),
'excerpt' => $excerpt ?: null,
'content' => $fullContent ?: null,
'cover_image' => $coverImage,
'author_id' => $author?->id,
'views' => max(0, (int) ($row->views ?? $row->hits ?? 0)),
'featured' => false,
'status' => 'published',
'published_at' => $publishedAt,
'legacy_interview_id' => $legacyId,
]);
$this->line(" Imported: #{$legacyId}{$slug}");
$inserted++;
} catch (Throwable $e) {
$failed++;
$this->warn(" FAILED #{$legacyId}: " . $e->getMessage());
Log::warning("stories:migrate-legacy failed for id={$legacyId}", ['error' => $e->getMessage()]);
}
}
});
$this->newLine();
$this->info("Migration complete.");
$this->table(
['Inserted', 'Skipped (existing)', 'Failed'],
[[$inserted, $skipped, $failed]]
);
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
// ── Helpers ───────────────────────────────────────────────────────────────
private function coerceString(mixed $value, string $default = ''): string
{
if ($value === null) {
return $default;
}
$str = trim((string) $value);
return $str !== '' ? $str : $default;
}
/**
* Ensure the slug is unique, appending a numeric suffix if needed.
*/
private function uniqueSlug(string $slug): string
{
if (! Story::where('slug', $slug)->exists()) {
return $slug;
}
$i = 2;
do {
$candidate = $slug . '-' . $i++;
} while (Story::where('slug', $candidate)->exists());
return $candidate;
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Copy views and downloads from the legacy `wallz` table into `artwork_stats`.
*
* Uses wallz.id as artwork_id.
* Rows that already exist are updated; missing rows are inserted with zeros
* for all other counters.
*
* Usage:
* php artisan skinbase:migrate-wallz-stats
* php artisan skinbase:migrate-wallz-stats --chunk=500 --dry-run
*/
class MigrateWallzStatsCommand extends Command
{
protected $signature = 'skinbase:migrate-wallz-stats
{--chunk=1000 : Number of wallz rows to process per batch}
{--dry-run : Preview counts without writing to the database}';
protected $description = 'Import views and downloads from legacy wallz table into artwork_stats';
public function handle(): int
{
$chunkSize = (int) $this->option('chunk');
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->warn('[DRY RUN] No data will be written.');
}
$total = (int) DB::connection('legacy')->table('wallz')->count();
$processed = 0;
$inserted = 0;
$updated = 0;
$this->info("Found {$total} rows in legacy wallz table. Chunk size: {$chunkSize}.");
$bar = $this->output->createProgressBar($total);
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% — ins: %message%');
$bar->setMessage('0 ins / 0 upd');
$bar->start();
DB::connection('legacy')
->table('wallz')
->select('id', 'views', 'dls', 'rating', 'rating_num')
->orderBy('id')
->chunk($chunkSize, function ($rows) use ($dryRun, &$processed, &$inserted, &$updated, $bar) {
$artworkIds = $rows->pluck('id')->all();
// Find which artwork_ids already have a stats row.
$existing = DB::table('artwork_stats')
->whereIn('artwork_id', $artworkIds)
->pluck('artwork_id')
->flip(); // flip → [artwork_id => index] for O(1) lookup
$toInsert = [];
$now = now()->toDateTimeString();
foreach ($rows as $row) {
$views = max(0, (int) $row->views);
$dls = max(0, (int) $row->dls);
$ratingAvg = max(0, (float) $row->rating);
$ratingCount = max(0, (int) $row->rating_num);
if ($existing->has($row->id)) {
// Update existing row.
if (! $dryRun) {
DB::table('artwork_stats')
->where('artwork_id', $row->id)
->update([
'views' => $views,
'downloads' => $dls,
'rating_avg' => $ratingAvg,
'rating_count' => $ratingCount,
]);
}
$updated++;
} else {
// Batch-collect for insert.
$toInsert[] = [
'artwork_id' => $row->id,
'views' => $views,
'views_24h' => 0,
'views_7d' => 0,
'downloads' => $dls,
'downloads_24h' => 0,
'downloads_7d' => 0,
'favorites' => 0,
'rating_avg' => $ratingAvg,
'rating_count' => $ratingCount,
];
$inserted++;
}
}
if (! $dryRun && ! empty($toInsert)) {
DB::table('artwork_stats')->insertOrIgnore($toInsert);
}
$processed += count($rows);
$bar->setMessage("{$inserted} ins / {$updated} upd");
$bar->advance(count($rows));
});
$bar->finish();
$this->newLine();
if ($dryRun) {
$this->warn("DRY RUN complete — would insert {$inserted}, update {$updated} ({$processed} rows scanned).");
} else {
$this->info("Done — inserted {$inserted}, updated {$updated} ({$processed} rows processed).");
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,120 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
final class NormalizeArtworkSlugsCommand extends Command
{
protected $signature = 'artworks:normalize-slugs
{--dry-run : Show the slug changes without writing them}
{--chunk=500 : Number of artworks to process per chunk}
{--only-mismatched : Only update rows whose current slug differs from the normalized title slug}';
protected $description = 'Normalize existing artwork slugs from artwork titles without enforcing uniqueness.';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$chunkSize = max(1, (int) $this->option('chunk'));
$onlyMismatched = (bool) $this->option('only-mismatched');
if (! $dryRun) {
$this->ensureSlugIsNotUnique();
}
$processed = 0;
$updated = 0;
DB::table('artworks')
->select(['id', 'title', 'slug'])
->orderBy('id')
->chunkById($chunkSize, function ($artworks) use ($dryRun, $onlyMismatched, &$processed, &$updated): void {
foreach ($artworks as $artwork) {
$processed++;
$normalizedSlug = Str::limit(Str::slug((string) ($artwork->title ?? '')) ?: 'artwork', 160, '');
$currentSlug = (string) ($artwork->slug ?? '');
if ($onlyMismatched && $currentSlug === $normalizedSlug) {
continue;
}
if ($currentSlug === $normalizedSlug) {
continue;
}
if ($dryRun) {
$this->line(sprintf('#%d %s => %s', $artwork->id, $currentSlug !== '' ? $currentSlug : '[empty]', $normalizedSlug));
$updated++;
continue;
}
DB::table('artworks')
->where('id', $artwork->id)
->update(['slug' => $normalizedSlug]);
$updated++;
}
});
if ($dryRun) {
$this->info(sprintf('Dry run complete. Checked %d artworks, %d would be updated.', $processed, $updated));
return self::SUCCESS;
}
$this->info(sprintf('Normalization complete. Checked %d artworks, updated %d.', $processed, $updated));
return self::SUCCESS;
}
private function ensureSlugIsNotUnique(): void
{
$driver = DB::getDriverName();
if ($driver === 'mysql') {
$indexes = collect(DB::select("SHOW INDEX FROM artworks WHERE Column_name = 'slug'"));
$indexes
->filter(fn ($index) => (int) ($index->Non_unique ?? 1) === 0)
->pluck('Key_name')
->filter()
->unique()
->each(function ($indexName): void {
$this->warn(sprintf('Dropping unique slug index %s before normalization.', $indexName));
DB::statement(sprintf('ALTER TABLE artworks DROP INDEX `%s`', str_replace('`', '``', (string) $indexName)));
});
$hasNonUniqueSlugIndex = $indexes->contains(fn ($index) => (string) ($index->Key_name ?? '') === 'artworks_slug_index' || (int) ($index->Non_unique ?? 0) === 1);
if (! $hasNonUniqueSlugIndex) {
DB::statement('CREATE INDEX artworks_slug_index ON artworks (slug)');
}
return;
}
if ($driver === 'sqlite') {
$indexes = collect(DB::select("PRAGMA index_list('artworks')"));
$indexes
->filter(function ($index): bool {
if ((int) ($index->unique ?? 0) !== 1) {
return false;
}
$columns = collect(DB::select(sprintf("PRAGMA index_info('%s')", str_replace("'", "''", (string) $index->name))))
->pluck('name')
->map(fn ($name) => (string) $name);
return $columns->contains('slug');
})
->pluck('name')
->each(fn ($indexName) => DB::statement(sprintf('DROP INDEX IF EXISTS "%s"', str_replace('"', '""', (string) $indexName))));
DB::statement('CREATE INDEX IF NOT EXISTS artworks_slug_index ON artworks (slug)');
}
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Prune old hourly metric snapshots to prevent unbounded table growth.
*
* Usage: php artisan nova:prune-metric-snapshots
* php artisan nova:prune-metric-snapshots --keep-days=7
*/
class PruneMetricSnapshotsCommand extends Command
{
protected $signature = 'nova:prune-metric-snapshots
{--keep-days=7 : Keep snapshots for this many days}';
protected $description = 'Delete old hourly metric snapshots beyond the retention window';
public function handle(): int
{
$keepDays = (int) $this->option('keep-days');
$cutoff = now()->subDays($keepDays);
$deleted = DB::table('artwork_metric_snapshots_hourly')
->where('bucket_hour', '<', $cutoff)
->delete();
$this->info("Pruned {$deleted} snapshot rows older than {$keepDays} days.");
Log::info('[nova:prune-metric-snapshots] completed', [
'deleted' => $deleted,
'keep_days' => $keepDays,
]);
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Delete artwork_view_events rows older than N days.
*
* The view event log grows ~proportionally to site traffic. Rows beyond the
* retention window are no longer useful for trending (which looks back ≤7
* days) or for computing "recently viewed" lists in the UI.
*
* Default retention is 90 days long enough for analytics queries and user
* history pages, short enough to keep the table from growing unbounded.
*
* Usage:
* php artisan skinbase:prune-view-events
* php artisan skinbase:prune-view-events --days=30
*/
class PruneViewEventsCommand extends Command
{
protected $signature = 'skinbase:prune-view-events {--days=90 : Delete events older than this many days}';
protected $description = 'Delete artwork_view_events rows older than N days';
public function handle(): int
{
$days = (int) $this->option('days');
$cutoff = now()->subDays($days);
$deleted = DB::table('artwork_view_events')
->where('viewed_at', '<', $cutoff)
->delete();
$this->info("Pruned {$deleted} view event(s) older than {$days} days (cutoff: {$cutoff}).");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,122 @@
<?php
namespace App\Console\Commands;
use App\Models\Artwork;
use App\Models\ActivityEvent;
use App\Jobs\IndexArtworkJob;
use App\Services\Activity\UserActivityService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* PublishScheduledArtworksCommand
*
* Runs every minute (via Kernel schedule).
* Finds artworks with:
* - artwork_status = 'scheduled'
* - publish_at <= now() (UTC)
* - is_approved = true (respect moderation gate)
*
* Publishes each one:
* - sets is_public = true
* - sets published_at = now()
* - sets artwork_status = 'published'
* - dispatches Meilisearch reindex (via Scout)
* - records activity event
*
* Safe to run concurrently (DB row lock prevents double-publish).
*/
class PublishScheduledArtworksCommand extends Command
{
protected $signature = 'artworks:publish-scheduled
{--dry-run : List candidate artworks without publishing}
{--limit=100 : Max artworks to process per run}';
protected $description = 'Publish scheduled artworks whose publish_at datetime has passed.';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$limit = (int) $this->option('limit');
$now = now()->utc();
$candidates = Artwork::query()
->where('artwork_status', 'scheduled')
->where('publish_at', '<=', $now)
->where('is_approved', true)
->orderBy('publish_at')
->limit($limit)
->get(['id', 'user_id', 'title', 'publish_at', 'artwork_status']);
if ($candidates->isEmpty()) {
$this->line('No scheduled artworks due for publishing.');
return self::SUCCESS;
}
$this->info("Found {$candidates->count()} artwork(s) to publish." . ($dryRun ? ' [DRY RUN]' : ''));
$published = 0;
$errors = 0;
foreach ($candidates as $candidate) {
if ($dryRun) {
$this->line(" [dry-run] Would publish artwork #{$candidate->id}: \"{$candidate->title}\"");
continue;
}
try {
DB::transaction(function () use ($candidate, $now, &$published) {
// Re-fetch with lock to avoid double-publish in concurrent runs
$artwork = Artwork::query()
->lockForUpdate()
->where('id', $candidate->id)
->where('artwork_status', 'scheduled')
->first();
if (! $artwork) {
// Already published or status changed skip
return;
}
$artwork->is_public = $artwork->visibility !== Artwork::VISIBILITY_PRIVATE;
$artwork->published_at = $now;
$artwork->artwork_status = 'published';
$artwork->save();
// Trigger Meilisearch reindex directly — no Scout hop.
IndexArtworkJob::dispatch((int) $artwork->id);
// Record activity event
try {
ActivityEvent::record(
actorId: (int) $artwork->user_id,
type: ActivityEvent::TYPE_UPLOAD,
targetType: ActivityEvent::TARGET_ARTWORK,
targetId: (int) $artwork->id,
);
} catch (\Throwable) {}
try {
app(UserActivityService::class)->logUpload((int) $artwork->user_id, (int) $artwork->id);
} catch (\Throwable) {}
$published++;
$this->line(" Published artwork #{$artwork->id}: \"{$artwork->title}\"");
});
} catch (\Throwable $e) {
$errors++;
Log::error("PublishScheduledArtworksCommand: failed to publish artwork #{$candidate->id}: {$e->getMessage()}");
$this->error(" Failed to publish #{$candidate->id}: {$e->getMessage()}");
}
}
if (! $dryRun) {
$this->info("Done. Published: {$published}, Errors: {$errors}.");
}
return $errors > 0 ? self::FAILURE : self::SUCCESS;
}
}

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use cPad\Plugins\News\Models\NewsArticle;
final class PublishScheduledNewsCommand extends Command
{
protected $signature = 'news:publish-scheduled
{--dry-run : List scheduled articles without publishing}
{--limit=100 : Max articles to process per run}';
protected $description = 'Publish scheduled News articles whose publish time has passed.';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$limit = (int) $this->option('limit');
$now = now()->utc();
$candidates = NewsArticle::query()
->where('editorial_status', NewsArticle::EDITORIAL_STATUS_SCHEDULED)
->whereNotNull('published_at')
->where('published_at', '<=', $now)
->orderBy('published_at')
->limit($limit)
->get(['id', 'title', 'published_at']);
if ($candidates->isEmpty()) {
$this->line('No scheduled News articles due for publishing.');
return self::SUCCESS;
}
$published = 0;
$errors = 0;
foreach ($candidates as $candidate) {
if ($dryRun) {
$this->line(sprintf('[dry-run] Would publish News article #%d: "%s"', $candidate->id, $candidate->title));
continue;
}
try {
DB::transaction(function () use ($candidate, $now, &$published): void {
$article = NewsArticle::query()
->lockForUpdate()
->where('id', $candidate->id)
->where('editorial_status', NewsArticle::EDITORIAL_STATUS_SCHEDULED)
->whereNotNull('published_at')
->where('published_at', '<=', $now)
->first();
if (! $article) {
return;
}
$article->forceFill([
'editorial_status' => NewsArticle::EDITORIAL_STATUS_PUBLISHED,
'status' => 'published',
'published_at' => $article->published_at ?? $now,
])->save();
$published++;
$this->line(sprintf('Published News article #%d: "%s"', $article->id, $article->title));
});
} catch (\Throwable $exception) {
$errors++;
Log::error('PublishScheduledNewsCommand failed', [
'article_id' => $candidate->id,
'message' => $exception->getMessage(),
]);
$this->error(sprintf('Failed to publish News article #%d: %s', $candidate->id, $exception->getMessage()));
}
}
if (! $dryRun) {
$this->info(sprintf('Done. Published: %d, Errors: %d.', $published, $errors));
}
return $errors > 0 ? self::FAILURE : self::SUCCESS;
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\NovaCard;
use App\Services\NovaCards\NovaCardPublishService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class PublishScheduledNovaCardsCommand extends Command
{
protected $signature = 'nova-cards:publish-scheduled {--dry-run : List scheduled cards without publishing} {--limit=100 : Max cards per run}';
protected $description = 'Publish scheduled Nova Cards whose scheduled time has passed.';
public function handle(NovaCardPublishService $publishService): int
{
$dryRun = (bool) $this->option('dry-run');
$limit = (int) $this->option('limit');
$now = now()->utc();
$candidates = NovaCard::query()
->where('status', NovaCard::STATUS_SCHEDULED)
->whereNotNull('scheduled_for')
->where('scheduled_for', '<=', $now)
->orderBy('scheduled_for')
->limit($limit)
->get(['id', 'title', 'scheduled_for']);
if ($candidates->isEmpty()) {
$this->line('No scheduled Nova Cards due for publishing.');
return self::SUCCESS;
}
$published = 0;
$errors = 0;
foreach ($candidates as $candidate) {
if ($dryRun) {
$this->line(sprintf('[dry-run] Would publish Nova Card #%d: "%s"', $candidate->id, $candidate->title));
continue;
}
try {
DB::transaction(function () use ($candidate, $publishService, &$published): void {
$card = NovaCard::query()
->lockForUpdate()
->where('id', $candidate->id)
->where('status', NovaCard::STATUS_SCHEDULED)
->first();
if (! $card) {
return;
}
$publishService->publishNow($card);
$published++;
$this->line(sprintf('Published Nova Card #%d: "%s"', $candidate->id, $candidate->title));
});
} catch (\Throwable $exception) {
$errors++;
Log::error('PublishScheduledNovaCardsCommand failed', [
'card_id' => $candidate->id,
'message' => $exception->getMessage(),
]);
$this->error(sprintf('Failed to publish Nova Card #%d: %s', $candidate->id, $exception->getMessage()));
}
}
if (! $dryRun) {
$this->info(sprintf('Done. Published: %d, Errors: %d.', $published, $errors));
}
return $errors > 0 ? self::FAILURE : self::SUCCESS;
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Console\Commands;
use App\Models\Post;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Publishes posts whose publish_at timestamp has passed.
* Scheduled every minute via console/kernel.
*/
class PublishScheduledPostsCommand extends Command
{
protected $signature = 'posts:publish-scheduled';
protected $description = 'Publish all scheduled posts whose publish_at time has been reached.';
public function handle(): int
{
$count = Post::where('status', Post::STATUS_SCHEDULED)
->where('publish_at', '<=', now())
->count();
if ($count === 0) {
$this->line('No scheduled posts to publish.');
return self::SUCCESS;
}
$published = 0;
Post::where('status', Post::STATUS_SCHEDULED)
->where('publish_at', '<=', now())
->chunkById(100, function ($posts) use (&$published) {
foreach ($posts as $post) {
DB::transaction(function () use ($post) {
$post->update(['status' => Post::STATUS_PUBLISHED]);
});
$published++;
}
});
$this->info("Published {$published} scheduled post(s).");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\Sitemaps\PublishSitemapReleaseJob;
use App\Services\Sitemaps\SitemapPublishService;
use Illuminate\Console\Command;
final class PublishSitemapsCommand extends Command
{
protected $signature = 'skinbase:sitemaps:publish
{--release= : Publish an existing built release}
{--queue : Dispatch publish flow to the queue}
{--sync : Run publish synchronously (default)}';
protected $description = 'Build, validate, and atomically publish a sitemap release.';
public function handle(SitemapPublishService $publish): int
{
$releaseId = $this->option('release');
if ((bool) $this->option('queue')) {
PublishSitemapReleaseJob::dispatch(is_string($releaseId) && $releaseId !== '' ? $releaseId : null);
$this->info('Queued sitemap publish flow' . (is_string($releaseId) && $releaseId !== '' ? ' for release [' . $releaseId . '].' : '.'));
return self::SUCCESS;
}
try {
$manifest = $publish->publish(is_string($releaseId) && $releaseId !== '' ? $releaseId : null);
} catch (\Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$this->info(sprintf(
'Published sitemap release [%s] with %d families and %d documents.',
(string) $manifest['release_id'],
(int) data_get($manifest, 'totals.families', 0),
(int) data_get($manifest, 'totals.documents', 0),
));
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\ArtworkSearchIndexer;
use App\Models\Artwork;
use Illuminate\Console\Command;
use Meilisearch\Client as MeilisearchClient;
class RebuildArtworkSearchIndex extends Command
{
protected $signature = 'artworks:search-rebuild
{--chunk=500 : Number of artworks per chunk}
{--limit= : Stop after processing this many artworks (useful for testing)}
{--reverse : Process artworks newest-first (highest ID first)}
{--sync : Write directly to Meilisearch (no queue) and show per-artwork results}';
protected $description = 'Re-queue all artworks for Meilisearch indexing (non-blocking, chunk-based). Use --sync for verbose direct writes.';
public function __construct(private readonly ArtworkSearchIndexer $indexer)
{
parent::__construct();
}
public function handle(MeilisearchClient $client): int
{
$chunk = max(1, (int) $this->option('chunk'));
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
$reverse = (bool) $this->option('reverse');
$sync = (bool) $this->option('sync');
if ($sync) {
return $this->handleSync($client, $chunk, $limit, $reverse);
}
return $this->handleQueue($chunk, $limit, $reverse);
}
// ── Queue mode (default) ──────────────────────────────────────────────────
private function handleQueue(int $chunk, ?int $limit, bool $reverse): int
{
$uncapped = Artwork::query()->public()->published()->count();
$total = $limit !== null ? min($limit, $uncapped) : $uncapped;
if ($total === 0) {
$this->warn('No public, published artworks matched the rebuild query. Nothing was queued.');
return self::SUCCESS;
}
$estimatedChunks = (int) ceil($total / $chunk);
$this->info(sprintf(
'Queueing Meilisearch rebuild for %d artwork(s) in %d chunk(s) of up to %d%s%s.',
$total,
$estimatedChunks,
$chunk,
$reverse ? ', newest first' : '',
$limit !== null ? " (limit {$limit})" : '',
));
$this->line('This command only dispatches queue jobs. Workers process the actual indexing asynchronously.');
$bar = $this->output->createProgressBar($total);
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%%');
$bar->start();
$startedAt = microtime(true);
$stats = $this->indexer->rebuildAll(
$chunk,
function (int $chunkNumber, int $chunkCount, int $dispatched, int $totalItems, int $firstId, int $lastId) use ($bar): void {
$bar->advance($chunkCount);
if ($this->output->isVerbose()) {
$bar->clear();
$this->line(sprintf(
'Chunk %d queued %d artwork(s) [ids %d-%d] (%d/%d dispatched).',
$chunkNumber,
$chunkCount,
$firstId,
$lastId,
$dispatched,
$totalItems,
));
$bar->display();
}
},
$reverse,
$limit,
);
$bar->finish();
$this->newLine(2);
$elapsed = microtime(true) - $startedAt;
$this->info(sprintf(
'Queued %d artwork(s) across %d chunk(s) in %.2f seconds.',
$stats['dispatched'],
$stats['chunks'],
$elapsed,
));
$this->line('Workers will process the actual Meilisearch writes asynchronously.');
if ($this->output->isVerbose()) {
$this->line('Tip: use -v for per-chunk output, or monitor Horizon/queue workers for completion.');
}
return self::SUCCESS;
}
// ── Sync mode (--sync) ────────────────────────────────────────────────────
private function handleSync(MeilisearchClient $client, int $chunk, ?int $limit, bool $reverse): int
{
$this->info(sprintf(
'<options=bold>[SYNC MODE]</> Writing directly to Meilisearch%s%s — no queue involved.',
$reverse ? ', newest first' : '',
$limit !== null ? ", limit {$limit}" : '',
));
$this->newLine();
$query = Artwork::with([
'user', 'group', 'tags', 'categories.contentType', 'stats', 'awardStat',
])
->withoutGlobalScopes() // include non-public so we can report "why not"
->whereNotNull('id'); // all artworks
if ($reverse) {
$query->orderByDesc('id');
} else {
$query->orderBy('id');
}
if ($limit !== null) {
$query->limit($limit);
}
$total = (clone $query)->count();
$indexed = 0;
$removed = 0;
$failed = 0;
$processed = 0;
$startedAt = microtime(true);
$bar = $this->output->createProgressBar($total);
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%%');
$bar->start();
$query->chunk($chunk, function ($artworks) use ($client, $bar, &$indexed, &$removed, &$failed, &$processed): void {
foreach ($artworks as $artwork) {
$processed++;
$id = (int) $artwork->id;
$title = (string) ($artwork->title ?? '(no title)');
// Determine eligibility and reason
$reasons = [];
if (! $artwork->is_public) { $reasons[] = 'not public'; }
if (! $artwork->is_approved) { $reasons[] = 'not approved'; }
if ($artwork->published_at === null) { $reasons[] = 'not published'; }
if ($artwork->deleted_at !== null) { $reasons[] = 'soft-deleted'; }
$eligible = empty($reasons);
try {
$indexName = $artwork->searchableAs();
if ($eligible) {
$document = $artwork->toSearchableArray();
$client->index($indexName)->addDocuments([$document]);
$indexed++;
$bar->clear();
$this->line(sprintf(' <info>✓ indexed</info> #%d "%s"', $id, $title));
} else {
$client->index($indexName)->deleteDocument($id);
$removed++;
$bar->clear();
$this->line(sprintf(' <comment> removed</comment> #%d "%s" [%s]', $id, $title, implode(', ', $reasons)));
}
} catch (\Throwable $e) {
$failed++;
$bar->clear();
$this->line(sprintf(' <error>✗ failed</error> #%d "%s" %s', $id, $title, $e->getMessage()));
}
$bar->advance();
}
});
$bar->finish();
$this->newLine(2);
$elapsed = microtime(true) - $startedAt;
$this->info(sprintf(
'Done in %.2f s — %d indexed, %d removed from index, %d failed (of %d processed).',
$elapsed,
$indexed,
$removed,
$failed,
$processed,
));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
}

View File

@@ -0,0 +1,200 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Recalculate heat_score for artworks based on hourly metric snapshots.
*
* Runs every 1015 minutes via scheduler.
*
* Formula:
* raw_heat = ((views_delta*1 + downloads_delta*3 + favourites_delta*6
* + comments_delta*8 + shares_delta*12) / window_hours)
*
* age_factor = 1 / (1 + hours_since_upload / 24)
*
* heat_score = raw_heat * age_factor
*
* Usage: php artisan nova:recalculate-heat
* php artisan nova:recalculate-heat --days=60 --chunk=1000 --lookback-hours=24 --dry-run
*/
class RecalculateHeatCommand extends Command
{
protected $signature = 'nova:recalculate-heat
{--days=60 : Only process artworks created within this many days}
{--chunk=1000 : Chunk size for DB queries}
{--lookback-hours=24 : Smooth heat deltas over this many trailing hours}
{--dry-run : Compute scores without writing to DB}';
protected $description = 'Recalculate heat/momentum scores for the Rising engine';
/** Delta weights per the spec */
private const WEIGHTS = [
'views' => 1,
'downloads' => 3,
'favourites' => 6,
'comments' => 8,
'shares' => 12,
];
public function handle(): int
{
$days = (int) $this->option('days');
$chunk = (int) $this->option('chunk');
$lookbackHours = max(1, (int) $this->option('lookback-hours'));
$dryRun = (bool) $this->option('dry-run');
$now = now();
$currentHour = $now->copy()->startOfHour();
$prevHour = $currentHour->copy()->subHour();
$lookbackStart = $currentHour->copy()->subHours($lookbackHours);
$this->info("[nova:recalculate-heat] current_hour={$currentHour->toDateTimeString()} prev_hour={$prevHour->toDateTimeString()} lookback_start={$lookbackStart->toDateTimeString()} lookback_hours={$lookbackHours} days={$days}" . ($dryRun ? ' (dry-run)' : ''));
$updatedCount = 0;
$skippedCount = 0;
// Process in chunks using artwork IDs that have at least one snapshot in the smoothing window
$artworkIds = DB::table('artwork_metric_snapshots_hourly')
->whereBetween('bucket_hour', [$lookbackStart, $currentHour])
->distinct()
->pluck('artwork_id');
if ($artworkIds->isEmpty()) {
$this->warn('No snapshots found inside the requested lookback window. Run nova:metrics-snapshot-hourly first.');
return self::SUCCESS;
}
// Load all snapshots for the lookback window in bulk
$snapshots = DB::table('artwork_metric_snapshots_hourly')
->whereBetween('bucket_hour', [$lookbackStart, $currentHour])
->whereIn('artwork_id', $artworkIds)
->orderBy('bucket_hour')
->get()
->groupBy('artwork_id');
// Load artwork published_at dates for age factor (use published_at, fall back to created_at)
$artworkDates = DB::table('artworks')
->whereIn('id', $artworkIds)
->whereNull('deleted_at')
->where('is_approved', true)
->select('id', 'published_at', 'created_at')
->get()
->mapWithKeys(fn ($row) => [
$row->id => \Carbon\Carbon::parse($row->published_at ?? $row->created_at),
]);
// Process in chunks
foreach ($artworkIds->chunk($chunk) as $chunkIds) {
$upsertRows = [];
foreach ($chunkIds as $artworkId) {
$createdAt = $artworkDates->get($artworkId);
if (!$createdAt) {
$skippedCount++;
continue;
}
$artworkSnapshots = $snapshots->get($artworkId);
if (!$artworkSnapshots || $artworkSnapshots->isEmpty()) {
$skippedCount++;
continue;
}
$currentSnapshot = $artworkSnapshots->firstWhere('bucket_hour', $currentHour->toDateTimeString());
if (! $currentSnapshot) {
$currentSnapshot = $artworkSnapshots->last();
}
$prevSnapshot = $artworkSnapshots->firstWhere('bucket_hour', $prevHour->toDateTimeString());
$baselineSnapshot = $artworkSnapshots
->filter(fn ($snapshot) => (string) $snapshot->bucket_hour < (string) ($currentSnapshot->bucket_hour ?? ''))
->first();
if (! $currentSnapshot) {
$skippedCount++;
continue;
}
// One-hour counters remain explicit fields for dashboards and debugging.
$viewsDelta1h = max(0, (int) ($currentSnapshot?->views_count ?? 0) - (int) ($prevSnapshot?->views_count ?? 0));
$downloadsDelta1h = max(0, (int) ($currentSnapshot?->downloads_count ?? 0) - (int) ($prevSnapshot?->downloads_count ?? 0));
$favouritesDelta1h = max(0, (int) ($currentSnapshot?->favourites_count ?? 0) - (int) ($prevSnapshot?->favourites_count ?? 0));
$commentsDelta1h = max(0, (int) ($currentSnapshot?->comments_count ?? 0) - (int) ($prevSnapshot?->comments_count ?? 0));
$sharesDelta1h = max(0, (int) ($currentSnapshot?->shares_count ?? 0) - (int) ($prevSnapshot?->shares_count ?? 0));
// Smooth the heat signal over a trailing window so low-traffic periods do not flatten Rising.
// A single snapshot without an earlier baseline should not count as new momentum.
if ($baselineSnapshot) {
$viewsDelta = max(0, (int) ($currentSnapshot?->views_count ?? 0) - (int) ($baselineSnapshot->views_count ?? 0));
$downloadsDelta = max(0, (int) ($currentSnapshot?->downloads_count ?? 0) - (int) ($baselineSnapshot->downloads_count ?? 0));
$favouritesDelta = max(0, (int) ($currentSnapshot?->favourites_count ?? 0) - (int) ($baselineSnapshot->favourites_count ?? 0));
$commentsDelta = max(0, (int) ($currentSnapshot?->comments_count ?? 0) - (int) ($baselineSnapshot->comments_count ?? 0));
$sharesDelta = max(0, (int) ($currentSnapshot?->shares_count ?? 0) - (int) ($baselineSnapshot->shares_count ?? 0));
$windowHours = max(
1.0,
abs($currentHour->copy()->parse($currentSnapshot->bucket_hour)->floatDiffInHours($currentHour->copy()->parse($baselineSnapshot->bucket_hour)))
);
} else {
$viewsDelta = 0;
$downloadsDelta = 0;
$favouritesDelta = 0;
$commentsDelta = 0;
$sharesDelta = 0;
$windowHours = 1.0;
}
// Raw heat
$rawHeat = (
($viewsDelta * self::WEIGHTS['views'])
+ ($downloadsDelta * self::WEIGHTS['downloads'])
+ ($favouritesDelta * self::WEIGHTS['favourites'])
+ ($commentsDelta * self::WEIGHTS['comments'])
+ ($sharesDelta * self::WEIGHTS['shares'])
) / $windowHours;
// Age factor: favors newer works
$hoursSinceUpload = abs($now->floatDiffInHours($createdAt));
$ageFactor = 1.0 / (1.0 + ($hoursSinceUpload / 24.0));
// Final heat score
$heatScore = max(0, $rawHeat * $ageFactor);
$upsertRows[] = [
'artwork_id' => $artworkId,
'heat_score' => round($heatScore, 4),
'heat_score_updated_at' => $now,
'views_1h' => $viewsDelta1h,
'downloads_1h' => $downloadsDelta1h,
'favourites_1h' => $favouritesDelta1h,
'comments_1h' => $commentsDelta1h,
'shares_1h' => $sharesDelta1h,
];
$updatedCount++;
}
if (!$dryRun && !empty($upsertRows)) {
DB::table('artwork_stats')->upsert(
$upsertRows,
['artwork_id'],
['heat_score', 'heat_score_updated_at', 'views_1h', 'downloads_1h', 'favourites_1h', 'comments_1h', 'shares_1h']
);
}
}
$this->info("Heat scores updated: {$updatedCount} | Skipped: {$skippedCount}");
Log::info('[nova:recalculate-heat] completed', [
'updated' => $updatedCount,
'skipped' => $skippedCount,
'dry_run' => $dryRun,
]);
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Ranking\ArtworkRankingService;
use Illuminate\Console\Command;
/**
* php artisan nova:recalculate-rankings [--chunk=500] [--sync-rank-scores] [--skip-index]
*
* Ranking Engine V2 recalculates ranking_score and engagement_velocity
* for all public, approved artworks. Designed to run every 30 minutes.
*/
class RecalculateRankingsCommand extends Command
{
protected $signature = 'nova:recalculate-rankings
{--chunk=500 : DB chunk size for batch processing}
{--sync-rank-scores : Also update rank_artwork_scores table with V2 formula}
{--skip-index : Skip dispatching Meilisearch re-index jobs}';
protected $description = 'Recalculate V2 ranking scores (engagement + shares + decay + authority + velocity)';
public function __construct(private readonly ArtworkRankingService $ranking)
{
parent::__construct();
}
public function handle(): int
{
$chunkSize = (int) $this->option('chunk');
$syncRankScores = (bool) $this->option('sync-rank-scores');
$skipIndex = (bool) $this->option('skip-index');
// ── Step 1: Recalculate ranking_score + engagement_velocity ─────
$this->info('Ranking V2: recalculating scores …');
$start = microtime(true);
$updated = $this->ranking->recalculateAll($chunkSize);
$elapsed = round(microtime(true) - $start, 2);
$this->info("{$updated} artworks scored in {$elapsed}s");
// ── Step 2 (optional): Sync to rank_artwork_scores ─────────────
if ($syncRankScores) {
$this->info('Syncing to rank_artwork_scores …');
$start2 = microtime(true);
$synced = $this->ranking->syncToRankScores($chunkSize);
$elapsed2 = round(microtime(true) - $start2, 2);
$this->info("{$synced} rank scores synced in {$elapsed2}s");
}
// ── Step 3 (optional): Trigger Meilisearch re-index ────────────
if (! $skipIndex) {
$this->info('Dispatching Meilisearch index jobs …');
$this->dispatchIndexJobs();
$this->info(' ✓ Index jobs dispatched');
}
return self::SUCCESS;
}
/**
* Dispatch IndexArtworkJob for artworks updated in the last 24 hours
* (or recently scored). Keeps the search index current.
*/
private function dispatchIndexJobs(): void
{
\App\Models\Artwork::query()
->select('id')
->where('is_public', true)
->where('is_approved', true)
->whereNull('deleted_at')
->whereNotNull('published_at')
->where('published_at', '>=', now()->subDays(30)->toDateTimeString())
->chunkById(500, function ($artworks): void {
foreach ($artworks as $artwork) {
\App\Jobs\IndexArtworkJob::dispatch($artwork->id);
}
});
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\TrendingService;
use Illuminate\Console\Command;
/**
* php artisan skinbase:recalculate-trending [--period=1h|24h|7d] [--chunk=1000] [--skip-index]
*/
class RecalculateTrendingCommand extends Command
{
protected $signature = 'skinbase:recalculate-trending
{--period=7d : Period to recalculate (1h, 24h or 7d). Use "all" to run all three.}
{--chunk=1000 : DB chunk size}
{--skip-index : Skip dispatching Meilisearch re-index jobs}';
protected $description = 'Recalculate trending scores for artworks and sync to Meilisearch';
public function __construct(private readonly TrendingService $trending)
{
parent::__construct();
}
public function handle(): int
{
$period = (string) $this->option('period');
$chunkSize = (int) $this->option('chunk');
$skipIndex = (bool) $this->option('skip-index');
$periods = $period === 'all' ? ['1h', '24h', '7d'] : [$period];
foreach ($periods as $p) {
if (! in_array($p, ['1h', '24h', '7d'], true)) {
$this->error("Invalid period '{$p}'. Use 1h, 24h, 7d, or all.");
return self::FAILURE;
}
$this->info("Recalculating trending ({$p}) …");
$start = microtime(true);
$updated = $this->trending->recalculate($p, $chunkSize);
$elapsed = round(microtime(true) - $start, 2);
$this->info("{$updated} artworks updated in {$elapsed}s");
if (! $skipIndex) {
$this->info(" Dispatching Meilisearch index jobs …");
$this->trending->syncToSearchIndex($p);
$this->info(" ✓ Index jobs dispatched");
}
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\AchievementService;
use App\Services\XPService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RecalculateUserXpCommand extends Command
{
protected $signature = 'skinbase:recalculate-user-xp
{user_id? : The ID of a single user to recompute}
{--all : Recompute XP and level for all non-deleted users}
{--chunk=1000 : Chunk size when --all is used}
{--dry-run : Show computed values without writing}
{--sync-achievements : Re-run achievement checks after a live recalculation}';
protected $description = 'Rebuild stored user XP, level, and rank from user_xp_logs';
public function handle(XPService $xp, AchievementService $achievements): int
{
$userId = $this->argument('user_id');
$all = (bool) $this->option('all');
$dryRun = (bool) $this->option('dry-run');
$syncAchievements = (bool) $this->option('sync-achievements');
$chunk = max(1, (int) $this->option('chunk'));
if ($userId !== null && $all) {
$this->error('Provide either a user_id or --all, not both.');
return self::FAILURE;
}
if ($userId !== null) {
return $this->recalculateSingle((int) $userId, $xp, $achievements, $dryRun, $syncAchievements);
}
if ($all) {
return $this->recalculateAll($xp, $achievements, $chunk, $dryRun, $syncAchievements);
}
$this->error('Provide a user_id or use --all.');
return self::FAILURE;
}
private function recalculateSingle(
int $userId,
XPService $xp,
AchievementService $achievements,
bool $dryRun,
bool $syncAchievements,
): int {
$exists = DB::table('users')->where('id', $userId)->exists();
if (! $exists) {
$this->error("User {$userId} not found.");
return self::FAILURE;
}
$label = $dryRun ? '[DRY-RUN]' : '[LIVE]';
$this->line("{$label} Recomputing XP for user #{$userId}...");
$result = $xp->recalculateStoredProgress($userId, ! $dryRun);
$this->table(
['Field', 'Stored', 'Computed'],
[
['xp', $result['previous']['xp'], $result['computed']['xp']],
['level', $result['previous']['level'], $result['computed']['level']],
['rank', $result['previous']['rank'], $result['computed']['rank']],
]
);
if ($dryRun) {
if ($syncAchievements) {
$pending = $achievements->previewUnlocks($userId);
$this->line('Achievements preview: ' . (empty($pending) ? 'no pending unlocks' : implode(', ', $pending)));
}
$this->warn('Dry-run: no changes written.');
return self::SUCCESS;
}
if ($syncAchievements) {
$unlocked = $achievements->checkAchievements($userId);
$this->line('Achievements checked: ' . (empty($unlocked) ? 'no new unlocks' : implode(', ', $unlocked)));
}
$this->info($result['changed'] ? "XP updated for user #{$userId}." : "User #{$userId} was already in sync.");
return self::SUCCESS;
}
private function recalculateAll(
XPService $xp,
AchievementService $achievements,
int $chunk,
bool $dryRun,
bool $syncAchievements,
): int {
$total = DB::table('users')->whereNull('deleted_at')->count();
$label = $dryRun ? '[DRY-RUN]' : '[LIVE]';
$this->info("{$label} Recomputing XP for {$total} users (chunk={$chunk})...");
$processed = 0;
$changed = 0;
$pendingAchievementUsers = 0;
$pendingAchievementUnlocks = 0;
$appliedAchievementUnlocks = 0;
$bar = $this->output->createProgressBar($total);
$bar->start();
DB::table('users')
->whereNull('deleted_at')
->orderBy('id')
->chunkById($chunk, function ($users) use ($xp, $achievements, $dryRun, $syncAchievements, &$processed, &$changed, &$pendingAchievementUsers, &$pendingAchievementUnlocks, &$appliedAchievementUnlocks, $bar): void {
foreach ($users as $user) {
$result = $xp->recalculateStoredProgress((int) $user->id, ! $dryRun);
if ($result['changed']) {
$changed++;
}
if ($syncAchievements) {
if ($dryRun) {
$pending = $achievements->previewUnlocks((int) $user->id);
if (! empty($pending)) {
$pendingAchievementUsers++;
$pendingAchievementUnlocks += count($pending);
}
} else {
$unlocked = $achievements->checkAchievements((int) $user->id);
$appliedAchievementUnlocks += count($unlocked);
}
}
$processed++;
$bar->advance();
}
});
$bar->finish();
$this->newLine();
$summary = "Done - {$processed} users processed, {$changed} " . ($dryRun ? 'would change.' : 'updated.');
if ($syncAchievements) {
if ($dryRun) {
$summary .= " Achievement preview: {$pendingAchievementUnlocks} pending unlock(s) across {$pendingAchievementUsers} user(s).";
} else {
$summary .= " Achievements re-checked: {$appliedAchievementUnlocks} unlock(s) applied.";
}
}
$this->info($summary);
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\RecomputeUserStatsJob;
use App\Services\UserStatsService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Recompute user_statistics counters from authoritative source tables.
*
* Usage:
* # Recompute a single user (live)
* php artisan skinbase:recompute-user-stats 42
*
* # Dry-run for a single user
* php artisan skinbase:recompute-user-stats 42 --dry-run
*
* # Recompute all users in chunks of 500
* php artisan skinbase:recompute-user-stats --all --chunk=500
*
* # Recompute all users via queue (one job per chunk)
* php artisan skinbase:recompute-user-stats --all --queue
*/
class RecomputeUserStatsCommand extends Command
{
protected $signature = 'skinbase:recompute-user-stats
{user_id? : The ID of a single user to recompute}
{--all : Recompute stats for ALL non-deleted users}
{--chunk=1000 : Chunk size when --all is used}
{--dry-run : Show what would be written without saving}
{--queue : Dispatch recompute jobs to the queue (--all mode only)}';
protected $description = 'Rebuild user_statistics counters from authoritative source tables';
public function handle(UserStatsService $statsService): int
{
$dryRun = (bool) $this->option('dry-run');
$all = (bool) $this->option('all');
$userId = $this->argument('user_id');
$chunk = max(1, (int) $this->option('chunk'));
$queue = (bool) $this->option('queue');
if ($userId !== null && $all) {
$this->error('Provide either a user_id OR --all, not both.');
return self::FAILURE;
}
if ($userId !== null) {
return $this->recomputeSingle((int) $userId, $statsService, $dryRun);
}
if ($all) {
return $this->recomputeAll($statsService, $chunk, $dryRun, $queue);
}
$this->error('Provide a user_id or use --all.');
return self::FAILURE;
}
// ─── Single user ─────────────────────────────────────────────────────────
private function recomputeSingle(int $userId, UserStatsService $statsService, bool $dryRun): int
{
$exists = DB::table('users')->where('id', $userId)->exists();
if (! $exists) {
$this->error("User {$userId} not found.");
return self::FAILURE;
}
$label = $dryRun ? '[DRY-RUN]' : '[LIVE]';
$this->line("{$label} Recomputing stats for user #{$userId}");
$computed = $statsService->recomputeUser($userId, $dryRun);
$rows = [];
foreach ($computed as $col => $val) {
$rows[] = [$col, $val ?? '(null)'];
}
$this->table(['Column', 'Value'], $rows);
if ($dryRun) {
$this->warn('Dry-run: no changes written.');
} else {
$this->info("Stats saved for user #{$userId}.");
}
return self::SUCCESS;
}
// ─── All users ────────────────────────────────────────────────────────────
private function recomputeAll(
UserStatsService $statsService,
int $chunk,
bool $dryRun,
bool $useQueue
): int {
$total = DB::table('users')->whereNull('deleted_at')->count();
$label = $dryRun ? '[DRY-RUN]' : ($useQueue ? '[QUEUE]' : '[LIVE]');
$this->info("{$label} Recomputing stats for {$total} users (chunk={$chunk})…");
if ($useQueue && ! $dryRun) {
$dispatched = 0;
DB::table('users')
->whereNull('deleted_at')
->orderBy('id')
->chunkById($chunk, function ($users) use (&$dispatched) {
$ids = $users->pluck('id')->all();
RecomputeUserStatsJob::dispatch($ids);
$dispatched += count($ids);
$this->line(" Queued chunk of " . count($ids) . " users (total dispatched: {$dispatched})");
});
$this->info("Done {$dispatched} users queued for recompute.");
return self::SUCCESS;
}
$processed = 0;
$bar = $this->output->createProgressBar($total);
$bar->start();
DB::table('users')
->whereNull('deleted_at')
->orderBy('id')
->chunkById($chunk, function ($users) use ($statsService, $dryRun, &$processed, $bar) {
foreach ($users as $user) {
$statsService->recomputeUser((int) $user->id, $dryRun);
$processed++;
$bar->advance();
}
});
$bar->finish();
$this->newLine();
$suffix = $dryRun ? ' (no changes written dry-run)' : '';
$this->info("Done {$processed} users recomputed{$suffix}.");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\IndexArtworkJob;
use App\Models\Artwork;
use Illuminate\Console\Command;
class ReindexRecentPublishedArtworksCommand extends Command
{
protected $signature = 'artworks:search-reindex-recent
{--hours=72 : Reindex artworks published in the last N hours}
{--limit=1000 : Maximum artworks to process in this run}
{--id=* : Specific artwork IDs to reindex (overrides --hours window)}
{--dry-run : Show candidates without dispatching index jobs}';
protected $description = 'Reindex recently published public artworks to recover missed search indexing.';
public function handle(): int
{
$hours = max(1, (int) $this->option('hours'));
$limit = max(1, (int) $this->option('limit'));
$ids = array_values(array_unique(array_filter(array_map('intval', (array) $this->option('id')), static fn (int $id): bool => $id > 0)));
$dryRun = (bool) $this->option('dry-run');
$since = now()->subHours($hours);
$query = Artwork::query()
->whereNull('deleted_at')
->where('is_public', true)
->where('is_approved', true)
->whereNotNull('published_at');
if ($ids !== []) {
$query->whereIn('id', $ids)->orderBy('id');
} else {
$query->where('published_at', '>=', $since)
->orderByDesc('published_at');
}
$candidates = $query->limit($limit)->get(['id', 'title', 'slug', 'published_at']);
if ($candidates->isEmpty()) {
if ($ids !== []) {
$this->line('No matching published artworks found for the provided --id values.');
} else {
$this->line("No published artworks found in the last {$hours} hour(s).");
}
return self::SUCCESS;
}
if ($ids !== []) {
$this->info('Found ' . $candidates->count() . ' target artwork(s) by --id.' . ($dryRun ? ' [DRY RUN]' : ''));
} else {
$this->info("Found {$candidates->count()} artwork(s) published in the last {$hours} hour(s)." . ($dryRun ? ' [DRY RUN]' : ''));
}
foreach ($candidates as $artwork) {
if ($dryRun) {
$this->line(" [dry-run] Would reindex #{$artwork->id} ({$artwork->slug})");
continue;
}
IndexArtworkJob::dispatchSync((int) $artwork->id);
$this->line(" Reindexed #{$artwork->id} ({$artwork->slug})");
}
if (! $dryRun) {
$this->info('Done. Recent published artworks were reindexed.');
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,342 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class RepairLegacyUserJoinDatesCommand extends Command
{
/** @var array<string, bool> */
private array $legacyTableExistsCache = [];
protected $signature = 'skinbase:repair-user-join-dates
{--chunk=500 : Number of users to process per batch}
{--legacy-connection=legacy : Legacy database connection name}
{--legacy-table=users : Legacy users table name}
{--only-null : Update only users whose current created_at is null}
{--dry-run : Preview join date updates without writing changes}';
protected $description = 'Backfill current users.created_at from legacy users.joinDate';
public function handle(): int
{
$chunk = max(1, (int) $this->option('chunk'));
$legacyConnection = (string) $this->option('legacy-connection');
$legacyTable = (string) $this->option('legacy-table');
$onlyNull = (bool) $this->option('only-null');
$dryRun = (bool) $this->option('dry-run');
if (! $this->legacyTableExists($legacyConnection, $legacyTable)) {
$this->error("Legacy table {$legacyConnection}.{$legacyTable} does not exist or the connection is unavailable.");
return self::FAILURE;
}
if ($dryRun) {
$this->warn('[DRY RUN] No changes will be written.');
}
$query = DB::table('users')->select(['id', 'created_at']);
if ($onlyNull) {
$query->whereNull('created_at');
}
$this->info('Scanning current users for legacy joinDate backfill.');
$processed = 0;
$matched = 0;
$updated = 0;
$unchanged = 0;
$skipped = 0;
$query
->chunkById($chunk, function (Collection $rows) use (
&$processed,
&$matched,
&$updated,
&$unchanged,
&$skipped,
$legacyConnection,
$legacyTable,
$dryRun
): void {
$legacyById = $this->loadLegacyUsersForChunk($rows, $legacyConnection, $legacyTable);
$activityById = $this->loadLegacyActivityDatesForChunk($rows, $legacyConnection);
foreach ($rows as $row) {
$processed++;
$legacyMatch = $legacyById[(int) $row->id] ?? null;
if ($legacyMatch === null) {
$skipped++;
continue;
}
$matched++;
$legacyJoinDate = $this->parseLegacyJoinDate($legacyMatch->joinDate ?? null);
$dateSource = 'joinDate';
if ($legacyJoinDate === null) {
$activityFallback = $activityById[(int) $row->id] ?? null;
$legacyJoinDate = $activityFallback['date'] ?? null;
$dateSource = $activityFallback['source'] ?? 'activity';
}
if ($legacyJoinDate === null) {
$skipped++;
continue;
}
$currentCreatedAt = $this->parseCurrentDate($row->created_at ?? null);
if ($currentCreatedAt !== null && $currentCreatedAt->equalTo($legacyJoinDate)) {
$unchanged++;
continue;
}
if ($dryRun) {
$this->line(sprintf(
'[dry] Would update user id=%d created_at %s => %s (%s)',
(int) $row->id,
$currentCreatedAt?->toDateTimeString() ?? '<null>',
$legacyJoinDate->toDateTimeString(),
$dateSource
));
$updated++;
continue;
}
$affected = DB::table('users')
->where('id', (int) $row->id)
->update([
'created_at' => $legacyJoinDate->toDateTimeString(),
]);
if ($affected > 0) {
$updated += $affected;
$this->line(sprintf(
'[update] user id=%d created_at => %s (%s)',
(int) $row->id,
$legacyJoinDate->toDateTimeString(),
$dateSource
));
}
}
}, 'id');
$this->info(sprintf(
'Finished. processed=%d matched=%d updated=%d unchanged=%d skipped=%d',
$processed,
$matched,
$updated,
$unchanged,
$skipped
));
if ($processed === 0) {
$this->info('No users matched the requested scope.');
}
return self::SUCCESS;
}
private function legacyTableExists(string $connection, string $table): bool
{
$cacheKey = strtolower($connection . ':' . $table);
if (array_key_exists($cacheKey, $this->legacyTableExistsCache)) {
return $this->legacyTableExistsCache[$cacheKey];
}
try {
return $this->legacyTableExistsCache[$cacheKey] = DB::connection($connection)->getSchemaBuilder()->hasTable($table);
} catch (\Throwable) {
return $this->legacyTableExistsCache[$cacheKey] = false;
}
}
/**
* @return array<int, object>
*/
private function loadLegacyUsersForChunk(Collection $rows, string $legacyConnection, string $legacyTable): array
{
$legacyById = [];
$ids = $rows
->pluck('id')
->map(static fn ($id): int => (int) $id)
->filter(static fn (int $id): bool => $id > 0)
->values()
->all();
if ($ids !== []) {
DB::connection($legacyConnection)
->table($legacyTable)
->select(['user_id', 'joinDate'])
->whereIn('user_id', $ids)
->get()
->each(function (object $legacyRow) use (&$legacyById): void {
$legacyById[(int) $legacyRow->user_id] = $legacyRow;
});
}
return $legacyById;
}
/**
* @return array<int, array{date: Carbon, source: string}>
*/
private function loadLegacyActivityDatesForChunk(Collection $rows, string $legacyConnection): array
{
$activityById = [];
$ids = $rows
->pluck('id')
->map(static fn ($id): int => (int) $id)
->filter(static fn (int $id): bool => $id > 0)
->values()
->all();
if ($ids === []) {
return $activityById;
}
if ($this->legacyTableExists($legacyConnection, 'wallz')) {
$this->registerChunkActivityDates(
$activityById,
DB::connection($legacyConnection)
->table('wallz')
->selectRaw('user_id, MIN(datum) as first_at')
->whereIn('user_id', $ids)
->whereRaw("datum IS NOT NULL AND datum <> '0000-00-00 00:00:00'")
->groupBy('user_id')
->get(),
'first upload'
);
}
if ($this->legacyTableExists($legacyConnection, 'forum_topics')) {
$this->registerChunkActivityDates(
$activityById,
DB::connection($legacyConnection)
->table('forum_topics')
->selectRaw('user_id, MIN(post_date) as first_at')
->whereIn('user_id', $ids)
->whereRaw("post_date <> '0000-00-00 00:00:00'")
->groupBy('user_id')
->get(),
'first forum topic'
);
}
if ($this->legacyTableExists($legacyConnection, 'forum_posts')) {
$this->registerChunkActivityDates(
$activityById,
DB::connection($legacyConnection)
->table('forum_posts')
->selectRaw('user_id, MIN(post_date) as first_at')
->whereIn('user_id', $ids)
->whereRaw("post_date <> '0000-00-00 00:00:00'")
->groupBy('user_id')
->get(),
'first forum post'
);
}
if ($this->legacyTableExists($legacyConnection, 'artworks_comments')) {
$this->registerChunkActivityDates(
$activityById,
DB::connection($legacyConnection)
->table('artworks_comments')
->selectRaw("user_id, MIN(TIMESTAMP(`date`, COALESCE(`time`, '00:00:00'))) as first_at")
->whereIn('user_id', $ids)
->whereRaw("`date` IS NOT NULL AND `date` <> '0000-00-00'")
->groupBy('user_id')
->get(),
'first artwork comment'
);
}
if ($this->legacyTableExists($legacyConnection, 'users_comments')) {
$this->registerChunkActivityDates(
$activityById,
DB::connection($legacyConnection)
->table('users_comments')
->selectRaw("user_id, MIN(TIMESTAMP(`date`, COALESCE(`time`, '00:00:00'))) as first_at")
->whereIn('user_id', $ids)
->whereRaw("`date` IS NOT NULL AND `date` <> '0000-00-00'")
->groupBy('user_id')
->get(),
'first profile comment'
);
}
return $activityById;
}
/**
* @param array<int, array{date: Carbon, source: string}> $activityById
*/
private function registerChunkActivityDates(array &$activityById, iterable $rows, string $source): void
{
foreach ($rows as $row) {
$candidate = $this->parseLegacyJoinDate($row->first_at ?? null);
if ($candidate === null) {
continue;
}
$userId = (int) ($row->user_id ?? 0);
if ($userId <= 0) {
continue;
}
$existing = $activityById[$userId]['date'] ?? null;
if ($existing === null || $candidate->lt($existing)) {
$activityById[$userId] = [
'date' => $candidate,
'source' => $source,
];
}
}
}
private function parseLegacyJoinDate(mixed $value): ?Carbon
{
$raw = trim((string) ($value ?? ''));
if ($raw === '' || str_starts_with($raw, '0000-00-00')) {
return null;
}
try {
return Carbon::parse($raw);
} catch (\Throwable) {
return null;
}
}
private function parseCurrentDate(mixed $value): ?Carbon
{
if ($value instanceof Carbon) {
return $value;
}
$raw = trim((string) ($value ?? ''));
if ($raw === '') {
return null;
}
try {
return Carbon::parse($raw);
} catch (\Throwable) {
return null;
}
}
}

View File

@@ -0,0 +1,301 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Support\UsernamePolicy;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Carbon\Carbon;
class RepairLegacyWallzUsersCommand extends Command
{
protected $signature = 'legacySB:repair-legacy-wallz-users
{--chunk=500 : Number of legacy wallz rows to scan per batch}
{--legacy-connection=legacy : Legacy database connection name}
{--legacy-table=wallz : Legacy table to update}
{--artworks-table=artworks : Current DB artworks table name}
{--fix-artworks : Backfill `artworks.user_id` from legacy `wallz.user_id` for rows where user_id = 0}
{--dry-run : Preview matches and inserts without writing changes}';
protected $description = 'Backfill legacy wallz.user_id from uname by matching or creating users in the new users table';
public function handle(): int
{
$chunk = max(1, (int) $this->option('chunk'));
$legacyConnection = (string) $this->option('legacy-connection');
$legacyTable = (string) $this->option('legacy-table');
$artworksTable = (string) $this->option('artworks-table');
$fixArtworks = (bool) $this->option('fix-artworks');
$dryRun = (bool) $this->option('dry-run');
if (! $this->legacyTableExists($legacyConnection, $legacyTable)) {
$this->error("Legacy table {$legacyConnection}.{$legacyTable} does not exist or the connection is unavailable.");
return self::FAILURE;
}
if ($dryRun) {
$this->warn('[DRY RUN] No changes will be written.');
}
if ($fixArtworks) {
$this->handleFixArtworks($chunk, $legacyConnection, $legacyTable, $artworksTable, $dryRun);
}
$total = (int) DB::connection($legacyConnection)
->table($legacyTable)
->where('user_id', 0)
->count();
if ($total === 0) {
if (! $fixArtworks) {
$this->info('No legacy wallz rows with user_id = 0 were found.');
}
return self::SUCCESS;
}
$this->info("Scanning {$total} legacy rows in {$legacyConnection}.{$legacyTable}.");
$processed = 0;
$updatedRows = 0;
$matchedUsers = 0;
$createdUsers = 0;
$skippedRows = 0;
$usernameMap = [];
DB::connection($legacyConnection)
->table($legacyTable)
->select(['id', 'uname'])
->where('user_id', 0)
->orderBy('id')
->chunkById($chunk, function ($rows) use (
&$processed,
&$updatedRows,
&$matchedUsers,
&$createdUsers,
&$skippedRows,
&$usernameMap,
$dryRun,
$legacyConnection,
$legacyTable
) {
foreach ($rows as $row) {
$processed++;
$rawUsername = trim((string) ($row->uname ?? ''));
if ($rawUsername === '') {
$skippedRows++;
$this->warn("Skipping wallz id={$row->id}: uname is empty.");
continue;
}
$lookupKey = UsernamePolicy::normalize($rawUsername);
if ($lookupKey === '') {
$skippedRows++;
$this->warn("Skipping wallz id={$row->id}: uname normalizes to empty.");
continue;
}
if (! array_key_exists($lookupKey, $usernameMap)) {
$existingUser = $this->findUserByUsername($lookupKey);
if ($existingUser !== null) {
$usernameMap[$lookupKey] = [
'user_id' => (int) $existingUser->id,
'created' => false,
];
} else {
$usernameMap[$lookupKey] = [
'user_id' => $dryRun
? 0
: $this->createUserForLegacyUsername($rawUsername, $legacyConnection),
'created' => true,
];
}
}
$resolved = $usernameMap[$lookupKey];
if ($resolved['created']) {
$createdUsers++;
$usernameMap[$lookupKey]['created'] = false;
$resolved['created'] = false;
$this->line($dryRun
? "[dry] Would create user for uname='{$rawUsername}'"
: "[create] Created user_id={$usernameMap[$lookupKey]['user_id']} for uname='{$rawUsername}'");
} else {
$matchedUsers++;
}
if ($dryRun) {
$targetUser = $usernameMap[$lookupKey]['user_id'] > 0
? (string) $usernameMap[$lookupKey]['user_id']
: '<new-user-id>';
$this->line("[dry] Would update wallz id={$row->id} to user_id={$targetUser} using uname='{$rawUsername}'");
$updatedRows++;
continue;
}
$affected = DB::connection($legacyConnection)
->table($legacyTable)
->where('id', $row->id)
->where('user_id', 0)
->update([
'user_id' => $usernameMap[$lookupKey]['user_id'],
]);
if ($affected > 0) {
$updatedRows += $affected;
}
}
}, 'id');
$this->info(sprintf(
'Finished. processed=%d updated=%d matched=%d created=%d skipped=%d',
$processed,
$updatedRows,
$matchedUsers,
$createdUsers,
$skippedRows
));
return self::SUCCESS;
}
private function handleFixArtworks(int $chunk, string $legacyConnection, string $legacyTable, string $artworksTable, bool $dryRun): void
{
$this->info("\nAttempting to backfill `{$artworksTable}.user_id` from legacy {$legacyConnection}.{$legacyTable} where user_id = 0");
$total = (int) DB::table($artworksTable)->where('user_id', 0)->count();
$this->info("Found {$total} rows in {$artworksTable} with user_id = 0. Chunk size: {$chunk}.");
$processed = 0;
$updated = 0;
DB::table($artworksTable)
->select(['id'])
->where('user_id', 0)
->orderBy('id')
->chunkById($chunk, function ($rows) use (&$processed, &$updated, $legacyConnection, $legacyTable, $artworksTable, $dryRun) {
foreach ($rows as $row) {
$processed++;
$legacyUser = DB::connection($legacyConnection)
->table($legacyTable)
->where('id', $row->id)
->value('user_id');
$legacyUser = (int) ($legacyUser ?? 0);
if ($legacyUser <= 0) {
continue;
}
if ($dryRun) {
$this->line("[dry] Would update {$artworksTable} id={$row->id} to user_id={$legacyUser}");
$updated++;
continue;
}
$affected = DB::table($artworksTable)
->where('id', $row->id)
->where('user_id', 0)
->update(['user_id' => $legacyUser]);
if ($affected > 0) {
$updated += $affected;
}
}
}, 'id');
$this->info(sprintf('Artworks backfill complete. processed=%d updated=%d', $processed, $updated));
}
private function legacyTableExists(string $connection, string $table): bool
{
try {
return DB::connection($connection)->getSchemaBuilder()->hasTable($table);
} catch (\Throwable) {
return false;
}
}
private function findUserByUsername(string $normalizedUsername): ?object
{
return DB::table('users')
->select(['id', 'username'])
->whereRaw('LOWER(username) = ?', [$normalizedUsername])
->first();
}
private function createUserForLegacyUsername(string $legacyUsername, string $legacyConnection): int
{
$username = UsernamePolicy::uniqueCandidate($legacyUsername);
$emailLocal = $this->sanitizeEmailLocal($username);
$email = $this->uniqueEmailCandidate($emailLocal . '@users.skinbase.org');
$now = now();
// Attempt to copy legacy joinDate from the legacy `users` table when available.
$legacyJoin = null;
try {
$legacyJoin = DB::connection($legacyConnection)
->table('users')
->whereRaw('LOWER(uname) = ?', [strtolower((string) $legacyUsername)])
->value('joinDate');
} catch (\Throwable) {
$legacyJoin = null;
}
$createdAt = $now;
if (! empty($legacyJoin) && strpos((string) $legacyJoin, '0000') !== 0) {
try {
$createdAt = Carbon::parse($legacyJoin);
} catch (\Throwable) {
$createdAt = $now;
}
}
$userId = (int) DB::table('users')->insertGetId([
'username' => $username,
'username_changed_at' => $now,
'name' => $legacyUsername,
'email' => $email,
'password' => Hash::make(Str::random(64)),
'is_active' => true,
'needs_password_reset' => true,
'role' => 'user',
'legacy_password_algo' => null,
'created_at' => $createdAt,
'updated_at' => $now,
]);
return $userId;
}
private function uniqueEmailCandidate(string $email): string
{
$candidate = strtolower(trim($email));
$suffix = 1;
while (DB::table('users')->whereRaw('LOWER(email) = ?', [$candidate])->exists()) {
$parts = explode('@', $email, 2);
$local = $parts[0] ?? 'user';
$domain = $parts[1] ?? 'users.skinbase.org';
$candidate = $local . '+' . $suffix . '@' . $domain;
$suffix++;
}
return $candidate;
}
private function sanitizeEmailLocal(string $value): string
{
$local = strtolower(trim($value));
$local = preg_replace('/[^a-z0-9._-]/', '-', $local) ?: 'user';
return trim($local, '.-') ?: 'user';
}
}

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Support\UsernamePolicy;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RepairTemporaryUsernamesCommand extends Command
{
protected $signature = 'skinbase:repair-temp-usernames
{--chunk=500 : Number of users to process per batch}
{--dry-run : Preview username changes without writing them}';
protected $description = 'Replace current users.username values like tmpu% using the users.name field';
public function handle(): int
{
$chunk = max(1, (int) $this->option('chunk'));
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->warn('[DRY RUN] No changes will be written.');
}
$total = (int) DB::table('users')
->where('username', 'like', 'tmpu%')
->count();
if ($total === 0) {
$this->info('No users with temporary tmpu% usernames were found.');
return self::SUCCESS;
}
$this->info("Found {$total} users with temporary tmpu% usernames.");
$processed = 0;
$updated = 0;
$skipped = 0;
DB::table('users')
->select(['id', 'name', 'username'])
->where('username', 'like', 'tmpu%')
->chunkById($chunk, function ($rows) use (&$processed, &$updated, &$skipped, $dryRun) {
foreach ($rows as $row) {
$processed++;
$sourceName = trim((string) ($row->name ?? ''));
if ($sourceName === '') {
$skipped++;
$this->warn("Skipping user id={$row->id}: name is empty.");
continue;
}
$candidate = $this->resolveCandidate($sourceName, (int) $row->id);
if ($candidate === null || strcasecmp($candidate, (string) $row->username) === 0) {
$skipped++;
$this->warn("Skipping user id={$row->id}: unable to resolve a better username from name='{$sourceName}'.");
continue;
}
if ($dryRun) {
$this->line("[dry] Would update user id={$row->id} username '{$row->username}' => '{$candidate}'");
$updated++;
continue;
}
$affected = DB::table('users')
->where('id', (int) $row->id)
->where('username', 'like', 'tmpu%')
->update([
'username' => $candidate,
'username_changed_at' => now(),
'updated_at' => now(),
]);
if ($affected > 0) {
$updated += $affected;
$this->line("[update] user id={$row->id} username '{$row->username}' => '{$candidate}'");
}
}
}, 'id');
$this->info(sprintf('Finished. processed=%d updated=%d skipped=%d', $processed, $updated, $skipped));
return self::SUCCESS;
}
private function resolveCandidate(string $sourceName, int $userId): ?string
{
$base = UsernamePolicy::sanitizeLegacy($sourceName);
$min = UsernamePolicy::min();
$max = UsernamePolicy::max();
if ($base === '') {
return null;
}
if (preg_match('/^tmpu\d+$/i', $base) === 1) {
$base = 'user' . $userId;
}
if (strlen($base) < $min) {
$base = substr($base . $userId, 0, $max);
}
if ($base === '' || $base === 'user') {
$base = 'user' . $userId;
}
$candidate = substr($base, 0, $max);
$suffix = 1;
while ($this->usernameExists($candidate, $userId) || UsernamePolicy::isReserved($candidate)) {
$suffixValue = (string) $suffix;
$prefixLen = max(1, $max - strlen($suffixValue));
$candidate = substr($base, 0, $prefixLen) . $suffixValue;
$suffix++;
}
return $candidate;
}
private function usernameExists(string $username, int $ignoreUserId): bool
{
return DB::table('users')
->whereRaw('LOWER(username) = ?', [strtolower($username)])
->where('id', '!=', $ignoreUserId)
->exists();
}
}

View File

@@ -0,0 +1,168 @@
<?php
namespace App\Console\Commands;
use App\Enums\ModerationContentType;
use App\Enums\ModerationStatus;
use App\Models\ContentModerationFinding;
use App\Services\Moderation\ContentModerationActionLogService;
use App\Services\Moderation\ContentModerationProcessingService;
use App\Services\Moderation\ContentModerationSourceService;
use Illuminate\Console\Command;
class RescanContentModerationCommand extends Command
{
protected $signature = 'skinbase:rescan-content-moderation
{--only= : comments, descriptions, titles, bios, profile-links, collections, stories, cards, or a comma-separated list}
{--status=pending : Filter findings by moderation status}
{--limit= : Maximum number of findings to rescan}
{--from-id= : Start rescanning at or after this finding ID}
{--force : Rescan all matching findings, including already resolved findings}';
protected $description = 'Rescan existing moderation findings using the latest rules and scanner version.';
public function __construct(
private readonly ContentModerationProcessingService $processing,
private readonly ContentModerationSourceService $sources,
private readonly ContentModerationActionLogService $actionLogs,
) {
parent::__construct();
}
public function handle(): int
{
$limit = max(0, (int) ($this->option('limit') ?? 0));
$fromId = max(0, (int) ($this->option('from-id') ?? 0));
$status = trim((string) ($this->option('status') ?? 'pending'));
$force = (bool) $this->option('force');
$types = $this->selectedTypes();
$counts = [
'rescanned' => 0,
'updated' => 0,
'auto_hidden' => 0,
'missing_source' => 0,
];
$query = ContentModerationFinding::query()->orderBy('id');
if ($status !== '') {
$query->where('status', $status);
}
if ($fromId > 0) {
$query->where('id', '>=', $fromId);
}
if ($types !== []) {
$query->whereIn('content_type', array_map(static fn (ModerationContentType $type): string => $type->value, $types));
}
if (! $force) {
$query->where('status', ModerationStatus::Pending->value);
}
if ($limit > 0) {
$query->limit($limit);
}
$query->chunkById(100, function ($findings) use (&$counts): bool {
foreach ($findings as $finding) {
$counts['rescanned']++;
$rescanned = $this->processing->rescanFinding($finding, $this->sources);
if ($rescanned === null) {
$counts['missing_source']++;
continue;
}
$counts['updated']++;
if ($rescanned->is_auto_hidden) {
$counts['auto_hidden']++;
}
$this->actionLogs->log(
$rescanned,
'finding',
$rescanned->id,
'rescan',
null,
null,
$rescanned->status->value,
null,
null,
'Finding rescanned with the latest moderation rules.',
['scanner_version' => $rescanned->scanner_version],
);
}
return true;
}, 'id');
$this->table(['Metric', 'Count'], [
['Rescanned', $counts['rescanned']],
['Updated', $counts['updated']],
['Auto-hidden', $counts['auto_hidden']],
['Missing source', $counts['missing_source']],
]);
$this->info('Content moderation rescan complete.');
return self::SUCCESS;
}
/**
* @return array<int, ModerationContentType>
*/
private function selectedTypes(): array
{
$raw = trim((string) ($this->option('only') ?? ''));
if ($raw === '') {
return [];
}
$selected = \collect(explode(',', $raw))
->map(static fn (string $value): string => trim(strtolower($value)))
->filter()
->values();
$types = [];
if ($selected->contains('comments')) {
$types[] = ModerationContentType::ArtworkComment;
}
if ($selected->contains('descriptions')) {
$types[] = ModerationContentType::ArtworkDescription;
}
if ($selected->contains('titles') || $selected->contains('artwork_titles')) {
$types[] = ModerationContentType::ArtworkTitle;
}
if ($selected->contains('bios') || $selected->contains('user_bios')) {
$types[] = ModerationContentType::UserBio;
}
if ($selected->contains('profile-links') || $selected->contains('profile_links')) {
$types[] = ModerationContentType::UserProfileLink;
}
if ($selected->contains('collections') || $selected->contains('collection_titles')) {
$types[] = ModerationContentType::CollectionTitle;
$types[] = ModerationContentType::CollectionDescription;
}
if ($selected->contains('stories') || $selected->contains('story_titles')) {
$types[] = ModerationContentType::StoryTitle;
$types[] = ModerationContentType::StoryContent;
}
if ($selected->contains('cards') || $selected->contains('card_titles')) {
$types[] = ModerationContentType::CardTitle;
$types[] = ModerationContentType::CardText;
}
return $types;
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class ResetAllUserPasswords extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'skinbase:reset-all-passwords {--chunk=500 : Chunk size for processing} {--yes : Skip confirmation}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Reset all user passwords to secure random values and flag for password reset.';
public function handle(): int
{
if (! $this->option('yes') && ! $this->confirm('This will replace every user password with a secure random value and mark accounts as needing a reset. Continue?')) {
$this->info('Aborted. No changes were made.');
return 0;
}
$chunk = (int) $this->option('chunk');
$count = DB::table('users')->count();
if ($count === 0) {
$this->info('No users found.');
return 0;
}
$bar = $this->output->createProgressBar($count);
$bar->start();
DB::table('users')->orderBy('id')->chunkById($chunk, function ($rows) use ($bar) {
foreach ($rows as $row) {
DB::table('users')->where('id', $row->id)->update([
'password' => Hash::make(Str::random(64)),
'needs_password_reset' => true,
'updated_at' => now(),
]);
$bar->advance();
}
}, 'id');
$bar->finish();
$this->newLine(2);
$this->info('All user passwords reset and accounts flagged for password reset.');
return 0;
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* php artisan skinbase:reset-windowed-stats --period=24h|7d
*
* Resets / recomputes the sliding-window stats columns in artwork_stats:
*
* views_24h / views_7d
* Zeroed on each reset because we have no per-view event log.
* Artworks re-accumulate from the next view event onward.
* (Low-traffic reset window: 03:30 means minimal trending disruption.)
*
* downloads_24h / downloads_7d
* Recomputed accurately from the artwork_downloads event log.
* A single bulk UPDATE with a correlated COUNT() is safe here because
* it runs once nightly/weekly, not in the hot path.
*
* Scheduled in routes/console.php:
* --period=24h daily at 03:30
* --period=7d weekly (Monday) at 03:30
*/
class ResetWindowedStatsCommand extends Command
{
protected $signature = 'skinbase:reset-windowed-stats
{--period=24h : Window to reset: 24h or 7d}';
protected $description = 'Reset windowed view/download counters in artwork_stats';
public function handle(): int
{
$period = (string) $this->option('period');
if (! in_array($period, ['24h', '7d'], true)) {
$this->error("Invalid period '{$period}'. Use 24h or 7d.");
return self::FAILURE;
}
[$viewsCol, $downloadsCol, $cutoff] = match ($period) {
'24h' => ['views_24h', 'downloads_24h', now()->subDay()],
default => ['views_7d', 'downloads_7d', now()->subDays(7)],
};
$start = microtime(true);
// ── 1. Zero the views window column ──────────────────────────────────
// We have no per-view event log, so we reset the accumulator.
$viewsReset = DB::table('artwork_stats')->update([$viewsCol => 0]);
// ── 2. Recompute downloads window from the event log ─────────────────
// artwork_downloads has created_at, so each row's window is accurate.
// Chunked PHP loop avoids MySQL-only functions (GREATEST, INTERVAL)
// so this command works in both MySQL (production) and SQLite (tests).
$downloadsRecomputed = 0;
DB::table('artwork_stats')
->orderBy('artwork_id')
->chunk(1000, function ($rows) use ($downloadsCol, $cutoff, &$downloadsRecomputed): void {
foreach ($rows as $row) {
$count = DB::table('artwork_downloads')
->where('artwork_id', $row->artwork_id)
->where('created_at', '>=', $cutoff)
->count();
DB::table('artwork_stats')
->where('artwork_id', $row->artwork_id)
->update([$downloadsCol => max(0, $count)]);
$downloadsRecomputed++;
}
});
$elapsed = round(microtime(true) - $start, 2);
$this->info("Period: {$period}");
$this->info(" {$viewsCol}: zeroed {$viewsReset} rows");
$this->info(" {$downloadsCol}: recomputed {$downloadsRecomputed} rows ({$elapsed}s)");
Log::info('ResetWindowedStats complete', [
'period' => $period,
'views_col' => $viewsCol,
'views_rows_reset' => $viewsReset,
'downloads_col' => $downloadsCol,
'downloads_recomputed' => $downloadsRecomputed,
'elapsed_s' => $elapsed,
]);
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Sitemaps\SitemapPublishService;
use Illuminate\Console\Command;
final class RollbackSitemapReleaseCommand extends Command
{
protected $signature = 'skinbase:sitemaps:rollback {release? : Release id to activate instead of the previous published release}';
protected $description = 'Rollback sitemap delivery to a previous published release.';
public function handle(SitemapPublishService $publish): int
{
try {
$manifest = $publish->rollback(($release = $this->argument('release')) !== null ? (string) $release : null);
} catch (\Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$this->info('Rolled back to sitemap release [' . (string) $manifest['release_id'] . '].');
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,188 @@
<?php
namespace App\Console\Commands;
use App\Services\ContentSanitizer;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* php artisan skinbase:sanitize-content
*
* Scans legacy content for unsafe HTML, converts it to Markdown-safe text,
* and populates the raw_content / rendered_content columns on artwork_comments.
*
* Options:
* --dry-run Preview changes without writing
* --chunk=200 Rows per batch
* --table= Limit to one target
* --artwork-id= Limit to a single artwork (filters artwork_comments by artwork_id, artworks by id)
*/
class SanitizeContent extends Command
{
protected $signature = 'skinbase:sanitize-content
{--dry-run : Preview changes without writing to the database}
{--chunk=200 : Number of rows per batch}
{--table= : Limit scan to a single target (artwork_comments|artworks|forum_posts)}
{--artwork-id= : Limit scan to a single artwork ID (skips forum_posts)}';
protected $description = 'Strip unsafe HTML from legacy content and populate sanitized columns.';
/**
* table => [read_col, write_raw_col, write_rendered_col|null]
*
* For artwork_comments we write two columns; for the others we only sanitize in-place.
*/
private const TARGETS = [
'artwork_comments' => [
'read' => 'content',
'write_raw' => 'raw_content',
'write_rendered' => 'rendered_content',
],
'artworks' => [
'read' => 'description',
'write_raw' => 'description',
'write_rendered' => null,
],
'forum_posts' => [
'read' => 'content',
'write_raw' => 'content',
'write_rendered' => null,
],
];
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$chunk = max(1, (int) $this->option('chunk'));
$tableOpt = $this->option('table');
$artworkId = $this->option('artwork-id');
if ($artworkId !== null) {
if (! ctype_digit((string) $artworkId) || (int) $artworkId < 1) {
$this->error("--artwork-id must be a positive integer. Got: {$artworkId}");
return self::FAILURE;
}
$artworkId = (int) $artworkId;
}
$targets = self::TARGETS;
if ($tableOpt) {
if (! isset($targets[$tableOpt])) {
$this->error("Unknown table: {$tableOpt}. Allowed: " . implode(', ', array_keys($targets)));
return self::FAILURE;
}
$targets = [$tableOpt => $targets[$tableOpt]];
}
// --artwork-id removes forum_posts (no artwork FK) and informs the user.
if ($artworkId !== null) {
unset($targets['forum_posts']);
$this->line("Filtering to artwork <info>#{$artworkId}</info> (forum_posts skipped).");
}
if ($dryRun) {
$this->warn('DRY-RUN mode — no changes will be written.');
}
$totalModified = 0;
$totalRows = 0;
foreach ($targets as $table => $def) {
$this->line("Processing <info>{$table}</info>…");
[$modified, $rows] = $this->processTable($table, $def, $chunk, $dryRun, $artworkId);
$totalModified += $modified;
$totalRows += $rows;
$this->line("{$rows} rows scanned, {$modified} modified.");
}
$this->newLine();
$this->info("Summary: {$totalRows} rows, {$totalModified} " . ($dryRun ? 'would be ' : '') . 'modified.');
return self::SUCCESS;
}
private function processTable(
string $table,
array $def,
int $chunk,
bool $dryRun,
?int $artworkId = null
): array {
$totalModified = 0;
$totalRows = 0;
$readCol = $def['read'];
$writeRawCol = $def['write_raw'];
$writeRenderedCol = $def['write_rendered'];
DB::table($table)
->whereNotNull($readCol)
->when($artworkId !== null, function ($q) use ($table, $artworkId) {
// artwork_comments has artwork_id; artworks is filtered by its own PK.
$filterCol = $table === 'artwork_comments' ? 'artwork_id' : 'id';
$q->where($filterCol, $artworkId);
})
->orderBy('id')
->chunk($chunk, function ($rows) use (
$table, $readCol, $writeRawCol, $writeRenderedCol,
$dryRun, &$totalModified, &$totalRows
) {
foreach ($rows as $row) {
$original = $row->$readCol ?? '';
$stripped = ContentSanitizer::stripToPlain($original);
$totalRows++;
// Detect if content had HTML that we need to clean
$hadHtml = $original !== $stripped && preg_match('/<[a-z][^>]*>/i', $original);
if ($writeRawCol === $readCol && ! $hadHtml) {
// Same column, no HTML, skip
continue;
}
$rendered = ContentSanitizer::render($stripped);
$totalModified++;
if ($hadHtml) {
$this->line(" [{$table}#{$row->id}] Stripped HTML from content.");
Log::info("skinbase:sanitize-content stripped HTML from {$table}#{$row->id}");
}
if ($dryRun) {
continue;
}
$update = [$writeRawCol => $stripped];
if ($writeRenderedCol) {
$update[$writeRenderedCol] = $rendered;
}
DB::table($table)->where('id', $row->id)->update($update);
}
// Also populate rendered_content for rows that have raw_content but no rendered_content
if ($writeRenderedCol && ! $dryRun) {
DB::table($table)
->whereNotNull($writeRawCol)
->whereNull($writeRenderedCol)
->orderBy('id')
->chunk(200, function ($missing) use ($table, $writeRawCol, $writeRenderedCol) {
foreach ($missing as $row) {
$rendered = ContentSanitizer::render($row->$writeRawCol ?? '');
DB::table($table)->where('id', $row->id)->update([
$writeRenderedCol => $rendered,
]);
}
});
}
});
return [$totalModified, $totalRows];
}
}

View File

@@ -0,0 +1,366 @@
<?php
namespace App\Console\Commands;
use App\Enums\ModerationContentType;
use App\Enums\ModerationStatus;
use App\Services\Moderation\ContentModerationPersistenceService;
use App\Services\Moderation\ContentModerationProcessingService;
use App\Services\Moderation\ContentModerationService;
use App\Services\Moderation\ContentModerationSourceService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class ScanContentModerationCommand extends Command
{
protected $signature = 'skinbase:scan-content-moderation
{--only= : comments, descriptions, titles, bios, profile-links, collections, stories, cards, or a comma-separated list}
{--limit= : Maximum number of rows to scan}
{--from-id= : Start scanning at or after this source ID}
{--status= : Reserved for compatibility with rescan tooling}
{--force : Re-scan unchanged content}
{--dry-run : Analyze content without persisting findings}';
protected $description = 'Scan artwork comments and descriptions for suspicious or spam-like content.';
public function __construct(
private readonly ContentModerationService $moderation,
private readonly ContentModerationPersistenceService $persistence,
private readonly ContentModerationProcessingService $processing,
private readonly ContentModerationSourceService $sources,
) {
parent::__construct();
}
public function handle(): int
{
$targets = $this->targets();
$limit = max(0, (int) ($this->option('limit') ?? 0));
$remaining = $limit > 0 ? $limit : null;
$counts = [
'scanned' => 0,
'flagged' => 0,
'created' => 0,
'updated' => 0,
'skipped' => 0,
'clean' => 0,
'auto_hidden' => 0,
];
$this->announceScanStart($targets, $limit);
foreach ($targets as $target) {
if ($remaining !== null && $remaining <= 0) {
$this->comment('Scan limit reached. Stopping before the next content target.');
break;
}
$counts = $this->scanTarget($target, $counts, $remaining);
}
$this->table(['Metric', 'Count'], [
['Scanned', $counts['scanned']],
['Flagged', $counts['flagged']],
['Created', $counts['created']],
['Updated', $counts['updated']],
['Auto-hidden', $counts['auto_hidden']],
['Clean', $counts['clean']],
['Skipped', $counts['skipped']],
]);
Log::info('Content moderation scan complete.', [
'targets' => array_map(static fn (ModerationContentType $target): string => $target->value, $targets),
'limit' => $limit > 0 ? $limit : null,
'from_id' => max(0, (int) ($this->option('from-id') ?? 0)) ?: null,
'force' => (bool) $this->option('force'),
'dry_run' => (bool) $this->option('dry-run'),
'counts' => $counts,
]);
$this->info('Content moderation scan complete.');
return self::SUCCESS;
}
/**
* @param array<string, int> $counts
* @return array<string, int>
*/
private function scanTarget(ModerationContentType $target, array $counts, ?int &$remaining): array
{
$before = $counts;
$this->info('Scanning ' . $target->label() . ' entries...');
$query = match ($target) {
ModerationContentType::ArtworkComment,
ModerationContentType::ArtworkDescription,
ModerationContentType::ArtworkTitle,
ModerationContentType::UserBio,
ModerationContentType::UserProfileLink,
ModerationContentType::CollectionTitle,
ModerationContentType::CollectionDescription,
ModerationContentType::StoryTitle,
ModerationContentType::StoryContent,
ModerationContentType::CardTitle,
ModerationContentType::CardText => $this->sources->queryForType($target),
};
$fromId = max(0, (int) ($this->option('from-id') ?? 0));
if ($fromId > 0) {
$query->where('id', '>=', $fromId);
}
$query->chunkById(200, function ($rows) use ($target, &$counts, &$remaining): bool {
foreach ($rows as $row) {
if ($remaining !== null && $remaining <= 0) {
return false;
}
$context = $this->sources->buildContext($target, $row);
$snapshot = (string) ($context['content_snapshot'] ?? '');
$sourceId = (int) ($context['content_id'] ?? 0);
if ($snapshot === '') {
$counts['skipped']++;
$this->verboseLine($target, $sourceId, 'skipped empty snapshot');
continue;
}
$analysis = $this->moderation->analyze($snapshot, $context);
$counts['scanned']++;
if (! $this->option('force') && ! $this->option('dry-run') && $this->persistence->hasCurrentFinding(
(string) $context['content_type'],
(int) $context['content_id'],
$analysis->contentHash,
$analysis->scannerVersion,
)) {
$counts['skipped']++;
$this->verboseLine($target, $sourceId, 'skipped unchanged content');
$remaining = $remaining !== null ? $remaining - 1 : null;
continue;
}
if ($this->option('dry-run')) {
if ($analysis->status === ModerationStatus::Pending) {
$counts['flagged']++;
$this->verboseAnalysis($target, $sourceId, $analysis, 'dry-run flagged');
} else {
$counts['clean']++;
$this->verboseLine($target, $sourceId, 'dry-run clean');
}
$remaining = $remaining !== null ? $remaining - 1 : null;
continue;
}
$result = $this->processing->process($snapshot, $context, true);
if ($analysis->status !== ModerationStatus::Pending) {
$counts['clean']++;
if ($result['updated']) {
$counts['updated']++;
}
$this->verboseLine($target, $sourceId, $result['updated'] ? 'clean, existing finding updated' : 'clean');
$remaining = $remaining !== null ? $remaining - 1 : null;
continue;
}
$counts['flagged']++;
if ($result['created']) {
$counts['created']++;
} elseif ($result['updated']) {
$counts['updated']++;
}
if ($result['auto_hidden']) {
$counts['auto_hidden']++;
}
$outcome = $result['created']
? 'flagged, finding created'
: ($result['updated'] ? 'flagged, finding updated' : 'flagged');
if ($result['auto_hidden']) {
$outcome .= ', auto-hidden';
}
$this->verboseAnalysis($target, $sourceId, $analysis, $outcome);
$remaining = $remaining !== null ? $remaining - 1 : null;
}
return true;
}, 'id');
$targetCounts = [
'scanned' => $counts['scanned'] - $before['scanned'],
'flagged' => $counts['flagged'] - $before['flagged'],
'created' => $counts['created'] - $before['created'],
'updated' => $counts['updated'] - $before['updated'],
'auto_hidden' => $counts['auto_hidden'] - $before['auto_hidden'],
'clean' => $counts['clean'] - $before['clean'],
'skipped' => $counts['skipped'] - $before['skipped'],
];
$this->line(sprintf(
'Finished %s: scanned=%d, flagged=%d, created=%d, updated=%d, auto-hidden=%d, clean=%d, skipped=%d',
$target->label(),
$targetCounts['scanned'],
$targetCounts['flagged'],
$targetCounts['created'],
$targetCounts['updated'],
$targetCounts['auto_hidden'],
$targetCounts['clean'],
$targetCounts['skipped'],
));
return $counts;
}
/**
* @param array<int, ModerationContentType> $targets
*/
private function announceScanStart(array $targets, int $limit): void
{
$this->info('Starting content moderation scan...');
$this->line('Targets: ' . implode(', ', array_map(static fn (ModerationContentType $target): string => $target->label(), $targets)));
$this->line('Mode: ' . ($this->option('dry-run') ? 'dry-run' : 'persist findings'));
$this->line('Force re-scan: ' . ($this->option('force') ? 'yes' : 'no'));
$this->line('From source ID: ' . (max(0, (int) ($this->option('from-id') ?? 0)) ?: 'start'));
$this->line('Limit: ' . ($limit > 0 ? (string) $limit : 'none'));
if ($this->output->isVerbose()) {
$this->comment('Verbose mode enabled. Use -vv for detailed reasons and matched domains.');
}
}
private function verboseLine(ModerationContentType $target, int $sourceId, string $message): void
{
if (! $this->output->isVerbose()) {
return;
}
$this->line(sprintf('[%s #%d] %s', $target->value, $sourceId, $message));
}
private function verboseAnalysis(ModerationContentType $target, int $sourceId, mixed $analysis, string $prefix): void
{
if (! $this->output->isVerbose()) {
return;
}
$message = sprintf(
'[%s #%d] %s; score=%d; severity=%s; policy=%s; queue=%s',
$target->value,
$sourceId,
$prefix,
$analysis->score,
$analysis->severity->value,
$analysis->policyName ?? 'default',
$analysis->status->value,
);
if ($analysis->priorityScore !== null) {
$message .= '; priority=' . $analysis->priorityScore;
}
if ($analysis->reviewBucket !== null) {
$message .= '; bucket=' . $analysis->reviewBucket;
}
if ($analysis->aiLabel !== null) {
$message .= '; ai=' . $analysis->aiLabel;
if ($analysis->aiConfidence !== null) {
$message .= ' (' . $analysis->aiConfidence . '%)';
}
}
$this->line($message);
if ($this->output->isVeryVerbose()) {
if ($analysis->matchedDomains !== []) {
$this->line(' matched domains: ' . implode(', ', $analysis->matchedDomains));
}
if ($analysis->matchedKeywords !== []) {
$this->line(' matched keywords: ' . implode(', ', $analysis->matchedKeywords));
}
if ($analysis->reasons !== []) {
$this->line(' reasons: ' . implode(' | ', $analysis->reasons));
}
}
}
/**
* @return array<int, ModerationContentType>
*/
private function targets(): array
{
$raw = trim((string) ($this->option('only') ?? ''));
if ($raw === '') {
return [
ModerationContentType::ArtworkComment,
ModerationContentType::ArtworkDescription,
];
}
$selected = collect(explode(',', $raw))
->map(static fn (string $value): string => trim(strtolower($value)))
->filter()
->values();
$targets = [];
if ($selected->contains('comments')) {
$targets[] = ModerationContentType::ArtworkComment;
}
if ($selected->contains('descriptions')) {
$targets[] = ModerationContentType::ArtworkDescription;
}
if ($selected->contains('titles') || $selected->contains('artwork_titles')) {
$targets[] = ModerationContentType::ArtworkTitle;
}
if ($selected->contains('bios') || $selected->contains('user_bios')) {
$targets[] = ModerationContentType::UserBio;
}
if ($selected->contains('profile-links') || $selected->contains('profile_links')) {
$targets[] = ModerationContentType::UserProfileLink;
}
if ($selected->contains('collections') || $selected->contains('collection_titles')) {
$targets[] = ModerationContentType::CollectionTitle;
$targets[] = ModerationContentType::CollectionDescription;
}
if ($selected->contains('stories') || $selected->contains('story_titles')) {
$targets[] = ModerationContentType::StoryTitle;
$targets[] = ModerationContentType::StoryContent;
}
if ($selected->contains('cards') || $selected->contains('card_titles')) {
$targets[] = ModerationContentType::CardTitle;
$targets[] = ModerationContentType::CardText;
}
return $targets === [] ? [
ModerationContentType::ArtworkComment,
ModerationContentType::ArtworkDescription,
ModerationContentType::ArtworkTitle,
ModerationContentType::UserBio,
ModerationContentType::UserProfileLink,
ModerationContentType::CollectionTitle,
ModerationContentType::CollectionDescription,
ModerationContentType::StoryTitle,
ModerationContentType::StoryContent,
ModerationContentType::CardTitle,
ModerationContentType::CardText,
] : $targets;
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use App\Models\Category;
use App\Services\Vision\VectorService;
use Illuminate\Console\Command;
final class SearchArtworkVectorsCommand extends Command
{
protected $signature = 'artworks:vectors-search
{artwork_id : Source artwork id}
{--limit=5 : Number of similar artworks to return}';
protected $description = 'Search similar artworks through the vector gateway using an artwork image URL';
public function handle(VectorService $vectors): int
{
if (! $vectors->isConfigured()) {
$this->error('Vision vector gateway is not configured. Set VISION_VECTOR_GATEWAY_URL and VISION_VECTOR_GATEWAY_API_KEY.');
return self::FAILURE;
}
$artworkId = max(1, (int) $this->argument('artwork_id'));
$limit = max(1, min((int) $this->option('limit'), 100));
$artwork = Artwork::query()
->with(['categories' => fn ($categories) => $categories->with('contentType')->orderBy('sort_order')->orderBy('name')])
->find($artworkId);
if (! $artwork) {
$this->error("Artwork {$artworkId} was not found.");
return self::FAILURE;
}
try {
$matches = $vectors->similarToArtwork($artwork, $limit);
} catch (\Throwable $e) {
$this->error('Vector search failed: ' . $e->getMessage());
return self::FAILURE;
}
$ids = collect($matches)->pluck('id')->map(fn (mixed $id): int => (int) $id)->filter()->values()->all();
if ($ids === []) {
$this->warn('No similar artworks were returned by the vector gateway.');
return self::SUCCESS;
}
$artworks = Artwork::query()
->with(['categories' => fn ($categories) => $categories->with('contentType')->orderBy('sort_order')->orderBy('name')])
->whereIn('id', $ids)
->public()
->published()
->get()
->keyBy('id');
$rows = [];
foreach ($matches as $match) {
$matchId = (int) ($match['id'] ?? 0);
if ($matchId <= 0) {
continue;
}
/** @var Artwork|null $matchedArtwork */
$matchedArtwork = $artworks->get($matchId);
if (! $matchedArtwork) {
continue;
}
$category = $this->primaryCategory($matchedArtwork);
$rows[] = [
'id' => $matchId,
'score' => number_format((float) ($match['score'] ?? 0.0), 4, '.', ''),
'title' => (string) $matchedArtwork->title,
'content_type' => (string) ($category?->contentType?->name ?? ''),
'category' => (string) ($category?->name ?? ''),
];
if (count($rows) >= $limit) {
break;
}
}
if ($rows === []) {
$this->warn('The vector gateway returned matches, but none resolved to public published artworks.');
return self::SUCCESS;
}
$this->table(['ID', 'Score', 'Title', 'Content Type', 'Category'], $rows);
return self::SUCCESS;
}
private function primaryCategory(Artwork $artwork): ?Category
{
/** @var Category|null $category */
$category = $artwork->categories->sortBy('sort_order')->first();
return $category;
}
}

View File

@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Tag;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
final class SeedTagInteractionDemoCommand extends Command
{
protected $signature = 'analytics:seed-tag-interaction-demo
{--days=14 : Number of days to generate demo events for}
{--per-day=90 : Approximate number of demo events to write per day}
{--refresh : Remove existing seeded demo events first}
{--force : Allow running outside local/testing environments}';
protected $description = 'Generate demo tag interaction events for local analytics dashboards and ranking validation';
public function handle(): int
{
if (! app()->environment(['local', 'testing']) && ! $this->option('force')) {
$this->error('This command is restricted to local/testing unless --force is provided.');
return self::FAILURE;
}
$days = max(1, min(60, (int) $this->option('days')));
$perDay = max(10, min(500, (int) $this->option('per-day')));
$tags = Tag::query()
->where('is_active', true)
->orderByDesc('usage_count')
->limit(20)
->get(['id', 'name', 'slug', 'usage_count']);
if ($tags->count() < 2) {
$this->error('At least two active tags are required to generate demo interaction data.');
return self::FAILURE;
}
$transitions = $this->buildTransitionMap($tags);
if ($this->option('refresh')) {
DB::table('tag_interaction_events')
->where('meta->seeded_demo', true)
->delete();
}
$now = now();
for ($offset = $days - 1; $offset >= 0; $offset--) {
$date = Carbon::today()->subDays($offset);
$rows = [];
for ($index = 0; $index < $perDay; $index++) {
$surface = $this->pickSurface();
$sourceTag = $tags->random();
$targetTag = $this->pickTargetTag($surface, $sourceTag->slug, $transitions, $tags);
$query = in_array($surface, ['search_suggestion', 'rescue_suggestion', 'recent_search'], true)
? $this->queryForTag($targetTag)
: null;
$rows[] = [
'event_date' => $date->toDateString(),
'event_type' => 'click',
'surface' => $surface,
'user_id' => null,
'session_key' => hash('sha256', 'demo-' . $date->toDateString() . '-' . $index . '-' . $surface),
'tag_slug' => $targetTag->slug,
'source_tag_slug' => in_array($surface, ['related_chip', 'related_cluster', 'top_companion'], true)
? $sourceTag->slug
: null,
'query' => $query,
'position' => random_int(1, 4),
'meta' => json_encode([
'seeded_demo' => true,
'seeded_at' => $now->toISOString(),
], JSON_THROW_ON_ERROR),
'occurred_at' => $date->copy()->setTime(random_int(8, 23), random_int(0, 59), random_int(0, 59)),
'created_at' => $now,
'updated_at' => $now,
];
}
foreach (array_chunk($rows, 250) as $chunk) {
DB::table('tag_interaction_events')->insert($chunk);
}
$this->call('analytics:aggregate-tag-interactions', ['--date' => $date->toDateString()]);
}
$this->info("Seeded demo tag interaction events for the last {$days} days.");
return self::SUCCESS;
}
private function buildTransitionMap(Collection $tags): array
{
$pairs = DB::table('artwork_tag as source_pivot')
->join('tags as source_tag', 'source_tag.id', '=', 'source_pivot.tag_id')
->join('artwork_tag as target_pivot', 'target_pivot.artwork_id', '=', 'source_pivot.artwork_id')
->join('tags as target_tag', 'target_tag.id', '=', 'target_pivot.tag_id')
->whereIn('source_tag.id', $tags->pluck('id')->all())
->whereIn('target_tag.id', $tags->pluck('id')->all())
->whereColumn('source_tag.id', '!=', 'target_tag.id')
->groupBy('source_tag.slug', 'target_tag.slug')
->orderByRaw('COUNT(*) DESC')
->get([
'source_tag.slug as source_slug',
'target_tag.slug as target_slug',
DB::raw('COUNT(*) as pair_count'),
]);
$map = [];
foreach ($pairs as $pair) {
$map[$pair->source_slug][] = $pair->target_slug;
}
return $map;
}
private function pickSurface(): string
{
$roll = random_int(1, 100);
return match (true) {
$roll <= 32 => 'search_suggestion',
$roll <= 46 => 'rescue_suggestion',
$roll <= 58 => 'recent_search',
$roll <= 80 => 'related_chip',
$roll <= 94 => 'related_cluster',
default => 'top_companion',
};
}
private function pickTargetTag(string $surface, string $sourceSlug, array $transitions, Collection $tags): object
{
if (in_array($surface, ['related_chip', 'related_cluster', 'top_companion'], true)) {
$candidateSlugs = $transitions[$sourceSlug] ?? [];
if ($candidateSlugs !== []) {
$slug = $candidateSlugs[array_rand($candidateSlugs)];
return $tags->firstWhere('slug', $slug) ?? $tags->where('slug', '!=', $sourceSlug)->random();
}
return $tags->where('slug', '!=', $sourceSlug)->random();
}
return $tags->random();
}
private function queryForTag(object $tag): string
{
$name = trim((string) ($tag->name ?? $tag->slug));
$options = array_values(array_filter([
strtolower($name),
strtolower((string) ($tag->slug ?? '')),
strtolower(substr($name, 0, max(3, min(strlen($name), 7)))),
]));
return $options[array_rand($options)];
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\UserStatsService;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class SyncArtworkCreatedAtCommand extends Command
{
public function __construct(private readonly UserStatsService $userStats)
{
parent::__construct();
}
protected $signature = 'artworks:sync-created-at
{--chunk=500 : Number of artworks to process per batch}
{--only-null : Update only artworks whose created_at is null}
{--dry-run : Preview changes without writing updates}';
protected $description = 'Copy artworks.published_at into artworks.created_at for published artworks.';
public function handle(): int
{
$chunk = max(1, (int) $this->option('chunk'));
$onlyNull = (bool) $this->option('only-null');
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->warn('[DRY RUN] No changes will be written.');
}
$query = DB::table('artworks')
->select(['id', 'user_id', 'created_at', 'published_at'])
->whereNotNull('published_at')
->orderBy('id');
if ($onlyNull) {
$query->whereNull('created_at');
}
$processed = 0;
$updated = 0;
$unchanged = 0;
$affectedUserIds = [];
$query->chunkById($chunk, function (Collection $rows) use (&$processed, &$updated, &$unchanged, &$affectedUserIds, $dryRun): void {
foreach ($rows as $row) {
$processed++;
$publishedAt = $this->normalizeTimestamp($row->published_at ?? null);
$createdAt = $this->normalizeTimestamp($row->created_at ?? null);
if ($publishedAt === null) {
$unchanged++;
continue;
}
if ($createdAt === $publishedAt) {
$unchanged++;
continue;
}
if ($dryRun) {
$this->line(sprintf(
'[dry] Would update artwork id=%d created_at %s => %s',
(int) $row->id,
$createdAt ?? '<null>',
$publishedAt
));
$updated++;
continue;
}
DB::table('artworks')
->where('id', (int) $row->id)
->update([
'created_at' => $publishedAt,
'updated_at' => now()->toDateTimeString(),
]);
$affectedUserIds[(int) $row->user_id] = true;
$updated++;
$this->line(sprintf('[update] artwork id=%d created_at => %s', (int) $row->id, $publishedAt));
}
}, 'id');
if (! $dryRun) {
foreach (array_keys($affectedUserIds) as $userId) {
$this->userStats->recomputeUser((int) $userId);
}
}
$this->info(sprintf('Finished. processed=%d updated=%d unchanged=%d', $processed, $updated, $unchanged));
return self::SUCCESS;
}
private function normalizeTimestamp(mixed $value): ?string
{
if ($value === null || $value === '') {
return null;
}
try {
return Carbon::parse((string) $value)->toDateTimeString();
} catch (\Throwable) {
return null;
}
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Collection;
use App\Services\CollectionCollaborationService;
use App\Services\CollectionLifecycleService;
use App\Services\CollectionSurfaceService;
use Illuminate\Console\Command;
class SyncCollectionLifecycleCommand extends Command
{
protected $signature = 'collections:sync-lifecycle';
protected $description = 'Expire pending collection invites, sync collection lifecycle states, and deactivate expired placements.';
public function handle(CollectionCollaborationService $collaborators, CollectionLifecycleService $lifecycle, CollectionSurfaceService $surfaces): int
{
$expiredInvites = $collaborators->expirePendingInvites();
$lifecycleResults = $lifecycle->syncScheduledCollections();
$expiredPlacements = $surfaces->syncPlacements();
$unfeaturedCollections = Collection::query()
->where('is_featured', true)
->whereNotNull('unpublished_at')
->where('unpublished_at', '<=', now())
->update([
'is_featured' => false,
'featured_at' => null,
'updated_at' => now(),
]);
$this->info(sprintf(
'Expired %d pending invites; published %d scheduled collections; expired %d collections; unfeatured %d unpublished collections; deactivated %d expired placements.',
$expiredInvites,
(int) ($lifecycleResults['scheduled'] ?? 0),
(int) ($lifecycleResults['expired'] ?? 0),
$unfeaturedCollections,
$expiredPlacements,
));
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Countries\CountrySyncService;
use Illuminate\Console\Command;
use Throwable;
final class SyncCountriesCommand extends Command
{
protected $signature = 'skinbase:sync-countries
{--deactivate-missing : Mark countries missing from the source as inactive}
{--no-fallback : Fail instead of using the local fallback dataset when remote fetch fails}';
protected $description = 'Synchronize ISO 3166 country metadata into the local countries table.';
public function __construct(
private readonly CountrySyncService $countrySyncService,
) {
parent::__construct();
}
public function handle(): int
{
try {
$summary = $this->countrySyncService->sync(
allowFallback: ! (bool) $this->option('no-fallback'),
deactivateMissing: (bool) $this->option('deactivate-missing') ? true : null,
);
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$this->info('Countries synchronized successfully.');
$this->line('Source: '.($summary['source'] ?? 'unknown'));
$this->line('Fetched: '.(int) ($summary['total_fetched'] ?? 0));
$this->line('Inserted: '.(int) ($summary['inserted'] ?? 0));
$this->line('Updated: '.(int) ($summary['updated'] ?? 0));
$this->line('Skipped: '.(int) ($summary['skipped'] ?? 0));
$this->line('Invalid: '.(int) ($summary['invalid'] ?? 0));
$this->line('Deactivated: '.(int) ($summary['deactivated'] ?? 0));
$this->line('Backfilled users: '.(int) ($summary['backfilled_users'] ?? 0));
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Throwable;
final class TestObjectStorageUploadCommand extends Command
{
protected $signature = 'storage:test-upload
{--disk=s3 : Filesystem disk to test}
{--file= : Optional absolute or relative path to an existing local file to upload}
{--path= : Optional remote object key to use}
{--keep : Keep the uploaded test object instead of deleting it afterwards}';
protected $description = 'Upload a probe file to the configured object storage disk and verify that it was stored.';
public function handle(): int
{
$diskName = (string) $this->option('disk');
$diskConfig = config("filesystems.disks.{$diskName}");
if (! is_array($diskConfig)) {
$this->error("Filesystem disk [{$diskName}] is not configured.");
return self::FAILURE;
}
$this->line('Testing object storage upload.');
$this->line('Disk: '.$diskName);
$this->line('Driver: '.(string) ($diskConfig['driver'] ?? 'unknown'));
$this->line('Bucket: '.(string) ($diskConfig['bucket'] ?? 'n/a'));
$this->line('Region: '.(string) ($diskConfig['region'] ?? 'n/a'));
$this->line('Endpoint: '.((string) ($diskConfig['endpoint'] ?? '') !== '' ? (string) $diskConfig['endpoint'] : '[not set]'));
$this->line('Path style: '.((bool) ($diskConfig['use_path_style_endpoint'] ?? false) ? 'true' : 'false'));
if ((string) ($diskConfig['endpoint'] ?? '') === '') {
$this->warn('No endpoint is configured for this S3 disk. Many S3-compatible providers, including Contabo object storage, require AWS_ENDPOINT to be set.');
}
$remotePath = $this->resolveRemotePath();
$keepObject = (bool) $this->option('keep');
$sourceFile = $this->option('file');
$filesystem = Storage::disk($diskName);
try {
if (is_string($sourceFile) && trim($sourceFile) !== '') {
$localPath = $this->resolveLocalPath($sourceFile);
if ($localPath === null) {
$this->error('The file passed to --file does not exist.');
return self::FAILURE;
}
$stream = fopen($localPath, 'rb');
if ($stream === false) {
$this->error('Unable to open the local file for reading.');
return self::FAILURE;
}
try {
$written = $filesystem->put($remotePath, $stream);
} finally {
fclose($stream);
}
$sourceLabel = $localPath;
} else {
$contents = $this->buildProbeContents($diskName);
$written = $filesystem->put($remotePath, $contents);
$sourceLabel = '[generated probe payload]';
}
if ($written !== true) {
$this->error('Upload did not complete successfully. The storage driver returned a failure status.');
return self::FAILURE;
}
$exists = $filesystem->exists($remotePath);
$size = $exists ? $filesystem->size($remotePath) : null;
$this->info('Upload succeeded.');
$this->line('Source: '.$sourceLabel);
$this->line('Object key: '.$remotePath);
$this->line('Exists after upload: '.($exists ? 'yes' : 'no'));
if ($size !== null) {
$this->line('Stored size: '.number_format((int) $size).' bytes');
}
if (! $keepObject && $exists) {
$filesystem->delete($remotePath);
$this->line('Cleanup: deleted uploaded test object');
} elseif ($keepObject) {
$this->warn('Cleanup skipped because --keep was used.');
}
return $exists ? self::SUCCESS : self::FAILURE;
} catch (Throwable $exception) {
$this->error('Object storage test failed.');
$this->line($exception->getMessage());
return self::FAILURE;
}
}
private function resolveRemotePath(): string
{
$provided = trim((string) $this->option('path'));
if ($provided !== '') {
return ltrim(str_replace('\\', '/', $provided), '/');
}
return 'tests/object-storage/'.now()->format('Ymd-His').'-'.Str::random(10).'.txt';
}
private function resolveLocalPath(string $path): ?string
{
$trimmed = trim($path);
if ($trimmed === '') {
return null;
}
if (is_file($trimmed)) {
return $trimmed;
}
$relative = base_path($trimmed);
return is_file($relative) ? $relative : null;
}
private function buildProbeContents(string $diskName): string
{
return implode("\n", [
'Skinbase object storage upload test',
'disk='.$diskName,
'timestamp='.now()->toIso8601String(),
'app_url='.(string) config('app.url'),
'random='.Str::uuid()->toString(),
'',
]);
}
}

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Sitemaps\SitemapBuildService;
use App\Services\Sitemaps\SitemapReleaseManager;
use App\Services\Sitemaps\SitemapReleaseValidator;
use App\Services\Sitemaps\SitemapValidationService;
use Illuminate\Console\Command;
final class ValidateSitemapsCommand extends Command
{
protected $signature = 'skinbase:sitemaps:validate
{--only=* : Limit validation to one or more sitemap families}
{--release= : Validate a specific sitemap release}
{--active : Validate the active published sitemap release when available}';
protected $description = 'Validate sitemap XML, shard integrity, and public URL safety.';
public function handle(SitemapValidationService $validation, SitemapBuildService $build, SitemapReleaseManager $releases, SitemapReleaseValidator $releaseValidator): int
{
$startedAt = microtime(true);
$families = $this->selectedFamilies($build);
$releaseId = ($value = $this->option('release')) !== null && trim((string) $value) !== ''
? trim((string) $value)
: ((bool) $this->option('active') ? $releases->activeReleaseId() : $releases->activeReleaseId());
if (is_string($releaseId) && $releaseId !== '') {
$report = $releaseValidator->validate($releaseId);
foreach ((array) ($report['families'] ?? []) as $familyReport) {
$this->line(sprintf(
'Family [%s]: documents=%d urls=%d shards=%d',
(string) $familyReport['family'],
(int) $familyReport['documents'],
(int) $familyReport['url_count'],
(int) $familyReport['shard_count'],
));
foreach ((array) ($familyReport['warnings'] ?? []) as $warning) {
$this->warn(' - ' . $warning);
}
foreach ((array) ($familyReport['errors'] ?? []) as $error) {
$this->error(' - ' . $error);
}
}
$this->info(sprintf(
'Sitemap release validation finished in %.2fs. release=%s families=%d documents=%d urls=%d shards=%d',
microtime(true) - $startedAt,
$releaseId,
(int) data_get($report, 'totals.families', 0),
(int) data_get($report, 'totals.documents', 0),
(int) data_get($report, 'totals.urls', 0),
(int) data_get($report, 'totals.shards', 0),
));
if ((bool) ($report['ok'] ?? false)) {
$this->info('Sitemap validation passed.');
return self::SUCCESS;
}
return self::FAILURE;
}
if ($families === []) {
$this->error('No valid sitemap families were selected for validation.');
return self::INVALID;
}
$report = $validation->validate($families);
foreach ((array) ($report['index']['errors'] ?? []) as $error) {
$this->error('Index: ' . $error);
}
foreach ((array) ($report['families'] ?? []) as $familyReport) {
$this->line(sprintf(
'Family [%s]: documents=%d urls=%d shards=%d',
(string) $familyReport['family'],
(int) $familyReport['documents'],
(int) $familyReport['url_count'],
(int) $familyReport['shard_count'],
));
foreach ((array) ($familyReport['warnings'] ?? []) as $warning) {
$this->warn(' - ' . $warning);
}
foreach ((array) ($familyReport['errors'] ?? []) as $error) {
$this->error(' - ' . $error);
}
}
if ((array) ($report['duplicates'] ?? []) !== []) {
foreach ((array) $report['duplicates'] as $duplicate) {
$this->error('Duplicate URL detected: ' . $duplicate);
}
}
$this->info(sprintf(
'Sitemap validation finished in %.2fs. families=%d documents=%d urls=%d shards=%d',
microtime(true) - $startedAt,
(int) data_get($report, 'totals.families', 0),
(int) data_get($report, 'totals.documents', 0),
(int) data_get($report, 'totals.urls', 0),
(int) data_get($report, 'totals.shards', 0),
));
if ((bool) ($report['ok'] ?? false)) {
$this->info('Sitemap validation passed.');
return self::SUCCESS;
}
return self::FAILURE;
}
/**
* @return list<string>
*/
private function selectedFamilies(SitemapBuildService $build): array
{
$only = [];
foreach ((array) $this->option('only') as $value) {
foreach (explode(',', (string) $value) as $family) {
$normalized = trim($family);
if ($normalized !== '') {
$only[] = $normalized;
}
}
}
$enabled = $build->enabledFamilies();
if ($only === []) {
return $enabled;
}
return array_values(array_filter($enabled, fn (string $family): bool => in_array($family, $only, true)));
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\HomepageService;
use Illuminate\Console\Command;
final class WarmHomepageGuestCacheCommand extends Command
{
protected $signature = 'homepage:warm-guest-cache';
protected $description = 'Warm the guest homepage payload cache';
public function handle(HomepageService $homepage): int
{
$startedAt = microtime(true);
$payload = $homepage->warmGuestPayloadCache();
$durationMs = (microtime(true) - $startedAt) * 1000;
$this->info(sprintf(
'Warmed guest homepage cache (%d sections) in %.2fms using store [%s].',
count($payload),
$durationMs,
$homepage->guestPayloadCacheStoreName(),
));
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Console\Commands;
use App\Services\Posts\PostTrendingService;
use Illuminate\Console\Command;
/**
* Warms the post trending cache so requests are fast.
* Scheduled every 2 minutes to match the cache TTL.
*/
class WarmPostTrendingCommand extends Command
{
protected $signature = 'posts:warm-trending';
protected $description = 'Refresh the post trending feed cache.';
public function __construct(private PostTrendingService $trending)
{
parent::__construct();
}
public function handle(): int
{
$ids = $this->trending->refresh();
$this->info('Trending feed cache refreshed. ' . count($ids) . ' post(s) ranked.');
return self::SUCCESS;
}
}

Some files were not shown because too many files have changed in this diff Show More