Save workspace changes
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ArtworkController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
abort(404);
|
||||
}
|
||||
|
||||
public function create(Request $request)
|
||||
{
|
||||
abort(404);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
abort(404);
|
||||
}
|
||||
|
||||
public function edit(Request $request, int $id)
|
||||
{
|
||||
abort(404);
|
||||
}
|
||||
|
||||
public function update(Request $request, int $id)
|
||||
{
|
||||
abort(404);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $id)
|
||||
{
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Collection;
|
||||
use App\Models\CollectionMember;
|
||||
use App\Services\CollectionCollaborationService;
|
||||
use App\Services\CollectionModerationService;
|
||||
use App\Services\CollectionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class CollectionModerationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CollectionModerationService $moderation,
|
||||
private readonly CollectionService $collections,
|
||||
private readonly CollectionCollaborationService $collaborators,
|
||||
) {
|
||||
}
|
||||
|
||||
public function updateModeration(Request $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'moderation_status' => ['required', 'in:active,under_review,restricted,hidden'],
|
||||
]);
|
||||
|
||||
$collection = $this->moderation->updateStatus($collection->loadMissing('user'), (string) $data['moderation_status']);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateInteractions(Request $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'allow_comments' => ['sometimes', 'boolean'],
|
||||
'allow_submissions' => ['sometimes', 'boolean'],
|
||||
'allow_saves' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
|
||||
$collection = $this->moderation->updateInteractions($collection->loadMissing('user'), $data);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
|
||||
]);
|
||||
}
|
||||
|
||||
public function unfeature(Request $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$collection = $this->moderation->unfeature($collection->loadMissing('user'));
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroyMember(Request $request, Collection $collection, CollectionMember $member): JsonResponse
|
||||
{
|
||||
$this->moderation->removeMember($collection, $member);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'collection' => $this->collections->mapCollectionDetailPayload($collection->fresh()->loadMissing('user'), true),
|
||||
'members' => $this->collaborators->mapMembers($collection->fresh(), $request->user()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Analytics\DiscoveryFeedbackReportService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class DiscoveryFeedbackReportController extends Controller
|
||||
{
|
||||
public function __construct(private readonly DiscoveryFeedbackReportService $reportService) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'from' => ['nullable', 'date_format:Y-m-d'],
|
||||
'to' => ['nullable', 'date_format:Y-m-d'],
|
||||
'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
]);
|
||||
|
||||
$from = (string) ($validated['from'] ?? now()->subDays(13)->toDateString());
|
||||
$to = (string) ($validated['to'] ?? now()->toDateString());
|
||||
$limit = (int) ($validated['limit'] ?? 20);
|
||||
|
||||
if ($from > $to) {
|
||||
return response()->json([
|
||||
'message' => 'Invalid date range: from must be before or equal to to.',
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$report = $this->reportService->buildReport($from, $to, $limit);
|
||||
|
||||
return response()->json([
|
||||
'meta' => [
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'limit' => $limit,
|
||||
'generated_at' => now()->toISOString(),
|
||||
],
|
||||
'overview' => $report['overview'],
|
||||
'daily_feedback' => $report['daily_feedback'],
|
||||
'trend_summary' => $report['trend_summary'],
|
||||
'by_surface' => $report['by_surface'],
|
||||
'by_algo_surface' => $report['by_algo_surface'],
|
||||
'top_artworks' => $report['top_artworks'],
|
||||
'latest_aggregated_date' => $report['latest_aggregated_date'],
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Recommendations\RecommendationFeedResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class FeedEngineDecisionController extends Controller
|
||||
{
|
||||
public function __construct(private readonly RecommendationFeedResolver $feedResolver) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'user_id' => ['required', 'integer', 'exists:users,id'],
|
||||
'algo_version' => ['nullable', 'string', 'max:64'],
|
||||
]);
|
||||
|
||||
$userId = (int) $validated['user_id'];
|
||||
$algoVersion = isset($validated['algo_version']) ? (string) $validated['algo_version'] : null;
|
||||
|
||||
return response()->json([
|
||||
'meta' => [
|
||||
'generated_at' => now()->toISOString(),
|
||||
],
|
||||
'decision' => $this->feedResolver->inspectDecision($userId, $algoVersion),
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class FeedPerformanceReportController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'from' => ['nullable', 'date_format:Y-m-d'],
|
||||
'to' => ['nullable', 'date_format:Y-m-d'],
|
||||
'limit' => ['nullable', 'integer', 'min:1', 'max:1000'],
|
||||
]);
|
||||
|
||||
$from = (string) ($validated['from'] ?? now()->subDays(29)->toDateString());
|
||||
$to = (string) ($validated['to'] ?? now()->toDateString());
|
||||
$limit = (int) ($validated['limit'] ?? 100);
|
||||
|
||||
if ($from > $to) {
|
||||
return response()->json([
|
||||
'message' => 'Invalid date range: from must be before or equal to to.',
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$rows = DB::table('feed_daily_metrics')
|
||||
->selectRaw('algo_version, source')
|
||||
->selectRaw('SUM(impressions) as impressions')
|
||||
->selectRaw('SUM(clicks) as clicks')
|
||||
->selectRaw('SUM(saves) as saves')
|
||||
->selectRaw('SUM(dwell_0_5) as dwell_0_5')
|
||||
->selectRaw('SUM(dwell_5_30) as dwell_5_30')
|
||||
->selectRaw('SUM(dwell_30_120) as dwell_30_120')
|
||||
->selectRaw('SUM(dwell_120_plus) as dwell_120_plus')
|
||||
->whereBetween('metric_date', [$from, $to])
|
||||
->groupBy('algo_version', 'source')
|
||||
->orderBy('algo_version')
|
||||
->orderBy('source')
|
||||
->get();
|
||||
|
||||
$byAlgoSource = $rows->map(static function ($row): array {
|
||||
$impressions = (int) ($row->impressions ?? 0);
|
||||
$clicks = (int) ($row->clicks ?? 0);
|
||||
$saves = (int) ($row->saves ?? 0);
|
||||
|
||||
return [
|
||||
'algo_version' => (string) $row->algo_version,
|
||||
'source' => (string) $row->source,
|
||||
'impressions' => $impressions,
|
||||
'clicks' => $clicks,
|
||||
'saves' => $saves,
|
||||
'ctr' => round($impressions > 0 ? $clicks / $impressions : 0.0, 6),
|
||||
'save_rate' => round($clicks > 0 ? $saves / $clicks : 0.0, 6),
|
||||
'dwell_buckets' => [
|
||||
'0_5' => (int) ($row->dwell_0_5 ?? 0),
|
||||
'5_30' => (int) ($row->dwell_5_30 ?? 0),
|
||||
'30_120' => (int) ($row->dwell_30_120 ?? 0),
|
||||
'120_plus' => (int) ($row->dwell_120_plus ?? 0),
|
||||
],
|
||||
];
|
||||
})->values();
|
||||
|
||||
$topClickedArtworks = DB::table('feed_events as e')
|
||||
->leftJoin('artworks as a', 'a.id', '=', 'e.artwork_id')
|
||||
->selectRaw('e.algo_version')
|
||||
->selectRaw('e.source')
|
||||
->selectRaw('e.artwork_id')
|
||||
->selectRaw('a.title as artwork_title')
|
||||
->selectRaw("SUM(CASE WHEN e.event_type = 'feed_impression' THEN 1 ELSE 0 END) AS impressions")
|
||||
->selectRaw("SUM(CASE WHEN e.event_type = 'feed_click' THEN 1 ELSE 0 END) AS clicks")
|
||||
->whereBetween('e.event_date', [$from, $to])
|
||||
->groupBy('e.algo_version', 'e.source', 'e.artwork_id', 'a.title')
|
||||
->get()
|
||||
->map(static function ($row): array {
|
||||
$impressions = (int) ($row->impressions ?? 0);
|
||||
$clicks = (int) ($row->clicks ?? 0);
|
||||
|
||||
return [
|
||||
'algo_version' => (string) $row->algo_version,
|
||||
'source' => (string) $row->source,
|
||||
'artwork_id' => (int) $row->artwork_id,
|
||||
'artwork_title' => (string) ($row->artwork_title ?? ''),
|
||||
'impressions' => $impressions,
|
||||
'clicks' => $clicks,
|
||||
'ctr' => round($impressions > 0 ? $clicks / $impressions : 0.0, 6),
|
||||
];
|
||||
})
|
||||
->sort(static function (array $a, array $b): int {
|
||||
$clickCompare = $b['clicks'] <=> $a['clicks'];
|
||||
if ($clickCompare !== 0) {
|
||||
return $clickCompare;
|
||||
}
|
||||
|
||||
return $b['ctr'] <=> $a['ctr'];
|
||||
})
|
||||
->take($limit)
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'meta' => [
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'generated_at' => now()->toISOString(),
|
||||
'limit' => $limit,
|
||||
],
|
||||
'by_algo_source' => $byAlgoSource,
|
||||
'top_clicked_artworks' => $topClickedArtworks,
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\NovaCard;
|
||||
use App\Models\Report;
|
||||
use App\Models\ReportHistory;
|
||||
use App\Services\NovaCards\NovaCardPublishModerationService;
|
||||
use App\Support\Moderation\ReportTargetResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class ModerationReportQueueController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ReportTargetResolver $targets,
|
||||
private readonly NovaCardPublishModerationService $moderation,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$status = (string) $request->query('status', 'open');
|
||||
$status = in_array($status, ['open', 'reviewing', 'closed'], true) ? $status : 'open';
|
||||
$group = (string) $request->query('group', '');
|
||||
|
||||
$query = Report::query()
|
||||
->with(['reporter:id,username', 'lastModeratedBy:id,username', 'historyEntries.actor:id,username'])
|
||||
->where('status', $status)
|
||||
->orderByDesc('id');
|
||||
|
||||
if ($group === 'nova_cards') {
|
||||
$query->whereIn('target_type', $this->targets->novaCardTargetTypes());
|
||||
}
|
||||
|
||||
$items = $query->paginate(30);
|
||||
|
||||
return response()->json([
|
||||
'data' => collect($items->items())
|
||||
->map(fn (Report $report): array => $this->serializeReport($report))
|
||||
->values()
|
||||
->all(),
|
||||
'meta' => [
|
||||
'current_page' => $items->currentPage(),
|
||||
'last_page' => $items->lastPage(),
|
||||
'per_page' => $items->perPage(),
|
||||
'total' => $items->total(),
|
||||
'from' => $items->firstItem(),
|
||||
'to' => $items->lastItem(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Report $report): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'status' => 'sometimes|in:open,reviewing,closed',
|
||||
'moderator_note' => 'sometimes|nullable|string|max:2000',
|
||||
]);
|
||||
|
||||
$before = [];
|
||||
$after = [];
|
||||
$user = $request->user();
|
||||
|
||||
DB::transaction(function () use ($data, $report, $user, &$before, &$after): void {
|
||||
if (array_key_exists('status', $data) && $data['status'] !== $report->status) {
|
||||
$before['status'] = (string) $report->status;
|
||||
$after['status'] = (string) $data['status'];
|
||||
$report->status = $data['status'];
|
||||
}
|
||||
|
||||
if (array_key_exists('moderator_note', $data)) {
|
||||
$normalizedNote = is_string($data['moderator_note']) ? trim($data['moderator_note']) : null;
|
||||
$normalizedNote = $normalizedNote !== '' ? $normalizedNote : null;
|
||||
|
||||
if ($normalizedNote !== $report->moderator_note) {
|
||||
$before['moderator_note'] = $report->moderator_note;
|
||||
$after['moderator_note'] = $normalizedNote;
|
||||
$report->moderator_note = $normalizedNote;
|
||||
}
|
||||
}
|
||||
|
||||
if ($before !== [] || $after !== []) {
|
||||
$report->last_moderated_by_id = $user?->id;
|
||||
$report->last_moderated_at = now();
|
||||
$report->save();
|
||||
|
||||
$report->historyEntries()->create([
|
||||
'actor_user_id' => $user?->id,
|
||||
'action_type' => 'report_updated',
|
||||
'summary' => $this->buildUpdateSummary($before, $after),
|
||||
'note' => $report->moderator_note,
|
||||
'before_json' => $before !== [] ? $before : null,
|
||||
'after_json' => $after !== [] ? $after : null,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
$report = $report->fresh(['reporter:id,username', 'lastModeratedBy:id,username', 'historyEntries.actor:id,username']);
|
||||
|
||||
return response()->json([
|
||||
'report' => $this->serializeReport($report),
|
||||
]);
|
||||
}
|
||||
|
||||
public function moderateTarget(Request $request, Report $report): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'action' => 'required|in:approve_card,flag_card,reject_card',
|
||||
'disposition' => 'nullable|in:' . implode(',', array_keys(NovaCardPublishModerationService::DISPOSITION_LABELS)),
|
||||
]);
|
||||
|
||||
$card = $this->targets->resolveModerationCard($report);
|
||||
abort_unless($card !== null, 422, 'This report does not have a Nova Card moderation target.');
|
||||
|
||||
DB::transaction(function () use ($card, $data, $report, $request): void {
|
||||
$before = [
|
||||
'card_id' => (int) $card->id,
|
||||
'moderation_status' => (string) $card->moderation_status,
|
||||
];
|
||||
|
||||
$nextStatus = match ($data['action']) {
|
||||
'approve_card' => NovaCard::MOD_APPROVED,
|
||||
'flag_card' => NovaCard::MOD_FLAGGED,
|
||||
'reject_card' => NovaCard::MOD_REJECTED,
|
||||
};
|
||||
$card = $this->moderation->recordStaffOverride(
|
||||
$card,
|
||||
$nextStatus,
|
||||
$request->user(),
|
||||
'report_queue',
|
||||
[
|
||||
'note' => $report->moderator_note,
|
||||
'report_id' => $report->id,
|
||||
'disposition' => $data['disposition'] ?? null,
|
||||
],
|
||||
);
|
||||
|
||||
$report->last_moderated_by_id = $request->user()?->id;
|
||||
$report->last_moderated_at = now();
|
||||
$report->save();
|
||||
|
||||
$report->historyEntries()->create([
|
||||
'actor_user_id' => $request->user()?->id,
|
||||
'action_type' => 'target_moderated',
|
||||
'summary' => $this->buildTargetModerationSummary($data['action'], $card),
|
||||
'note' => $report->moderator_note,
|
||||
'before_json' => $before,
|
||||
'after_json' => [
|
||||
'card_id' => (int) $card->id,
|
||||
'moderation_status' => (string) $card->moderation_status,
|
||||
'action' => (string) $data['action'],
|
||||
],
|
||||
'created_at' => now(),
|
||||
]);
|
||||
});
|
||||
|
||||
$report = $report->fresh(['reporter:id,username', 'lastModeratedBy:id,username', 'historyEntries.actor:id,username']);
|
||||
|
||||
return response()->json([
|
||||
'report' => $this->serializeReport($report),
|
||||
]);
|
||||
}
|
||||
|
||||
private function buildUpdateSummary(array $before, array $after): string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
if (array_key_exists('status', $after)) {
|
||||
$parts[] = sprintf('Status %s -> %s', $before['status'], $after['status']);
|
||||
}
|
||||
|
||||
if (array_key_exists('moderator_note', $after)) {
|
||||
$parts[] = $after['moderator_note'] ? 'Moderator note updated' : 'Moderator note cleared';
|
||||
}
|
||||
|
||||
return $parts !== [] ? implode(' • ', $parts) : 'Report reviewed';
|
||||
}
|
||||
|
||||
private function buildTargetModerationSummary(string $action, NovaCard $card): string
|
||||
{
|
||||
return match ($action) {
|
||||
'approve_card' => sprintf('Approved card #%d', $card->id),
|
||||
'flag_card' => sprintf('Flagged card #%d', $card->id),
|
||||
'reject_card' => sprintf('Rejected card #%d', $card->id),
|
||||
default => sprintf('Updated card #%d', $card->id),
|
||||
};
|
||||
}
|
||||
|
||||
private function serializeReport(Report $report): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $report->id,
|
||||
'status' => (string) $report->status,
|
||||
'target_type' => (string) $report->target_type,
|
||||
'target_id' => (int) $report->target_id,
|
||||
'reason' => (string) $report->reason,
|
||||
'details' => $report->details,
|
||||
'moderator_note' => $report->moderator_note,
|
||||
'created_at' => optional($report->created_at)?->toISOString(),
|
||||
'updated_at' => optional($report->updated_at)?->toISOString(),
|
||||
'last_moderated_at' => optional($report->last_moderated_at)?->toISOString(),
|
||||
'reporter' => $report->reporter ? [
|
||||
'id' => (int) $report->reporter->id,
|
||||
'username' => (string) $report->reporter->username,
|
||||
] : null,
|
||||
'last_moderated_by' => $report->lastModeratedBy ? [
|
||||
'id' => (int) $report->lastModeratedBy->id,
|
||||
'username' => (string) $report->lastModeratedBy->username,
|
||||
] : null,
|
||||
'target' => $this->targets->summarize($report),
|
||||
'history' => $report->historyEntries
|
||||
->take(8)
|
||||
->map(fn (ReportHistory $entry): array => [
|
||||
'id' => (int) $entry->id,
|
||||
'action_type' => (string) $entry->action_type,
|
||||
'summary' => $entry->summary,
|
||||
'note' => $entry->note,
|
||||
'before' => $entry->before_json,
|
||||
'after' => $entry->after_json,
|
||||
'created_at' => optional($entry->created_at)?->toISOString(),
|
||||
'actor' => $entry->actor ? [
|
||||
'id' => (int) $entry->actor->id,
|
||||
'username' => (string) $entry->actor->username,
|
||||
] : null,
|
||||
])
|
||||
->values()
|
||||
->all(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class SimilarArtworkReportController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'from' => ['nullable', 'date_format:Y-m-d'],
|
||||
'to' => ['nullable', 'date_format:Y-m-d'],
|
||||
'limit' => ['nullable', 'integer', 'min:1', 'max:1000'],
|
||||
]);
|
||||
|
||||
$from = (string) ($validated['from'] ?? now()->subDays(29)->toDateString());
|
||||
$to = (string) ($validated['to'] ?? now()->toDateString());
|
||||
$limit = (int) ($validated['limit'] ?? 100);
|
||||
|
||||
if ($from > $to) {
|
||||
return response()->json([
|
||||
'message' => 'Invalid date range: from must be before or equal to to.',
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$byAlgoRows = 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")
|
||||
->whereBetween('event_date', [$from, $to])
|
||||
->groupBy('algo_version')
|
||||
->orderBy('algo_version')
|
||||
->get();
|
||||
|
||||
$byAlgo = $byAlgoRows->map(static function ($row): array {
|
||||
$impressions = (int) ($row->impressions ?? 0);
|
||||
$clicks = (int) ($row->clicks ?? 0);
|
||||
$ctr = $impressions > 0 ? $clicks / $impressions : 0.0;
|
||||
|
||||
return [
|
||||
'algo_version' => (string) $row->algo_version,
|
||||
'impressions' => $impressions,
|
||||
'clicks' => $clicks,
|
||||
'ctr' => round($ctr, 6),
|
||||
];
|
||||
})->values();
|
||||
|
||||
$pairRows = DB::table('similar_artwork_events as e')
|
||||
->leftJoin('artworks as source', 'source.id', '=', 'e.source_artwork_id')
|
||||
->leftJoin('artworks as similar', 'similar.id', '=', 'e.similar_artwork_id')
|
||||
->selectRaw('e.algo_version')
|
||||
->selectRaw('e.source_artwork_id')
|
||||
->selectRaw('e.similar_artwork_id')
|
||||
->selectRaw('source.title as source_title')
|
||||
->selectRaw('similar.title as similar_title')
|
||||
->selectRaw("SUM(CASE WHEN e.event_type = 'impression' THEN 1 ELSE 0 END) AS impressions")
|
||||
->selectRaw("SUM(CASE WHEN e.event_type = 'click' THEN 1 ELSE 0 END) AS clicks")
|
||||
->whereBetween('e.event_date', [$from, $to])
|
||||
->whereNotNull('e.similar_artwork_id')
|
||||
->groupBy('e.algo_version', 'e.source_artwork_id', 'e.similar_artwork_id', 'source.title', 'similar.title')
|
||||
->get();
|
||||
|
||||
$topSimilarities = $pairRows
|
||||
->map(static function ($row): array {
|
||||
$impressions = (int) ($row->impressions ?? 0);
|
||||
$clicks = (int) ($row->clicks ?? 0);
|
||||
$ctr = $impressions > 0 ? $clicks / $impressions : 0.0;
|
||||
|
||||
return [
|
||||
'algo_version' => (string) $row->algo_version,
|
||||
'source_artwork_id' => (int) $row->source_artwork_id,
|
||||
'source_title' => (string) ($row->source_title ?? ''),
|
||||
'similar_artwork_id' => (int) $row->similar_artwork_id,
|
||||
'similar_title' => (string) ($row->similar_title ?? ''),
|
||||
'impressions' => $impressions,
|
||||
'clicks' => $clicks,
|
||||
'ctr' => round($ctr, 6),
|
||||
];
|
||||
})
|
||||
->sort(function (array $a, array $b): int {
|
||||
$ctrCompare = $b['ctr'] <=> $a['ctr'];
|
||||
if ($ctrCompare !== 0) {
|
||||
return $ctrCompare;
|
||||
}
|
||||
|
||||
$clickCompare = $b['clicks'] <=> $a['clicks'];
|
||||
if ($clickCompare !== 0) {
|
||||
return $clickCompare;
|
||||
}
|
||||
|
||||
return $b['impressions'] <=> $a['impressions'];
|
||||
})
|
||||
->take($limit)
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'meta' => [
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'generated_at' => now()->toISOString(),
|
||||
'limit' => $limit,
|
||||
],
|
||||
'by_algo_version' => $byAlgo,
|
||||
'top_similarities' => $topSimilarities,
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Analytics\TagInteractionReportService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class TagInteractionReportController extends Controller
|
||||
{
|
||||
public function __construct(private readonly TagInteractionReportService $reportService) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'from' => ['nullable', 'date_format:Y-m-d'],
|
||||
'to' => ['nullable', 'date_format:Y-m-d'],
|
||||
'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
]);
|
||||
|
||||
$from = (string) ($validated['from'] ?? now()->subDays(13)->toDateString());
|
||||
$to = (string) ($validated['to'] ?? now()->toDateString());
|
||||
$limit = (int) ($validated['limit'] ?? 15);
|
||||
|
||||
if ($from > $to) {
|
||||
return response()->json([
|
||||
'message' => 'Invalid date range: from must be before or equal to to.',
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$report = $this->reportService->buildReport($from, $to, $limit);
|
||||
|
||||
return response()->json([
|
||||
'meta' => [
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'limit' => $limit,
|
||||
'generated_at' => now()->toISOString(),
|
||||
'latest_aggregated_date' => $report['latest_aggregated_date'],
|
||||
],
|
||||
'overview' => $report['overview'],
|
||||
'daily_clicks' => $report['daily_clicks'],
|
||||
'by_surface' => $report['by_surface'],
|
||||
'top_tags' => $report['top_tags'],
|
||||
'top_queries' => $report['top_queries'],
|
||||
'top_transitions' => $report['top_transitions'],
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Upload;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class UploadModerationController extends Controller
|
||||
{
|
||||
public function pending(): JsonResponse
|
||||
{
|
||||
$uploads = Upload::query()
|
||||
->where('status', 'draft')
|
||||
->where('moderation_status', 'pending')
|
||||
->orderBy('created_at')
|
||||
->get([
|
||||
'id',
|
||||
'user_id',
|
||||
'type',
|
||||
'status',
|
||||
'processing_state',
|
||||
'title',
|
||||
'preview_path',
|
||||
'created_at',
|
||||
'moderation_status',
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'data' => $uploads,
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
public function approve(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$upload = Upload::query()->find($id);
|
||||
|
||||
if (! $upload) {
|
||||
return response()->json(['message' => 'Upload not found.'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$upload->moderation_status = 'approved';
|
||||
$upload->moderated_at = now();
|
||||
$upload->moderated_by = (int) $request->user()->id;
|
||||
$upload->moderation_note = $request->input('note');
|
||||
$upload->save();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'id' => (string) $upload->id,
|
||||
'moderation_status' => (string) $upload->moderation_status,
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
public function reject(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$upload = Upload::query()->find($id);
|
||||
|
||||
if (! $upload) {
|
||||
return response()->json(['message' => 'Upload not found.'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$upload->moderation_status = 'rejected';
|
||||
$upload->status = 'rejected';
|
||||
$upload->processing_state = 'rejected';
|
||||
$upload->moderated_at = now();
|
||||
$upload->moderated_by = (int) $request->user()->id;
|
||||
$upload->moderation_note = (string) $request->input('note', '');
|
||||
$upload->save();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'id' => (string) $upload->id,
|
||||
'status' => (string) $upload->status,
|
||||
'processing_state' => (string) $upload->processing_state,
|
||||
'moderation_status' => (string) $upload->moderation_status,
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class UsernameApprovalController extends Controller
|
||||
{
|
||||
public function pending(): JsonResponse
|
||||
{
|
||||
$rows = DB::table('username_approval_requests')
|
||||
->where('status', 'pending')
|
||||
->orderBy('created_at')
|
||||
->get([
|
||||
'id',
|
||||
'user_id',
|
||||
'requested_username',
|
||||
'context',
|
||||
'similar_to',
|
||||
'payload',
|
||||
'created_at',
|
||||
]);
|
||||
|
||||
return response()->json(['data' => $rows], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
public function approve(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$row = DB::table('username_approval_requests')->where('id', $id)->first();
|
||||
if (! $row) {
|
||||
return response()->json(['message' => 'Request not found.'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
if ((string) $row->status !== 'pending') {
|
||||
return response()->json(['message' => 'Request is not pending.'], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
DB::table('username_approval_requests')
|
||||
->where('id', $id)
|
||||
->update([
|
||||
'status' => 'approved',
|
||||
'reviewed_by' => (int) $request->user()->id,
|
||||
'reviewed_at' => now(),
|
||||
'review_note' => (string) $request->input('note', ''),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
if ((string) $row->context === 'profile_update' && ! empty($row->user_id)) {
|
||||
$this->applyProfileRename((int) $row->user_id, (string) $row->requested_username);
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
return response()->json(['message' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'id' => $id,
|
||||
'status' => 'approved',
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
public function reject(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$affected = DB::table('username_approval_requests')
|
||||
->where('id', $id)
|
||||
->where('status', 'pending')
|
||||
->update([
|
||||
'status' => 'rejected',
|
||||
'reviewed_by' => (int) $request->user()->id,
|
||||
'reviewed_at' => now(),
|
||||
'review_note' => (string) $request->input('note', ''),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
if ($affected === 0) {
|
||||
return response()->json(['message' => 'Request not found or not pending.'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'id' => $id,
|
||||
'status' => 'rejected',
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
private function applyProfileRename(int $userId, string $requestedUsername): void
|
||||
{
|
||||
$user = User::query()->find($userId);
|
||||
if (! $user) {
|
||||
return;
|
||||
}
|
||||
|
||||
$requested = UsernamePolicy::normalize($requestedUsername);
|
||||
if ($requested === '') {
|
||||
throw new \RuntimeException('Requested username is invalid.');
|
||||
}
|
||||
|
||||
$exists = User::query()
|
||||
->whereRaw('LOWER(username) = ?', [$requested])
|
||||
->where('id', '!=', $userId)
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
throw new \RuntimeException('Requested username is already taken.');
|
||||
}
|
||||
|
||||
$old = UsernamePolicy::normalize((string) ($user->username ?? ''));
|
||||
if ($old === $requested) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user->username = $requested;
|
||||
$user->username_changed_at = now();
|
||||
if (Schema::hasColumn('users', 'last_username_change_at')) {
|
||||
$user->last_username_change_at = now();
|
||||
}
|
||||
$user->save();
|
||||
|
||||
if ($old !== '') {
|
||||
DB::table('username_history')->insert([
|
||||
'user_id' => $userId,
|
||||
'old_username' => $old,
|
||||
'changed_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('username_redirects')->updateOrInsert(
|
||||
['old_username' => $old],
|
||||
[
|
||||
'new_username' => $requested,
|
||||
'user_id' => $userId,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkAward;
|
||||
use App\Models\ArtworkMedal;
|
||||
use App\Models\ArtworkMedalStat;
|
||||
use App\Services\ArtworkMedalService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class ArtworkAwardController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ArtworkMedalService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* POST /api/artworks/{id}/award
|
||||
* Award the artwork with a medal.
|
||||
*/
|
||||
public function store(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$artwork = Artwork::findOrFail($id);
|
||||
|
||||
$this->authorize('award', [ArtworkAward::class, $artwork]);
|
||||
|
||||
$data = $request->validate([
|
||||
'medal' => ['required', 'string', 'in:gold,silver,bronze'],
|
||||
]);
|
||||
|
||||
$this->service->award($artwork, $user, $data['medal']);
|
||||
|
||||
// Record activity event
|
||||
try {
|
||||
\App\Models\ActivityEvent::record(
|
||||
actorId: $user->id,
|
||||
type: \App\Models\ActivityEvent::TYPE_AWARD,
|
||||
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
|
||||
targetId: $artwork->id,
|
||||
meta: ['medal' => $data['medal']],
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
|
||||
return response()->json(
|
||||
$this->buildPayload($artwork->id, $user->id),
|
||||
201
|
||||
);
|
||||
}
|
||||
|
||||
public function upsert(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$artwork = Artwork::findOrFail($id);
|
||||
|
||||
$this->authorize('award', [ArtworkAward::class, $artwork]);
|
||||
|
||||
$data = $request->validate([
|
||||
'medal_type' => ['required', 'string', 'in:gold,silver,bronze'],
|
||||
]);
|
||||
|
||||
$existed = ArtworkMedal::query()
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('user_id', $user->id)
|
||||
->exists();
|
||||
|
||||
$this->service->upsert($artwork, $user, $data['medal_type']);
|
||||
|
||||
return response()->json(
|
||||
array_merge($this->buildPayload($artwork->id, $user->id), [
|
||||
'message' => $existed ? 'Medal updated.' : 'Medal added.',
|
||||
]),
|
||||
$existed ? 200 : 201,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/artworks/{id}/award
|
||||
* Change an existing award medal.
|
||||
*/
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$artwork = Artwork::findOrFail($id);
|
||||
|
||||
$existingAward = ArtworkMedal::where('artwork_id', $artwork->id)
|
||||
->where('user_id', $user->id)
|
||||
->firstOrFail();
|
||||
|
||||
$this->authorize('change', $existingAward);
|
||||
|
||||
$data = $request->validate([
|
||||
'medal' => ['required', 'string', 'in:gold,silver,bronze'],
|
||||
]);
|
||||
|
||||
$this->service->changeMedal($artwork, $user, $data['medal']);
|
||||
|
||||
return response()->json($this->buildPayload($artwork->id, $user->id));
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/artworks/{id}/award
|
||||
* Remove the user's award for this artwork.
|
||||
*/
|
||||
public function destroy(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$artwork = Artwork::findOrFail($id);
|
||||
|
||||
$existingAward = ArtworkMedal::where('artwork_id', $artwork->id)
|
||||
->where('user_id', $user->id)
|
||||
->firstOrFail();
|
||||
|
||||
$this->authorize('remove', $existingAward);
|
||||
|
||||
$this->service->removeMedal($artwork, $user);
|
||||
|
||||
return response()->json($this->buildPayload($artwork->id, $user->id));
|
||||
}
|
||||
|
||||
public function destroyMedal(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$artwork = Artwork::findOrFail($id);
|
||||
|
||||
$this->service->removeMedal($artwork, $user);
|
||||
|
||||
return response()->json(array_merge($this->buildPayload($artwork->id, $user->id), [
|
||||
'message' => 'Medal removed.',
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/artworks/{id}/awards
|
||||
* Return award stats + viewer's current award.
|
||||
*/
|
||||
public function show(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$artwork = Artwork::findOrFail($id);
|
||||
|
||||
return response()->json($this->buildPayload($artwork->id, $request->user()?->id));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// All authorization is delegated to ArtworkAwardPolicy via $this->authorize().
|
||||
|
||||
private function buildPayload(int $artworkId, ?int $userId): array
|
||||
{
|
||||
$stat = ArtworkMedalStat::find($artworkId);
|
||||
|
||||
$userAward = $userId
|
||||
? ArtworkMedal::where('artwork_id', $artworkId)
|
||||
->where('user_id', $userId)
|
||||
->value('medal_type')
|
||||
: null;
|
||||
|
||||
$medals = [
|
||||
'gold' => (int) ($stat?->gold_count ?? 0),
|
||||
'silver' => (int) ($stat?->silver_count ?? 0),
|
||||
'bronze' => (int) ($stat?->bronze_count ?? 0),
|
||||
'score' => (int) ($stat?->score_total ?? 0),
|
||||
'score_7d' => (int) ($stat?->score_7d ?? 0),
|
||||
'score_30d' => (int) ($stat?->score_30d ?? 0),
|
||||
'last_medaled_at' => $stat?->last_medaled_at?->toIsoString(),
|
||||
];
|
||||
|
||||
return [
|
||||
'awards' => $medals,
|
||||
'medals' => $medals,
|
||||
'viewer_award' => $userAward,
|
||||
'current_user_medal' => $userAward,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Activity\UserActivityService;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Models\User;
|
||||
use App\Models\UserMention;
|
||||
use App\Notifications\ArtworkCommentedNotification;
|
||||
use App\Notifications\ArtworkMentionedNotification;
|
||||
use App\Services\ContentSanitizer;
|
||||
use App\Support\AvatarUrl;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
/**
|
||||
* Artwork comment CRUD.
|
||||
*
|
||||
* POST /api/artworks/{artworkId}/comments → store
|
||||
* PUT /api/artworks/{artworkId}/comments/{id} → update (own comment)
|
||||
* DELETE /api/artworks/{artworkId}/comments/{id} → delete (own or admin)
|
||||
* GET /api/artworks/{artworkId}/comments → list (paginated)
|
||||
*/
|
||||
class ArtworkCommentController extends Controller
|
||||
{
|
||||
private const MAX_LENGTH = 10_000;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// List
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function index(Request $request, int $artworkId): JsonResponse
|
||||
{
|
||||
$artwork = Artwork::public()->published()->findOrFail($artworkId);
|
||||
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$perPage = 20;
|
||||
|
||||
// Only fetch top-level comments (no parent). Replies are recursively eager-loaded.
|
||||
$comments = ArtworkComment::with([
|
||||
'user', 'user.profile',
|
||||
'approvedReplies',
|
||||
])
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('is_approved', true)
|
||||
->whereNull('parent_id')
|
||||
->orderByDesc('created_at')
|
||||
->paginate($perPage, ['*'], 'page', $page);
|
||||
|
||||
$userId = $request->user()?->id;
|
||||
$items = $comments->getCollection()->map(fn ($c) => $this->formatComment($c, $userId, true));
|
||||
|
||||
return response()->json([
|
||||
'data' => $items,
|
||||
'meta' => [
|
||||
'current_page' => $comments->currentPage(),
|
||||
'last_page' => $comments->lastPage(),
|
||||
'total' => $comments->total(),
|
||||
'per_page' => $comments->perPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Store
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function store(Request $request, int $artworkId): JsonResponse
|
||||
{
|
||||
$artwork = Artwork::public()->published()->findOrFail($artworkId);
|
||||
|
||||
$request->validate([
|
||||
'content' => ['required', 'string', 'min:1', 'max:' . self::MAX_LENGTH],
|
||||
'parent_id' => ['nullable', 'integer', 'exists:artwork_comments,id'],
|
||||
]);
|
||||
|
||||
$raw = $request->input('content');
|
||||
$parentId = $request->input('parent_id');
|
||||
|
||||
// If replying, validate parent belongs to same artwork and is approved
|
||||
if ($parentId) {
|
||||
$parent = ArtworkComment::where('artwork_id', $artwork->id)
|
||||
->where('is_approved', true)
|
||||
->find($parentId);
|
||||
|
||||
if (! $parent) {
|
||||
return response()->json([
|
||||
'errors' => ['parent_id' => ['The comment you are replying to is no longer available.']],
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate markdown-lite content
|
||||
$errors = ContentSanitizer::validate($raw);
|
||||
if ($errors) {
|
||||
return response()->json(['errors' => ['content' => $errors]], 422);
|
||||
}
|
||||
|
||||
$rendered = ContentSanitizer::render($raw);
|
||||
|
||||
$comment = ArtworkComment::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $request->user()->id,
|
||||
'parent_id' => $parentId,
|
||||
'content' => $raw, // legacy column (plain text fallback)
|
||||
'raw_content' => $raw,
|
||||
'rendered_content' => $rendered,
|
||||
'is_approved' => true, // auto-approve; extend with moderation as needed
|
||||
]);
|
||||
|
||||
// Bust the comments cache for this user's 'all' feed
|
||||
Cache::forget('comments.latest.all.page1');
|
||||
|
||||
$comment->load(['user', 'user.profile']);
|
||||
$this->notifyRecipients($artwork, $comment, $request->user(), $parentId ? (int) $parentId : null);
|
||||
|
||||
// Record activity event (fire-and-forget; never break the response)
|
||||
try {
|
||||
\App\Models\ActivityEvent::record(
|
||||
actorId: $request->user()->id,
|
||||
type: \App\Models\ActivityEvent::TYPE_COMMENT,
|
||||
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
|
||||
targetId: $artwork->id,
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
|
||||
try {
|
||||
app(UserActivityService::class)->logComment(
|
||||
(int) $request->user()->id,
|
||||
(int) $comment->id,
|
||||
$parentId !== null,
|
||||
['artwork_id' => (int) $artwork->id],
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
|
||||
return response()->json(['data' => $this->formatComment($comment, $request->user()->id, false)], 201);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Update
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function update(Request $request, int $artworkId, int $commentId): JsonResponse
|
||||
{
|
||||
$comment = ArtworkComment::where('artwork_id', $artworkId)
|
||||
->findOrFail($commentId);
|
||||
|
||||
Gate::authorize('update', $comment);
|
||||
|
||||
$request->validate([
|
||||
'content' => ['required', 'string', 'min:1', 'max:' . self::MAX_LENGTH],
|
||||
]);
|
||||
|
||||
$raw = $request->input('content');
|
||||
$errors = ContentSanitizer::validate($raw);
|
||||
if ($errors) {
|
||||
return response()->json(['errors' => ['content' => $errors]], 422);
|
||||
}
|
||||
|
||||
$rendered = ContentSanitizer::render($raw);
|
||||
|
||||
$comment->update([
|
||||
'content' => $raw,
|
||||
'raw_content' => $raw,
|
||||
'rendered_content' => $rendered,
|
||||
]);
|
||||
|
||||
Cache::forget('comments.latest.all.page1');
|
||||
|
||||
$comment->load(['user', 'user.profile']);
|
||||
|
||||
return response()->json(['data' => $this->formatComment($comment, $request->user()->id, false)]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Delete
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function destroy(Request $request, int $artworkId, int $commentId): JsonResponse
|
||||
{
|
||||
$comment = ArtworkComment::where('artwork_id', $artworkId)->findOrFail($commentId);
|
||||
|
||||
Gate::authorize('delete', $comment);
|
||||
|
||||
$comment->delete();
|
||||
Cache::forget('comments.latest.all.page1');
|
||||
|
||||
return response()->json(['message' => 'Comment deleted.'], 200);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private function formatComment(ArtworkComment $c, ?int $currentUserId, bool $includeReplies = false): array
|
||||
{
|
||||
$user = $c->user;
|
||||
$userId = (int) ($c->user_id ?? 0);
|
||||
$avatarHash = $user?->profile?->avatar_hash ?? null;
|
||||
|
||||
$data = [
|
||||
'id' => $c->id,
|
||||
'parent_id' => $c->parent_id,
|
||||
'raw_content' => $c->raw_content ?? $c->content,
|
||||
'rendered_content' => $this->renderCommentContent($c),
|
||||
'created_at' => $c->created_at?->toIso8601String(),
|
||||
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
|
||||
'can_edit' => $currentUserId === $userId,
|
||||
'can_delete' => $currentUserId === $userId,
|
||||
'user' => [
|
||||
'id' => $userId,
|
||||
'username' => $user?->username,
|
||||
'display' => $user?->username ?? $user?->name ?? 'User',
|
||||
'profile_url' => $user?->username ? '/@' . $user->username : '/profile/' . $userId,
|
||||
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
|
||||
'level' => (int) ($user?->level ?? 1),
|
||||
'rank' => (string) ($user?->rank ?? 'Newbie'),
|
||||
],
|
||||
];
|
||||
|
||||
if ($includeReplies && $c->relationLoaded('approvedReplies')) {
|
||||
$data['replies'] = $c->approvedReplies->map(fn ($r) => $this->formatComment($r, $currentUserId, true))->values()->toArray();
|
||||
} elseif ($includeReplies && $c->relationLoaded('replies')) {
|
||||
$data['replies'] = $c->replies->map(fn ($r) => $this->formatComment($r, $currentUserId, true))->values()->toArray();
|
||||
} else {
|
||||
$data['replies'] = [];
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function renderCommentContent(ArtworkComment $comment): string
|
||||
{
|
||||
$rawContent = (string) ($comment->raw_content ?? $comment->content ?? '');
|
||||
$renderedContent = $comment->rendered_content;
|
||||
|
||||
if (! is_string($renderedContent) || trim($renderedContent) === '') {
|
||||
$renderedContent = $rawContent !== ''
|
||||
? ContentSanitizer::render($rawContent)
|
||||
: nl2br(e(strip_tags((string) ($comment->content ?? ''))));
|
||||
}
|
||||
|
||||
return ContentSanitizer::sanitizeRenderedHtml(
|
||||
$renderedContent,
|
||||
$this->commentAuthorCanPublishLinks($comment)
|
||||
);
|
||||
}
|
||||
|
||||
private function commentAuthorCanPublishLinks(ArtworkComment $comment): bool
|
||||
{
|
||||
$level = (int) ($comment->user?->level ?? 1);
|
||||
$rank = strtolower((string) ($comment->user?->rank ?? 'Newbie'));
|
||||
|
||||
return $level > 1 && $rank !== 'newbie';
|
||||
}
|
||||
|
||||
private function notifyRecipients(Artwork $artwork, ArtworkComment $comment, User $actor, ?int $parentId): void
|
||||
{
|
||||
$notifiedUserIds = [];
|
||||
$creatorId = (int) ($artwork->user_id ?? 0);
|
||||
|
||||
if ($creatorId > 0 && $creatorId !== (int) $actor->id) {
|
||||
$creator = User::query()->find($creatorId);
|
||||
if ($creator) {
|
||||
$creator->notify(new ArtworkCommentedNotification($artwork, $comment, $actor));
|
||||
$notifiedUserIds[] = (int) $creator->id;
|
||||
}
|
||||
}
|
||||
|
||||
if ($parentId) {
|
||||
$parentUserId = (int) (ArtworkComment::query()->whereKey($parentId)->value('user_id') ?? 0);
|
||||
if ($parentUserId > 0 && $parentUserId !== (int) $actor->id && ! in_array($parentUserId, $notifiedUserIds, true)) {
|
||||
$parentUser = User::query()->find($parentUserId);
|
||||
if ($parentUser) {
|
||||
$parentUser->notify(new ArtworkCommentedNotification($artwork, $comment, $actor));
|
||||
$notifiedUserIds[] = (int) $parentUser->id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
User::query()
|
||||
->whereIn(
|
||||
'id',
|
||||
UserMention::query()
|
||||
->where('comment_id', (int) $comment->id)
|
||||
->pluck('mentioned_user_id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->unique()
|
||||
->all()
|
||||
)
|
||||
->get()
|
||||
->each(function (User $mentionedUser) use ($artwork, $comment, $actor): void {
|
||||
if ((int) $mentionedUser->id === (int) $actor->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$mentionedUser->notify(new ArtworkMentionedNotification($artwork, $comment, $actor));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Artworks\ArtworkCreateRequest;
|
||||
use App\Http\Resources\ArtworkListResource;
|
||||
use App\Http\Resources\ArtworkResource;
|
||||
use App\Services\ArtworkService;
|
||||
use App\Services\Artworks\ArtworkDraftService;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ArtworkController extends Controller
|
||||
{
|
||||
protected ArtworkService $service;
|
||||
|
||||
public function __construct(ArtworkService $service)
|
||||
{
|
||||
$this->service = $service;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/artworks
|
||||
* Creates a draft artwork placeholder for the upload pipeline.
|
||||
*/
|
||||
public function store(ArtworkCreateRequest $request, ArtworkDraftService $drafts)
|
||||
{
|
||||
$user = $request->user();
|
||||
$data = $request->validated();
|
||||
|
||||
$categoryId = isset($data['category']) && ctype_digit((string) $data['category'])
|
||||
? (int) $data['category']
|
||||
: null;
|
||||
|
||||
$result = $drafts->createDraft(
|
||||
$user,
|
||||
(string) $data['title'],
|
||||
isset($data['description']) ? (string) $data['description'] : null,
|
||||
$categoryId,
|
||||
(bool) ($data['is_mature'] ?? false),
|
||||
$data['group'] ?? null,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'artwork_id' => $result->artworkId,
|
||||
'status' => $result->status,
|
||||
], Response::HTTP_CREATED);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/artworks/{slug}
|
||||
* Returns a single public artwork resource by slug.
|
||||
*/
|
||||
public function show(string $slug)
|
||||
{
|
||||
$artwork = $this->service->getPublicArtworkBySlug($slug);
|
||||
|
||||
// Return the artwork instance (service already loads lightweight relations).
|
||||
// Log resolved resource for debugging failing test assertions.
|
||||
// Return the resolved payload directly to avoid JsonResource wrapping inconsistencies
|
||||
return response()->json((new ArtworkResource($artwork))->resolve(), 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/categories/{slug}/artworks
|
||||
* Uses route-model binding for Category (slug). Returns paginated list resource.
|
||||
*/
|
||||
public function categoryArtworks(Request $request, Category $category)
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 24);
|
||||
|
||||
$paginator = $this->service->getCategoryArtworks($category, $perPage);
|
||||
|
||||
return ArtworkListResource::collection($paginator);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkStatsService;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* POST /api/art/{id}/download
|
||||
*
|
||||
* Records a download event and returns the full-resolution download URL.
|
||||
*
|
||||
* Responsibilities:
|
||||
* 1. Validates the artwork is public and published.
|
||||
* 2. Inserts a row in artwork_downloads (artwork_id, user_id, ip, user_agent).
|
||||
* 3. Increments artwork_stats.downloads + forwards to creator stats.
|
||||
* 4. Returns {"ok": true, "url": "<download_url>"} so the frontend can
|
||||
* trigger the actual browser download.
|
||||
*
|
||||
* The frontend fires this POST on click, then uses the returned URL to
|
||||
* trigger the file download (or falls back to the pre-resolved URL it
|
||||
* already has).
|
||||
*/
|
||||
final class ArtworkDownloadController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ArtworkStatsService $stats) {}
|
||||
|
||||
public function __invoke(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$artwork = Artwork::public()
|
||||
->published()
|
||||
->with(['user:id'])
|
||||
->where('id', $id)
|
||||
->first();
|
||||
|
||||
if (! $artwork) {
|
||||
return response()->json(['error' => 'Not found'], 404);
|
||||
}
|
||||
|
||||
// Record the download event — non-blocking, errors are swallowed.
|
||||
$this->recordDownload($request, $artwork);
|
||||
|
||||
// Increment counters — deferred via Redis when available.
|
||||
try {
|
||||
$this->stats->incrementDownloads((int) $artwork->id, 1, defer: true);
|
||||
} catch (\Throwable) {
|
||||
// Stats failure must never interrupt the download.
|
||||
}
|
||||
|
||||
// Resolve the highest-resolution download URL available.
|
||||
$url = $this->resolveDownloadUrl($artwork);
|
||||
|
||||
// Build a user-friendly download filename: "title-slug.file_ext"
|
||||
$ext = $artwork->file_ext ?: $artwork->thumb_ext ?: 'webp';
|
||||
$slug = Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: (string) $artwork->id;
|
||||
$filename = $slug . '.' . $ext;
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'url' => $url,
|
||||
'filename' => $filename,
|
||||
'size' => (int) ($artwork->file_size ?? 0),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a row in artwork_downloads.
|
||||
* Uses a raw insert for the binary(16) IP column.
|
||||
* Silently ignores failures (analytics should never break user flow).
|
||||
*/
|
||||
private function recordDownload(Request $request, Artwork $artwork): void
|
||||
{
|
||||
try {
|
||||
$ip = $request->ip() ?? '0.0.0.0';
|
||||
$bin = @inet_pton($ip);
|
||||
|
||||
DB::table('artwork_downloads')->insert([
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $request->user()?->id,
|
||||
'ip' => $bin !== false ? $bin : null,
|
||||
'ip_address' => mb_substr((string) $ip, 0, 45),
|
||||
'user_agent' => mb_substr((string) $request->userAgent(), 0, 1024),
|
||||
'referer' => mb_substr((string) $request->headers->get('referer'), 0, 65535),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
} catch (\Throwable) {
|
||||
// Analytics failure must never interrupt the download.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the original full-resolution CDN URL.
|
||||
*
|
||||
* Originals are stored at: {cdn}/original/{h1}/{h2}/{hash}.{file_ext}
|
||||
* h1 = first 2 chars of hash, h2 = next 2 chars, filename = full hash + file_ext.
|
||||
* Falls back to XL → LG → MD thumbnail when hash is unavailable.
|
||||
*/
|
||||
private function resolveDownloadUrl(Artwork $artwork): string
|
||||
{
|
||||
$filePath = trim((string) ($artwork->file_path ?? ''), '/');
|
||||
$cdn = rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/');
|
||||
|
||||
if ($filePath !== '') {
|
||||
return $cdn . '/' . $filePath;
|
||||
}
|
||||
|
||||
$hash = $artwork->hash ?? null;
|
||||
$ext = ltrim((string) ($artwork->file_ext ?: $artwork->thumb_ext ?: 'webp'), '.');
|
||||
|
||||
if (!empty($hash)) {
|
||||
$h = strtolower(preg_replace('/[^a-f0-9]/', '', $hash));
|
||||
$h1 = substr($h, 0, 2);
|
||||
$h2 = substr($h, 2, 2);
|
||||
$prefix = trim((string) config('uploads.object_storage.prefix', 'artworks'), '/');
|
||||
|
||||
return sprintf('%s/%s/original/%s/%s/%s.%s', $cdn, $prefix, $h1, $h2, $h, $ext);
|
||||
}
|
||||
|
||||
// Fallback: best available thumbnail size
|
||||
foreach (['xl', 'lg', 'md'] as $size) {
|
||||
$thumb = ThumbnailPresenter::present($artwork, $size);
|
||||
if (!empty($thumb['url'])) {
|
||||
return (string) $thumb['url'];
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Events\Achievements\AchievementCheckRequested;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Notifications\ArtworkLikedNotification;
|
||||
use App\Services\FollowService;
|
||||
use App\Services\Activity\UserActivityService;
|
||||
use App\Services\UserStatsService;
|
||||
use App\Services\XPService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
final class ArtworkInteractionController extends Controller
|
||||
{
|
||||
public function bookmark(Request $request, int $artworkId): JsonResponse
|
||||
{
|
||||
$this->toggleSimple(
|
||||
request: $request,
|
||||
table: 'artwork_bookmarks',
|
||||
keyColumns: ['user_id', 'artwork_id'],
|
||||
keyValues: ['user_id' => (int) $request->user()->id, 'artwork_id' => $artworkId],
|
||||
insertPayload: ['created_at' => now(), 'updated_at' => now()],
|
||||
requiredTable: 'artwork_bookmarks'
|
||||
);
|
||||
|
||||
return response()->json($this->statusPayload((int) $request->user()->id, $artworkId));
|
||||
}
|
||||
|
||||
public function favorite(Request $request, int $artworkId): JsonResponse
|
||||
{
|
||||
$state = $request->boolean('state', true);
|
||||
|
||||
$changed = $this->toggleSimple(
|
||||
request: $request,
|
||||
table: 'artwork_favourites',
|
||||
keyColumns: ['user_id', 'artwork_id'],
|
||||
keyValues: ['user_id' => (int) $request->user()->id, 'artwork_id' => $artworkId],
|
||||
insertPayload: ['created_at' => now(), 'updated_at' => now()],
|
||||
requiredTable: 'artwork_favourites'
|
||||
);
|
||||
|
||||
$this->syncArtworkStats($artworkId);
|
||||
|
||||
// Update creator's favorites_received_count
|
||||
$creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id');
|
||||
if ($creatorId) {
|
||||
$svc = app(UserStatsService::class);
|
||||
if ($state && $changed) {
|
||||
$svc->incrementFavoritesReceived($creatorId);
|
||||
$svc->setLastActiveAt((int) $request->user()->id);
|
||||
|
||||
// Record activity event (new favourite only)
|
||||
try {
|
||||
\App\Models\ActivityEvent::record(
|
||||
actorId: (int) $request->user()->id,
|
||||
type: \App\Models\ActivityEvent::TYPE_FAVORITE,
|
||||
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
|
||||
targetId: $artworkId,
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
|
||||
try {
|
||||
app(UserActivityService::class)->logFavourite((int) $request->user()->id, $artworkId);
|
||||
} catch (\Throwable) {}
|
||||
} elseif (! $state && $changed) {
|
||||
$svc->decrementFavoritesReceived($creatorId);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json($this->statusPayload((int) $request->user()->id, $artworkId));
|
||||
}
|
||||
|
||||
public function like(Request $request, int $artworkId): JsonResponse
|
||||
{
|
||||
$changed = $this->toggleSimple(
|
||||
request: $request,
|
||||
table: 'artwork_likes',
|
||||
keyColumns: ['user_id', 'artwork_id'],
|
||||
keyValues: ['user_id' => (int) $request->user()->id, 'artwork_id' => $artworkId],
|
||||
insertPayload: ['created_at' => now(), 'updated_at' => now()],
|
||||
requiredTable: 'artwork_likes'
|
||||
);
|
||||
|
||||
$this->syncArtworkStats($artworkId);
|
||||
|
||||
if ($request->boolean('state', true) && $changed) {
|
||||
$creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id');
|
||||
$actorId = (int) $request->user()->id;
|
||||
try {
|
||||
app(UserActivityService::class)->logLike($actorId, $artworkId);
|
||||
} catch (\Throwable) {}
|
||||
if ($creatorId > 0 && $creatorId !== $actorId) {
|
||||
app(XPService::class)->awardArtworkLikeReceived($creatorId, $artworkId, $actorId);
|
||||
$creator = \App\Models\User::query()->find($creatorId);
|
||||
$artwork = Artwork::query()->find($artworkId);
|
||||
if ($creator && $artwork) {
|
||||
$creator->notify(new ArtworkLikedNotification($artwork, $request->user()));
|
||||
}
|
||||
event(new AchievementCheckRequested($creatorId));
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json($this->statusPayload((int) $request->user()->id, $artworkId));
|
||||
}
|
||||
|
||||
public function report(Request $request, int $artworkId): JsonResponse
|
||||
{
|
||||
if (! Schema::hasTable('artwork_reports')) {
|
||||
return response()->json(['message' => 'Reporting unavailable'], 422);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'reason' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
DB::table('artwork_reports')->updateOrInsert(
|
||||
[
|
||||
'artwork_id' => $artworkId,
|
||||
'reporter_user_id' => (int) $request->user()->id,
|
||||
],
|
||||
[
|
||||
'reason' => trim((string) ($data['reason'] ?? '')) ?: null,
|
||||
'reported_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]
|
||||
);
|
||||
|
||||
return response()->json(['ok' => true, 'reported' => true]);
|
||||
}
|
||||
|
||||
public function follow(Request $request, int $userId): JsonResponse
|
||||
{
|
||||
$actorId = (int) $request->user()->id;
|
||||
|
||||
if ($actorId === $userId) {
|
||||
return response()->json(['message' => 'Cannot follow yourself'], 422);
|
||||
}
|
||||
|
||||
$svc = app(FollowService::class);
|
||||
$state = $request->has('state')
|
||||
? $request->boolean('state')
|
||||
: ! $request->isMethod('delete');
|
||||
|
||||
if ($state) {
|
||||
$svc->follow($actorId, $userId);
|
||||
} else {
|
||||
$svc->unfollow($actorId, $userId);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'is_following' => $state,
|
||||
'followers_count' => $svc->followersCount($userId),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/artworks/{id}/share — record a share event (Phase 2 tracking).
|
||||
*/
|
||||
public function share(Request $request, int $artworkId): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'platform' => ['required', 'string', 'in:facebook,twitter,pinterest,email,copy,embed'],
|
||||
]);
|
||||
|
||||
if (Schema::hasTable('artwork_shares')) {
|
||||
DB::table('artwork_shares')->insert([
|
||||
'artwork_id' => $artworkId,
|
||||
'user_id' => $request->user()?->id,
|
||||
'platform' => $data['platform'],
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
private function toggleSimple(
|
||||
Request $request,
|
||||
string $table,
|
||||
array $keyColumns,
|
||||
array $keyValues,
|
||||
array $insertPayload,
|
||||
string $requiredTable
|
||||
): bool {
|
||||
if (! Schema::hasTable($requiredTable)) {
|
||||
abort(422, 'Interaction unavailable');
|
||||
}
|
||||
|
||||
$state = $request->boolean('state', true);
|
||||
|
||||
$query = DB::table($table);
|
||||
foreach ($keyColumns as $column) {
|
||||
$query->where($column, $keyValues[$column]);
|
||||
}
|
||||
|
||||
if ($state) {
|
||||
if (! $query->exists()) {
|
||||
DB::table($table)->insert(array_merge($keyValues, $insertPayload));
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
return $query->delete() > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function syncArtworkStats(int $artworkId): void
|
||||
{
|
||||
if (! Schema::hasTable('artwork_stats')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$favorites = Schema::hasTable('artwork_favourites')
|
||||
? (int) DB::table('artwork_favourites')->where('artwork_id', $artworkId)->count()
|
||||
: 0;
|
||||
|
||||
$likes = Schema::hasTable('artwork_likes')
|
||||
? (int) DB::table('artwork_likes')->where('artwork_id', $artworkId)->count()
|
||||
: 0;
|
||||
|
||||
DB::table('artwork_stats')->updateOrInsert(
|
||||
['artwork_id' => $artworkId],
|
||||
[
|
||||
'favorites' => $favorites,
|
||||
'rating_count' => $likes,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function statusPayload(int $viewerId, int $artworkId): array
|
||||
{
|
||||
$isBookmarked = Schema::hasTable('artwork_bookmarks')
|
||||
? DB::table('artwork_bookmarks')->where('user_id', $viewerId)->where('artwork_id', $artworkId)->exists()
|
||||
: false;
|
||||
|
||||
$isFavorited = Schema::hasTable('artwork_favourites')
|
||||
? DB::table('artwork_favourites')->where('user_id', $viewerId)->where('artwork_id', $artworkId)->exists()
|
||||
: false;
|
||||
|
||||
$isLiked = Schema::hasTable('artwork_likes')
|
||||
? DB::table('artwork_likes')->where('user_id', $viewerId)->where('artwork_id', $artworkId)->exists()
|
||||
: false;
|
||||
|
||||
$favorites = Schema::hasTable('artwork_favourites')
|
||||
? (int) DB::table('artwork_favourites')->where('artwork_id', $artworkId)->count()
|
||||
: 0;
|
||||
|
||||
$bookmarks = Schema::hasTable('artwork_bookmarks')
|
||||
? (int) DB::table('artwork_bookmarks')->where('artwork_id', $artworkId)->count()
|
||||
: 0;
|
||||
|
||||
$likes = Schema::hasTable('artwork_likes')
|
||||
? (int) DB::table('artwork_likes')->where('artwork_id', $artworkId)->count()
|
||||
: 0;
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'is_bookmarked' => $isBookmarked,
|
||||
'is_favorited' => $isFavorited,
|
||||
'is_liked' => $isLiked,
|
||||
'stats' => [
|
||||
'bookmarks' => $bookmarks,
|
||||
'favorites' => $favorites,
|
||||
'likes' => $likes,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\ArtworkResource;
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ArtworkNavigationController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /api/artworks/navigation/{id}
|
||||
*
|
||||
* Returns prev/next published artworks by the same author.
|
||||
*/
|
||||
public function neighbors(int $id): JsonResponse
|
||||
{
|
||||
$artwork = Artwork::published()
|
||||
->select(['id', 'user_id', 'title', 'slug'])
|
||||
->find($id);
|
||||
|
||||
if (! $artwork) {
|
||||
return response()->json([
|
||||
'prev_id' => null, 'next_id' => null,
|
||||
'prev_url' => null, 'next_url' => null,
|
||||
'prev_slug' => null, 'next_slug' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$scope = Artwork::published()
|
||||
->select(['id', 'title', 'slug'])
|
||||
->where('user_id', $artwork->user_id);
|
||||
|
||||
$prev = (clone $scope)->where('id', '<', $id)->orderByDesc('id')->first();
|
||||
$next = (clone $scope)->where('id', '>', $id)->orderBy('id')->first();
|
||||
|
||||
// Infinite loop: wrap around when reaching the first or last artwork
|
||||
if (! $prev) {
|
||||
$prev = (clone $scope)->where('id', '!=', $id)->orderByDesc('id')->first();
|
||||
}
|
||||
if (! $next) {
|
||||
$next = (clone $scope)->where('id', '!=', $id)->orderBy('id')->first();
|
||||
}
|
||||
|
||||
$prevSlug = $prev ? (Str::slug($prev->slug ?: $prev->title) ?: (string) $prev->id) : null;
|
||||
$nextSlug = $next ? (Str::slug($next->slug ?: $next->title) ?: (string) $next->id) : null;
|
||||
|
||||
return response()->json([
|
||||
'prev_id' => $prev?->id,
|
||||
'next_id' => $next?->id,
|
||||
'prev_url' => $prev ? url('/art/' . $prev->id . '/' . $prevSlug) : null,
|
||||
'next_url' => $next ? url('/art/' . $next->id . '/' . $nextSlug) : null,
|
||||
'prev_slug' => $prevSlug,
|
||||
'next_slug' => $nextSlug,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/artworks/{id}/page
|
||||
*
|
||||
* Returns full artwork resource by numeric ID for client-side (no-reload) navigation.
|
||||
*/
|
||||
public function pageData(int $id): JsonResponse
|
||||
{
|
||||
$artwork = Artwork::with(['user.profile', 'categories.contentType', 'categories.parent.contentType', 'tags', 'stats'])
|
||||
->published()
|
||||
->find($id);
|
||||
|
||||
if (! $artwork) {
|
||||
return response()->json(['error' => 'Not found'], 404);
|
||||
}
|
||||
|
||||
$resource = (new ArtworkResource($artwork))->toArray(request());
|
||||
|
||||
return response()->json($resource);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Artworks\ArtworkTagsStoreRequest;
|
||||
use App\Http\Requests\Artworks\ArtworkTagsUpdateRequest;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Tag;
|
||||
use App\Services\TagService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use App\Jobs\AutoTagArtworkJob;
|
||||
|
||||
final class ArtworkTagController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TagService $tags,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(int $id): JsonResponse
|
||||
{
|
||||
$artwork = Artwork::query()->findOrFail($id);
|
||||
$this->authorizeOrNotFound(request()->user(), $artwork);
|
||||
|
||||
$queueConnection = (string) config('queue.default', 'sync');
|
||||
$visionEnabled = (bool) config('vision.enabled', true);
|
||||
|
||||
$queuedCount = 0;
|
||||
$failedCount = 0;
|
||||
|
||||
if (in_array($queueConnection, ['database', 'redis'], true)) {
|
||||
try {
|
||||
$queuedCount = (int) DB::table('jobs')
|
||||
->where('payload', 'like', '%AutoTagArtworkJob%')
|
||||
->where('payload', 'like', '%' . $artwork->id . '%')
|
||||
->count();
|
||||
} catch (\Throwable) {
|
||||
$queuedCount = 0;
|
||||
}
|
||||
|
||||
try {
|
||||
$failedCount = (int) DB::table('failed_jobs')
|
||||
->where('payload', 'like', '%AutoTagArtworkJob%')
|
||||
->where('payload', 'like', '%' . $artwork->id . '%')
|
||||
->count();
|
||||
} catch (\Throwable) {
|
||||
$failedCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
$triggered = false;
|
||||
$shouldTrigger = request()->boolean('trigger', false);
|
||||
if ($shouldTrigger && $visionEnabled && ! empty($artwork->hash) && $queuedCount === 0) {
|
||||
AutoTagArtworkJob::dispatch((int) $artwork->id, (string) $artwork->hash);
|
||||
$triggered = true;
|
||||
$queuedCount = max(1, $queuedCount);
|
||||
}
|
||||
|
||||
$tags = $artwork->tags()
|
||||
->select('tags.id', 'tags.name', 'tags.slug')
|
||||
->withPivot(['source', 'confidence'])
|
||||
->orderByDesc('artwork_tag.confidence')
|
||||
->get()
|
||||
->map(static function ($tag): array {
|
||||
$source = (string) ($tag->pivot->source ?? 'manual');
|
||||
return [
|
||||
'id' => (int) $tag->id,
|
||||
'name' => (string) $tag->name,
|
||||
'slug' => (string) $tag->slug,
|
||||
'source' => $source,
|
||||
'confidence' => (float) ($tag->pivot->confidence ?? 0),
|
||||
'is_ai' => $source === 'ai',
|
||||
];
|
||||
})
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'vision_enabled' => $visionEnabled,
|
||||
'tags' => $tags,
|
||||
'ai_tags' => $tags->where('is_ai', true)->values(),
|
||||
'debug' => [
|
||||
'queue_connection' => $queueConnection,
|
||||
'queued_jobs' => $queuedCount,
|
||||
'failed_jobs' => $failedCount,
|
||||
'triggered' => $triggered,
|
||||
'ai_tag_count' => (int) $tags->where('is_ai', true)->count(),
|
||||
'total_tag_count' => (int) $tags->count(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(int $id, ArtworkTagsStoreRequest $request): JsonResponse
|
||||
{
|
||||
$artwork = Artwork::query()->findOrFail($id);
|
||||
$this->authorizeOrNotFound($request->user(), $artwork);
|
||||
|
||||
try {
|
||||
$payload = $request->validated();
|
||||
$this->tags->attachUserTags($artwork, $payload['tags']);
|
||||
|
||||
return response()->json(['ok' => true], Response::HTTP_CREATED);
|
||||
} catch (\Throwable $e) {
|
||||
$ref = (string) Str::uuid();
|
||||
logger()->error('Artwork tag attach failed', ['ref' => $ref, 'artwork_id' => $artwork->id, 'user_id' => $request->user()?->id, 'exception' => $e]);
|
||||
return response()->json([
|
||||
'message' => 'Unable to update tags right now.',
|
||||
'ref' => $ref,
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
}
|
||||
|
||||
public function update(int $id, ArtworkTagsUpdateRequest $request): JsonResponse
|
||||
{
|
||||
$artwork = Artwork::query()->findOrFail($id);
|
||||
$this->authorizeOrNotFound($request->user(), $artwork);
|
||||
|
||||
try {
|
||||
$payload = $request->validated();
|
||||
$this->tags->syncTags($artwork, $payload['tags']);
|
||||
return response()->json(['ok' => true]);
|
||||
} catch (\Throwable $e) {
|
||||
$ref = (string) Str::uuid();
|
||||
logger()->error('Artwork tag sync failed', ['ref' => $ref, 'artwork_id' => $artwork->id, 'user_id' => $request->user()?->id, 'exception' => $e]);
|
||||
return response()->json([
|
||||
'message' => 'Unable to update tags right now.',
|
||||
'ref' => $ref,
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
}
|
||||
|
||||
public function destroy(int $id, Tag $tag): JsonResponse
|
||||
{
|
||||
$artwork = Artwork::query()->findOrFail($id);
|
||||
$this->authorizeOrNotFound(request()->user(), $artwork);
|
||||
|
||||
try {
|
||||
$this->tags->detachTags($artwork, [$tag->id]);
|
||||
return response()->json(['ok' => true]);
|
||||
} catch (\Throwable $e) {
|
||||
$ref = (string) Str::uuid();
|
||||
logger()->error('Artwork tag detach failed', ['ref' => $ref, 'artwork_id' => $artwork->id, 'tag_id' => $tag->id, 'user_id' => request()->user()?->id, 'exception' => $e]);
|
||||
return response()->json([
|
||||
'message' => 'Unable to update tags right now.',
|
||||
'ref' => $ref,
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
}
|
||||
|
||||
private function authorizeOrNotFound($user, Artwork $artwork): void
|
||||
{
|
||||
if (! $user) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user->can('updateTags', $artwork)) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkStatsService;
|
||||
use App\Services\XPService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* POST /api/art/{id}/view
|
||||
*
|
||||
* Fire-and-forget view tracker.
|
||||
*
|
||||
* Deduplication strategy (layered):
|
||||
* 1. Session key (`art_viewed.{id}`) — prevents double-counts within the
|
||||
* same browser session (survives page reloads).
|
||||
* 2. Route throttle (5 per 10 minutes per IP+artwork) — catches bots that
|
||||
* don't send session cookies.
|
||||
*
|
||||
* The frontend should additionally guard with sessionStorage so it only
|
||||
* calls this endpoint once per page load.
|
||||
*/
|
||||
final class ArtworkViewController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ArtworkStatsService $stats,
|
||||
private readonly XPService $xp,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$artwork = Artwork::public()
|
||||
->published()
|
||||
->where('id', $id)
|
||||
->first();
|
||||
|
||||
if (! $artwork) {
|
||||
return response()->json(['error' => 'Not found'], 404);
|
||||
}
|
||||
|
||||
$sessionKey = 'art_viewed.' . $id;
|
||||
|
||||
// Already counted this session — return early without touching the DB.
|
||||
if ($request->hasSession() && $request->session()->has($sessionKey)) {
|
||||
return response()->json(['ok' => true, 'counted' => false]);
|
||||
}
|
||||
|
||||
// Write persistent event log (auth user_id or null for guests).
|
||||
$this->stats->logViewEvent((int) $artwork->id, $request->user()?->id);
|
||||
|
||||
// Defer to Redis when available, fall back to direct DB increment.
|
||||
$this->stats->incrementViews((int) $artwork->id, 1, defer: true);
|
||||
|
||||
$viewerId = $request->user()?->id;
|
||||
if ($artwork->user_id !== null && (int) $artwork->user_id !== (int) ($viewerId ?? 0)) {
|
||||
$this->xp->awardArtworkViewReceived(
|
||||
(int) $artwork->user_id,
|
||||
(int) $artwork->id,
|
||||
$viewerId,
|
||||
(string) $request->ip(),
|
||||
);
|
||||
}
|
||||
|
||||
// Mark this session so the artwork is not counted again.
|
||||
if ($request->hasSession()) {
|
||||
$request->session()->put($sessionKey, true);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true, 'counted' => true]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\ArtworkListResource;
|
||||
use App\Services\ArtworkService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
|
||||
class BrowseController extends Controller
|
||||
{
|
||||
protected ArtworkService $service;
|
||||
|
||||
public function __construct(ArtworkService $service)
|
||||
{
|
||||
$this->service = $service;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/browse
|
||||
* Public browse feed powered by authoritative artworks table.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$perPage = $this->resolvePerPage($request);
|
||||
$sort = (string) $request->get('sort', 'latest');
|
||||
|
||||
$paginator = $this->service->browsePublicArtworks($perPage, $sort);
|
||||
$paginator->appends([
|
||||
'limit' => $perPage,
|
||||
'sort' => $sort,
|
||||
]);
|
||||
|
||||
return ArtworkListResource::collection($paginator);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/browse/{content_type}
|
||||
* Browse by content type slug.
|
||||
*/
|
||||
public function byContentType(Request $request, string $contentTypeSlug)
|
||||
{
|
||||
$perPage = $this->resolvePerPage($request);
|
||||
$sort = (string) $request->get('sort', 'latest');
|
||||
|
||||
try {
|
||||
$paginator = $this->service->getArtworksByContentType($contentTypeSlug, $perPage, $sort);
|
||||
} catch (ModelNotFoundException $e) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$paginator->appends([
|
||||
'limit' => $perPage,
|
||||
'sort' => $sort,
|
||||
]);
|
||||
|
||||
if ($paginator->count() === 0) {
|
||||
return response()->json(['message' => 'Gone'], 410);
|
||||
}
|
||||
|
||||
return ArtworkListResource::collection($paginator);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/browse/{content_type}/{category_path}
|
||||
* Browse by content type + category path (slug segments).
|
||||
*/
|
||||
public function byCategoryPath(Request $request, string $contentTypeSlug, string $categoryPath)
|
||||
{
|
||||
$perPage = $this->resolvePerPage($request);
|
||||
$sort = (string) $request->get('sort', 'latest');
|
||||
|
||||
$slugs = array_merge([
|
||||
strtolower($contentTypeSlug),
|
||||
], array_values(array_filter(explode('/', trim($categoryPath, '/')))));
|
||||
|
||||
try {
|
||||
$paginator = $this->service->getArtworksByCategoryPath($slugs, $perPage, $sort);
|
||||
} catch (ModelNotFoundException $e) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$paginator->appends([
|
||||
'limit' => $perPage,
|
||||
'sort' => $sort,
|
||||
]);
|
||||
|
||||
if ($paginator->count() === 0) {
|
||||
return response()->json(['message' => 'Gone'], 410);
|
||||
}
|
||||
|
||||
return ArtworkListResource::collection($paginator);
|
||||
}
|
||||
|
||||
private function resolvePerPage(Request $request): int
|
||||
{
|
||||
$limit = (int) $request->query('limit', 0);
|
||||
$perPage = (int) $request->query('per_page', 0);
|
||||
|
||||
$value = $limit > 0 ? $limit : ($perPage > 0 ? $perPage : 24);
|
||||
|
||||
return min(max($value, 1), 100);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\CommunityActivityService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class CommunityActivityController extends Controller
|
||||
{
|
||||
public function __construct(private readonly CommunityActivityService $activityService)
|
||||
{
|
||||
}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$filter = $this->resolveFilter($request);
|
||||
|
||||
if ($this->activityService->requiresAuthentication($filter) && ! $request->user()) {
|
||||
return response()->json(['error' => 'Unauthenticated'], 401);
|
||||
}
|
||||
|
||||
$feed = $this->activityService->getFeed(
|
||||
viewer: $request->user(),
|
||||
filter: $filter,
|
||||
page: (int) $request->query('page', 1),
|
||||
perPage: (int) $request->query('per_page', CommunityActivityService::DEFAULT_PER_PAGE),
|
||||
actorUserId: $request->filled('user_id') ? (int) $request->query('user_id') : null,
|
||||
);
|
||||
|
||||
return response()->json($feed);
|
||||
}
|
||||
|
||||
private function resolveFilter(Request $request): string
|
||||
{
|
||||
if ($request->filled('type') && ! $request->filled('filter')) {
|
||||
return (string) $request->query('type', 'all');
|
||||
}
|
||||
|
||||
if ($request->boolean('following') && ! $request->filled('filter')) {
|
||||
return 'following';
|
||||
}
|
||||
|
||||
return (string) $request->query('filter', 'all');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\IngestUserDiscoveryEventJob;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class DiscoveryEventController extends Controller
|
||||
{
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'event_id' => ['nullable', 'uuid'],
|
||||
'event_type' => ['required', 'string', 'in:view,click,favorite,download,dwell,scroll'],
|
||||
'artwork_id' => ['required', 'integer', 'exists:artworks,id'],
|
||||
'occurred_at' => ['nullable', 'date'],
|
||||
'algo_version' => ['nullable', 'string', 'max:64'],
|
||||
'meta' => ['nullable', 'array'],
|
||||
]);
|
||||
|
||||
$eventId = (string) ($payload['event_id'] ?? (string) Str::uuid());
|
||||
$algoVersion = (string) ($payload['algo_version'] ?? config('discovery.algo_version', 'clip-cosine-v1'));
|
||||
$occurredAt = isset($payload['occurred_at'])
|
||||
? (string) $payload['occurred_at']
|
||||
: now()->toIso8601String();
|
||||
|
||||
IngestUserDiscoveryEventJob::dispatch(
|
||||
eventId: $eventId,
|
||||
userId: (int) $request->user()->id,
|
||||
artworkId: (int) $payload['artwork_id'],
|
||||
eventType: (string) $payload['event_type'],
|
||||
algoVersion: $algoVersion,
|
||||
occurredAt: $occurredAt,
|
||||
meta: (array) ($payload['meta'] ?? [])
|
||||
)->onQueue((string) config('discovery.queue', 'default'));
|
||||
|
||||
return response()->json([
|
||||
'queued' => true,
|
||||
'event_id' => $eventId,
|
||||
'algo_version' => $algoVersion,
|
||||
], Response::HTTP_ACCEPTED);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tag;
|
||||
use App\Models\UserNegativeSignal;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class DiscoveryNegativeSignalController extends Controller
|
||||
{
|
||||
public function hideArtwork(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'artwork_id' => ['required', 'integer', 'exists:artworks,id'],
|
||||
'algo_version' => ['nullable', 'string', 'max:64'],
|
||||
'source' => ['nullable', 'string', 'max:64'],
|
||||
'meta' => ['nullable', 'array'],
|
||||
]);
|
||||
|
||||
$signal = UserNegativeSignal::query()->updateOrCreate(
|
||||
[
|
||||
'user_id' => (int) $request->user()->id,
|
||||
'signal_type' => 'hide_artwork',
|
||||
'artwork_id' => (int) $payload['artwork_id'],
|
||||
],
|
||||
[
|
||||
'tag_id' => null,
|
||||
'algo_version' => $payload['algo_version'] ?? null,
|
||||
'source' => $payload['source'] ?? 'api',
|
||||
'meta' => (array) ($payload['meta'] ?? []),
|
||||
]
|
||||
);
|
||||
|
||||
$this->recordFeedbackEvent(
|
||||
userId: (int) $request->user()->id,
|
||||
artworkId: (int) $payload['artwork_id'],
|
||||
eventType: 'hide_artwork',
|
||||
algoVersion: isset($payload['algo_version']) ? (string) $payload['algo_version'] : null,
|
||||
meta: (array) ($payload['meta'] ?? [])
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'stored' => true,
|
||||
'signal_id' => (int) $signal->id,
|
||||
'signal_type' => 'hide_artwork',
|
||||
], Response::HTTP_ACCEPTED);
|
||||
}
|
||||
|
||||
public function dislikeTag(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'tag_id' => ['nullable', 'integer', 'exists:tags,id'],
|
||||
'tag_slug' => ['nullable', 'string', 'max:191'],
|
||||
'artwork_id' => ['nullable', 'integer', 'exists:artworks,id'],
|
||||
'algo_version' => ['nullable', 'string', 'max:64'],
|
||||
'source' => ['nullable', 'string', 'max:64'],
|
||||
'meta' => ['nullable', 'array'],
|
||||
]);
|
||||
|
||||
$tagId = isset($payload['tag_id']) ? (int) $payload['tag_id'] : null;
|
||||
if ($tagId === null && ! empty($payload['tag_slug'])) {
|
||||
$tagId = Tag::query()->where('slug', (string) $payload['tag_slug'])->value('id');
|
||||
}
|
||||
|
||||
abort_if($tagId === null || $tagId <= 0, Response::HTTP_UNPROCESSABLE_ENTITY, 'A valid tag is required.');
|
||||
|
||||
$signal = UserNegativeSignal::query()->updateOrCreate(
|
||||
[
|
||||
'user_id' => (int) $request->user()->id,
|
||||
'signal_type' => 'dislike_tag',
|
||||
'tag_id' => $tagId,
|
||||
],
|
||||
[
|
||||
'artwork_id' => null,
|
||||
'algo_version' => $payload['algo_version'] ?? null,
|
||||
'source' => $payload['source'] ?? 'api',
|
||||
'meta' => (array) ($payload['meta'] ?? []),
|
||||
]
|
||||
);
|
||||
|
||||
$this->recordFeedbackEvent(
|
||||
userId: (int) $request->user()->id,
|
||||
artworkId: isset($payload['artwork_id']) ? (int) $payload['artwork_id'] : (int) (($payload['meta']['artwork_id'] ?? 0)),
|
||||
eventType: 'dislike_tag',
|
||||
algoVersion: isset($payload['algo_version']) ? (string) $payload['algo_version'] : null,
|
||||
meta: array_merge((array) ($payload['meta'] ?? []), ['tag_id' => $tagId])
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'stored' => true,
|
||||
'signal_id' => (int) $signal->id,
|
||||
'signal_type' => 'dislike_tag',
|
||||
'tag_id' => $tagId,
|
||||
], Response::HTTP_ACCEPTED);
|
||||
}
|
||||
|
||||
public function unhideArtwork(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'artwork_id' => ['required', 'integer', 'exists:artworks,id'],
|
||||
'algo_version' => ['nullable', 'string', 'max:64'],
|
||||
'meta' => ['nullable', 'array'],
|
||||
]);
|
||||
|
||||
$deleted = UserNegativeSignal::query()
|
||||
->where('user_id', (int) $request->user()->id)
|
||||
->where('signal_type', 'hide_artwork')
|
||||
->where('artwork_id', (int) $payload['artwork_id'])
|
||||
->delete();
|
||||
|
||||
if ($deleted > 0) {
|
||||
$this->recordFeedbackEvent(
|
||||
userId: (int) $request->user()->id,
|
||||
artworkId: (int) $payload['artwork_id'],
|
||||
eventType: 'unhide_artwork',
|
||||
algoVersion: isset($payload['algo_version']) ? (string) $payload['algo_version'] : null,
|
||||
meta: (array) ($payload['meta'] ?? [])
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'revoked' => $deleted > 0,
|
||||
'signal_type' => 'hide_artwork',
|
||||
'artwork_id' => (int) $payload['artwork_id'],
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
public function undislikeTag(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'tag_id' => ['nullable', 'integer', 'exists:tags,id'],
|
||||
'tag_slug' => ['nullable', 'string', 'max:191'],
|
||||
'artwork_id' => ['nullable', 'integer', 'exists:artworks,id'],
|
||||
'algo_version' => ['nullable', 'string', 'max:64'],
|
||||
'meta' => ['nullable', 'array'],
|
||||
]);
|
||||
|
||||
$tagId = isset($payload['tag_id']) ? (int) $payload['tag_id'] : null;
|
||||
if ($tagId === null && ! empty($payload['tag_slug'])) {
|
||||
$tagId = Tag::query()->where('slug', (string) $payload['tag_slug'])->value('id');
|
||||
}
|
||||
|
||||
abort_if($tagId === null || $tagId <= 0, Response::HTTP_UNPROCESSABLE_ENTITY, 'A valid tag is required.');
|
||||
|
||||
$deleted = UserNegativeSignal::query()
|
||||
->where('user_id', (int) $request->user()->id)
|
||||
->where('signal_type', 'dislike_tag')
|
||||
->where('tag_id', $tagId)
|
||||
->delete();
|
||||
|
||||
if ($deleted > 0) {
|
||||
$this->recordFeedbackEvent(
|
||||
userId: (int) $request->user()->id,
|
||||
artworkId: isset($payload['artwork_id']) ? (int) $payload['artwork_id'] : (int) (($payload['meta']['artwork_id'] ?? 0)),
|
||||
eventType: 'undo_dislike_tag',
|
||||
algoVersion: isset($payload['algo_version']) ? (string) $payload['algo_version'] : null,
|
||||
meta: array_merge((array) ($payload['meta'] ?? []), ['tag_id' => $tagId])
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'revoked' => $deleted > 0,
|
||||
'signal_type' => 'dislike_tag',
|
||||
'tag_id' => $tagId,
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
private function recordFeedbackEvent(int $userId, int $artworkId, string $eventType, ?string $algoVersion = null, array $meta = []): void
|
||||
{
|
||||
if ($artworkId <= 0 || ! Schema::hasTable('user_discovery_events')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$categoryId = DB::table('artwork_category')
|
||||
->where('artwork_id', $artworkId)
|
||||
->orderBy('category_id')
|
||||
->value('category_id');
|
||||
|
||||
DB::table('user_discovery_events')->insert([
|
||||
'event_id' => (string) Str::uuid(),
|
||||
'user_id' => $userId,
|
||||
'artwork_id' => $artworkId,
|
||||
'category_id' => $categoryId !== null ? (int) $categoryId : null,
|
||||
'event_type' => $eventType,
|
||||
'event_version' => (string) config('discovery.event_version', 'event-v1'),
|
||||
'algo_version' => (string) ($algoVersion ?: config('discovery.v2.algo_version', config('discovery.algo_version', 'clip-cosine-v1'))),
|
||||
'weight' => 0.0,
|
||||
'event_date' => now()->toDateString(),
|
||||
'occurred_at' => now()->toDateTimeString(),
|
||||
'meta' => json_encode($meta, JSON_THROW_ON_ERROR),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class FeedAnalyticsController extends Controller
|
||||
{
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'event_type' => ['required', 'string', 'in:feed_impression,feed_click'],
|
||||
'artwork_id' => ['required', 'integer', 'exists:artworks,id'],
|
||||
'position' => ['nullable', 'integer', 'min:1', 'max:500'],
|
||||
'algo_version' => ['required', 'string', 'max:64'],
|
||||
'source' => ['required', 'string', 'in:personalized,cold_start,fallback'],
|
||||
'dwell_seconds' => ['nullable', 'integer', 'min:0', 'max:86400'],
|
||||
'occurred_at' => ['nullable', 'date'],
|
||||
]);
|
||||
|
||||
$occurredAt = isset($payload['occurred_at']) ? now()->parse((string) $payload['occurred_at']) : now();
|
||||
|
||||
DB::table('feed_events')->insert([
|
||||
'event_date' => $occurredAt->toDateString(),
|
||||
'event_type' => (string) $payload['event_type'],
|
||||
'user_id' => (int) $request->user()->id,
|
||||
'artwork_id' => (int) $payload['artwork_id'],
|
||||
'position' => isset($payload['position']) ? (int) $payload['position'] : null,
|
||||
'algo_version' => (string) $payload['algo_version'],
|
||||
'source' => (string) $payload['source'],
|
||||
'dwell_seconds' => isset($payload['dwell_seconds']) ? (int) $payload['dwell_seconds'] : null,
|
||||
'occurred_at' => $occurredAt,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return response()->json(['success' => true], Response::HTTP_OK);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Recommendations\RecommendationFeedResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class FeedController extends Controller
|
||||
{
|
||||
public function __construct(private readonly RecommendationFeedResolver $feedResolver)
|
||||
{
|
||||
}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'limit' => ['nullable', 'integer', 'min:1', 'max:50'],
|
||||
'cursor' => ['nullable', 'string', 'max:512'],
|
||||
'algo_version' => ['nullable', 'string', 'max:64'],
|
||||
]);
|
||||
|
||||
$result = $this->feedResolver->getFeed(
|
||||
userId: (int) $request->user()->id,
|
||||
limit: isset($payload['limit']) ? (int) $payload['limit'] : 24,
|
||||
cursor: isset($payload['cursor']) ? (string) $payload['cursor'] : null,
|
||||
algoVersion: isset($payload['algo_version']) ? (string) $payload['algo_version'] : null
|
||||
);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Services\FollowService;
|
||||
use App\Support\AvatarUrl;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* API endpoints for the follow system.
|
||||
*
|
||||
* POST /api/user/{username}/follow → follow a user
|
||||
* DELETE /api/user/{username}/follow → unfollow a user
|
||||
* GET /api/user/{username}/followers → paginated followers list
|
||||
* GET /api/user/{username}/following → paginated following list
|
||||
*/
|
||||
final class FollowController extends Controller
|
||||
{
|
||||
public function __construct(private readonly FollowService $followService) {}
|
||||
|
||||
// ─── POST /api/user/{username}/follow ────────────────────────────────────
|
||||
|
||||
public function follow(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$target = $this->resolveUser($username);
|
||||
$actor = Auth::user();
|
||||
|
||||
if ($actor->id === $target->id) {
|
||||
return response()->json(['error' => 'Cannot follow yourself.'], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->followService->follow((int) $actor->id, (int) $target->id);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json(['error' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'following' => true,
|
||||
'followers_count' => $this->followService->followersCount((int) $target->id),
|
||||
'following_count' => $this->followService->followingCount((int) $actor->id),
|
||||
'context' => $this->followService->relationshipContext((int) $actor->id, (int) $target->id),
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── DELETE /api/user/{username}/follow ──────────────────────────────────
|
||||
|
||||
public function unfollow(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$target = $this->resolveUser($username);
|
||||
$actor = Auth::user();
|
||||
|
||||
$this->followService->unfollow((int) $actor->id, (int) $target->id);
|
||||
|
||||
return response()->json([
|
||||
'following' => false,
|
||||
'followers_count' => $this->followService->followersCount((int) $target->id),
|
||||
'following_count' => $this->followService->followingCount((int) $actor->id),
|
||||
'context' => $this->followService->relationshipContext((int) $actor->id, (int) $target->id),
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── GET /api/user/{username}/followers ──────────────────────────────────
|
||||
|
||||
public function followers(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$target = $this->resolveUser($username);
|
||||
$perPage = min((int) $request->query('per_page', 24), 100);
|
||||
|
||||
$rows = DB::table('user_followers as uf')
|
||||
->join('users as u', 'u.id', '=', 'uf.follower_id')
|
||||
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
||||
->where('uf.user_id', $target->id)
|
||||
->whereNull('u.deleted_at')
|
||||
->orderByDesc('uf.created_at')
|
||||
->select([
|
||||
'u.id', 'u.username', 'u.name',
|
||||
'up.avatar_hash',
|
||||
'uf.created_at as followed_at',
|
||||
])
|
||||
->paginate($perPage)
|
||||
->through(fn ($row) => [
|
||||
'id' => $row->id,
|
||||
'username' => $row->username,
|
||||
'display_name'=> $row->username ?? $row->name,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50),
|
||||
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
|
||||
'followed_at' => $row->followed_at,
|
||||
]);
|
||||
|
||||
return response()->json($rows);
|
||||
}
|
||||
|
||||
// ─── GET /api/user/{username}/following ──────────────────────────────────
|
||||
|
||||
public function following(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$target = $this->resolveUser($username);
|
||||
$perPage = min((int) $request->query('per_page', 24), 100);
|
||||
|
||||
$rows = DB::table('user_followers as uf')
|
||||
->join('users as u', 'u.id', '=', 'uf.user_id')
|
||||
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
||||
->where('uf.follower_id', $target->id)
|
||||
->whereNull('u.deleted_at')
|
||||
->orderByDesc('uf.created_at')
|
||||
->select([
|
||||
'u.id', 'u.username', 'u.name',
|
||||
'up.avatar_hash',
|
||||
'uf.created_at as followed_at',
|
||||
])
|
||||
->paginate($perPage)
|
||||
->through(fn ($row) => [
|
||||
'id' => $row->id,
|
||||
'username' => $row->username,
|
||||
'display_name'=> $row->username ?? $row->name,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50),
|
||||
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
|
||||
'followed_at' => $row->followed_at,
|
||||
]);
|
||||
|
||||
return response()->json($rows);
|
||||
}
|
||||
|
||||
// ─── Private helpers ─────────────────────────────────────────────────────
|
||||
|
||||
private function resolveUser(string $username): User
|
||||
{
|
||||
$normalized = UsernamePolicy::normalize($username);
|
||||
|
||||
return User::query()
|
||||
->whereRaw('LOWER(username) = ?', [$normalized])
|
||||
->firstOrFail();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Vision\VectorService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use RuntimeException;
|
||||
|
||||
final class ImageSearchController extends Controller
|
||||
{
|
||||
public function __construct(private readonly VectorService $vectors)
|
||||
{
|
||||
}
|
||||
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'image' => ['required', 'file', 'image', 'max:10240'],
|
||||
'limit' => ['nullable', 'integer', 'min:1', 'max:24'],
|
||||
]);
|
||||
|
||||
if (! $this->vectors->isConfigured()) {
|
||||
return response()->json([
|
||||
'data' => [],
|
||||
'reason' => 'vector_gateway_not_configured',
|
||||
], 503);
|
||||
}
|
||||
|
||||
$limit = (int) ($payload['limit'] ?? 12);
|
||||
|
||||
try {
|
||||
$items = $this->vectors->searchByUploadedImage($payload['image'], $limit);
|
||||
} catch (RuntimeException $e) {
|
||||
return response()->json([
|
||||
'data' => [],
|
||||
'reason' => 'vector_gateway_error',
|
||||
'message' => $e->getMessage(),
|
||||
], 502);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $items,
|
||||
'meta' => [
|
||||
'source' => 'vector_gateway',
|
||||
'limit' => $limit,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Support\AvatarUrl;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class LatestCommentsApiController extends Controller
|
||||
{
|
||||
private const PER_PAGE = 20;
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$type = $request->query('type', 'all');
|
||||
|
||||
// Validate filter type
|
||||
if (! in_array($type, ['all', 'following', 'mine'], true)) {
|
||||
$type = 'all';
|
||||
}
|
||||
|
||||
// 'mine' and 'following' require auth
|
||||
if (in_array($type, ['mine', 'following'], true) && ! $request->user()) {
|
||||
return response()->json(['error' => 'Unauthenticated'], 401);
|
||||
}
|
||||
|
||||
$query = ArtworkComment::with(['user', 'user.profile', 'artwork'])
|
||||
->whereHas('artwork', function ($q) {
|
||||
$q->public()->published()->whereNull('deleted_at');
|
||||
})
|
||||
->orderByDesc('artwork_comments.created_at');
|
||||
|
||||
switch ($type) {
|
||||
case 'mine':
|
||||
$query->where('artwork_comments.user_id', $request->user()->id);
|
||||
break;
|
||||
|
||||
case 'following':
|
||||
$followingIds = $request->user()
|
||||
->following()
|
||||
->pluck('users.id');
|
||||
$query->whereIn('artwork_comments.user_id', $followingIds);
|
||||
break;
|
||||
|
||||
default:
|
||||
// 'all' — cache the first page only
|
||||
if ((int) $request->query('page', 1) === 1) {
|
||||
$cacheKey = 'comments.latest.all.page1';
|
||||
$ttl = 120; // 2 minutes
|
||||
|
||||
$paginator = Cache::remember($cacheKey, $ttl, fn () => $query->paginate(self::PER_PAGE));
|
||||
} else {
|
||||
$paginator = $query->paginate(self::PER_PAGE);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (! isset($paginator)) {
|
||||
$paginator = $query->paginate(self::PER_PAGE);
|
||||
}
|
||||
|
||||
$items = $paginator->getCollection()->map(function (ArtworkComment $c) {
|
||||
$art = $c->artwork;
|
||||
$user = $c->user;
|
||||
|
||||
$present = $art ? ThumbnailPresenter::present($art, 'md') : null;
|
||||
$thumb = $present ? ($present['url'] ?? null) : null;
|
||||
|
||||
$userId = (int) ($c->user_id ?? 0);
|
||||
$avatarHash = $user?->profile?->avatar_hash ?? null;
|
||||
|
||||
return [
|
||||
'comment_id' => $c->getKey(),
|
||||
'comment_text' => e(strip_tags($c->content ?? '')),
|
||||
'created_at' => $c->created_at?->toIso8601String(),
|
||||
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
|
||||
|
||||
'commenter' => [
|
||||
'id' => $userId,
|
||||
'username' => $user?->username ?? null,
|
||||
'display' => $user?->username ?? $user?->name ?? 'User',
|
||||
'profile_url' => $user?->username ? '/@' . $user->username : '/profile/' . $userId,
|
||||
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
|
||||
],
|
||||
|
||||
'artwork' => $art ? [
|
||||
'id' => $art->id,
|
||||
'title' => $art->title,
|
||||
'slug' => $art->slug ?? Str::slug($art->title ?? ''),
|
||||
'url' => '/art/' . $art->id . '/' . ($art->slug ?? Str::slug($art->title ?? '')),
|
||||
'thumb' => $thumb,
|
||||
] : null,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'data' => $items,
|
||||
'meta' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
'has_more' => $paginator->hasMorePages(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Leaderboard;
|
||||
use App\Services\LeaderboardService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class LeaderboardController extends Controller
|
||||
{
|
||||
public function creators(Request $request, LeaderboardService $leaderboards): JsonResponse
|
||||
{
|
||||
return response()->json(
|
||||
$leaderboards->getLeaderboard(Leaderboard::TYPE_CREATOR, (string) $request->query('period', 'weekly'))
|
||||
);
|
||||
}
|
||||
|
||||
public function artworks(Request $request, LeaderboardService $leaderboards): JsonResponse
|
||||
{
|
||||
return response()->json(
|
||||
$leaderboards->getLeaderboard(Leaderboard::TYPE_ARTWORK, (string) $request->query('period', 'weekly'))
|
||||
);
|
||||
}
|
||||
|
||||
public function groups(Request $request, LeaderboardService $leaderboards): JsonResponse
|
||||
{
|
||||
return response()->json(
|
||||
$leaderboards->getLeaderboard(Leaderboard::TYPE_GROUP, (string) $request->query('period', 'weekly'))
|
||||
);
|
||||
}
|
||||
|
||||
public function stories(Request $request, LeaderboardService $leaderboards): JsonResponse
|
||||
{
|
||||
return response()->json(
|
||||
$leaderboards->getLeaderboard(Leaderboard::TYPE_STORY, (string) $request->query('period', 'weekly'))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\TransferException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class LinkPreviewController extends Controller
|
||||
{
|
||||
private const TIMEOUT = 8; // seconds
|
||||
private const MAX_BYTES = 524_288; // 512 KB – enough to get the <head>
|
||||
private const USER_AGENT = 'Skinbase-LinkPreview/1.0 (+https://skinbase.org)';
|
||||
|
||||
/** Blocked IP ranges (SSRF protection). */
|
||||
private const BLOCKED_CIDRS = [
|
||||
'0.0.0.0/8',
|
||||
'10.0.0.0/8',
|
||||
'100.64.0.0/10',
|
||||
'127.0.0.0/8',
|
||||
'169.254.0.0/16',
|
||||
'172.16.0.0/12',
|
||||
'192.0.0.0/24',
|
||||
'192.168.0.0/16',
|
||||
'198.18.0.0/15',
|
||||
'198.51.100.0/24',
|
||||
'203.0.113.0/24',
|
||||
'240.0.0.0/4',
|
||||
'::1/128',
|
||||
'fc00::/7',
|
||||
'fe80::/10',
|
||||
];
|
||||
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'url' => ['required', 'string', 'max:2048'],
|
||||
]);
|
||||
|
||||
$rawUrl = trim((string) $request->input('url'));
|
||||
|
||||
// Must be http(s)
|
||||
if (! preg_match('#^https?://#i', $rawUrl)) {
|
||||
return response()->json(['error' => 'Invalid URL scheme.'], 422);
|
||||
}
|
||||
|
||||
$parsed = parse_url($rawUrl);
|
||||
$host = $parsed['host'] ?? '';
|
||||
|
||||
if (empty($host)) {
|
||||
return response()->json(['error' => 'Invalid URL.'], 422);
|
||||
}
|
||||
|
||||
// Resolve hostname and block private/loopback IPs (SSRF protection)
|
||||
$resolved = gethostbyname($host);
|
||||
if ($this->isBlockedIp($resolved)) {
|
||||
return response()->json(['error' => 'URL not allowed.'], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$client = new Client([
|
||||
'timeout' => self::TIMEOUT,
|
||||
'connect_timeout' => 4,
|
||||
'allow_redirects' => ['max' => 5, 'strict' => false],
|
||||
'headers' => [
|
||||
'User-Agent' => self::USER_AGENT,
|
||||
'Accept' => 'text/html,application/xhtml+xml',
|
||||
],
|
||||
'verify' => true,
|
||||
]);
|
||||
|
||||
$response = $client->get($rawUrl);
|
||||
$status = $response->getStatusCode();
|
||||
|
||||
if ($status < 200 || $status >= 400) {
|
||||
return response()->json(['error' => 'Could not fetch URL.'], 422);
|
||||
}
|
||||
|
||||
// Read up to MAX_BYTES – we only need the HTML <head>
|
||||
$body = '';
|
||||
$stream = $response->getBody();
|
||||
while (! $stream->eof() && strlen($body) < self::MAX_BYTES) {
|
||||
$body .= $stream->read(4096);
|
||||
}
|
||||
$stream->close();
|
||||
|
||||
} catch (TransferException $e) {
|
||||
return response()->json(['error' => 'Could not reach URL.'], 422);
|
||||
}
|
||||
|
||||
$preview = $this->extractMeta($body, $rawUrl);
|
||||
|
||||
return response()->json($preview);
|
||||
}
|
||||
|
||||
/** Extract OG / Twitter / fallback meta tags. */
|
||||
private function extractMeta(string $html, string $originalUrl): array
|
||||
{
|
||||
// Limit to roughly the <head> block for speed
|
||||
$head = substr($html, 0, 50_000);
|
||||
|
||||
$og = [];
|
||||
|
||||
// OG / Twitter meta tags
|
||||
preg_match_all(
|
||||
'/<meta\s[^>]*(?:property|name)\s*=\s*["\']([^"\']+)["\'][^>]*content\s*=\s*["\']([^"\']*)["\'][^>]*>/i',
|
||||
$head,
|
||||
$m1,
|
||||
PREG_SET_ORDER,
|
||||
);
|
||||
preg_match_all(
|
||||
'/<meta\s[^>]*content\s*=\s*["\']([^"\']*)["\'][^>]*(?:property|name)\s*=\s*["\']([^"\']+)["\'][^>]*>/i',
|
||||
$head,
|
||||
$m2,
|
||||
PREG_SET_ORDER,
|
||||
);
|
||||
|
||||
$allMeta = array_merge(
|
||||
array_map(fn ($r) => ['key' => strtolower($r[1]), 'value' => $r[2]], $m1),
|
||||
array_map(fn ($r) => ['key' => strtolower($r[2]), 'value' => $r[1]], $m2),
|
||||
);
|
||||
|
||||
$map = [];
|
||||
foreach ($allMeta as $entry) {
|
||||
$map[$entry['key']] ??= $entry['value'];
|
||||
}
|
||||
|
||||
// Canonical URL
|
||||
$canonical = $originalUrl;
|
||||
if (preg_match('/<link[^>]+rel\s*=\s*["\']canonical["\'][^>]+href\s*=\s*["\']([^"\']+)["\'][^>]*>/i', $head, $mc)) {
|
||||
$canonical = $mc[1];
|
||||
} elseif (preg_match('/<link[^>]+href\s*=\s*["\']([^"\']+)["\'][^>]+rel\s*=\s*["\']canonical["\'][^>]*>/i', $head, $mc)) {
|
||||
$canonical = $mc[1];
|
||||
}
|
||||
|
||||
// Title
|
||||
$title = $map['og:title']
|
||||
?? $map['twitter:title']
|
||||
?? null;
|
||||
if (! $title && preg_match('/<title[^>]*>([^<]+)<\/title>/i', $head, $mt)) {
|
||||
$title = trim(html_entity_decode($mt[1]));
|
||||
}
|
||||
|
||||
// Description
|
||||
$description = $map['og:description']
|
||||
?? $map['twitter:description']
|
||||
?? $map['description']
|
||||
?? null;
|
||||
|
||||
// Image
|
||||
$image = $map['og:image']
|
||||
?? $map['twitter:image']
|
||||
?? $map['twitter:image:src']
|
||||
?? null;
|
||||
|
||||
// Resolve relative image URL
|
||||
if ($image && ! preg_match('#^https?://#i', $image)) {
|
||||
$parsed = parse_url($originalUrl);
|
||||
$base = ($parsed['scheme'] ?? 'https') . '://' . ($parsed['host'] ?? '');
|
||||
$image = $base . '/' . ltrim($image, '/');
|
||||
}
|
||||
|
||||
// Site name
|
||||
$siteName = $map['og:site_name'] ?? parse_url($originalUrl, PHP_URL_HOST) ?? null;
|
||||
|
||||
return [
|
||||
'url' => $canonical,
|
||||
'title' => $title ? html_entity_decode($title) : null,
|
||||
'description' => $description ? html_entity_decode($description) : null,
|
||||
'image' => $image,
|
||||
'site_name' => $siteName,
|
||||
];
|
||||
}
|
||||
|
||||
private function isBlockedIp(string $ip): bool
|
||||
{
|
||||
if (! filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
return true; // could not resolve
|
||||
}
|
||||
foreach (self::BLOCKED_CIDRS as $cidr) {
|
||||
if ($this->ipInCidr($ip, $cidr)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function ipInCidr(string $ip, string $cidr): bool
|
||||
{
|
||||
[$subnet, $bits] = explode('/', $cidr) + [1 => 32];
|
||||
|
||||
// IPv6
|
||||
if (str_contains($cidr, ':')) {
|
||||
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
|
||||
return false;
|
||||
}
|
||||
$ipBin = inet_pton($ip);
|
||||
$subnetBin = inet_pton($subnet);
|
||||
if ($ipBin === false || $subnetBin === false) {
|
||||
return false;
|
||||
}
|
||||
$bits = (int) $bits;
|
||||
$mask = str_repeat("\xff", (int) ($bits / 8));
|
||||
$remain = $bits % 8;
|
||||
if ($remain) {
|
||||
$mask .= chr(0xff << (8 - $remain));
|
||||
}
|
||||
$mask = str_pad($mask, strlen($subnetBin), "\x00");
|
||||
return ($ipBin & $mask) === ($subnetBin & $mask);
|
||||
}
|
||||
|
||||
// IPv4
|
||||
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
return false;
|
||||
}
|
||||
$ipLong = ip2long($ip);
|
||||
$subnetLong = ip2long($subnet);
|
||||
$maskLong = $bits == 32 ? -1 : ~((1 << (32 - (int) $bits)) - 1);
|
||||
return ($ipLong & $maskLong) === ($subnetLong & $maskLong);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Messaging;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\MessageAttachment;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class AttachmentController extends Controller
|
||||
{
|
||||
public function show(Request $request, int $id)
|
||||
{
|
||||
$attachment = MessageAttachment::query()
|
||||
->with('message:id,conversation_id')
|
||||
->findOrFail($id);
|
||||
|
||||
$conversationId = (int) ($attachment->message?->conversation_id ?? 0);
|
||||
abort_if($conversationId <= 0, 404, 'Attachment not available.');
|
||||
|
||||
$authorized = \App\Models\ConversationParticipant::query()
|
||||
->where('conversation_id', $conversationId)
|
||||
->where('user_id', $request->user()->id)
|
||||
->whereNull('left_at')
|
||||
->exists();
|
||||
|
||||
abort_unless($authorized, 403, 'You are not allowed to access this attachment.');
|
||||
|
||||
$diskName = (string) config('messaging.attachments.disk', 'local');
|
||||
$disk = Storage::disk($diskName);
|
||||
|
||||
return new StreamedResponse(function () use ($disk, $attachment): void {
|
||||
echo $disk->get($attachment->storage_path);
|
||||
}, 200, [
|
||||
'Content-Type' => $attachment->mime,
|
||||
'Content-Disposition' => 'inline; filename="' . addslashes($attachment->original_name) . '"',
|
||||
'Content-Length' => (string) $attachment->size_bytes,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Messaging;
|
||||
|
||||
use App\Events\ConversationUpdated;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Messaging\ManageConversationParticipantRequest;
|
||||
use App\Http\Requests\Messaging\RenameConversationRequest;
|
||||
use App\Http\Requests\Messaging\StoreConversationRequest;
|
||||
use App\Models\Conversation;
|
||||
use App\Models\ConversationParticipant;
|
||||
use App\Models\User;
|
||||
use App\Services\Messaging\ConversationReadService;
|
||||
use App\Services\Messaging\ConversationStateService;
|
||||
use App\Services\Messaging\SendMessageAction;
|
||||
use App\Services\Messaging\UnreadCounterService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class ConversationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ConversationStateService $conversationState,
|
||||
private readonly ConversationReadService $conversationReads,
|
||||
private readonly SendMessageAction $sendMessage,
|
||||
private readonly UnreadCounterService $unreadCounters,
|
||||
) {}
|
||||
|
||||
// ── GET /api/messages/conversations ─────────────────────────────────────
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$page = max(1, (int) $request->integer('page', 1));
|
||||
$cacheVersion = (int) Cache::get($this->cacheVersionKey($user->id), 1);
|
||||
$cacheKey = $this->conversationListCacheKey($user->id, $page, $cacheVersion);
|
||||
|
||||
$conversations = Cache::remember($cacheKey, now()->addSeconds(20), function () use ($user, $page) {
|
||||
$query = Conversation::query()
|
||||
->select('conversations.*')
|
||||
->join('conversation_participants as cp_me', function ($join) use ($user) {
|
||||
$join->on('cp_me.conversation_id', '=', 'conversations.id')
|
||||
->where('cp_me.user_id', '=', $user->id)
|
||||
->whereNull('cp_me.left_at');
|
||||
})
|
||||
->where('conversations.is_active', true)
|
||||
->with([
|
||||
'allParticipants' => fn ($q) => $q->whereNull('left_at')->with(['user:id,username']),
|
||||
'latestMessage.sender:id,username',
|
||||
])
|
||||
->orderByDesc('cp_me.is_pinned')
|
||||
->orderByDesc('cp_me.pinned_at')
|
||||
->orderByDesc('last_message_at')
|
||||
->orderByDesc('conversations.id');
|
||||
|
||||
$this->unreadCounters->applyUnreadCountSelect($query, $user, 'cp_me');
|
||||
|
||||
return $query->paginate(20, ['conversations.*'], 'page', $page);
|
||||
});
|
||||
|
||||
$conversations->through(function ($conv) use ($user) {
|
||||
$conv->my_participant = $conv->allParticipants
|
||||
->firstWhere('user_id', $user->id);
|
||||
return $conv;
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
...$conversations->toArray(),
|
||||
'summary' => [
|
||||
'unread_total' => $this->unreadCounters->totalUnreadForUser($user),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// ── GET /api/messages/conversation/{id} ─────────────────────────────────
|
||||
|
||||
public function show(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$conv = $this->findAuthorized($request, $id);
|
||||
|
||||
$conv->load([
|
||||
'allParticipants.user:id,username',
|
||||
'creator:id,username',
|
||||
]);
|
||||
|
||||
return response()->json($conv);
|
||||
}
|
||||
|
||||
// ── POST /api/messages/conversation ─────────────────────────────────────
|
||||
|
||||
public function store(StoreConversationRequest $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$data = $request->validated();
|
||||
|
||||
if ($data['type'] === 'direct') {
|
||||
return $this->createDirect($request, $user, $data);
|
||||
}
|
||||
|
||||
return $this->createGroup($request, $user, $data);
|
||||
}
|
||||
|
||||
// ── POST /api/messages/{conversation_id}/read ────────────────────────────
|
||||
|
||||
public function markRead(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$conversation = $this->findAuthorized($request, $id);
|
||||
$participant = $this->conversationReads->markConversationRead(
|
||||
$conversation,
|
||||
$request->user(),
|
||||
$request->integer('message_id') ?: null,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'last_read_at' => optional($participant->last_read_at)?->toIso8601String(),
|
||||
'last_read_message_id' => $participant->last_read_message_id,
|
||||
'unread_total' => $this->unreadCounters->totalUnreadForUser($request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── POST /api/messages/{conversation_id}/archive ─────────────────────────
|
||||
|
||||
public function archive(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$conversation = $this->findAuthorized($request, $id);
|
||||
$participant = $this->participantRecord($request, $id);
|
||||
$participant->update(['is_archived' => ! $participant->is_archived]);
|
||||
$this->broadcastConversationUpdate($conversation, 'conversation.archived');
|
||||
|
||||
return response()->json(['is_archived' => $participant->is_archived]);
|
||||
}
|
||||
|
||||
// ── POST /api/messages/{conversation_id}/mute ────────────────────────────
|
||||
|
||||
public function mute(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$conversation = $this->findAuthorized($request, $id);
|
||||
$participant = $this->participantRecord($request, $id);
|
||||
$participant->update(['is_muted' => ! $participant->is_muted]);
|
||||
$this->broadcastConversationUpdate($conversation, 'conversation.muted');
|
||||
|
||||
return response()->json(['is_muted' => $participant->is_muted]);
|
||||
}
|
||||
|
||||
public function pin(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$conversation = $this->findAuthorized($request, $id);
|
||||
$participant = $this->participantRecord($request, $id);
|
||||
$participant->update(['is_pinned' => true, 'pinned_at' => now()]);
|
||||
$this->broadcastConversationUpdate($conversation, 'conversation.pinned');
|
||||
|
||||
return response()->json(['is_pinned' => true]);
|
||||
}
|
||||
|
||||
public function unpin(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$conversation = $this->findAuthorized($request, $id);
|
||||
$participant = $this->participantRecord($request, $id);
|
||||
$participant->update(['is_pinned' => false, 'pinned_at' => null]);
|
||||
$this->broadcastConversationUpdate($conversation, 'conversation.unpinned');
|
||||
|
||||
return response()->json(['is_pinned' => false]);
|
||||
}
|
||||
|
||||
// ── DELETE /api/messages/{conversation_id}/leave ─────────────────────────
|
||||
|
||||
public function leave(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$conv = $this->findAuthorized($request, $id);
|
||||
$participant = $this->participantRecord($request, $id);
|
||||
$participantUserIds = ConversationParticipant::where('conversation_id', $id)
|
||||
->whereNull('left_at')
|
||||
->pluck('user_id')
|
||||
->all();
|
||||
|
||||
if ($conv->isGroup()) {
|
||||
// Last admin protection
|
||||
$adminCount = ConversationParticipant::where('conversation_id', $id)
|
||||
->where('role', 'admin')
|
||||
->whereNull('left_at')
|
||||
->count();
|
||||
|
||||
if ($adminCount === 1 && $participant->role === 'admin') {
|
||||
$otherMember = ConversationParticipant::where('conversation_id', $id)
|
||||
->where('user_id', '!=', $request->user()->id)
|
||||
->whereNull('left_at')
|
||||
->first();
|
||||
|
||||
if ($otherMember) {
|
||||
$otherMember->update(['role' => 'admin']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$participant->update(['left_at' => now()]);
|
||||
$this->conversationState->touchConversationCachesForUsers($participantUserIds);
|
||||
$this->broadcastConversationUpdate($conv, 'conversation.left', $participantUserIds);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
// ── POST /api/messages/{conversation_id}/add-user ────────────────────────
|
||||
|
||||
public function addUser(ManageConversationParticipantRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$conv = $this->findAuthorized($request, $id);
|
||||
$this->requireAdmin($request, $id);
|
||||
$participantUserIds = ConversationParticipant::where('conversation_id', $id)
|
||||
->whereNull('left_at')
|
||||
->pluck('user_id')
|
||||
->all();
|
||||
|
||||
$data = $request->validated();
|
||||
|
||||
$existing = ConversationParticipant::where('conversation_id', $id)
|
||||
->where('user_id', $data['user_id'])
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
if ($existing->left_at) {
|
||||
$existing->update(['left_at' => null, 'joined_at' => now()]);
|
||||
}
|
||||
} else {
|
||||
ConversationParticipant::create([
|
||||
'conversation_id' => $id,
|
||||
'user_id' => $data['user_id'],
|
||||
'role' => 'member',
|
||||
'joined_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$participantUserIds[] = (int) $data['user_id'];
|
||||
$this->conversationState->touchConversationCachesForUsers($participantUserIds);
|
||||
$this->broadcastConversationUpdate($conv, 'conversation.participant_added', $participantUserIds);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
// ── DELETE /api/messages/{conversation_id}/remove-user ───────────────────
|
||||
|
||||
public function removeUser(ManageConversationParticipantRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$this->requireAdmin($request, $id);
|
||||
$data = $request->validated();
|
||||
|
||||
// Cannot remove the conversation creator
|
||||
$conv = Conversation::findOrFail($id);
|
||||
abort_if($conv->created_by === (int) $data['user_id'], 403, 'Cannot remove the conversation creator.');
|
||||
|
||||
$targetParticipant = ConversationParticipant::where('conversation_id', $id)
|
||||
->where('user_id', $data['user_id'])
|
||||
->whereNull('left_at')
|
||||
->first();
|
||||
|
||||
if ($targetParticipant && $targetParticipant->role === 'admin') {
|
||||
$adminCount = ConversationParticipant::where('conversation_id', $id)
|
||||
->where('role', 'admin')
|
||||
->whereNull('left_at')
|
||||
->count();
|
||||
|
||||
abort_if($adminCount <= 1, 422, 'Cannot remove the last admin from this conversation.');
|
||||
}
|
||||
|
||||
$participantUserIds = ConversationParticipant::where('conversation_id', $id)
|
||||
->whereNull('left_at')
|
||||
->pluck('user_id')
|
||||
->all();
|
||||
|
||||
ConversationParticipant::where('conversation_id', $id)
|
||||
->where('user_id', $data['user_id'])
|
||||
->whereNull('left_at')
|
||||
->update(['left_at' => now()]);
|
||||
|
||||
$this->conversationState->touchConversationCachesForUsers($participantUserIds);
|
||||
$this->broadcastConversationUpdate($conv, 'conversation.participant_removed', $participantUserIds);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
// ── POST /api/messages/{conversation_id}/rename ──────────────────────────
|
||||
|
||||
public function rename(RenameConversationRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$conv = $this->findAuthorized($request, $id);
|
||||
abort_unless($conv->isGroup(), 422, 'Only group conversations can be renamed.');
|
||||
$this->requireAdmin($request, $id);
|
||||
|
||||
$data = $request->validated();
|
||||
$conv->update(['title' => $data['title']]);
|
||||
$participantUserIds = ConversationParticipant::where('conversation_id', $id)
|
||||
->whereNull('left_at')
|
||||
->pluck('user_id')
|
||||
->all();
|
||||
$this->conversationState->touchConversationCachesForUsers($participantUserIds);
|
||||
$this->broadcastConversationUpdate($conv, 'conversation.renamed', $participantUserIds);
|
||||
|
||||
return response()->json(['title' => $conv->title]);
|
||||
}
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private function createDirect(Request $request, User $user, array $data): JsonResponse
|
||||
{
|
||||
$recipient = User::findOrFail($data['recipient_id']);
|
||||
|
||||
abort_if($recipient->id === $user->id, 422, 'You cannot message yourself.');
|
||||
|
||||
if (! $recipient->allowsMessagesFrom($user)) {
|
||||
abort(403, 'This user does not accept messages from you.');
|
||||
}
|
||||
|
||||
$this->assertNotBlockedBetween($user, $recipient);
|
||||
|
||||
// Reuse existing conversation if one exists
|
||||
$conv = Conversation::findDirect($user->id, $recipient->id);
|
||||
|
||||
if (! $conv) {
|
||||
$conv = DB::transaction(function () use ($user, $recipient) {
|
||||
$conv = Conversation::create([
|
||||
'uuid' => (string) \Illuminate\Support\Str::uuid(),
|
||||
'type' => 'direct',
|
||||
'created_by' => $user->id,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
ConversationParticipant::insert([
|
||||
['conversation_id' => $conv->id, 'user_id' => $user->id, 'role' => 'admin', 'joined_at' => now()],
|
||||
['conversation_id' => $conv->id, 'user_id' => $recipient->id, 'role' => 'member', 'joined_at' => now()],
|
||||
]);
|
||||
|
||||
return $conv;
|
||||
});
|
||||
}
|
||||
|
||||
$this->sendMessage->execute($conv, $user, [
|
||||
'body' => $data['body'],
|
||||
'client_temp_id' => $data['client_temp_id'] ?? null,
|
||||
]);
|
||||
|
||||
return response()->json($conv->fresh()->load('allParticipants.user:id,username'), 201);
|
||||
}
|
||||
|
||||
private function createGroup(Request $request, User $user, array $data): JsonResponse
|
||||
{
|
||||
$participantIds = array_unique(array_merge([$user->id], $data['participant_ids']));
|
||||
|
||||
$conv = DB::transaction(function () use ($user, $data, $participantIds) {
|
||||
$conv = Conversation::create([
|
||||
'uuid' => (string) \Illuminate\Support\Str::uuid(),
|
||||
'type' => 'group',
|
||||
'title' => $data['title'],
|
||||
'created_by' => $user->id,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$rows = array_map(fn ($uid) => [
|
||||
'conversation_id' => $conv->id,
|
||||
'user_id' => $uid,
|
||||
'role' => $uid === $user->id ? 'admin' : 'member',
|
||||
'joined_at' => now(),
|
||||
], $participantIds);
|
||||
|
||||
ConversationParticipant::insert($rows);
|
||||
|
||||
return $conv;
|
||||
});
|
||||
|
||||
$this->sendMessage->execute($conv, $user, [
|
||||
'body' => $data['body'],
|
||||
'client_temp_id' => $data['client_temp_id'] ?? null,
|
||||
]);
|
||||
|
||||
return response()->json($conv->fresh()->load('allParticipants.user:id,username'), 201);
|
||||
}
|
||||
|
||||
private function findAuthorized(Request $request, int $id): Conversation
|
||||
{
|
||||
$conv = Conversation::findOrFail($id);
|
||||
$this->authorize('view', $conv);
|
||||
return $conv;
|
||||
}
|
||||
|
||||
private function participantRecord(Request $request, int $conversationId): ConversationParticipant
|
||||
{
|
||||
return ConversationParticipant::where('conversation_id', $conversationId)
|
||||
->where('user_id', $request->user()->id)
|
||||
->whereNull('left_at')
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
private function assertParticipant(Request $request, int $id): void
|
||||
{
|
||||
abort_unless(
|
||||
ConversationParticipant::where('conversation_id', $id)
|
||||
->where('user_id', $request->user()->id)
|
||||
->whereNull('left_at')
|
||||
->exists(),
|
||||
403,
|
||||
'You are not a participant of this conversation.'
|
||||
);
|
||||
}
|
||||
|
||||
private function requireAdmin(Request $request, int $id): void
|
||||
{
|
||||
$conversation = Conversation::findOrFail($id);
|
||||
$this->authorize('manageParticipants', $conversation);
|
||||
}
|
||||
|
||||
private function touchConversationCachesForUsers(array $userIds): void
|
||||
{
|
||||
$this->conversationState->touchConversationCachesForUsers($userIds);
|
||||
}
|
||||
|
||||
private function cacheVersionKey(int $userId): string
|
||||
{
|
||||
return "messages:conversations:version:{$userId}";
|
||||
}
|
||||
|
||||
private function conversationListCacheKey(int $userId, int $page, int $version): string
|
||||
{
|
||||
return "messages:conversations:user:{$userId}:page:{$page}:v:{$version}";
|
||||
}
|
||||
|
||||
private function broadcastConversationUpdate(Conversation $conversation, string $reason, ?array $participantIds = null): void
|
||||
{
|
||||
$participantIds ??= $this->conversationState->activeParticipantIds($conversation);
|
||||
$this->conversationState->touchConversationCachesForUsers($participantIds);
|
||||
|
||||
foreach ($participantIds as $participantId) {
|
||||
event(new ConversationUpdated((int) $participantId, $conversation, $reason));
|
||||
}
|
||||
}
|
||||
|
||||
private function assertNotBlockedBetween(User $sender, User $recipient): void
|
||||
{
|
||||
if (! Schema::hasTable('user_blocks')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$blocked = false;
|
||||
|
||||
if (Schema::hasColumns('user_blocks', ['user_id', 'blocked_user_id'])) {
|
||||
$blocked = DB::table('user_blocks')
|
||||
->where(function ($q) use ($sender, $recipient) {
|
||||
$q->where('user_id', $sender->id)->where('blocked_user_id', $recipient->id);
|
||||
})
|
||||
->orWhere(function ($q) use ($sender, $recipient) {
|
||||
$q->where('user_id', $recipient->id)->where('blocked_user_id', $sender->id);
|
||||
})
|
||||
->exists();
|
||||
} elseif (Schema::hasColumns('user_blocks', ['blocker_id', 'blocked_id'])) {
|
||||
$blocked = DB::table('user_blocks')
|
||||
->where(function ($q) use ($sender, $recipient) {
|
||||
$q->where('blocker_id', $sender->id)->where('blocked_id', $recipient->id);
|
||||
})
|
||||
->orWhere(function ($q) use ($sender, $recipient) {
|
||||
$q->where('blocker_id', $recipient->id)->where('blocked_id', $sender->id);
|
||||
})
|
||||
->exists();
|
||||
}
|
||||
|
||||
abort_if($blocked, 403, 'Messaging is not available between these users.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Messaging;
|
||||
|
||||
use App\Events\ConversationUpdated;
|
||||
use App\Events\MessageDeleted;
|
||||
use App\Events\MessageUpdated;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Messaging\StoreMessageRequest;
|
||||
use App\Http\Requests\Messaging\ToggleMessageReactionRequest;
|
||||
use App\Http\Requests\Messaging\UpdateMessageRequest;
|
||||
use App\Models\Conversation;
|
||||
use App\Models\ConversationParticipant;
|
||||
use App\Models\Message;
|
||||
use App\Models\MessageReaction;
|
||||
use App\Services\Messaging\ConversationDeltaService;
|
||||
use App\Services\Messaging\ConversationStateService;
|
||||
use App\Services\Messaging\MessagingPayloadFactory;
|
||||
use App\Services\Messaging\MessageSearchIndexer;
|
||||
use App\Services\Messaging\SendMessageAction;
|
||||
use App\Services\Messaging\UnreadCounterService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class MessageController extends Controller
|
||||
{
|
||||
private const PAGE_SIZE = 30;
|
||||
|
||||
public function __construct(
|
||||
private readonly ConversationDeltaService $conversationDelta,
|
||||
private readonly ConversationStateService $conversationState,
|
||||
private readonly MessagingPayloadFactory $payloadFactory,
|
||||
private readonly SendMessageAction $sendMessage,
|
||||
private readonly UnreadCounterService $unreadCounters,
|
||||
) {}
|
||||
|
||||
// ── GET /api/messages/{conversation_id} ──────────────────────────────────
|
||||
|
||||
public function index(Request $request, int $conversationId): JsonResponse
|
||||
{
|
||||
$conversation = $this->findConversationOrFail($conversationId);
|
||||
$cursor = $request->integer('cursor') ?: $request->integer('before_id');
|
||||
$afterId = $request->integer('after_id');
|
||||
|
||||
if ($afterId) {
|
||||
$messages = $this->conversationDelta->messagesAfter($conversation, $request->user(), $afterId);
|
||||
|
||||
return response()->json([
|
||||
'data' => $messages,
|
||||
'next_cursor' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$query = Message::withTrashed()
|
||||
->where('conversation_id', $conversationId)
|
||||
->with(['sender:id,username', 'reactions', 'attachments'])
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id');
|
||||
|
||||
if ($cursor) {
|
||||
$query->where('id', '<', $cursor);
|
||||
}
|
||||
|
||||
$chunk = $query->limit(self::PAGE_SIZE + 1)->get();
|
||||
$hasMore = $chunk->count() > self::PAGE_SIZE;
|
||||
$messages = $chunk->take(self::PAGE_SIZE)->reverse()->values();
|
||||
$nextCursor = $hasMore && $messages->isNotEmpty() ? (int) $messages->first()->id : null;
|
||||
|
||||
return response()->json([
|
||||
'data' => $messages->map(fn (Message $message) => $this->payloadFactory->message($message, (int) $request->user()->id))->values(),
|
||||
'next_cursor' => $nextCursor,
|
||||
]);
|
||||
}
|
||||
|
||||
public function delta(Request $request, int $conversationId): JsonResponse
|
||||
{
|
||||
$conversation = $this->findConversationOrFail($conversationId);
|
||||
$afterMessageId = max(0, (int) $request->integer('after_message_id'));
|
||||
|
||||
abort_if($afterMessageId < 1, 422, 'after_message_id is required.');
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->conversationDelta->messagesAfter($conversation, $request->user(), $afterMessageId),
|
||||
'conversation' => $this->payloadFactory->conversationSummary($conversation->fresh(), (int) $request->user()->id),
|
||||
'summary' => [
|
||||
'unread_total' => $this->unreadCounters->totalUnreadForUser($request->user()),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// ── POST /api/messages/{conversation_id} ─────────────────────────────────
|
||||
|
||||
public function store(StoreMessageRequest $request, int $conversationId): JsonResponse
|
||||
{
|
||||
$conversation = $this->findConversationOrFail($conversationId);
|
||||
$data = $request->validated();
|
||||
$data['attachments'] = $request->file('attachments', []);
|
||||
|
||||
$body = trim((string) ($data['body'] ?? ''));
|
||||
abort_if($body === '' && empty($data['attachments']), 422, 'Message body or attachment is required.');
|
||||
|
||||
$message = $this->sendMessage->execute($conversation, $request->user(), $data);
|
||||
|
||||
return response()->json($this->payloadFactory->message($message, (int) $request->user()->id), 201);
|
||||
}
|
||||
|
||||
// ── POST /api/messages/{conversation_id}/react ───────────────────────────
|
||||
|
||||
public function react(ToggleMessageReactionRequest $request, int $conversationId, int $messageId): JsonResponse
|
||||
{
|
||||
$this->findConversationOrFail($conversationId);
|
||||
$data = $request->validated();
|
||||
$this->assertAllowedReaction($data['reaction']);
|
||||
|
||||
$existing = MessageReaction::where([
|
||||
'message_id' => $messageId,
|
||||
'user_id' => $request->user()->id,
|
||||
'reaction' => $data['reaction'],
|
||||
])->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
} else {
|
||||
MessageReaction::create([
|
||||
'message_id' => $messageId,
|
||||
'user_id' => $request->user()->id,
|
||||
'reaction' => $data['reaction'],
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json($this->reactionSummary($messageId, (int) $request->user()->id));
|
||||
}
|
||||
|
||||
// ── DELETE /api/messages/{conversation_id}/react ─────────────────────────
|
||||
|
||||
public function unreact(ToggleMessageReactionRequest $request, int $conversationId, int $messageId): JsonResponse
|
||||
{
|
||||
$this->findConversationOrFail($conversationId);
|
||||
$data = $request->validated();
|
||||
$this->assertAllowedReaction($data['reaction']);
|
||||
|
||||
MessageReaction::where([
|
||||
'message_id' => $messageId,
|
||||
'user_id' => $request->user()->id,
|
||||
'reaction' => $data['reaction'],
|
||||
])->delete();
|
||||
|
||||
return response()->json($this->reactionSummary($messageId, (int) $request->user()->id));
|
||||
}
|
||||
|
||||
public function reactByMessage(ToggleMessageReactionRequest $request, int $messageId): JsonResponse
|
||||
{
|
||||
$message = Message::query()->findOrFail($messageId);
|
||||
$this->findConversationOrFail((int) $message->conversation_id);
|
||||
$data = $request->validated();
|
||||
$this->assertAllowedReaction($data['reaction']);
|
||||
|
||||
$existing = MessageReaction::where([
|
||||
'message_id' => $messageId,
|
||||
'user_id' => $request->user()->id,
|
||||
'reaction' => $data['reaction'],
|
||||
])->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
} else {
|
||||
MessageReaction::create([
|
||||
'message_id' => $messageId,
|
||||
'user_id' => $request->user()->id,
|
||||
'reaction' => $data['reaction'],
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json($this->reactionSummary($messageId, (int) $request->user()->id));
|
||||
}
|
||||
|
||||
public function unreactByMessage(ToggleMessageReactionRequest $request, int $messageId): JsonResponse
|
||||
{
|
||||
$message = Message::query()->findOrFail($messageId);
|
||||
$this->findConversationOrFail((int) $message->conversation_id);
|
||||
$data = $request->validated();
|
||||
$this->assertAllowedReaction($data['reaction']);
|
||||
|
||||
MessageReaction::where([
|
||||
'message_id' => $messageId,
|
||||
'user_id' => $request->user()->id,
|
||||
'reaction' => $data['reaction'],
|
||||
])->delete();
|
||||
|
||||
return response()->json($this->reactionSummary($messageId, (int) $request->user()->id));
|
||||
}
|
||||
|
||||
// ── PATCH /api/messages/message/{messageId} ───────────────────────────────
|
||||
|
||||
public function update(UpdateMessageRequest $request, int $messageId): JsonResponse
|
||||
{
|
||||
$message = Message::findOrFail($messageId);
|
||||
|
||||
$this->authorize('update', $message);
|
||||
|
||||
abort_if($message->deleted_at !== null, 422, 'Cannot edit a deleted message.');
|
||||
|
||||
$data = $request->validated();
|
||||
|
||||
$message->update([
|
||||
'body' => $data['body'],
|
||||
'edited_at' => now(),
|
||||
]);
|
||||
app(MessageSearchIndexer::class)->updateMessage($message);
|
||||
|
||||
$participantUserIds = $this->conversationState->activeParticipantIds((int) $message->conversation_id);
|
||||
$this->conversationState->touchConversationCachesForUsers($participantUserIds);
|
||||
|
||||
DB::afterCommit(function () use ($message, $participantUserIds): void {
|
||||
event(new MessageUpdated($message->fresh(['sender:id,username,name', 'attachments', 'reactions'])));
|
||||
|
||||
$conversation = Conversation::find($message->conversation_id);
|
||||
if ($conversation) {
|
||||
foreach ($participantUserIds as $participantId) {
|
||||
event(new ConversationUpdated((int) $participantId, $conversation, 'message.updated'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return response()->json($this->payloadFactory->message($message->fresh(['sender:id,username,name', 'attachments', 'reactions']), (int) $request->user()->id));
|
||||
}
|
||||
|
||||
// ── DELETE /api/messages/message/{messageId} ──────────────────────────────
|
||||
|
||||
public function destroy(Request $request, int $messageId): JsonResponse
|
||||
{
|
||||
$message = Message::findOrFail($messageId);
|
||||
|
||||
$this->authorize('delete', $message);
|
||||
|
||||
$participantUserIds = $this->conversationState->activeParticipantIds((int) $message->conversation_id);
|
||||
app(MessageSearchIndexer::class)->deleteMessage($message);
|
||||
$message->delete();
|
||||
$this->conversationState->touchConversationCachesForUsers($participantUserIds);
|
||||
|
||||
DB::afterCommit(function () use ($message, $participantUserIds): void {
|
||||
$message->refresh();
|
||||
event(new MessageDeleted($message));
|
||||
|
||||
$conversation = Conversation::find($message->conversation_id);
|
||||
if ($conversation) {
|
||||
foreach ($participantUserIds as $participantId) {
|
||||
event(new ConversationUpdated((int) $participantId, $conversation, 'message.deleted'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private function assertParticipant(Request $request, int $conversationId): void
|
||||
{
|
||||
abort_unless(
|
||||
ConversationParticipant::where('conversation_id', $conversationId)
|
||||
->where('user_id', $request->user()->id)
|
||||
->whereNull('left_at')
|
||||
->exists(),
|
||||
403,
|
||||
'You are not a participant of this conversation.'
|
||||
);
|
||||
}
|
||||
|
||||
private function touchConversationCachesForUsers(array $userIds): void
|
||||
{
|
||||
$this->conversationState->touchConversationCachesForUsers($userIds);
|
||||
}
|
||||
|
||||
private function assertAllowedReaction(string $reaction): void
|
||||
{
|
||||
$allowed = (array) config('messaging.reactions.allowed', []);
|
||||
abort_unless(in_array($reaction, $allowed, true), 422, 'Reaction is not allowed.');
|
||||
}
|
||||
|
||||
private function reactionSummary(int $messageId, int $userId): array
|
||||
{
|
||||
$rows = MessageReaction::query()
|
||||
->selectRaw('reaction, count(*) as aggregate_count')
|
||||
->where('message_id', $messageId)
|
||||
->groupBy('reaction')
|
||||
->get();
|
||||
|
||||
$summary = [];
|
||||
foreach ($rows as $row) {
|
||||
$summary[(string) $row->reaction] = (int) $row->aggregate_count;
|
||||
}
|
||||
|
||||
$mine = MessageReaction::query()
|
||||
->where('message_id', $messageId)
|
||||
->where('user_id', $userId)
|
||||
->pluck('reaction')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$summary['me'] = $mine;
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
private function findConversationOrFail(int $conversationId): Conversation
|
||||
{
|
||||
$conversation = Conversation::query()->findOrFail($conversationId);
|
||||
$this->authorize('view', $conversation);
|
||||
|
||||
return $conversation;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Messaging;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ConversationParticipant;
|
||||
use App\Models\Message;
|
||||
use App\Services\Messaging\MessageSearchIndexer;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Meilisearch\Client;
|
||||
|
||||
class MessageSearchController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MessageSearchIndexer $indexer,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$data = $request->validate([
|
||||
'q' => 'required|string|min:1|max:200',
|
||||
'conversation_id' => 'nullable|integer|exists:conversations,id',
|
||||
'cursor' => 'nullable|integer|min:0',
|
||||
]);
|
||||
|
||||
$allowedConversationIds = ConversationParticipant::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('left_at')
|
||||
->pluck('conversation_id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
|
||||
$conversationId = isset($data['conversation_id']) ? (int) $data['conversation_id'] : null;
|
||||
if ($conversationId !== null && ! in_array($conversationId, $allowedConversationIds, true)) {
|
||||
abort(403, 'You are not a participant of this conversation.');
|
||||
}
|
||||
|
||||
if (empty($allowedConversationIds)) {
|
||||
return response()->json(['data' => [], 'next_cursor' => null]);
|
||||
}
|
||||
|
||||
$limit = max(1, (int) config('messaging.search.page_size', 20));
|
||||
$offset = max(0, (int) ($data['cursor'] ?? 0));
|
||||
|
||||
$hits = collect();
|
||||
$estimated = 0;
|
||||
|
||||
try {
|
||||
$client = new Client(
|
||||
config('scout.meilisearch.host'),
|
||||
config('scout.meilisearch.key')
|
||||
);
|
||||
|
||||
$prefix = (string) config('scout.prefix', '');
|
||||
$indexName = $prefix . (string) config('messaging.search.index', 'messages');
|
||||
|
||||
$conversationFilter = $conversationId !== null
|
||||
? "conversation_id = {$conversationId}"
|
||||
: 'conversation_id IN [' . implode(',', $allowedConversationIds) . ']';
|
||||
|
||||
$result = $client
|
||||
->index($indexName)
|
||||
->search((string) $data['q'], [
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
'sort' => ['created_at:desc'],
|
||||
'filter' => $conversationFilter,
|
||||
]);
|
||||
|
||||
$hits = collect($result->getHits() ?? []);
|
||||
$estimated = (int) ($result->getEstimatedTotalHits() ?? $hits->count());
|
||||
|
||||
if ($hits->isEmpty()) {
|
||||
[$hits, $estimated] = $this->fallbackHits($allowedConversationIds, $conversationId, (string) $data['q'], $offset, $limit);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
[$hits, $estimated] = $this->fallbackHits($allowedConversationIds, $conversationId, (string) $data['q'], $offset, $limit);
|
||||
}
|
||||
$messageIds = $hits->pluck('id')->map(fn ($id) => (int) $id)->all();
|
||||
|
||||
$messages = Message::query()
|
||||
->whereIn('id', $messageIds)
|
||||
->whereIn('conversation_id', $allowedConversationIds)
|
||||
->whereNull('deleted_at')
|
||||
->with(['sender:id,username', 'attachments'])
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$ordered = $hits
|
||||
->map(function (array $hit) use ($messages) {
|
||||
$message = $messages->get((int) ($hit['id'] ?? 0));
|
||||
if (! $message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $message->id,
|
||||
'conversation_id' => $message->conversation_id,
|
||||
'sender_id' => $message->sender_id,
|
||||
'sender' => $message->sender,
|
||||
'body' => $message->body,
|
||||
'created_at' => optional($message->created_at)?->toISOString(),
|
||||
'has_attachments' => $message->attachments->isNotEmpty(),
|
||||
];
|
||||
})
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
$nextCursor = ($offset + $limit) < $estimated ? ($offset + $limit) : null;
|
||||
|
||||
return response()->json([
|
||||
'data' => $ordered,
|
||||
'next_cursor' => $nextCursor,
|
||||
]);
|
||||
}
|
||||
|
||||
private function fallbackHits(array $allowedConversationIds, ?int $conversationId, string $queryString, int $offset, int $limit): array
|
||||
{
|
||||
$query = Message::query()
|
||||
->select('id')
|
||||
->whereNull('deleted_at')
|
||||
->whereIn('conversation_id', $allowedConversationIds)
|
||||
->when($conversationId !== null, fn ($builder) => $builder->where('conversation_id', $conversationId))
|
||||
->where('body', 'like', '%' . $queryString . '%')
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id');
|
||||
|
||||
$estimated = (clone $query)->count();
|
||||
$hits = $query->offset($offset)->limit($limit)->get()->map(fn ($row) => ['id' => (int) $row->id]);
|
||||
|
||||
return [$hits, $estimated];
|
||||
}
|
||||
|
||||
public function rebuild(Request $request): JsonResponse
|
||||
{
|
||||
abort_unless($request->user()?->isAdmin(), 403, 'Admin access required.');
|
||||
|
||||
$conversationId = $request->integer('conversation_id');
|
||||
if ($conversationId > 0) {
|
||||
$this->indexer->rebuildConversation($conversationId);
|
||||
return response()->json(['queued' => true, 'scope' => 'conversation']);
|
||||
}
|
||||
|
||||
$this->indexer->rebuildAll();
|
||||
|
||||
return response()->json(['queued' => true, 'scope' => 'all']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Messaging;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Manages per-user messaging privacy preference.
|
||||
*
|
||||
* GET /api/messages/settings → return current setting
|
||||
* PATCH /api/messages/settings → update setting
|
||||
*/
|
||||
class MessagingSettingsController extends Controller
|
||||
{
|
||||
public function show(Request $request): JsonResponse
|
||||
{
|
||||
$realtimeReady = (bool) config('messaging.realtime', false)
|
||||
&& config('broadcasting.default') === 'reverb'
|
||||
&& filled(config('broadcasting.connections.reverb.key'));
|
||||
|
||||
return response()->json([
|
||||
'allow_messages_from' => $request->user()->allow_messages_from ?? 'everyone',
|
||||
'realtime_enabled' => $realtimeReady,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'allow_messages_from' => 'required|in:everyone,followers,mutual_followers,nobody',
|
||||
]);
|
||||
|
||||
$request->user()->update($data);
|
||||
|
||||
return response()->json([
|
||||
'allow_messages_from' => $request->user()->allow_messages_from,
|
||||
'realtime_enabled' => (bool) config('messaging.realtime', false),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Messaging;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Conversation;
|
||||
use App\Services\Messaging\MessagingPresenceService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PresenceController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MessagingPresenceService $presence,
|
||||
) {}
|
||||
|
||||
public function heartbeat(Request $request): JsonResponse
|
||||
{
|
||||
$conversationId = $request->integer('conversation_id') ?: null;
|
||||
|
||||
if ($conversationId) {
|
||||
$conversation = Conversation::query()->findOrFail($conversationId);
|
||||
$this->authorize('view', $conversation);
|
||||
}
|
||||
|
||||
$this->presence->touch($request->user(), $conversationId);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'conversation_id' => $conversationId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Messaging;
|
||||
|
||||
use App\Events\TypingStarted;
|
||||
use App\Events\TypingStopped;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Conversation;
|
||||
use App\Models\ConversationParticipant;
|
||||
use Illuminate\Cache\Repository;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class TypingController extends Controller
|
||||
{
|
||||
public function start(Request $request, int $conversationId): JsonResponse
|
||||
{
|
||||
$this->findConversationOrFail($conversationId);
|
||||
|
||||
$ttl = max(5, (int) config('messaging.typing.ttl_seconds', 8));
|
||||
$this->store()->put($this->key($conversationId, (int) $request->user()->id), 1, now()->addSeconds($ttl));
|
||||
|
||||
if ((bool) config('messaging.realtime', false)) {
|
||||
event(new TypingStarted($conversationId, $request->user()));
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function stop(Request $request, int $conversationId): JsonResponse
|
||||
{
|
||||
$this->findConversationOrFail($conversationId);
|
||||
$this->store()->forget($this->key($conversationId, (int) $request->user()->id));
|
||||
|
||||
if ((bool) config('messaging.realtime', false)) {
|
||||
event(new TypingStopped($conversationId, $request->user()));
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function index(Request $request, int $conversationId): JsonResponse
|
||||
{
|
||||
$this->findConversationOrFail($conversationId);
|
||||
$userId = (int) $request->user()->id;
|
||||
|
||||
$participants = ConversationParticipant::query()
|
||||
->where('conversation_id', $conversationId)
|
||||
->whereNull('left_at')
|
||||
->where('user_id', '!=', $userId)
|
||||
->with('user:id,username')
|
||||
->get();
|
||||
|
||||
$typing = $participants
|
||||
->filter(fn ($p) => $this->store()->has($this->key($conversationId, (int) $p->user_id)))
|
||||
->map(fn ($p) => [
|
||||
'user_id' => (int) $p->user_id,
|
||||
'username' => (string) ($p->user->username ?? ''),
|
||||
])
|
||||
->values();
|
||||
|
||||
return response()->json(['typing' => $typing]);
|
||||
}
|
||||
|
||||
private function assertParticipant(Request $request, int $conversationId): void
|
||||
{
|
||||
abort_unless(
|
||||
ConversationParticipant::query()
|
||||
->where('conversation_id', $conversationId)
|
||||
->where('user_id', $request->user()->id)
|
||||
->whereNull('left_at')
|
||||
->exists(),
|
||||
403,
|
||||
'You are not a participant of this conversation.'
|
||||
);
|
||||
}
|
||||
|
||||
private function key(int $conversationId, int $userId): string
|
||||
{
|
||||
return "typing:{$conversationId}:{$userId}";
|
||||
}
|
||||
|
||||
private function store(): Repository
|
||||
{
|
||||
$store = (string) config('messaging.typing.cache_store', 'redis');
|
||||
if ($store === 'redis' && ! class_exists('Redis')) {
|
||||
return Cache::store();
|
||||
}
|
||||
|
||||
try {
|
||||
return Cache::store($store);
|
||||
} catch (\Throwable) {
|
||||
return Cache::store();
|
||||
}
|
||||
}
|
||||
|
||||
private function findConversationOrFail(int $conversationId): Conversation
|
||||
{
|
||||
$conversation = Conversation::query()->findOrFail($conversationId);
|
||||
$this->authorize('view', $conversation);
|
||||
|
||||
return $conversation;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Services\NotificationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
/**
|
||||
* GET /api/notifications — digestd notification list
|
||||
* POST /api/notifications/read-all — mark all unread as read
|
||||
* POST /api/notifications/{id}/read — mark single as read
|
||||
*/
|
||||
class NotificationController extends Controller
|
||||
{
|
||||
public function __construct(private NotificationService $notifications) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json(
|
||||
$this->notifications->listForUser($request->user(), (int) $request->query('page', 1), 20)
|
||||
);
|
||||
}
|
||||
|
||||
public function readAll(Request $request): JsonResponse
|
||||
{
|
||||
$this->notifications->markAllRead($request->user());
|
||||
return response()->json(['message' => 'All notifications marked as read.']);
|
||||
}
|
||||
|
||||
public function markRead(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$this->notifications->markRead($request->user(), $id);
|
||||
return response()->json(['message' => 'Notification marked as read.']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\NovaCards;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\NovaCard;
|
||||
use App\Services\NovaCards\NovaCardAiAssistService;
|
||||
use App\Services\NovaCards\NovaCardPresenter;
|
||||
use App\Services\NovaCards\NovaCardRelatedCardsService;
|
||||
use App\Services\NovaCards\NovaCardRisingService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class NovaCardDiscoveryController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NovaCardPresenter $presenter,
|
||||
private readonly NovaCardRisingService $rising,
|
||||
private readonly NovaCardRelatedCardsService $related,
|
||||
private readonly NovaCardAiAssistService $aiAssist,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/cards/rising
|
||||
* Returns recently published cards gaining traction fast.
|
||||
*/
|
||||
public function rising(Request $request): JsonResponse
|
||||
{
|
||||
$limit = min((int) $request->query('limit', 18), 36);
|
||||
$cards = $this->rising->risingCards($limit);
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->presenter->cards($cards->all(), false, $request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/cards/{id}/related
|
||||
* Returns related cards for a given card.
|
||||
*/
|
||||
public function related(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$card = NovaCard::query()->publiclyVisible()->findOrFail($id);
|
||||
$limit = min((int) $request->query('limit', 8), 16);
|
||||
$relatedCards = $this->related->related($card, $limit);
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->presenter->cards($relatedCards->all(), false, $request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/cards/{id}/ai-suggest
|
||||
* Returns AI-assist suggestions for the given draft card.
|
||||
* The creator must own the card.
|
||||
*/
|
||||
public function suggest(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$card = NovaCard::query()
|
||||
->where('user_id', $request->user()->id)
|
||||
->findOrFail($id);
|
||||
|
||||
$suggestions = $this->aiAssist->allSuggestions($card);
|
||||
|
||||
return response()->json([
|
||||
'data' => $suggestions,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\NovaCards;
|
||||
|
||||
use App\Events\NovaCards\NovaCardBackgroundUploaded;
|
||||
use App\Events\NovaCards\NovaCardPublished;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\NovaCards\SaveNovaCardDraftRequest;
|
||||
use App\Http\Requests\NovaCards\StoreNovaCardDraftRequest;
|
||||
use App\Http\Requests\NovaCards\UploadNovaCardBackgroundRequest;
|
||||
use App\Models\NovaCard;
|
||||
use App\Services\NovaCards\NovaCardBackgroundService;
|
||||
use App\Services\NovaCards\NovaCardDraftService;
|
||||
use App\Services\NovaCards\NovaCardPresenter;
|
||||
use App\Services\NovaCards\NovaCardPublishService;
|
||||
use App\Services\NovaCards\NovaCardRenderService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class NovaCardDraftController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NovaCardDraftService $drafts,
|
||||
private readonly NovaCardBackgroundService $backgrounds,
|
||||
private readonly NovaCardRenderService $renders,
|
||||
private readonly NovaCardPublishService $publishes,
|
||||
private readonly NovaCardPresenter $presenter,
|
||||
) {
|
||||
}
|
||||
|
||||
public function store(StoreNovaCardDraftRequest $request): JsonResponse
|
||||
{
|
||||
$card = $this->drafts->createDraft($request->user(), $request->validated());
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->presenter->card($card, true, $request->user()),
|
||||
], Response::HTTP_CREATED);
|
||||
}
|
||||
|
||||
public function show(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$card = $this->editableCard($request, $id);
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->presenter->card($card, true, $request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(SaveNovaCardDraftRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$card = $this->editableCard($request, $id);
|
||||
$card = $this->drafts->autosave($card, $request->validated());
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->presenter->card($card, true, $request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function autosave(SaveNovaCardDraftRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$card = $this->editableCard($request, $id);
|
||||
$card = $this->drafts->autosave($card, $request->validated());
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->presenter->card($card, true, $request->user()),
|
||||
'meta' => [
|
||||
'saved_at' => now()->toISOString(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function background(UploadNovaCardBackgroundRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$card = $this->editableCard($request, $id);
|
||||
$background = $this->backgrounds->storeUploadedBackground($request->user(), $request->file('background'));
|
||||
|
||||
$card = $this->drafts->autosave($card, [
|
||||
'background_type' => 'upload',
|
||||
'background_image_id' => $background->id,
|
||||
'project_json' => [
|
||||
'background' => [
|
||||
'type' => 'upload',
|
||||
'background_image_id' => $background->id,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
event(new NovaCardBackgroundUploaded($card, $background));
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->presenter->card($card, true, $request->user()),
|
||||
'background' => [
|
||||
'id' => (int) $background->id,
|
||||
'processed_url' => $background->processedUrl(),
|
||||
'width' => (int) $background->width,
|
||||
'height' => (int) $background->height,
|
||||
],
|
||||
], Response::HTTP_CREATED);
|
||||
}
|
||||
|
||||
public function render(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$card = $this->editableCard($request, $id);
|
||||
$result = $this->renders->render($card->loadMissing('backgroundImage'));
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->presenter->card($card->fresh()->loadMissing(['user.profile', 'category', 'template', 'backgroundImage', 'tags']), true, $request->user()),
|
||||
'render' => $result,
|
||||
]);
|
||||
}
|
||||
|
||||
public function publish(SaveNovaCardDraftRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$card = $this->editableCard($request, $id);
|
||||
$validated = $request->validated();
|
||||
|
||||
if ($validated !== []) {
|
||||
$card = $this->drafts->autosave($card, $validated);
|
||||
}
|
||||
|
||||
if (trim((string) $card->title) === '' || trim((string) $card->quote_text) === '') {
|
||||
return response()->json([
|
||||
'message' => 'Title and quote text are required before publishing.',
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$publishMode = (string) ($validated['publish_mode'] ?? 'now');
|
||||
|
||||
if ($publishMode === 'schedule') {
|
||||
if (empty($validated['scheduled_for'])) {
|
||||
return response()->json([
|
||||
'message' => 'Choose a date and time for scheduled publishing.',
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
try {
|
||||
$card = $this->publishes->schedule(
|
||||
$card->loadMissing('backgroundImage'),
|
||||
Carbon::parse((string) $validated['scheduled_for']),
|
||||
isset($validated['scheduling_timezone']) ? (string) $validated['scheduling_timezone'] : null,
|
||||
);
|
||||
} catch (\InvalidArgumentException $exception) {
|
||||
return response()->json([
|
||||
'message' => $exception->getMessage(),
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->presenter->card($card, true, $request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
$card = $this->publishes->publishNow($card->loadMissing('backgroundImage'));
|
||||
event(new NovaCardPublished($card));
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->presenter->card($card, true, $request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$card = $this->editableCard($request, $id);
|
||||
|
||||
if ($card->status === NovaCard::STATUS_PUBLISHED && in_array($card->visibility, [NovaCard::VISIBILITY_PUBLIC, NovaCard::VISIBILITY_UNLISTED], true)) {
|
||||
return response()->json([
|
||||
'message' => 'Published cards cannot be deleted from the draft API.',
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$card->delete();
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
private function editableCard(Request $request, int $id): NovaCard
|
||||
{
|
||||
return NovaCard::query()
|
||||
->with(['user.profile', 'category', 'template', 'backgroundImage', 'tags'])
|
||||
->where('user_id', $request->user()->id)
|
||||
->findOrFail($id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\NovaCards;
|
||||
|
||||
use App\Jobs\UpdateNovaCardStatsJob;
|
||||
use App\Events\NovaCards\NovaCardDownloaded;
|
||||
use App\Events\NovaCards\NovaCardShared;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\NovaCard;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class NovaCardEngagementController extends Controller
|
||||
{
|
||||
public function share(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$card = $this->card($request, $id);
|
||||
$card->increment('shares_count');
|
||||
$card->refresh();
|
||||
UpdateNovaCardStatsJob::dispatch($card->id);
|
||||
|
||||
event(new NovaCardShared($card, $request->user()?->id));
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'shares_count' => (int) $card->shares_count,
|
||||
]);
|
||||
}
|
||||
|
||||
public function download(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$card = $this->card($request, $id);
|
||||
abort_unless($card->allow_download && $card->previewUrl() !== null, 404);
|
||||
|
||||
$card->increment('downloads_count');
|
||||
$card->refresh();
|
||||
UpdateNovaCardStatsJob::dispatch($card->id);
|
||||
|
||||
event(new NovaCardDownloaded($card, $request->user()?->id));
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'downloads_count' => (int) $card->downloads_count,
|
||||
'download_url' => $card->previewUrl(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function card(Request $request, int $id): NovaCard
|
||||
{
|
||||
$card = NovaCard::query()
|
||||
->with(['user.profile', 'category', 'template', 'backgroundImage', 'tags'])
|
||||
->published()
|
||||
->findOrFail($id);
|
||||
|
||||
abort_unless($card->canBeViewedBy($request->user()), 404);
|
||||
|
||||
return $card;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\NovaCards;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\NovaCard;
|
||||
use App\Models\NovaCardExport;
|
||||
use App\Services\NovaCards\NovaCardExportService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class NovaCardExportController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NovaCardExportService $exports,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Request an export for the given card.
|
||||
*
|
||||
* POST /api/cards/{id}/export
|
||||
*/
|
||||
public function store(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$card = NovaCard::query()
|
||||
->where(function ($q) use ($request): void {
|
||||
// Owner can export any status; others can only export published cards.
|
||||
$q->where('user_id', $request->user()->id)
|
||||
->orWhere(function ($inner) use ($request): void {
|
||||
$inner->where('status', NovaCard::STATUS_PUBLISHED)
|
||||
->where('visibility', NovaCard::VISIBILITY_PUBLIC)
|
||||
->where('allow_export', true);
|
||||
});
|
||||
})
|
||||
->findOrFail($id);
|
||||
|
||||
$data = $request->validate([
|
||||
'export_type' => ['required', 'string', 'in:' . implode(',', array_keys(NovaCardExportService::EXPORT_SPECS))],
|
||||
'options' => ['sometimes', 'array'],
|
||||
]);
|
||||
|
||||
$export = $this->exports->requestExport(
|
||||
$request->user(),
|
||||
$card,
|
||||
$data['export_type'],
|
||||
(array) ($data['options'] ?? []),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->exports->getStatus($export),
|
||||
], $export->wasRecentlyCreated ? Response::HTTP_ACCEPTED : Response::HTTP_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll export status.
|
||||
*
|
||||
* GET /api/cards/exports/{exportId}
|
||||
*/
|
||||
public function show(Request $request, int $exportId): JsonResponse
|
||||
{
|
||||
$export = NovaCardExport::query()
|
||||
->where('user_id', $request->user()->id)
|
||||
->findOrFail($exportId);
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->exports->getStatus($export),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\NovaCards;
|
||||
|
||||
use App\Jobs\UpdateNovaCardStatsJob;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\NovaCard;
|
||||
use App\Models\NovaCardChallenge;
|
||||
use App\Models\NovaCardReaction;
|
||||
use App\Models\NovaCardVersion;
|
||||
use App\Services\NovaCards\NovaCardChallengeService;
|
||||
use App\Services\NovaCards\NovaCardCollectionService;
|
||||
use App\Services\NovaCards\NovaCardDraftService;
|
||||
use App\Services\NovaCards\NovaCardLineageService;
|
||||
use App\Services\NovaCards\NovaCardPresenter;
|
||||
use App\Services\NovaCards\NovaCardReactionService;
|
||||
use App\Services\NovaCards\NovaCardVersionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class NovaCardInteractionController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NovaCardReactionService $reactions,
|
||||
private readonly NovaCardCollectionService $collections,
|
||||
private readonly NovaCardDraftService $drafts,
|
||||
private readonly NovaCardVersionService $versions,
|
||||
private readonly NovaCardChallengeService $challenges,
|
||||
private readonly NovaCardLineageService $lineage,
|
||||
private readonly NovaCardPresenter $presenter,
|
||||
) {
|
||||
}
|
||||
|
||||
public function lineage(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$card = $this->visibleCard($request, $id);
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->lineage->resolve($card, $request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function like(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$card = $this->visibleCard($request, $id);
|
||||
$state = $this->reactions->setReaction($request->user(), $card, NovaCardReaction::TYPE_LIKE, true);
|
||||
|
||||
return response()->json(['ok' => true, ...$state]);
|
||||
}
|
||||
|
||||
public function unlike(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$card = $this->visibleCard($request, $id);
|
||||
$state = $this->reactions->setReaction($request->user(), $card, NovaCardReaction::TYPE_LIKE, false);
|
||||
|
||||
return response()->json(['ok' => true, ...$state]);
|
||||
}
|
||||
|
||||
public function favorite(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$card = $this->visibleCard($request, $id);
|
||||
$state = $this->reactions->setReaction($request->user(), $card, NovaCardReaction::TYPE_FAVORITE, true);
|
||||
|
||||
return response()->json(['ok' => true, ...$state]);
|
||||
}
|
||||
|
||||
public function unfavorite(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$card = $this->visibleCard($request, $id);
|
||||
$state = $this->reactions->setReaction($request->user(), $card, NovaCardReaction::TYPE_FAVORITE, false);
|
||||
|
||||
return response()->json(['ok' => true, ...$state]);
|
||||
}
|
||||
|
||||
public function collections(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'data' => $this->collections->listCollections($request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeCollection(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'name' => ['required', 'string', 'min:2', 'max:120'],
|
||||
'slug' => ['nullable', 'string', 'max:140'],
|
||||
'description' => ['nullable', 'string', 'max:500'],
|
||||
'visibility' => ['nullable', 'in:private,public'],
|
||||
]);
|
||||
|
||||
$collection = $this->collections->createCollection($request->user(), $payload);
|
||||
|
||||
return response()->json([
|
||||
'collection' => [
|
||||
'id' => (int) $collection->id,
|
||||
'slug' => (string) $collection->slug,
|
||||
'name' => (string) $collection->name,
|
||||
'description' => $collection->description,
|
||||
'visibility' => (string) $collection->visibility,
|
||||
'cards_count' => (int) $collection->cards_count,
|
||||
],
|
||||
], Response::HTTP_CREATED);
|
||||
}
|
||||
|
||||
public function updateCollection(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$collection = $this->ownedCollection($request, $id);
|
||||
$payload = $request->validate([
|
||||
'name' => ['required', 'string', 'min:2', 'max:120'],
|
||||
'slug' => ['nullable', 'string', 'max:140'],
|
||||
'description' => ['nullable', 'string', 'max:500'],
|
||||
'visibility' => ['nullable', 'in:private,public'],
|
||||
]);
|
||||
|
||||
$collection->update([
|
||||
'name' => $payload['name'],
|
||||
'slug' => $payload['slug'] ?: $collection->slug,
|
||||
'description' => $payload['description'] ?? null,
|
||||
'visibility' => $payload['visibility'] ?? $collection->visibility,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'collection' => $this->presenter->collection($collection->fresh(['user', 'items.card.user.profile', 'items.card.category', 'items.card.template', 'items.card.backgroundImage', 'items.card.tags']), $request->user(), true),
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeCollectionItem(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$collection = $this->ownedCollection($request, $id);
|
||||
$payload = $request->validate([
|
||||
'card_id' => ['required', 'integer'],
|
||||
'note' => ['nullable', 'string', 'max:500'],
|
||||
'sort_order' => ['nullable', 'integer', 'min:0'],
|
||||
]);
|
||||
|
||||
$card = $this->visibleCard($request, (int) $payload['card_id']);
|
||||
$this->collections->addCardToCollection($collection, $card, $payload['note'] ?? null, $payload['sort_order'] ?? null);
|
||||
|
||||
return response()->json([
|
||||
'collection' => $this->presenter->collection($collection->fresh(['user', 'items.card.user.profile', 'items.card.category', 'items.card.template', 'items.card.backgroundImage', 'items.card.tags']), $request->user(), true),
|
||||
], Response::HTTP_CREATED);
|
||||
}
|
||||
|
||||
public function destroyCollectionItem(Request $request, int $id, int $cardId): JsonResponse
|
||||
{
|
||||
$collection = $this->ownedCollection($request, $id);
|
||||
$card = NovaCard::query()->findOrFail($cardId);
|
||||
$this->collections->removeCardFromCollection($collection, $card);
|
||||
|
||||
return response()->json([
|
||||
'collection' => $this->presenter->collection($collection->fresh(['user', 'items.card.user.profile', 'items.card.category', 'items.card.template', 'items.card.backgroundImage', 'items.card.tags']), $request->user(), true),
|
||||
]);
|
||||
}
|
||||
|
||||
public function challenges(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'data' => $this->presenter->options()['challenge_feed'] ?? [],
|
||||
]);
|
||||
}
|
||||
|
||||
public function assets(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'data' => $this->presenter->options()['asset_packs'] ?? [],
|
||||
]);
|
||||
}
|
||||
|
||||
public function templates(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'packs' => $this->presenter->options()['template_packs'] ?? [],
|
||||
'templates' => $this->presenter->options()['templates'] ?? [],
|
||||
]);
|
||||
}
|
||||
|
||||
public function save(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$card = $this->visibleCard($request, $id);
|
||||
$payload = $request->validate([
|
||||
'collection_id' => ['nullable', 'integer'],
|
||||
'note' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
$collection = $this->collections->saveCard($request->user(), $card, $payload['collection_id'] ?? null, $payload['note'] ?? null);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'saves_count' => (int) $card->fresh()->saves_count,
|
||||
'collection' => [
|
||||
'id' => (int) $collection->id,
|
||||
'name' => (string) $collection->name,
|
||||
'slug' => (string) $collection->slug,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function unsave(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$card = $this->visibleCard($request, $id);
|
||||
$payload = $request->validate([
|
||||
'collection_id' => ['nullable', 'integer'],
|
||||
]);
|
||||
$this->collections->unsaveCard($request->user(), $card, $payload['collection_id'] ?? null);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'saves_count' => (int) $card->fresh()->saves_count,
|
||||
]);
|
||||
}
|
||||
|
||||
public function remix(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$source = $this->visibleCard($request, $id);
|
||||
abort_unless($source->allow_remix, 422, 'This card does not allow remixes.');
|
||||
|
||||
$card = $this->drafts->createRemix($request->user(), $source);
|
||||
$source->increment('remixes_count');
|
||||
UpdateNovaCardStatsJob::dispatch($source->id);
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->presenter->card($card, true, $request->user()),
|
||||
], Response::HTTP_CREATED);
|
||||
}
|
||||
|
||||
public function duplicate(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$source = $this->ownedCard($request, $id);
|
||||
$card = $this->drafts->createDuplicate($request->user(), $source->loadMissing('tags'));
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->presenter->card($card, true, $request->user()),
|
||||
], Response::HTTP_CREATED);
|
||||
}
|
||||
|
||||
public function versions(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$card = $this->ownedCard($request, $id);
|
||||
$versions = $card->versions()
|
||||
->latest('version_number')
|
||||
->get()
|
||||
->map(fn (NovaCardVersion $version): array => [
|
||||
'id' => (int) $version->id,
|
||||
'version_number' => (int) $version->version_number,
|
||||
'label' => $version->label,
|
||||
'created_at' => $version->created_at?->toISOString(),
|
||||
'snapshot_json' => is_array($version->snapshot_json) ? $version->snapshot_json : [],
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return response()->json(['data' => $versions]);
|
||||
}
|
||||
|
||||
public function restoreVersion(Request $request, int $id, int $versionId): JsonResponse
|
||||
{
|
||||
$card = $this->ownedCard($request, $id);
|
||||
$version = $card->versions()->findOrFail($versionId);
|
||||
$card = $this->versions->restore($card, $version, $request->user());
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->presenter->card($card, true, $request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function submitChallenge(Request $request, int $challengeId, int $id): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'note' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
return $this->submitChallengeWithPayload($request, $challengeId, $id, $payload['note'] ?? null);
|
||||
}
|
||||
|
||||
public function submitChallengeByChallenge(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'card_id' => ['required', 'integer'],
|
||||
'note' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
return $this->submitChallengeWithPayload($request, $id, (int) $payload['card_id'], $payload['note'] ?? null);
|
||||
}
|
||||
|
||||
private function submitChallengeWithPayload(Request $request, int $challengeId, int $cardId, ?string $note = null): JsonResponse
|
||||
{
|
||||
$card = $this->ownedCard($request, $cardId);
|
||||
abort_unless($card->status === NovaCard::STATUS_PUBLISHED, 422, 'Publish the card before entering a challenge.');
|
||||
|
||||
$challenge = NovaCardChallenge::query()
|
||||
->where('status', NovaCardChallenge::STATUS_ACTIVE)
|
||||
->findOrFail($challengeId);
|
||||
|
||||
$entry = $this->challenges->submit($request->user(), $challenge, $card, $note);
|
||||
UpdateNovaCardStatsJob::dispatch($card->id);
|
||||
|
||||
return response()->json([
|
||||
'entry' => [
|
||||
'id' => (int) $entry->id,
|
||||
'challenge_id' => (int) $entry->challenge_id,
|
||||
'card_id' => (int) $entry->card_id,
|
||||
'status' => (string) $entry->status,
|
||||
],
|
||||
'challenge_entries_count' => (int) $card->fresh()->challenge_entries_count,
|
||||
]);
|
||||
}
|
||||
|
||||
private function visibleCard(Request $request, int $id): NovaCard
|
||||
{
|
||||
$card = NovaCard::query()
|
||||
->with(['user.profile', 'category', 'template', 'backgroundImage', 'tags', 'originalCard', 'rootCard'])
|
||||
->published()
|
||||
->findOrFail($id);
|
||||
|
||||
abort_unless($card->canBeViewedBy($request->user()), 404);
|
||||
|
||||
return $card;
|
||||
}
|
||||
|
||||
private function ownedCard(Request $request, int $id): NovaCard
|
||||
{
|
||||
return NovaCard::query()
|
||||
->with(['user.profile', 'category', 'template', 'backgroundImage', 'tags', 'versions'])
|
||||
->where('user_id', $request->user()->id)
|
||||
->findOrFail($id);
|
||||
}
|
||||
|
||||
private function ownedCollection(Request $request, int $id): \App\Models\NovaCardCollection
|
||||
{
|
||||
return \App\Models\NovaCardCollection::query()
|
||||
->where('user_id', $request->user()->id)
|
||||
->findOrFail($id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\NovaCards;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\NovaCard;
|
||||
use App\Models\NovaCardCreatorPreset;
|
||||
use App\Services\NovaCards\NovaCardCreatorPresetService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class NovaCardPresetController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NovaCardCreatorPresetService $presets,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$type = $request->query('type');
|
||||
$items = $this->presets->listForUser(
|
||||
$request->user(),
|
||||
is_string($type) && in_array($type, NovaCardCreatorPreset::TYPES, true) ? $type : null,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => $items->map(fn ($p) => $this->presets->toArray($p))->values()->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:120'],
|
||||
'preset_type' => ['required', 'string', 'in:' . implode(',', NovaCardCreatorPreset::TYPES)],
|
||||
'config_json' => ['required', 'array'],
|
||||
'is_default' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
|
||||
$preset = $this->presets->create($request->user(), $data);
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->presets->toArray($preset),
|
||||
], Response::HTTP_CREATED);
|
||||
}
|
||||
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$preset = NovaCardCreatorPreset::query()
|
||||
->where('user_id', $request->user()->id)
|
||||
->findOrFail($id);
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['sometimes', 'string', 'max:120'],
|
||||
'config_json' => ['sometimes', 'array'],
|
||||
'is_default' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
|
||||
$preset = $this->presets->update($request->user(), $preset, $data);
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->presets->toArray($preset),
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$preset = NovaCardCreatorPreset::query()->findOrFail($id);
|
||||
|
||||
$this->presets->delete($request->user(), $preset);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a preset from an existing published card.
|
||||
*/
|
||||
public function captureFromCard(Request $request, int $cardId): JsonResponse
|
||||
{
|
||||
$card = NovaCard::query()
|
||||
->where('user_id', $request->user()->id)
|
||||
->findOrFail($cardId);
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:120'],
|
||||
'preset_type' => ['required', 'string', 'in:' . implode(',', NovaCardCreatorPreset::TYPES)],
|
||||
]);
|
||||
|
||||
$preset = $this->presets->captureFromCard(
|
||||
$request->user(),
|
||||
$card,
|
||||
$data['name'],
|
||||
$data['preset_type'],
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->presets->toArray($preset),
|
||||
], Response::HTTP_CREATED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a saved preset to a draft card, returning a project_json patch.
|
||||
*/
|
||||
public function applyToCard(Request $request, int $presetId, int $cardId): JsonResponse
|
||||
{
|
||||
$preset = NovaCardCreatorPreset::query()
|
||||
->where('user_id', $request->user()->id)
|
||||
->findOrFail($presetId);
|
||||
|
||||
$card = NovaCard::query()
|
||||
->where('user_id', $request->user()->id)
|
||||
->whereIn('status', [NovaCard::STATUS_DRAFT, NovaCard::STATUS_PUBLISHED])
|
||||
->findOrFail($cardId);
|
||||
|
||||
$currentProject = is_array($card->project_json) ? $card->project_json : [];
|
||||
$patch = $this->presets->applyToProjectPatch($preset, $currentProject);
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'preset' => $this->presets->toArray($preset),
|
||||
'project_patch' => $patch,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Post;
|
||||
use App\Services\Posts\PostAnalyticsService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* POST /api/posts/{id}/impression — record an impression (throttled)
|
||||
* GET /api/posts/{id}/analytics — owner analytics summary
|
||||
*/
|
||||
class PostAnalyticsController extends Controller
|
||||
{
|
||||
public function __construct(private PostAnalyticsService $analytics) {}
|
||||
|
||||
public function impression(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$post = Post::where('status', Post::STATUS_PUBLISHED)->findOrFail($id);
|
||||
|
||||
// Session key: authenticated user ID or hashed IP
|
||||
$sessionKey = $request->user()
|
||||
? 'u:' . $request->user()->id
|
||||
: 'ip:' . md5($request->ip());
|
||||
|
||||
$counted = $this->analytics->trackImpression($post, $sessionKey);
|
||||
|
||||
return response()->json(['counted' => $counted]);
|
||||
}
|
||||
|
||||
public function show(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$post = Post::findOrFail($id);
|
||||
|
||||
// Only the post owner can view analytics
|
||||
if ($request->user()?->id !== $post->user_id) {
|
||||
abort(403, 'You do not own this post.');
|
||||
}
|
||||
|
||||
return response()->json(['data' => $this->analytics->getSummary($post)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Events\Posts\PostCommented;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Posts\CreateCommentRequest;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostComment;
|
||||
use App\Services\ContentSanitizer;
|
||||
use App\Services\Posts\PostCountersService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
class PostCommentController extends Controller
|
||||
{
|
||||
public function __construct(private PostCountersService $counters) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// List
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function index(Request $request, int $postId): JsonResponse
|
||||
{
|
||||
$post = Post::findOrFail($postId);
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
|
||||
$comments = PostComment::with(['user', 'user.profile'])
|
||||
->where('post_id', $post->id)
|
||||
->orderByDesc('is_highlighted') // highlighted first
|
||||
->orderBy('created_at')
|
||||
->paginate(20, ['*'], 'page', $page);
|
||||
|
||||
$formatted = $comments->getCollection()->map(fn ($c) => $this->formatComment($c));
|
||||
|
||||
return response()->json([
|
||||
'data' => $formatted,
|
||||
'meta' => [
|
||||
'total' => $comments->total(),
|
||||
'current_page' => $comments->currentPage(),
|
||||
'last_page' => $comments->lastPage(),
|
||||
'per_page' => $comments->perPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Store
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function store(CreateCommentRequest $request, int $postId): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Rate limit: 30 comments per hour
|
||||
$key = 'comment_post:' . $user->id;
|
||||
if (RateLimiter::tooManyAttempts($key, 30)) {
|
||||
$seconds = RateLimiter::availableIn($key);
|
||||
return response()->json([
|
||||
'message' => "You're commenting too quickly. Please wait {$seconds} seconds.",
|
||||
], 429);
|
||||
}
|
||||
RateLimiter::hit($key, 3600);
|
||||
|
||||
$post = Post::findOrFail($postId);
|
||||
$body = ContentSanitizer::render($request->input('body'));
|
||||
|
||||
$comment = PostComment::create([
|
||||
'post_id' => $post->id,
|
||||
'user_id' => $user->id,
|
||||
'body' => $body,
|
||||
]);
|
||||
|
||||
$this->counters->incrementComments($post);
|
||||
|
||||
// Fire event for notification
|
||||
if ($post->user_id !== $user->id) {
|
||||
event(new PostCommented($post, $comment, $user));
|
||||
}
|
||||
|
||||
$comment->load(['user', 'user.profile']);
|
||||
|
||||
return response()->json(['comment' => $this->formatComment($comment)], 201);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Destroy
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function destroy(Request $request, int $postId, int $commentId): JsonResponse
|
||||
{
|
||||
$comment = PostComment::where('post_id', $postId)->findOrFail($commentId);
|
||||
Gate::authorize('delete', $comment);
|
||||
|
||||
$comment->delete();
|
||||
$this->counters->decrementComments(Post::findOrFail($postId));
|
||||
|
||||
return response()->json(['message' => 'Comment deleted.']);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Format
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private function formatComment(PostComment $comment): array
|
||||
{
|
||||
return [
|
||||
'id' => $comment->id,
|
||||
'body' => $comment->body,
|
||||
'is_highlighted' => (bool) $comment->is_highlighted,
|
||||
'created_at' => $comment->created_at->toISOString(),
|
||||
'author' => [
|
||||
'id' => $comment->user->id,
|
||||
'username' => $comment->user->username,
|
||||
'name' => $comment->user->name,
|
||||
'avatar' => $comment->user->profile?->avatar_url ?? null,
|
||||
'level' => (int) ($comment->user->level ?? 1),
|
||||
'rank' => (string) ($comment->user->rank ?? 'Newbie'),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostComment;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* POST /api/posts/{post_id}/comments/{comment_id}/highlight
|
||||
* DELETE /api/posts/{post_id}/comments/{comment_id}/highlight
|
||||
*
|
||||
* Only the post owner may highlight/un-highlight.
|
||||
* Only 1 highlighted comment per post is allowed at a time.
|
||||
*/
|
||||
class PostCommentHighlightController extends Controller
|
||||
{
|
||||
public function highlight(Request $request, int $postId, int $commentId): JsonResponse
|
||||
{
|
||||
$post = Post::findOrFail($postId);
|
||||
$comment = PostComment::where('post_id', $postId)->findOrFail($commentId);
|
||||
|
||||
if ($request->user()->id !== $post->user_id) {
|
||||
abort(403, 'Only the post owner can highlight comments.');
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($post, $comment) {
|
||||
// Remove any existing highlight on this post
|
||||
PostComment::where('post_id', $post->id)
|
||||
->where('is_highlighted', true)
|
||||
->update(['is_highlighted' => false]);
|
||||
|
||||
$comment->update(['is_highlighted' => true]);
|
||||
});
|
||||
|
||||
return response()->json(['message' => 'Comment highlighted.', 'comment_id' => $comment->id]);
|
||||
}
|
||||
|
||||
public function unhighlight(Request $request, int $postId, int $commentId): JsonResponse
|
||||
{
|
||||
$post = Post::findOrFail($postId);
|
||||
$comment = PostComment::where('post_id', $postId)->findOrFail($commentId);
|
||||
|
||||
if ($request->user()->id !== $post->user_id) {
|
||||
abort(403, 'Only the post owner can remove comment highlights.');
|
||||
}
|
||||
|
||||
$comment->update(['is_highlighted' => false]);
|
||||
|
||||
return response()->json(['message' => 'Highlight removed.', 'comment_id' => $comment->id]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Posts\CreatePostRequest;
|
||||
use App\Http\Requests\Posts\UpdatePostRequest;
|
||||
use App\Models\Post;
|
||||
use App\Services\Posts\PostFeedService;
|
||||
use App\Services\Posts\PostService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
class PostController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private PostService $postService,
|
||||
private PostFeedService $feedService,
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Create
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function store(CreatePostRequest $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Rate limit: 10 post creations per hour
|
||||
$key = 'create_post:' . $user->id;
|
||||
if (RateLimiter::tooManyAttempts($key, 10)) {
|
||||
$seconds = RateLimiter::availableIn($key);
|
||||
return response()->json([
|
||||
'message' => "You're posting too quickly. Please wait {$seconds} seconds.",
|
||||
], 429);
|
||||
}
|
||||
RateLimiter::hit($key, 3600);
|
||||
|
||||
Gate::authorize('create', Post::class);
|
||||
|
||||
$post = $this->postService->createPost(
|
||||
user: $user,
|
||||
type: $request->input('type', Post::TYPE_TEXT),
|
||||
visibility: $request->input('visibility', Post::VISIBILITY_PUBLIC),
|
||||
body: $request->input('body'),
|
||||
targets: $request->input('targets', []),
|
||||
linkPreview: $request->input('link_preview'),
|
||||
taggedUsers: $request->input('tagged_users'), publishAt: $request->filled('publish_at') ? Carbon::parse($request->input('publish_at')) : null, );
|
||||
|
||||
$post->load(['user', 'user.profile', 'targets', 'targets.artwork', 'targets.artwork.user', 'targets.artwork.user.profile', 'reactions']);
|
||||
|
||||
return response()->json([
|
||||
'post' => $this->feedService->formatPost($post, $user->id),
|
||||
], 201);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Update
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function update(UpdatePostRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$post = Post::findOrFail($id);
|
||||
Gate::authorize('update', $post);
|
||||
|
||||
$updated = $this->postService->updatePost(
|
||||
post: $post,
|
||||
body: $request->input('body'),
|
||||
visibility: $request->input('visibility'),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'post' => $this->feedService->formatPost($updated->load(['user', 'user.profile', 'targets', 'targets.artwork', 'targets.artwork.user', 'targets.artwork.user.profile', 'reactions']), $request->user()?->id),
|
||||
]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Delete
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
$post = Post::findOrFail($id);
|
||||
Gate::authorize('delete', $post);
|
||||
|
||||
$this->postService->deletePost($post);
|
||||
|
||||
return response()->json(['message' => 'Post deleted.']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Services\Posts\PostFeedService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PostFeedController extends Controller
|
||||
{
|
||||
public function __construct(private PostFeedService $feedService) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Profile feed — GET /api/posts/profile/{username}
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function profile(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$profileUser = User::where('username', $username)->firstOrFail();
|
||||
$viewerId = $request->user()?->id;
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
|
||||
$paginated = $this->feedService->getProfileFeed($profileUser, $viewerId, $page);
|
||||
|
||||
$formatted = collect($paginated['data'])
|
||||
->map(fn ($post) => $this->feedService->formatPost($post, $viewerId))
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $formatted,
|
||||
'meta' => $paginated['meta'],
|
||||
]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Following feed — GET /api/posts/following
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function following(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$filter = $request->query('filter', 'all');
|
||||
|
||||
$result = $this->feedService->getFollowingFeed($user, $page, $filter);
|
||||
|
||||
$viewerId = $user->id;
|
||||
$formatted = array_map(
|
||||
fn ($post) => $this->feedService->formatPost($post, $viewerId),
|
||||
$result['data'],
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => $formatted,
|
||||
'meta' => $result['meta'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Post;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
/**
|
||||
* POST /api/posts/{id}/pin
|
||||
* DELETE /api/posts/{id}/pin
|
||||
*/
|
||||
class PostPinController extends Controller
|
||||
{
|
||||
private const MAX_PINNED = 3;
|
||||
|
||||
public function pin(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$post = Post::where('status', Post::STATUS_PUBLISHED)->findOrFail($id);
|
||||
Gate::authorize('update', $post);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
// Count existing pinned posts
|
||||
$pinnedCount = Post::where('user_id', $user->id)
|
||||
->where('is_pinned', true)
|
||||
->count();
|
||||
|
||||
if ($post->is_pinned) {
|
||||
return response()->json(['message' => 'Post is already pinned.'], 409);
|
||||
}
|
||||
|
||||
if ($pinnedCount >= self::MAX_PINNED) {
|
||||
return response()->json([
|
||||
'message' => 'You can pin a maximum of ' . self::MAX_PINNED . ' posts.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$nextOrder = Post::where('user_id', $user->id)
|
||||
->where('is_pinned', true)
|
||||
->max('pinned_order') ?? 0;
|
||||
|
||||
$post->update([
|
||||
'is_pinned' => true,
|
||||
'pinned_order' => $nextOrder + 1,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Post pinned.', 'post_id' => $post->id]);
|
||||
}
|
||||
|
||||
public function unpin(int $id): JsonResponse
|
||||
{
|
||||
$post = Post::findOrFail($id);
|
||||
Gate::authorize('update', $post);
|
||||
|
||||
if (! $post->is_pinned) {
|
||||
return response()->json(['message' => 'Post is not pinned.'], 409);
|
||||
}
|
||||
|
||||
$post->update(['is_pinned' => false, 'pinned_order' => null]);
|
||||
|
||||
return response()->json(['message' => 'Post unpinned.', 'post_id' => $post->id]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostReaction;
|
||||
use App\Services\Posts\PostCountersService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
class PostReactionController extends Controller
|
||||
{
|
||||
public function __construct(private PostCountersService $counters) {}
|
||||
|
||||
/**
|
||||
* POST /api/posts/{id}/reactions
|
||||
* payload: { reaction: 'like' }
|
||||
*/
|
||||
public function store(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$key = 'react_post:' . $user->id;
|
||||
if (RateLimiter::tooManyAttempts($key, 60)) {
|
||||
return response()->json(['message' => 'Too many reactions. Please slow down.'], 429);
|
||||
}
|
||||
RateLimiter::hit($key, 3600);
|
||||
|
||||
$post = Post::findOrFail($id);
|
||||
$reaction = $request->input('reaction', 'like');
|
||||
|
||||
$existing = PostReaction::where('post_id', $post->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('reaction', $reaction)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return response()->json(['message' => 'Already reacted.', 'reactions_count' => $post->reactions_count], 200);
|
||||
}
|
||||
|
||||
PostReaction::create([
|
||||
'post_id' => $post->id,
|
||||
'user_id' => $user->id,
|
||||
'reaction' => $reaction,
|
||||
]);
|
||||
|
||||
$this->counters->incrementReactions($post);
|
||||
$post->refresh();
|
||||
|
||||
return response()->json(['reactions_count' => $post->reactions_count, 'viewer_liked' => true], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/posts/{id}/reactions/{reaction}
|
||||
*/
|
||||
public function destroy(Request $request, int $id, string $reaction = 'like'): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$post = Post::findOrFail($id);
|
||||
|
||||
$deleted = PostReaction::where('post_id', $post->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('reaction', $reaction)
|
||||
->delete();
|
||||
|
||||
if ($deleted) {
|
||||
$this->counters->decrementReactions($post);
|
||||
$post->refresh();
|
||||
}
|
||||
|
||||
return response()->json(['reactions_count' => $post->reactions_count, 'viewer_liked' => false]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostReport;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class PostReportController extends Controller
|
||||
{
|
||||
/**
|
||||
* POST /api/posts/{id}/report
|
||||
* payload: { reason, message? }
|
||||
*/
|
||||
public function store(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$post = Post::findOrFail($id);
|
||||
|
||||
Gate::authorize('report', $post);
|
||||
|
||||
$request->validate([
|
||||
'reason' => ['required', 'string', 'max:64'],
|
||||
'message' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
// Unique report per user+post
|
||||
$existing = PostReport::where('post_id', $post->id)
|
||||
->where('reporter_user_id', $user->id)
|
||||
->exists();
|
||||
|
||||
if ($existing) {
|
||||
return response()->json(['message' => 'You have already reported this post.'], 409);
|
||||
}
|
||||
|
||||
PostReport::create([
|
||||
'post_id' => $post->id,
|
||||
'reporter_user_id' => $user->id,
|
||||
'reason' => $request->input('reason'),
|
||||
'message' => $request->input('message'),
|
||||
'status' => 'open',
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Report submitted. Thank you for helping keep Skinbase safe.'], 201);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostSave;
|
||||
use App\Services\Posts\PostCountersService;
|
||||
use App\Services\Posts\PostFeedService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* POST /api/posts/{id}/save
|
||||
* DELETE /api/posts/{id}/save
|
||||
* GET /api/posts/saved
|
||||
*/
|
||||
class PostSaveController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private PostCountersService $counters,
|
||||
private PostFeedService $feedService,
|
||||
) {}
|
||||
|
||||
public function save(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$post = Post::where('status', Post::STATUS_PUBLISHED)->findOrFail($id);
|
||||
$user = $request->user();
|
||||
|
||||
if (PostSave::where('post_id', $post->id)->where('user_id', $user->id)->exists()) {
|
||||
return response()->json(['message' => 'Already saved.', 'saved' => true], 200);
|
||||
}
|
||||
|
||||
PostSave::create(['post_id' => $post->id, 'user_id' => $user->id]);
|
||||
$this->counters->incrementSaves($post);
|
||||
|
||||
return response()->json(['message' => 'Post saved.', 'saved' => true, 'saves_count' => $post->fresh()->saves_count]);
|
||||
}
|
||||
|
||||
public function unsave(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$post = Post::findOrFail($id);
|
||||
$user = $request->user();
|
||||
$save = PostSave::where('post_id', $post->id)->where('user_id', $user->id)->first();
|
||||
|
||||
if (! $save) {
|
||||
return response()->json(['message' => 'Not saved.', 'saved' => false], 200);
|
||||
}
|
||||
|
||||
$save->delete();
|
||||
$this->counters->decrementSaves($post);
|
||||
|
||||
return response()->json(['message' => 'Post unsaved.', 'saved' => false, 'saves_count' => $post->fresh()->saves_count]);
|
||||
}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$result = $this->feedService->getSavedFeed($user, $page);
|
||||
|
||||
$formatted = array_map(
|
||||
fn ($post) => $this->feedService->formatPost($post, $user->id),
|
||||
$result['data'],
|
||||
);
|
||||
|
||||
return response()->json(['data' => array_values($formatted), 'meta' => $result['meta']]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Post;
|
||||
use App\Services\Posts\PostFeedService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* GET /api/feed/search?q=...
|
||||
*
|
||||
* Searches posts body + hashtags via Meilisearch (Laravel Scout).
|
||||
* Falls back to a simple LIKE query if Scout is unavailable.
|
||||
*/
|
||||
class PostSearchController extends Controller
|
||||
{
|
||||
public function __construct(private PostFeedService $feedService) {}
|
||||
|
||||
public function search(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'q' => ['required', 'string', 'min:2', 'max:100'],
|
||||
'page' => ['nullable', 'integer', 'min:1'],
|
||||
]);
|
||||
|
||||
$query = trim($request->input('q'));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$perPage = 20;
|
||||
$viewerId = $request->user()?->id;
|
||||
|
||||
// Scout search (Meilisearch)
|
||||
try {
|
||||
$results = Post::search($query)
|
||||
->where('visibility', Post::VISIBILITY_PUBLIC)
|
||||
->where('status', Post::STATUS_PUBLISHED)
|
||||
->paginate($perPage, 'page', $page);
|
||||
|
||||
// Load relations
|
||||
$results->load($this->feedService->publicEagerLoads());
|
||||
|
||||
$formatted = $results->getCollection()
|
||||
->map(fn ($post) => $this->feedService->formatPost($post, $viewerId))
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $formatted,
|
||||
'query' => $query,
|
||||
'meta' => [
|
||||
'total' => $results->total(),
|
||||
'current_page' => $results->currentPage(),
|
||||
'last_page' => $results->lastPage(),
|
||||
'per_page' => $results->perPage(),
|
||||
],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
// Fallback: basic LIKE search on body
|
||||
$paginated = Post::with($this->feedService->publicEagerLoads())
|
||||
->where('status', Post::STATUS_PUBLISHED)
|
||||
->where('visibility', Post::VISIBILITY_PUBLIC)
|
||||
->where(function ($q) use ($query) {
|
||||
$q->where('body', 'like', '%' . $query . '%')
|
||||
->orWhereHas('hashtags', fn ($hq) => $hq->where('tag', 'like', '%' . mb_strtolower($query) . '%'));
|
||||
})
|
||||
->orderByDesc('created_at')
|
||||
->paginate($perPage, ['*'], 'page', $page);
|
||||
|
||||
$formatted = $paginated->getCollection()
|
||||
->map(fn ($post) => $this->feedService->formatPost($post, $viewerId))
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $formatted,
|
||||
'query' => $query,
|
||||
'meta' => [
|
||||
'total' => $paginated->total(),
|
||||
'current_page' => $paginated->currentPage(),
|
||||
'last_page' => $paginated->lastPage(),
|
||||
'per_page' => $paginated->perPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Events\Posts\ArtworkShared;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Posts\ShareArtworkRequest;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\Posts\PostFeedService;
|
||||
use App\Services\Posts\PostShareService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
class PostShareController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private PostShareService $shareService,
|
||||
private PostFeedService $feedService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* POST /api/posts/share/artwork/{artwork_id}
|
||||
* payload: { body?, visibility }
|
||||
*/
|
||||
public function shareArtwork(ShareArtworkRequest $request, int $artworkId): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$artwork = Artwork::findOrFail($artworkId);
|
||||
|
||||
// Rate limit: 10 artwork shares per hour
|
||||
$key = 'share_artwork:' . $user->id;
|
||||
if (RateLimiter::tooManyAttempts($key, 10)) {
|
||||
$seconds = RateLimiter::availableIn($key);
|
||||
return response()->json([
|
||||
'message' => "You're sharing too quickly. Please wait {$seconds} seconds.",
|
||||
], 429);
|
||||
}
|
||||
RateLimiter::hit($key, 3600);
|
||||
|
||||
$post = $this->shareService->shareArtwork(
|
||||
user: $user,
|
||||
artwork: $artwork,
|
||||
body: $request->input('body'),
|
||||
visibility: $request->input('visibility', 'public'),
|
||||
);
|
||||
|
||||
$post->load(['user', 'user.profile', 'targets', 'targets.artwork', 'targets.artwork.user', 'targets.artwork.user.profile', 'reactions']);
|
||||
|
||||
// Notify original artwork owner (unless self-share)
|
||||
if ($artwork->user_id !== $user->id) {
|
||||
event(new ArtworkShared($post, $artwork, $user));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'post' => $this->feedService->formatPost($post, $user->id),
|
||||
], 201);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Posts\PostFeedService;
|
||||
use App\Services\Posts\PostHashtagService;
|
||||
use App\Services\Posts\PostTrendingService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* GET /api/feed/trending
|
||||
* GET /api/feed/hashtag/{tag}
|
||||
* GET /api/feed/hashtags/trending
|
||||
*/
|
||||
class PostTrendingFeedController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private PostTrendingService $trendingService,
|
||||
private PostFeedService $feedService,
|
||||
private PostHashtagService $hashtagService,
|
||||
) {}
|
||||
|
||||
public function trending(Request $request): JsonResponse
|
||||
{
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$viewer = $request->user()?->id;
|
||||
|
||||
$result = $this->trendingService->getTrending($viewer, $page);
|
||||
|
||||
$formatted = array_map(
|
||||
fn ($post) => $this->feedService->formatPost($post, $viewer),
|
||||
$result['data'],
|
||||
);
|
||||
|
||||
return response()->json(['data' => array_values($formatted), 'meta' => $result['meta']]);
|
||||
}
|
||||
|
||||
public function hashtag(Request $request, string $tag): JsonResponse
|
||||
{
|
||||
$tag = mb_strtolower(preg_replace('/[^A-Za-z0-9_]/', '', $tag));
|
||||
if (strlen($tag) < 2 || strlen($tag) > 64) {
|
||||
return response()->json(['message' => 'Invalid hashtag.'], 422);
|
||||
}
|
||||
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$viewer = $request->user()?->id;
|
||||
|
||||
$result = $this->feedService->getHashtagFeed($tag, $viewer, $page);
|
||||
|
||||
$formatted = array_map(
|
||||
fn ($post) => $this->feedService->formatPost($post, $viewer),
|
||||
$result['data'],
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'tag' => $tag,
|
||||
'data' => array_values($formatted),
|
||||
'meta' => $result['meta'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function trendingHashtags(): JsonResponse
|
||||
{
|
||||
$tags = Cache::remember('trending_hashtags', 300, function () {
|
||||
return $this->hashtagService->trending(10, 24);
|
||||
});
|
||||
|
||||
return response()->json(['hashtags' => $tags]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Activity\UserActivityService;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\User;
|
||||
|
||||
final class ProfileActivityController extends Controller
|
||||
{
|
||||
public function __construct(private readonly UserActivityService $activities) {}
|
||||
|
||||
public function __invoke(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$normalized = UsernamePolicy::normalize($username);
|
||||
|
||||
$user = User::query()
|
||||
->with('profile:user_id,avatar_hash')
|
||||
->whereRaw('LOWER(username) = ?', [$normalized])
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->firstOrFail();
|
||||
|
||||
return response()->json(
|
||||
$this->activities->feedForUser(
|
||||
$user,
|
||||
(string) $request->query('filter', 'all'),
|
||||
(int) $request->query('page', 1),
|
||||
(int) $request->query('per_page', UserActivityService::DEFAULT_PER_PAGE),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonInterface;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* ProfileApiController
|
||||
* JSON API endpoints for Profile page v2 tabs.
|
||||
*/
|
||||
final class ProfileApiController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /api/profile/{username}/artworks
|
||||
* Returns cursor-paginated artworks for the profile page tabs.
|
||||
* Supports: sort=latest|trending|rising|views|favs, cursor=...
|
||||
*/
|
||||
public function artworks(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$user = $this->resolveUser($username);
|
||||
if (! $user) {
|
||||
return response()->json(['error' => 'User not found'], 404);
|
||||
}
|
||||
|
||||
$isOwner = Auth::check() && Auth::id() === $user->id;
|
||||
$sort = $request->input('sort', 'latest');
|
||||
|
||||
$query = Artwork::with([
|
||||
'user:id,name,username,level,rank',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'group:id,name,slug,avatar_path',
|
||||
'stats:artwork_id,views,downloads,favorites',
|
||||
'categories' => function ($query) {
|
||||
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||
->with(['contentType:id,slug,name']);
|
||||
},
|
||||
])
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('deleted_at');
|
||||
|
||||
if (! $isOwner) {
|
||||
$query->where('is_public', true)->where('is_approved', true)->whereNotNull('published_at');
|
||||
}
|
||||
|
||||
$query = match ($sort) {
|
||||
'trending' => $query->orderByDesc('ranking_score'),
|
||||
'rising' => $query->orderByDesc('heat_score'),
|
||||
'views' => $query->orderByDesc('view_count'),
|
||||
'favs' => $query->orderByDesc('favourite_count'),
|
||||
default => $query->orderByDesc('published_at'),
|
||||
};
|
||||
|
||||
$perPage = 24;
|
||||
$paginator = $query->cursorPaginate($perPage);
|
||||
|
||||
$data = collect($paginator->items())
|
||||
->map(fn (Artwork $art) => $this->mapArtworkCardPayload($art))
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $data,
|
||||
'next_cursor' => $paginator->nextCursor()?->encode(),
|
||||
'has_more' => $paginator->hasMorePages(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/profile/{username}/favourites
|
||||
* Returns cursor-paginated favourites for the profile.
|
||||
*/
|
||||
public function favourites(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$favouriteTable = $this->resolveFavouriteTable();
|
||||
if ($favouriteTable === null) {
|
||||
return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]);
|
||||
}
|
||||
|
||||
$user = $this->resolveUser($username);
|
||||
if (! $user) {
|
||||
return response()->json(['error' => 'User not found'], 404);
|
||||
}
|
||||
|
||||
$perPage = 24;
|
||||
$offset = max(0, (int) base64_decode((string) $request->input('cursor', ''), true));
|
||||
|
||||
$favIds = DB::table($favouriteTable . ' as af')
|
||||
->join('artworks as a', 'a.id', '=', 'af.artwork_id')
|
||||
->where('af.user_id', $user->id)
|
||||
->whereNull('a.deleted_at')
|
||||
->where('a.is_public', true)
|
||||
->where('a.is_approved', true)
|
||||
->whereNotNull('a.published_at')
|
||||
->orderByDesc('af.created_at')
|
||||
->orderByDesc('af.artwork_id')
|
||||
->offset($offset)
|
||||
->limit($perPage + 1)
|
||||
->pluck('a.id');
|
||||
|
||||
$hasMore = $favIds->count() > $perPage;
|
||||
$favIds = $favIds->take($perPage);
|
||||
|
||||
if ($favIds->isEmpty()) {
|
||||
return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]);
|
||||
}
|
||||
|
||||
$indexed = Artwork::with([
|
||||
'user:id,name,username,level,rank',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'group:id,name,slug,avatar_path',
|
||||
'stats:artwork_id,views,downloads,favorites',
|
||||
'categories' => function ($query) {
|
||||
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||
->with(['contentType:id,slug,name']);
|
||||
},
|
||||
])
|
||||
->whereIn('id', $favIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$data = $favIds
|
||||
->filter(fn ($id) => $indexed->has($id))
|
||||
->map(fn ($id) => $this->mapArtworkCardPayload($indexed[$id]))
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $data,
|
||||
'next_cursor' => $hasMore ? base64_encode((string) ($offset + $perPage)) : null,
|
||||
'has_more' => $hasMore,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/profile/{username}/stats
|
||||
* Returns profile statistics.
|
||||
*/
|
||||
public function stats(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$user = $this->resolveUser($username);
|
||||
if (! $user) {
|
||||
return response()->json(['error' => 'User not found'], 404);
|
||||
}
|
||||
|
||||
$stats = null;
|
||||
if (Schema::hasTable('user_statistics')) {
|
||||
$stats = DB::table('user_statistics')->where('user_id', $user->id)->first();
|
||||
}
|
||||
|
||||
$followerCount = 0;
|
||||
if (Schema::hasTable('user_followers')) {
|
||||
$followerCount = DB::table('user_followers')->where('user_id', $user->id)->count();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'stats' => $stats,
|
||||
'follower_count' => $followerCount,
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveUser(string $username): ?User
|
||||
{
|
||||
$normalized = UsernamePolicy::normalize($username);
|
||||
return User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
|
||||
}
|
||||
|
||||
private function resolveFavouriteTable(): ?string
|
||||
{
|
||||
foreach (['artwork_favourites', 'user_favorites', 'artworks_favourites', 'favourites'] as $table) {
|
||||
if (Schema::hasTable($table)) {
|
||||
return $table;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mapArtworkCardPayload(Artwork $art): array
|
||||
{
|
||||
$present = ThumbnailPresenter::present($art, 'md');
|
||||
$category = $art->categories->first();
|
||||
$contentType = $category?->contentType;
|
||||
$stats = $art->stats;
|
||||
$group = $art->group;
|
||||
$isGroupPublisher = $group !== null;
|
||||
$displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($art->user?->name ?? 'Skinbase');
|
||||
$username = $isGroupPublisher ? null : ($art->user?->username ?? null);
|
||||
$avatarUrl = $isGroupPublisher ? $group->avatarUrl() : ($art->user?->profile?->avatar_url ?? null);
|
||||
$profileUrl = $isGroupPublisher
|
||||
? $group->publicUrl()
|
||||
: ($username ? '/@' . $username : null);
|
||||
$publisherType = $isGroupPublisher ? 'group' : 'user';
|
||||
|
||||
return [
|
||||
'id' => $art->id,
|
||||
'name' => $art->title,
|
||||
'thumb' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'width' => $art->width,
|
||||
'height' => $art->height,
|
||||
'username' => $username,
|
||||
'uname' => $displayName,
|
||||
'avatar_url' => $avatarUrl,
|
||||
'profile_url' => $profileUrl,
|
||||
'published_as_type' => $publisherType,
|
||||
'publisher' => [
|
||||
'type' => $publisherType,
|
||||
'id' => $isGroupPublisher ? (int) $group->id : (int) ($art->user?->id ?? 0),
|
||||
'name' => $displayName,
|
||||
'username' => $username ?? '',
|
||||
'avatar_url' => $avatarUrl,
|
||||
'profile_url' => $profileUrl,
|
||||
],
|
||||
'user_id' => $art->user_id,
|
||||
'author_level' => $isGroupPublisher ? 0 : (int) ($art->user?->level ?? 1),
|
||||
'author_rank' => $isGroupPublisher ? '' : (string) ($art->user?->rank ?? 'Newbie'),
|
||||
'content_type' => $contentType?->name,
|
||||
'content_type_slug' => $contentType?->slug,
|
||||
'category' => $category?->name,
|
||||
'category_slug' => $category?->slug,
|
||||
'views' => (int) ($stats?->views ?? $art->view_count ?? 0),
|
||||
'downloads' => (int) ($stats?->downloads ?? 0),
|
||||
'likes' => (int) ($stats?->favorites ?? $art->favourite_count ?? 0),
|
||||
'published_at' => $this->formatIsoDate($art->published_at),
|
||||
];
|
||||
}
|
||||
|
||||
private function formatIsoDate(mixed $value): ?string
|
||||
{
|
||||
if ($value instanceof CarbonInterface) {
|
||||
return $value->toISOString();
|
||||
}
|
||||
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return $value->format(DATE_ATOM);
|
||||
}
|
||||
|
||||
return is_string($value) ? $value : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\ArtworkListResource;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Services\ContentTypes\ContentTypeSlugResolver;
|
||||
use App\Services\RankingService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
/**
|
||||
* RankController
|
||||
*
|
||||
* Serves pre-computed ranked artwork lists.
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /api/rank/global?type=trending|new_hot|best
|
||||
* GET /api/rank/category/{id}?type=trending|new_hot|best
|
||||
* GET /api/rank/type/{contentType}?type=trending|new_hot|best
|
||||
*/
|
||||
class RankController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RankingService $ranking,
|
||||
private readonly ContentTypeSlugResolver $contentTypeResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* GET /api/rank/global
|
||||
*
|
||||
* Returns: { data: [...], meta: { list_type, computed_at, model_version, fallback } }
|
||||
*/
|
||||
public function global(Request $request): AnonymousResourceCollection|JsonResponse
|
||||
{
|
||||
$listType = $this->resolveListType($request);
|
||||
$result = $this->ranking->getList('global', null, $listType);
|
||||
|
||||
return $this->buildResponse($result, $listType);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/rank/category/{id}
|
||||
*/
|
||||
public function byCategory(Request $request, int $id): AnonymousResourceCollection|JsonResponse
|
||||
{
|
||||
if (! Category::where('id', $id)->where('is_active', true)->exists()) {
|
||||
return response()->json(['message' => 'Category not found.'], 404);
|
||||
}
|
||||
|
||||
$listType = $this->resolveListType($request);
|
||||
$result = $this->ranking->getList('category', $id, $listType);
|
||||
|
||||
return $this->buildResponse($result, $listType);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/rank/type/{contentType}
|
||||
*
|
||||
* {contentType} is accepted as either a slug (string) or numeric id.
|
||||
*/
|
||||
public function byContentType(Request $request, string $contentType): AnonymousResourceCollection|JsonResponse
|
||||
{
|
||||
$ct = is_numeric($contentType)
|
||||
? ContentType::find((int) $contentType)
|
||||
: $this->contentTypeResolver->resolve($contentType)->contentType;
|
||||
|
||||
if ($ct === null) {
|
||||
return response()->json(['message' => 'Content type not found.'], 404);
|
||||
}
|
||||
|
||||
$listType = $this->resolveListType($request);
|
||||
$result = $this->ranking->getList('content_type', $ct->id, $listType);
|
||||
|
||||
return $this->buildResponse($result, $listType);
|
||||
}
|
||||
|
||||
// ── Private helpers ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate and normalise the ?type query param.
|
||||
* Defaults to 'trending'.
|
||||
*/
|
||||
private function resolveListType(Request $request): string
|
||||
{
|
||||
$allowed = ['trending', 'new_hot', 'best'];
|
||||
$type = $request->query('type', 'trending');
|
||||
|
||||
return in_array($type, $allowed, true) ? $type : 'trending';
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate artwork IDs into Eloquent models (no N+1) and wrap in resources.
|
||||
*
|
||||
* @param array{ids: int[], computed_at: string|null, model_version: string, fallback: bool} $result
|
||||
*/
|
||||
private function buildResponse(array $result, string $listType = 'trending'): AnonymousResourceCollection
|
||||
{
|
||||
$ids = $result['ids'];
|
||||
$artworks = collect();
|
||||
|
||||
if (! empty($ids)) {
|
||||
// Single whereIn query — no N+1
|
||||
$keyed = Artwork::whereIn('id', $ids)
|
||||
->with([
|
||||
'user:id,name',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'categories' => function ($q): void {
|
||||
$q->select(
|
||||
'categories.id',
|
||||
'categories.content_type_id',
|
||||
'categories.parent_id',
|
||||
'categories.name',
|
||||
'categories.slug',
|
||||
'categories.sort_order'
|
||||
)->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
|
||||
},
|
||||
])
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
// Restore the ranked order
|
||||
$artworks = collect($ids)
|
||||
->filter(fn ($id) => $keyed->has($id))
|
||||
->map(fn ($id) => $keyed[$id]);
|
||||
}
|
||||
|
||||
$collection = ArtworkListResource::collection($artworks);
|
||||
|
||||
// Attach ranking meta as additional data
|
||||
$collection->additional([
|
||||
'meta' => [
|
||||
'list_type' => $listType,
|
||||
'computed_at' => $result['computed_at'],
|
||||
'model_version' => $result['model_version'],
|
||||
'fallback' => $result['fallback'],
|
||||
'count' => $artworks->count(),
|
||||
],
|
||||
]);
|
||||
|
||||
return $collection;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Enums\ReactionType;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Models\ArtworkReaction;
|
||||
use App\Models\CommentReaction;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Handles reaction toggling for artworks and comments.
|
||||
*
|
||||
* POST /api/artworks/{id}/reactions → toggle artwork reaction
|
||||
* POST /api/comments/{id}/reactions → toggle comment reaction
|
||||
* GET /api/artworks/{id}/reactions → list artwork reactions
|
||||
* GET /api/comments/{id}/reactions → list comment reactions
|
||||
*/
|
||||
class ReactionController extends Controller
|
||||
{
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Artwork reactions
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function artworkReactions(Request $request, int $artworkId): JsonResponse
|
||||
{
|
||||
return $this->listReactions('artwork', $artworkId, $request->user()?->id);
|
||||
}
|
||||
|
||||
public function toggleArtworkReaction(Request $request, int $artworkId): JsonResponse
|
||||
{
|
||||
$this->validateExists('artworks', $artworkId);
|
||||
$slug = $this->validateReactionSlug($request);
|
||||
|
||||
return $this->toggle(
|
||||
model: new ArtworkReaction(),
|
||||
where: ['artwork_id' => $artworkId, 'user_id' => $request->user()->id, 'reaction' => $slug],
|
||||
countWhere: ['artwork_id' => $artworkId],
|
||||
entityId: $artworkId,
|
||||
entityType: 'artwork',
|
||||
userId: $request->user()->id,
|
||||
slug: $slug,
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Comment reactions
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function commentReactions(Request $request, int $commentId): JsonResponse
|
||||
{
|
||||
return $this->listReactions('comment', $commentId, $request->user()?->id);
|
||||
}
|
||||
|
||||
public function toggleCommentReaction(Request $request, int $commentId): JsonResponse
|
||||
{
|
||||
// Make sure comment exists and belongs to a public artwork
|
||||
$comment = ArtworkComment::with('artwork')
|
||||
->where('id', $commentId)
|
||||
->whereHas('artwork', fn ($q) => $q->public()->published())
|
||||
->firstOrFail();
|
||||
|
||||
$slug = $this->validateReactionSlug($request);
|
||||
|
||||
return $this->toggle(
|
||||
model: new CommentReaction(),
|
||||
where: ['comment_id' => $commentId, 'user_id' => $request->user()->id, 'reaction' => $slug],
|
||||
countWhere: ['comment_id' => $commentId],
|
||||
entityId: $commentId,
|
||||
entityType: 'comment',
|
||||
userId: $request->user()->id,
|
||||
slug: $slug,
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Shared internals
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private function toggle(
|
||||
\Illuminate\Database\Eloquent\Model $model,
|
||||
array $where,
|
||||
array $countWhere,
|
||||
int $entityId,
|
||||
string $entityType,
|
||||
int $userId,
|
||||
string $slug,
|
||||
): JsonResponse {
|
||||
$table = $model->getTable();
|
||||
$existing = DB::table($table)->where($where)->first();
|
||||
|
||||
if ($existing) {
|
||||
// Toggle off
|
||||
DB::table($table)->where($where)->delete();
|
||||
$active = false;
|
||||
} else {
|
||||
// Toggle on
|
||||
DB::table($table)->insertOrIgnore(array_merge($where, [
|
||||
'created_at' => now(),
|
||||
]));
|
||||
$active = true;
|
||||
}
|
||||
|
||||
// Return fresh totals per reaction type
|
||||
$totals = $this->getTotals($table, $countWhere, $userId);
|
||||
|
||||
return response()->json([
|
||||
'entity_type' => $entityType,
|
||||
'entity_id' => $entityId,
|
||||
'reaction' => $slug,
|
||||
'active' => $active,
|
||||
'totals' => $totals,
|
||||
]);
|
||||
}
|
||||
|
||||
private function listReactions(string $entityType, int $entityId, ?int $userId): JsonResponse
|
||||
{
|
||||
if ($entityType === 'artwork') {
|
||||
$table = 'artwork_reactions';
|
||||
$where = ['artwork_id' => $entityId];
|
||||
} else {
|
||||
$table = 'comment_reactions';
|
||||
$where = ['comment_id' => $entityId];
|
||||
}
|
||||
|
||||
$totals = $this->getTotals($table, $where, $userId);
|
||||
|
||||
return response()->json([
|
||||
'entity_type' => $entityType,
|
||||
'entity_id' => $entityId,
|
||||
'totals' => $totals,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return per-slug totals and whether the current user has each reaction.
|
||||
*/
|
||||
private function getTotals(string $table, array $where, ?int $userId): array
|
||||
{
|
||||
$rows = DB::table($table)
|
||||
->where($where)
|
||||
->selectRaw('reaction, COUNT(*) as total')
|
||||
->groupBy('reaction')
|
||||
->get()
|
||||
->keyBy('reaction');
|
||||
|
||||
$totals = [];
|
||||
foreach (ReactionType::cases() as $type) {
|
||||
$slug = $type->value;
|
||||
$count = (int) ($rows[$slug]->total ?? 0);
|
||||
|
||||
// Check if current user has this reaction
|
||||
$mine = false;
|
||||
if ($userId && $count > 0) {
|
||||
$mine = DB::table($table)
|
||||
->where($where)
|
||||
->where('reaction', $slug)
|
||||
->where('user_id', $userId)
|
||||
->exists();
|
||||
}
|
||||
|
||||
$totals[$slug] = [
|
||||
'emoji' => $type->emoji(),
|
||||
'label' => $type->label(),
|
||||
'count' => $count,
|
||||
'mine' => $mine,
|
||||
];
|
||||
}
|
||||
|
||||
return $totals;
|
||||
}
|
||||
|
||||
private function validateReactionSlug(Request $request): string
|
||||
{
|
||||
$request->validate([
|
||||
'reaction' => ['required', 'string', 'in:' . implode(',', ReactionType::values())],
|
||||
]);
|
||||
|
||||
return $request->input('reaction');
|
||||
}
|
||||
|
||||
private function validateExists(string $table, int $id): void
|
||||
{
|
||||
if (! DB::table($table)->where('id', $id)->exists()) {
|
||||
throw new ModelNotFoundException("No [{$table}] record found with id [{$id}].");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Report;
|
||||
use App\Support\Moderation\ReportTargetResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ReportController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ReportTargetResolver $targets) {}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$data = $request->validate([
|
||||
'target_type' => ['required', Rule::in($this->targets->supportedTargetTypes())],
|
||||
'target_id' => 'required|integer|min:1',
|
||||
'reason' => 'required|string|max:120',
|
||||
'details' => 'nullable|string|max:4000',
|
||||
]);
|
||||
|
||||
$targetType = $data['target_type'];
|
||||
$targetId = (int) $data['target_id'];
|
||||
|
||||
$this->targets->validateForReporter($user, $targetType, $targetId);
|
||||
|
||||
$report = Report::query()->create([
|
||||
'reporter_id' => $user->id,
|
||||
'target_type' => $targetType,
|
||||
'target_id' => $targetId,
|
||||
'reason' => $data['reason'],
|
||||
'details' => $data['details'] ?? null,
|
||||
'status' => 'open',
|
||||
]);
|
||||
|
||||
return response()->json(['id' => $report->id, 'status' => $report->status], 201);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\Search;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\ArtworkListResource;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Tag;
|
||||
use App\Services\ArtworkSearchService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Artwork search endpoints powered by Meilisearch.
|
||||
*
|
||||
* GET /api/search/artworks?q=&tags[]=&category=&orientation=&sort=
|
||||
* GET /api/search/artworks/tag/{slug}
|
||||
* GET /api/search/artworks/category/{cat}
|
||||
* GET /api/search/artworks/related/{id}
|
||||
*/
|
||||
class ArtworkSearchController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ArtworkSearchService $search
|
||||
) {}
|
||||
|
||||
/**
|
||||
* GET /api/search/artworks
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'q' => ['nullable', 'string', 'max:200'],
|
||||
'tags' => ['nullable', 'array', 'max:10'],
|
||||
'tags.*' => ['string', 'max:80'],
|
||||
'category' => ['nullable', 'string', 'max:80'],
|
||||
'orientation' => ['nullable', 'in:landscape,portrait,square'],
|
||||
'resolution' => ['nullable', 'string', 'max:20'],
|
||||
'author_id' => ['nullable', 'integer', 'min:1'],
|
||||
'sort' => ['nullable', 'string', 'regex:/^(created_at|downloads|likes|views):(asc|desc)$/'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
]);
|
||||
|
||||
$results = $this->search->search(
|
||||
q: (string) ($validated['q'] ?? ''),
|
||||
filters: array_filter([
|
||||
'tags' => $validated['tags'] ?? [],
|
||||
'category' => $validated['category'] ?? null,
|
||||
'orientation' => $validated['orientation'] ?? null,
|
||||
'resolution' => $validated['resolution'] ?? null,
|
||||
'author_id' => $validated['author_id'] ?? null,
|
||||
'sort' => $validated['sort'] ?? null,
|
||||
]),
|
||||
perPage: (int) ($validated['per_page'] ?? 24),
|
||||
);
|
||||
|
||||
// Eager-load relations needed by ArtworkListResource
|
||||
$results->getCollection()->loadMissing(['user', 'categories.contentType']);
|
||||
|
||||
return ArtworkListResource::collection($results)->response();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/search/artworks/tag/{slug}
|
||||
*/
|
||||
public function byTag(Request $request, string $slug): JsonResponse
|
||||
{
|
||||
$tag = Tag::where('slug', $slug)->first();
|
||||
|
||||
if (! $tag) {
|
||||
return response()->json(['message' => 'Tag not found.'], 404);
|
||||
}
|
||||
|
||||
$results = $this->search->byTag($slug, (int) $request->query('per_page', 24));
|
||||
|
||||
return response()->json([
|
||||
'tag' => ['id' => $tag->id, 'name' => $tag->name, 'slug' => $tag->slug],
|
||||
'results' => $results,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/search/artworks/category/{cat}
|
||||
*/
|
||||
public function byCategory(Request $request, string $cat): JsonResponse
|
||||
{
|
||||
$results = $this->search->byCategory($cat, (int) $request->query('per_page', 24));
|
||||
|
||||
return response()->json($results);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/search/artworks/related/{id}
|
||||
*/
|
||||
public function related(int $id): JsonResponse
|
||||
{
|
||||
$artwork = Artwork::with(['tags'])->find($id);
|
||||
|
||||
if (! $artwork) {
|
||||
return response()->json(['message' => 'Artwork not found.'], 404);
|
||||
}
|
||||
|
||||
$results = $this->search->related($artwork, 12);
|
||||
|
||||
return response()->json($results);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\Search;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\GroupDiscoveryService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class GroupSearchController extends Controller
|
||||
{
|
||||
public function __construct(private readonly GroupDiscoveryService $groups) {}
|
||||
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$q = trim((string) $request->query('q', ''));
|
||||
if (mb_strlen($q) < 2) {
|
||||
return response()->json(['data' => []]);
|
||||
}
|
||||
|
||||
$perPage = min(max((int) $request->query('per_page', 6), 1), 12);
|
||||
$items = array_map(function (array $group): array {
|
||||
$group['group_type'] = $group['type'] ?? null;
|
||||
$group['type'] = 'group';
|
||||
|
||||
return $group;
|
||||
}, $this->groups->searchCards($q, $request->user(), $perPage));
|
||||
|
||||
return response()->json([
|
||||
'data' => $items,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Search;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class UserSearchController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /api/search/users?q=gregor&per_page=4
|
||||
*
|
||||
* Public, rate-limited. Strips a leading @ from the query so that
|
||||
* typing "@gregor" and "gregor" both work.
|
||||
*/
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$raw = trim((string) $request->query('q', ''));
|
||||
$q = ltrim($raw, '@');
|
||||
|
||||
if (strlen($q) < 2) {
|
||||
return response()->json(['data' => []]);
|
||||
}
|
||||
|
||||
$perPage = min((int) $request->query('per_page', 4), 8);
|
||||
|
||||
$users = User::query()
|
||||
->where('is_active', 1)
|
||||
->whereNull('deleted_at')
|
||||
->where(function ($qb) use ($q) {
|
||||
$qb->whereRaw('LOWER(username) LIKE ?', ['%' . strtolower($q) . '%'])
|
||||
->orWhereRaw('LOWER(name) LIKE ?', ['%' . strtolower($q) . '%']);
|
||||
})
|
||||
->with(['profile', 'statistics'])
|
||||
->orderByRaw('LOWER(username) = ? DESC', [strtolower($q)]) // exact match first
|
||||
->orderBy('username')
|
||||
->limit($perPage)
|
||||
->get(['id', 'username', 'name']);
|
||||
|
||||
$data = $users->map(function (User $user) {
|
||||
$username = strtolower((string) ($user->username ?? ''));
|
||||
$avatarHash = $user->profile?->avatar_hash;
|
||||
$uploadsCount = (int) ($user->statistics?->uploads_count ?? 0);
|
||||
|
||||
return [
|
||||
'id' => $user->id,
|
||||
'type' => 'user',
|
||||
'username' => $username,
|
||||
'name' => $user->name ?? $username,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $user->id, $avatarHash, 64),
|
||||
'uploads_count' => $uploadsCount,
|
||||
'profile_url' => '/@' . $username,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json(['data' => $data]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\Vision\VectorService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use RuntimeException;
|
||||
|
||||
final class SimilarAiArtworksController extends Controller
|
||||
{
|
||||
public function __construct(private readonly VectorService $vectors)
|
||||
{
|
||||
}
|
||||
|
||||
public function __invoke(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$artwork = Artwork::query()->public()->published()->find($id);
|
||||
if ($artwork === null) {
|
||||
return response()->json(['error' => 'Artwork not found'], 404);
|
||||
}
|
||||
|
||||
if (! $this->vectors->isConfigured()) {
|
||||
return response()->json([
|
||||
'data' => [],
|
||||
'reason' => 'vector_gateway_not_configured',
|
||||
], 503);
|
||||
}
|
||||
|
||||
$limit = max(1, min(24, (int) $request->query('limit', 12)));
|
||||
|
||||
try {
|
||||
$items = $this->vectors->similarToArtwork($artwork, $limit);
|
||||
} catch (RuntimeException $e) {
|
||||
return response()->json([
|
||||
'data' => [],
|
||||
'reason' => 'vector_gateway_error',
|
||||
'message' => $e->getMessage(),
|
||||
], 502);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $items,
|
||||
'meta' => [
|
||||
'source' => 'vector_gateway',
|
||||
'artwork_id' => $artwork->id,
|
||||
'limit' => $limit,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class SimilarArtworkAnalyticsController extends Controller
|
||||
{
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'event_type' => ['required', 'string', 'in:impression,click'],
|
||||
'algo_version' => ['required', 'string', 'max:64'],
|
||||
'source_artwork_id' => ['required', 'integer', 'exists:artworks,id'],
|
||||
'similar_artwork_id' => ['nullable', 'integer', 'exists:artworks,id'],
|
||||
'position' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
'items_count' => ['nullable', 'integer', 'min:0', 'max:100'],
|
||||
]);
|
||||
|
||||
DB::table('similar_artwork_events')->insert([
|
||||
'event_date' => now()->toDateString(),
|
||||
'event_type' => (string) $payload['event_type'],
|
||||
'algo_version' => (string) $payload['algo_version'],
|
||||
'source_artwork_id' => (int) $payload['source_artwork_id'],
|
||||
'similar_artwork_id' => isset($payload['similar_artwork_id']) ? (int) $payload['similar_artwork_id'] : null,
|
||||
'position' => isset($payload['position']) ? (int) $payload['position'] : null,
|
||||
'items_count' => isset($payload['items_count']) ? (int) $payload['items_count'] : null,
|
||||
'occurred_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return response()->json(['success' => true], Response::HTTP_OK);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkSearchService;
|
||||
use App\Services\Recommendations\HybridSimilarArtworksService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* GET /api/art/{id}/similar
|
||||
*
|
||||
* Returns up to 12 similar artworks using the hybrid recommender (precomputed lists)
|
||||
* with a Meilisearch-based fallback if no precomputed data exists.
|
||||
*
|
||||
* Query params:
|
||||
* ?type=similar (default) | visual | tags | behavior
|
||||
*
|
||||
* Priority (default):
|
||||
* 1. Hybrid precomputed (tag + behavior + optional vector)
|
||||
* 2. Meilisearch tag-overlap fallback (legacy)
|
||||
*/
|
||||
final class SimilarArtworksController extends Controller
|
||||
{
|
||||
private const LIMIT = 12;
|
||||
|
||||
public function __construct(
|
||||
private readonly ArtworkSearchService $search,
|
||||
private readonly HybridSimilarArtworksService $hybridService,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$artwork = Artwork::public()
|
||||
->published()
|
||||
->with(['tags:id,slug', 'categories:id,slug'])
|
||||
->find($id);
|
||||
|
||||
if (! $artwork) {
|
||||
return response()->json(['error' => 'Artwork not found'], 404);
|
||||
}
|
||||
|
||||
$type = $request->query('type');
|
||||
$validTypes = ['similar', 'visual', 'tags', 'behavior'];
|
||||
if ($type !== null && ! in_array($type, $validTypes, true)) {
|
||||
$type = null; // ignore invalid, fall through to default
|
||||
}
|
||||
|
||||
// Service handles its own caching (6h TTL), no extra controller-level cache
|
||||
$hybridResults = $this->hybridService->forArtwork($artwork->id, self::LIMIT, $type);
|
||||
|
||||
if ($hybridResults->isNotEmpty()) {
|
||||
// Eager-load relations needed for formatting
|
||||
$ids = $hybridResults->pluck('id')->all();
|
||||
$loaded = Artwork::query()
|
||||
->whereIn('id', $ids)
|
||||
->with(['tags:id,slug', 'stats', 'user:id,name', 'user.profile:user_id,avatar_hash'])
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$items = $hybridResults->values()->map(function (Artwork $a) use ($loaded) {
|
||||
$full = $loaded->get($a->id) ?? $a;
|
||||
return $this->formatArtwork($full);
|
||||
})->all();
|
||||
|
||||
return response()->json(['data' => $items]);
|
||||
}
|
||||
|
||||
// Fall back to Meilisearch tag-overlap search
|
||||
$items = $this->findSimilarViaSearch($artwork);
|
||||
|
||||
return response()->json(['data' => $items]);
|
||||
}
|
||||
|
||||
private function formatArtwork(Artwork $artwork): array
|
||||
{
|
||||
return [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'thumb' => $artwork->thumbUrl('md'),
|
||||
'url' => '/art/' . $artwork->id . '/' . $artwork->slug,
|
||||
'author' => $artwork->user?->name ?? 'Artist',
|
||||
'author_avatar' => $artwork->user?->profile?->avatar_url,
|
||||
'author_id' => $artwork->user_id,
|
||||
'orientation' => $this->orientation($artwork),
|
||||
'width' => $artwork->width,
|
||||
'height' => $artwork->height,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy Meilisearch-based similar artworks (fallback).
|
||||
*/
|
||||
private function findSimilarViaSearch(Artwork $artwork): array
|
||||
{
|
||||
$tagSlugs = $artwork->tags->pluck('slug')->values()->all();
|
||||
$categorySlugs = $artwork->categories->pluck('slug')->values()->all();
|
||||
$srcOrientation = $this->orientation($artwork);
|
||||
|
||||
$filterParts = [
|
||||
'is_public = true',
|
||||
'is_approved = true',
|
||||
'id != ' . $artwork->id,
|
||||
'author_id != ' . $artwork->user_id,
|
||||
];
|
||||
|
||||
if ($tagSlugs !== []) {
|
||||
$tagFilter = implode(' OR ', array_map(
|
||||
fn (string $t): string => 'tags = "' . addslashes($t) . '"',
|
||||
$tagSlugs
|
||||
));
|
||||
$filterParts[] = '(' . $tagFilter . ')';
|
||||
} elseif ($categorySlugs !== []) {
|
||||
$catFilter = implode(' OR ', array_map(
|
||||
fn (string $c): string => 'category = "' . addslashes($c) . '"',
|
||||
$categorySlugs
|
||||
));
|
||||
$filterParts[] = '(' . $catFilter . ')';
|
||||
}
|
||||
|
||||
$results = Artwork::search('')
|
||||
->options([
|
||||
'filter' => implode(' AND ', $filterParts),
|
||||
'sort' => ['trending_score_7d:desc', 'created_at:desc'],
|
||||
])
|
||||
->paginate(200, 'page', 1);
|
||||
|
||||
$collection = $results->getCollection();
|
||||
$collection->load(['tags:id,slug', 'stats', 'user:id,name', 'user.profile:user_id,avatar_hash']);
|
||||
|
||||
$srcTagSet = array_flip($tagSlugs);
|
||||
$srcW = (int) ($artwork->width ?? 0);
|
||||
$srcH = (int) ($artwork->height ?? 0);
|
||||
|
||||
$scored = $collection->map(function (Artwork $candidate) use (
|
||||
$srcTagSet, $tagSlugs, $srcOrientation, $srcW, $srcH
|
||||
): array {
|
||||
$cTagSlugs = $candidate->tags->pluck('slug')->all();
|
||||
$cTagSet = array_flip($cTagSlugs);
|
||||
|
||||
$common = count(array_intersect_key($srcTagSet, $cTagSet));
|
||||
$total = max(1, count($srcTagSet) + count($cTagSet) - $common);
|
||||
$tagOverlap = $common / $total;
|
||||
|
||||
$orientBonus = $this->orientation($candidate) === $srcOrientation ? 0.10 : 0.0;
|
||||
|
||||
$cW = (int) ($candidate->width ?? 0);
|
||||
$cH = (int) ($candidate->height ?? 0);
|
||||
$resBonus = ($srcW > 0 && $srcH > 0 && $cW > 0 && $cH > 0
|
||||
&& abs($cW - $srcW) / $srcW <= 0.25
|
||||
&& abs($cH - $srcH) / $srcH <= 0.25
|
||||
) ? 0.05 : 0.0;
|
||||
|
||||
$views = max(0, (int) ($candidate->stats?->views ?? 0));
|
||||
$popularity = min(0.15, log(1 + $views) / 13.0);
|
||||
|
||||
$publishedAt = $candidate->published_at ?? $candidate->created_at ?? now();
|
||||
$ageDays = max(0.0, (float) $publishedAt->diffInSeconds(now()) / 86400);
|
||||
$freshness = exp(-$ageDays / 60.0) * 0.10;
|
||||
|
||||
$score = $tagOverlap * 0.60
|
||||
+ $orientBonus
|
||||
+ $resBonus
|
||||
+ $popularity
|
||||
+ $freshness;
|
||||
|
||||
return ['score' => $score, 'artwork' => $candidate];
|
||||
})->all();
|
||||
|
||||
usort($scored, fn ($a, $b) => $b['score'] <=> $a['score']);
|
||||
|
||||
return array_values(
|
||||
array_map(fn (array $item): array => array_merge(
|
||||
$this->formatArtwork($item['artwork']),
|
||||
['score' => round((float) $item['score'], 5)]
|
||||
), array_slice($scored, 0, self::LIMIT))
|
||||
);
|
||||
}
|
||||
|
||||
private function orientation(Artwork $artwork): string
|
||||
{
|
||||
if (! $artwork->width || ! $artwork->height) {
|
||||
return 'square';
|
||||
}
|
||||
|
||||
return match (true) {
|
||||
$artwork->width > $artwork->height => 'landscape',
|
||||
$artwork->height > $artwork->width => 'portrait',
|
||||
default => 'square',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\ActivityService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class SocialActivityController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ActivityService $activity) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$filter = (string) $request->query('filter', 'all');
|
||||
|
||||
if ($this->activity->requiresAuthentication($filter) && ! $request->user()) {
|
||||
return response()->json(['error' => 'Unauthenticated'], 401);
|
||||
}
|
||||
|
||||
return response()->json(
|
||||
$this->activity->communityFeed(
|
||||
viewer: $request->user(),
|
||||
filter: $filter,
|
||||
page: (int) $request->query('page', 1),
|
||||
perPage: (int) $request->query('per_page', 20),
|
||||
actorUserId: $request->filled('user_id') ? (int) $request->query('user_id') : null,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Story;
|
||||
use App\Models\StoryBookmark;
|
||||
use App\Services\SocialService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class SocialCompatibilityController extends Controller
|
||||
{
|
||||
public function __construct(private readonly SocialService $social) {}
|
||||
|
||||
public function like(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'entity_type' => ['required', 'string', 'in:artwork,story'],
|
||||
'entity_id' => ['required', 'integer'],
|
||||
'state' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$state = array_key_exists('state', $payload)
|
||||
? (bool) $payload['state']
|
||||
: ! $request->isMethod('delete');
|
||||
|
||||
if ($payload['entity_type'] === 'story') {
|
||||
$story = Story::published()->findOrFail((int) $payload['entity_id']);
|
||||
|
||||
$result = $this->social->toggleStoryLike($request->user(), $story, $state);
|
||||
|
||||
return response()->json([
|
||||
'ok' => (bool) ($result['ok'] ?? true),
|
||||
'liked' => (bool) ($result['liked'] ?? false),
|
||||
'likes_count' => (int) ($result['likes_count'] ?? 0),
|
||||
'is_liked' => (bool) ($result['liked'] ?? false),
|
||||
'stats' => [
|
||||
'likes' => (int) ($result['likes_count'] ?? 0),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$artworkId = (int) $payload['entity_id'];
|
||||
abort_unless(Artwork::public()->published()->whereKey($artworkId)->exists(), 404);
|
||||
|
||||
return app(ArtworkInteractionController::class)->like(
|
||||
$request->merge(['state' => $state]),
|
||||
$artworkId,
|
||||
);
|
||||
}
|
||||
|
||||
public function comments(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'entity_type' => ['required', 'string', 'in:artwork,story'],
|
||||
'entity_id' => ['required', 'integer'],
|
||||
'content' => [$request->isMethod('get') ? 'nullable' : 'required', 'string', 'min:1', 'max:10000'],
|
||||
'parent_id' => ['nullable', 'integer'],
|
||||
]);
|
||||
|
||||
if ($payload['entity_type'] === 'story') {
|
||||
if ($request->isMethod('get')) {
|
||||
$story = Story::published()->findOrFail((int) $payload['entity_id']);
|
||||
|
||||
return response()->json(
|
||||
$this->social->listStoryComments($story, $request->user()?->id, (int) $request->query('page', 1), 20)
|
||||
);
|
||||
}
|
||||
|
||||
$story = Story::published()->findOrFail((int) $payload['entity_id']);
|
||||
$comment = $this->social->addStoryComment(
|
||||
$request->user(),
|
||||
$story,
|
||||
(string) $payload['content'],
|
||||
isset($payload['parent_id']) ? (int) $payload['parent_id'] : null,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->social->formatComment($comment, (int) $request->user()->id, true),
|
||||
], 201);
|
||||
}
|
||||
|
||||
$artworkId = (int) $payload['entity_id'];
|
||||
abort_unless(Artwork::public()->published()->whereKey($artworkId)->exists(), 404);
|
||||
|
||||
if ($request->isMethod('get')) {
|
||||
return app(ArtworkCommentController::class)->index($request, $artworkId);
|
||||
}
|
||||
|
||||
return app(ArtworkCommentController::class)->store(
|
||||
$request->merge([
|
||||
'content' => $payload['content'],
|
||||
'parent_id' => $payload['parent_id'] ?? null,
|
||||
]),
|
||||
$artworkId,
|
||||
);
|
||||
}
|
||||
|
||||
public function bookmark(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'entity_type' => ['required', 'string', 'in:artwork,story'],
|
||||
'entity_id' => ['required', 'integer'],
|
||||
'state' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$state = array_key_exists('state', $payload)
|
||||
? (bool) $payload['state']
|
||||
: ! $request->isMethod('delete');
|
||||
|
||||
if ($payload['entity_type'] === 'story') {
|
||||
$story = Story::published()->findOrFail((int) $payload['entity_id']);
|
||||
|
||||
$result = $this->social->toggleStoryBookmark($request->user(), $story, $state);
|
||||
|
||||
return response()->json([
|
||||
'ok' => (bool) ($result['ok'] ?? true),
|
||||
'bookmarked' => (bool) ($result['bookmarked'] ?? false),
|
||||
'bookmarks_count' => (int) ($result['bookmarks_count'] ?? 0),
|
||||
'is_bookmarked' => (bool) ($result['bookmarked'] ?? false),
|
||||
'stats' => [
|
||||
'bookmarks' => (int) ($result['bookmarks_count'] ?? 0),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$artworkId = (int) $payload['entity_id'];
|
||||
abort_unless(Artwork::public()->published()->whereKey($artworkId)->exists(), 404);
|
||||
|
||||
return app(ArtworkInteractionController::class)->bookmark(
|
||||
$request->merge(['state' => $state]),
|
||||
$artworkId,
|
||||
);
|
||||
}
|
||||
|
||||
public function bookmarks(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'entity_type' => ['nullable', 'string', 'in:artwork,story'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
|
||||
]);
|
||||
|
||||
$perPage = (int) ($payload['per_page'] ?? 20);
|
||||
$userId = (int) $request->user()->id;
|
||||
$type = $payload['entity_type'] ?? null;
|
||||
|
||||
$items = collect();
|
||||
|
||||
if ($type === null || $type === 'artwork') {
|
||||
$items = $items->concat(
|
||||
Schema::hasTable('artwork_bookmarks')
|
||||
? DB::table('artwork_bookmarks')
|
||||
->join('artworks', 'artworks.id', '=', 'artwork_bookmarks.artwork_id')
|
||||
->where('artwork_bookmarks.user_id', $userId)
|
||||
->where('artworks.is_public', true)
|
||||
->where('artworks.is_approved', true)
|
||||
->select([
|
||||
'artwork_bookmarks.created_at as saved_at',
|
||||
'artworks.id',
|
||||
'artworks.title',
|
||||
'artworks.slug',
|
||||
])
|
||||
->latest('artwork_bookmarks.created_at')
|
||||
->limit($perPage)
|
||||
->get()
|
||||
->map(fn ($row) => [
|
||||
'type' => 'artwork',
|
||||
'id' => (int) $row->id,
|
||||
'title' => (string) $row->title,
|
||||
'url' => route('art.show', ['id' => (int) $row->id, 'slug' => Str::slug((string) ($row->slug ?: $row->title)) ?: (string) $row->id]),
|
||||
'saved_at' => Carbon::parse($row->saved_at)->toIso8601String(),
|
||||
])
|
||||
: collect()
|
||||
);
|
||||
}
|
||||
|
||||
if ($type === null || $type === 'story') {
|
||||
$items = $items->concat(
|
||||
StoryBookmark::query()
|
||||
->with('story:id,slug,title')
|
||||
->where('user_id', $userId)
|
||||
->latest('created_at')
|
||||
->limit($perPage)
|
||||
->get()
|
||||
->filter(fn (StoryBookmark $bookmark) => $bookmark->story !== null)
|
||||
->map(fn (StoryBookmark $bookmark) => [
|
||||
'type' => 'story',
|
||||
'id' => (int) $bookmark->story->id,
|
||||
'title' => (string) $bookmark->story->title,
|
||||
'url' => route('stories.show', ['slug' => $bookmark->story->slug]),
|
||||
'saved_at' => $bookmark->created_at?->toIso8601String(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $items
|
||||
->sortByDesc('saved_at')
|
||||
->take($perPage)
|
||||
->values()
|
||||
->all(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Story;
|
||||
use App\Models\StoryTag;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Stories API — JSON endpoints for React frontend.
|
||||
*
|
||||
* GET /api/stories list published stories (paginated)
|
||||
* GET /api/stories/{slug} single story detail
|
||||
* GET /api/stories/tag/{tag} stories by tag
|
||||
* GET /api/stories/author/{author} stories by author
|
||||
* GET /api/stories/featured featured stories
|
||||
*/
|
||||
final class StoriesApiController extends Controller
|
||||
{
|
||||
/**
|
||||
* List published stories (paginated).
|
||||
* GET /api/stories?page=1&per_page=12
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = min((int) $request->get('per_page', 12), 50);
|
||||
$page = (int) $request->get('page', 1);
|
||||
|
||||
$cacheKey = "stories:api:list:{$perPage}:{$page}";
|
||||
|
||||
$stories = Cache::remember($cacheKey, 300, fn () =>
|
||||
Story::published()
|
||||
->with('creator.profile', 'tags')
|
||||
->orderByDesc('published_at')
|
||||
->paginate($perPage, ['*'], 'page', $page)
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => $stories->getCollection()->map(fn (Story $s) => $this->formatCard($s)),
|
||||
'meta' => [
|
||||
'current_page' => $stories->currentPage(),
|
||||
'last_page' => $stories->lastPage(),
|
||||
'per_page' => $stories->perPage(),
|
||||
'total' => $stories->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single story detail.
|
||||
* GET /api/stories/{slug}
|
||||
*/
|
||||
public function show(string $slug): JsonResponse
|
||||
{
|
||||
$story = Cache::remember('stories:api:' . $slug, 600, fn () =>
|
||||
Story::published()
|
||||
->with('creator.profile', 'tags')
|
||||
->where('slug', $slug)
|
||||
->firstOrFail()
|
||||
);
|
||||
|
||||
return response()->json($this->formatFull($story));
|
||||
}
|
||||
|
||||
/**
|
||||
* Featured story.
|
||||
* GET /api/stories/featured
|
||||
*/
|
||||
public function featured(): JsonResponse
|
||||
{
|
||||
$story = Cache::remember('stories:api:featured', 300, fn () =>
|
||||
Story::published()->featured()
|
||||
->with('creator.profile', 'tags')
|
||||
->orderByDesc('published_at')
|
||||
->first()
|
||||
);
|
||||
|
||||
if (! $story) {
|
||||
return response()->json(null);
|
||||
}
|
||||
|
||||
return response()->json($this->formatFull($story));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stories by tag.
|
||||
* GET /api/stories/tag/{tag}?page=1
|
||||
*/
|
||||
public function byTag(Request $request, string $tag): JsonResponse
|
||||
{
|
||||
$storyTag = StoryTag::where('slug', $tag)->firstOrFail();
|
||||
$page = (int) $request->get('page', 1);
|
||||
|
||||
$stories = Cache::remember("stories:api:tag:{$tag}:{$page}", 300, fn () =>
|
||||
Story::published()
|
||||
->with('creator.profile', 'tags')
|
||||
->whereHas('tags', fn ($q) => $q->where('story_tags.id', $storyTag->id))
|
||||
->orderByDesc('published_at')
|
||||
->paginate(12, ['*'], 'page', $page)
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'tag' => ['id' => $storyTag->id, 'slug' => $storyTag->slug, 'name' => $storyTag->name],
|
||||
'data' => $stories->getCollection()->map(fn (Story $s) => $this->formatCard($s)),
|
||||
'meta' => [
|
||||
'current_page' => $stories->currentPage(),
|
||||
'last_page' => $stories->lastPage(),
|
||||
'per_page' => $stories->perPage(),
|
||||
'total' => $stories->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stories by author.
|
||||
* GET /api/stories/author/{username}?page=1
|
||||
*/
|
||||
public function byAuthor(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$author = User::query()->whereRaw('LOWER(username) = ?', [strtolower($username)])->firstOrFail();
|
||||
|
||||
$page = (int) $request->get('page', 1);
|
||||
|
||||
$stories = Cache::remember("stories:api:author:{$author->id}:{$page}", 300, fn () =>
|
||||
Story::published()
|
||||
->with('creator.profile', 'tags')
|
||||
->where('creator_id', $author->id)
|
||||
->orderByDesc('published_at')
|
||||
->paginate(12, ['*'], 'page', $page)
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'author' => $this->formatCreator($author),
|
||||
'data' => $stories->getCollection()->map(fn (Story $s) => $this->formatCard($s)),
|
||||
'meta' => [
|
||||
'current_page' => $stories->currentPage(),
|
||||
'last_page' => $stories->lastPage(),
|
||||
'per_page' => $stories->perPage(),
|
||||
'total' => $stories->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Private formatters ────────────────────────────────────────────────
|
||||
|
||||
private function formatCard(Story $story): array
|
||||
{
|
||||
return [
|
||||
'id' => $story->id,
|
||||
'slug' => $story->slug,
|
||||
'url' => $story->url,
|
||||
'title' => $story->title,
|
||||
'excerpt' => $story->excerpt,
|
||||
'cover_image' => $story->cover_url,
|
||||
'author' => $story->creator ? $this->formatCreator($story->creator) : null,
|
||||
'tags' => $story->tags->map(fn ($t) => ['id' => $t->id, 'slug' => $t->slug, 'name' => $t->name, 'url' => $t->url]),
|
||||
'views' => $story->views,
|
||||
'featured' => $story->featured,
|
||||
'reading_time' => $story->reading_time,
|
||||
'published_at' => $story->published_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
private function formatFull(Story $story): array
|
||||
{
|
||||
return array_merge($this->formatCard($story), [
|
||||
'content' => $story->content,
|
||||
]);
|
||||
}
|
||||
|
||||
private function formatCreator(User $creator): array
|
||||
{
|
||||
$avatarHash = $creator->profile?->avatar_hash;
|
||||
|
||||
return [
|
||||
'id' => $creator->id,
|
||||
'name' => $creator->username ?? $creator->name,
|
||||
'avatar_url' => $avatarHash
|
||||
? \App\Support\AvatarUrl::forUser((int) $creator->id, $avatarHash, 96)
|
||||
: \App\Support\AvatarUrl::default(),
|
||||
'bio' => $creator->profile?->about,
|
||||
'profile_url' => '/@' . strtolower((string) ($creator->username ?? $creator->id)),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Story;
|
||||
use App\Models\StoryComment;
|
||||
use App\Services\SocialService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class StoryCommentController extends Controller
|
||||
{
|
||||
public function __construct(private readonly SocialService $social) {}
|
||||
|
||||
public function index(Request $request, int $storyId): JsonResponse
|
||||
{
|
||||
$story = Story::published()->findOrFail($storyId);
|
||||
|
||||
return response()->json(
|
||||
$this->social->listStoryComments($story, $request->user()?->id, (int) $request->query('page', 1), 20)
|
||||
);
|
||||
}
|
||||
|
||||
public function store(Request $request, int $storyId): JsonResponse
|
||||
{
|
||||
$story = Story::published()->findOrFail($storyId);
|
||||
|
||||
$payload = $request->validate([
|
||||
'content' => ['required', 'string', 'min:1', 'max:10000'],
|
||||
'parent_id' => ['nullable', 'integer'],
|
||||
]);
|
||||
|
||||
$comment = $this->social->addStoryComment(
|
||||
$request->user(),
|
||||
$story,
|
||||
(string) $payload['content'],
|
||||
isset($payload['parent_id']) ? (int) $payload['parent_id'] : null,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->social->formatComment($comment, $request->user()->id, true),
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $storyId, int $commentId): JsonResponse
|
||||
{
|
||||
$comment = StoryComment::query()
|
||||
->where('story_id', $storyId)
|
||||
->findOrFail($commentId);
|
||||
|
||||
$this->social->deleteStoryComment($request->user(), $comment);
|
||||
|
||||
return response()->json(['message' => 'Comment deleted.']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Story;
|
||||
use App\Services\SocialService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class StoryInteractionController extends Controller
|
||||
{
|
||||
public function __construct(private readonly SocialService $social) {}
|
||||
|
||||
public function like(Request $request, int $storyId): JsonResponse
|
||||
{
|
||||
$story = Story::published()->findOrFail($storyId);
|
||||
|
||||
return response()->json(
|
||||
$this->social->toggleStoryLike($request->user(), $story, $request->boolean('state', true))
|
||||
);
|
||||
}
|
||||
|
||||
public function bookmark(Request $request, int $storyId): JsonResponse
|
||||
{
|
||||
$story = Story::published()->findOrFail($storyId);
|
||||
|
||||
return response()->json(
|
||||
$this->social->toggleStoryBookmark($request->user(), $story, $request->boolean('state', true))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Recommendation\UserPreferenceBuilder;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* GET /api/user/suggestions/creators
|
||||
*
|
||||
* Returns up to 12 creators the authenticated user might want to follow.
|
||||
*
|
||||
* Ranking algorithm (Phase 1 – no embeddings):
|
||||
* 1. Creators followed by people you follow (mutual-follow signal)
|
||||
* 2. Creators whose recent works overlap your top tags
|
||||
* 3. High-quality creators (followers_count / artworks_count) in your categories
|
||||
*
|
||||
* Exclusions: yourself, already-followed creators.
|
||||
*
|
||||
* Cached per user for config('recommendations.ttl.creator_suggestions') seconds (default 30 min).
|
||||
*/
|
||||
final class SuggestedCreatorsController extends Controller
|
||||
{
|
||||
private const LIMIT = 12;
|
||||
|
||||
public function __construct(private readonly UserPreferenceBuilder $prefBuilder) {}
|
||||
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$ttl = (int) config('recommendations.ttl.creator_suggestions', 30 * 60);
|
||||
$cacheKey = "creator_suggestions:{$user->id}";
|
||||
|
||||
$data = Cache::remember($cacheKey, $ttl, function () use ($user) {
|
||||
return $this->buildSuggestions($user);
|
||||
});
|
||||
|
||||
return response()->json(['data' => $data]);
|
||||
}
|
||||
|
||||
private function buildSuggestions(\App\Models\User $user): array
|
||||
{
|
||||
try {
|
||||
$profile = $this->prefBuilder->build($user);
|
||||
$followingIds = $profile->strongCreatorIds;
|
||||
$topTagSlugs = array_slice($profile->topTagSlugs, 0, 10);
|
||||
|
||||
// ── 1. Mutual-follow candidates ───────────────────────────────────
|
||||
$mutualCandidates = [];
|
||||
if ($followingIds !== []) {
|
||||
$rows = DB::table('user_followers as uf')
|
||||
->join('users as u', 'u.id', '=', 'uf.user_id')
|
||||
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
||||
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
|
||||
->whereIn('uf.follower_id', $followingIds)
|
||||
->where('uf.user_id', '!=', $user->id)
|
||||
->whereNotIn('uf.user_id', array_merge($followingIds, [$user->id]))
|
||||
->where('u.is_active', true)
|
||||
->selectRaw('
|
||||
u.id,
|
||||
u.name,
|
||||
u.username,
|
||||
up.avatar_hash,
|
||||
COALESCE(us.followers_count, 0) as followers_count,
|
||||
COALESCE(us.uploads_count, 0) as artworks_count,
|
||||
COUNT(*) as mutual_weight
|
||||
')
|
||||
->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash', 'us.followers_count', 'us.uploads_count')
|
||||
->orderByDesc('mutual_weight')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$mutualCandidates[(int) $row->id] = [
|
||||
'id' => (int) $row->id,
|
||||
'name' => $row->name,
|
||||
'username' => $row->username,
|
||||
'avatar_hash' => $row->avatar_hash,
|
||||
'followers_count' => (int) $row->followers_count,
|
||||
'artworks_count' => (int) $row->artworks_count,
|
||||
'score' => (float) $row->mutual_weight * 3.0,
|
||||
'reason' => 'Popular among creators you follow',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Tag-affinity candidates ────────────────────────────────────
|
||||
$tagCandidates = [];
|
||||
if ($topTagSlugs !== []) {
|
||||
$tagFilter = implode(',', array_fill(0, count($topTagSlugs), '?'));
|
||||
|
||||
$rows = DB::table('tags as t')
|
||||
->join('artwork_tag as at', 'at.tag_id', '=', 't.id')
|
||||
->join('artworks as a', 'a.id', '=', 'at.artwork_id')
|
||||
->join('users as u', 'u.id', '=', 'a.user_id')
|
||||
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
||||
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
|
||||
->whereIn('t.slug', $topTagSlugs)
|
||||
->where('a.is_public', true)
|
||||
->where('a.is_approved', true)
|
||||
->whereNull('a.deleted_at')
|
||||
->where('u.id', '!=', $user->id)
|
||||
->whereNotIn('u.id', array_merge($followingIds, [$user->id]))
|
||||
->where('u.is_active', true)
|
||||
->selectRaw('
|
||||
u.id,
|
||||
u.name,
|
||||
u.username,
|
||||
up.avatar_hash,
|
||||
COALESCE(us.followers_count, 0) as followers_count,
|
||||
COALESCE(us.uploads_count, 0) as artworks_count,
|
||||
COUNT(DISTINCT t.id) as matched_tags
|
||||
')
|
||||
->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash', 'us.followers_count', 'us.uploads_count')
|
||||
->orderByDesc('matched_tags')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if (isset($mutualCandidates[(int) $row->id])) {
|
||||
// Boost mutual candidate that also matches tags
|
||||
$mutualCandidates[(int) $row->id]['score'] += (float) $row->matched_tags;
|
||||
continue;
|
||||
}
|
||||
|
||||
$tagCandidates[(int) $row->id] = [
|
||||
'id' => (int) $row->id,
|
||||
'name' => $row->name,
|
||||
'username' => $row->username,
|
||||
'avatar_hash' => $row->avatar_hash,
|
||||
'followers_count' => (int) $row->followers_count,
|
||||
'artworks_count' => (int) $row->artworks_count,
|
||||
'score' => (float) $row->matched_tags * 2.0,
|
||||
'reason' => 'Matches your interests',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Merge & rank ───────────────────────────────────────────────
|
||||
$combined = array_values(array_merge($mutualCandidates, $tagCandidates));
|
||||
usort($combined, fn ($a, $b) => $b['score'] <=> $a['score']);
|
||||
$top = array_slice($combined, 0, self::LIMIT);
|
||||
|
||||
if (count($top) < self::LIMIT) {
|
||||
$topIds = array_column($top, 'id');
|
||||
$excluded = array_unique(array_merge($followingIds, [$user->id], $topIds));
|
||||
$top = array_merge($top, $this->highQualityFallback($excluded, self::LIMIT - count($top)));
|
||||
}
|
||||
|
||||
return array_map(fn (array $c): array => [
|
||||
'id' => $c['id'],
|
||||
'name' => $c['name'],
|
||||
'username' => $c['username'],
|
||||
'url' => $c['username'] ? '/@' . $c['username'] : '/profile/' . $c['id'],
|
||||
'avatar' => AvatarUrl::forUser((int) $c['id'], $c['avatar_hash'] ?? null, 64),
|
||||
'followers_count' => (int) ($c['followers_count'] ?? 0),
|
||||
'artworks_count' => (int) ($c['artworks_count'] ?? 0),
|
||||
'reason' => $c['reason'] ?? null,
|
||||
], $top);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('SuggestedCreatorsController: failed', [
|
||||
'user_id' => $user->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $excludedIds
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function highQualityFallback(array $excludedIds, int $limit): array
|
||||
{
|
||||
if ($limit <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = DB::table('users as u')
|
||||
->join('user_profiles as up', 'up.user_id', '=', 'u.id')
|
||||
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
|
||||
->whereNotIn('u.id', $excludedIds)
|
||||
->where('u.is_active', true)
|
||||
->selectRaw('
|
||||
u.id,
|
||||
u.name,
|
||||
u.username,
|
||||
up.avatar_hash,
|
||||
COALESCE(us.followers_count, 0) as followers_count,
|
||||
COALESCE(us.uploads_count, 0) as artworks_count
|
||||
')
|
||||
->orderByDesc('followers_count')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
return $rows->map(fn ($r) => [
|
||||
'id' => (int) $r->id,
|
||||
'name' => $r->name,
|
||||
'username' => $r->username,
|
||||
'avatar_hash' => $r->avatar_hash,
|
||||
'followers_count' => (int) $r->followers_count,
|
||||
'artworks_count' => (int) $r->artworks_count,
|
||||
'score' => (float) $r->followers_count * 0.1,
|
||||
'reason' => 'Popular creator',
|
||||
])->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Recommendation\UserPreferenceBuilder;
|
||||
use App\Services\Tags\TagDiscoveryService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* GET /api/user/suggestions/tags
|
||||
*
|
||||
* Returns up to 20 tag suggestions for the authenticated user.
|
||||
*
|
||||
* Sources:
|
||||
* 1. Tags from the user's favourited artworks and awards (affinity-ranked)
|
||||
* 2. Trending tags from global activity (fallback / discovery)
|
||||
*
|
||||
* Does NOT require the user to follow tags. This endpoint provides the foundation
|
||||
* for a future "follow tags" feature while being useful immediately as discovery input.
|
||||
*
|
||||
* Cached per user for config('recommendations.ttl.tag_suggestions') seconds (default 60 min).
|
||||
*/
|
||||
final class SuggestedTagsController extends Controller
|
||||
{
|
||||
private const LIMIT = 20;
|
||||
|
||||
public function __construct(
|
||||
private readonly UserPreferenceBuilder $prefBuilder,
|
||||
private readonly TagDiscoveryService $tagDiscoveryService,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$ttl = (int) config('recommendations.ttl.tag_suggestions', 60 * 60);
|
||||
$cacheKey = "tag_suggestions:{$user->id}";
|
||||
|
||||
$data = Cache::remember($cacheKey, $ttl, function () use ($user) {
|
||||
return $this->buildSuggestions($user);
|
||||
});
|
||||
|
||||
return response()->json(['data' => $data]);
|
||||
}
|
||||
|
||||
private function buildSuggestions(\App\Models\User $user): array
|
||||
{
|
||||
try {
|
||||
$profile = $this->prefBuilder->build($user);
|
||||
$knownTagSlugs = $profile->topTagSlugs; // already in user's profile – skip
|
||||
|
||||
// ── Personalised tags (with normalised weights) ───────────────────
|
||||
$personalised = [];
|
||||
foreach ($profile->tagWeights as $slug => $weight) {
|
||||
if ($weight > 0.0) {
|
||||
$personalised[$slug] = (float) $weight;
|
||||
}
|
||||
}
|
||||
arsort($personalised);
|
||||
|
||||
// ── Trending tags (global, last 7 days) ───────────────────────────
|
||||
$trending = $this->trendingTags(40);
|
||||
|
||||
// ── Merge: personalised first, then trending discovery ─────────────
|
||||
$merged = [];
|
||||
foreach ($personalised as $slug => $weight) {
|
||||
$merged[$slug] = [
|
||||
'slug' => $slug,
|
||||
'score' => $weight * 2.0, // boost personal signal
|
||||
'source' => 'affinity',
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($trending as $row) {
|
||||
$slug = (string) $row->slug;
|
||||
if (isset($merged[$slug])) {
|
||||
$merged[$slug]['score'] += (float) $row->trend_score;
|
||||
} else {
|
||||
$merged[$slug] = [
|
||||
'slug' => $slug,
|
||||
'score' => (float) $row->trend_score,
|
||||
'source' => 'trending',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
uasort($merged, fn ($a, $b) => $b['score'] <=> $a['score']);
|
||||
$top = array_slice(array_values($merged), 0, self::LIMIT);
|
||||
|
||||
// ── Hydrate with DB info ──────────────────────────────────────────
|
||||
$slugs = array_column($top, 'slug');
|
||||
$tagRows = DB::table('tags')
|
||||
->whereIn('slug', $slugs)
|
||||
->where('is_active', true)
|
||||
->get(['id', 'name', 'slug', 'usage_count'])
|
||||
->keyBy('slug');
|
||||
|
||||
$result = [];
|
||||
foreach ($top as $item) {
|
||||
$tag = $tagRows->get($item['slug']);
|
||||
if ($tag === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[] = [
|
||||
'id' => (int) $tag->id,
|
||||
'name' => (string) $tag->name,
|
||||
'slug' => (string) $tag->slug,
|
||||
'usage_count' => (int) $tag->usage_count,
|
||||
'source' => (string) $item['source'],
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('SuggestedTagsController: failed', [
|
||||
'user_id' => $user->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Support\Collection<int, object{slug:string, trend_score:float}>
|
||||
*/
|
||||
private function trendingTags(int $limit): \Illuminate\Support\Collection
|
||||
{
|
||||
return $this->tagDiscoveryService
|
||||
->popularTags($limit, 7)
|
||||
->map(static fn ($tag) => (object) [
|
||||
'slug' => (string) $tag->slug,
|
||||
'trend_score' => max(
|
||||
(float) ($tag->recent_clicks ?? 0),
|
||||
(float) ($tag->usage_count ?? 0) / 1000
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Tags\PopularTagsRequest;
|
||||
use App\Http\Requests\Tags\TagSearchRequest;
|
||||
use App\Services\Tags\TagDiscoveryService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
final class TagController extends Controller
|
||||
{
|
||||
public function __construct(private readonly TagDiscoveryService $tagDiscoveryService) {}
|
||||
|
||||
public function search(TagSearchRequest $request): JsonResponse
|
||||
{
|
||||
$q = trim((string) ($request->validated()['q'] ?? ''));
|
||||
|
||||
// Short results cached for 2 min; empty-query (popular suggestions) for 5 min.
|
||||
$ttl = $q === '' ? 300 : 120;
|
||||
$cacheKey = 'tags.search.v2.' . ($q === '' ? '__empty__' : md5($q));
|
||||
$legacyCacheKey = 'tags.search.' . ($q === '' ? '__empty__' : md5($q));
|
||||
|
||||
$data = Cache::remember($cacheKey, $ttl, function () use ($q): mixed {
|
||||
return $this->tagDiscoveryService->searchSuggestions($q, 20);
|
||||
});
|
||||
|
||||
Cache::put($legacyCacheKey, $data, $ttl);
|
||||
|
||||
return response()->json(['data' => $data]);
|
||||
}
|
||||
|
||||
public function popular(PopularTagsRequest $request): JsonResponse
|
||||
{
|
||||
$limit = (int) ($request->validated()['limit'] ?? 20);
|
||||
$cacheKey = 'tags.popular.v2.' . $limit;
|
||||
$legacyCacheKey = 'tags.popular.' . $limit;
|
||||
|
||||
$data = Cache::remember($cacheKey, 300, function () use ($limit): mixed {
|
||||
return $this->tagDiscoveryService->popularTags($limit);
|
||||
});
|
||||
|
||||
Cache::put($legacyCacheKey, $data, 300);
|
||||
|
||||
return response()->json(['data' => $data]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class TagInteractionAnalyticsController extends Controller
|
||||
{
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'event_type' => ['required', 'string', 'in:click'],
|
||||
'surface' => ['required', 'string', 'in:search_suggestion,recent_search,rescue_suggestion,related_chip,related_cluster,top_companion'],
|
||||
'tag_slug' => ['nullable', 'string', 'max:120'],
|
||||
'source_tag_slug' => ['nullable', 'string', 'max:120'],
|
||||
'query' => ['nullable', 'string', 'max:120'],
|
||||
'position' => ['nullable', 'integer', 'min:1', 'max:50'],
|
||||
'occurred_at' => ['nullable', 'date'],
|
||||
'meta' => ['nullable', 'array'],
|
||||
]);
|
||||
|
||||
if (($payload['surface'] ?? null) !== 'recent_search' && empty($payload['tag_slug'])) {
|
||||
return response()->json([
|
||||
'message' => 'The selected analytics surface requires a tag slug.',
|
||||
'errors' => ['tag_slug' => ['The tag slug field is required for this surface.']],
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$occurredAt = isset($payload['occurred_at'])
|
||||
? CarbonImmutable::parse((string) $payload['occurred_at'])
|
||||
: CarbonImmutable::now();
|
||||
$sessionId = $request->hasSession() ? (string) $request->session()->getId() : '';
|
||||
$sessionKey = $sessionId !== '' ? hash('sha256', $sessionId) : null;
|
||||
|
||||
DB::table('tag_interaction_events')->insert([
|
||||
'event_date' => $occurredAt->toDateString(),
|
||||
'event_type' => (string) $payload['event_type'],
|
||||
'surface' => (string) $payload['surface'],
|
||||
'user_id' => $request->user()?->id,
|
||||
'session_key' => $sessionKey,
|
||||
'tag_slug' => isset($payload['tag_slug']) ? (string) $payload['tag_slug'] : null,
|
||||
'source_tag_slug' => isset($payload['source_tag_slug']) ? (string) $payload['source_tag_slug'] : null,
|
||||
'query' => isset($payload['query']) ? trim((string) $payload['query']) : null,
|
||||
'position' => isset($payload['position']) ? (int) $payload['position'] : null,
|
||||
'meta' => $payload['meta'] ?? null,
|
||||
'occurred_at' => $occurredAt,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return response()->json(['success' => true], Response::HTTP_OK);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,808 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Uploads\UploadFinishRequest;
|
||||
use App\Http\Requests\Uploads\UploadInitRequest;
|
||||
use App\Http\Requests\Uploads\UploadChunkRequest;
|
||||
use App\Http\Requests\Uploads\UploadCancelRequest;
|
||||
use App\Http\Requests\Uploads\UploadStatusRequest;
|
||||
use App\Jobs\GenerateDerivativesJob;
|
||||
use App\Jobs\AutoTagArtworkJob;
|
||||
use App\Jobs\GenerateArtworkEmbeddingJob;
|
||||
use App\Repositories\Uploads\UploadSessionRepository;
|
||||
use App\Services\Uploads\UploadChunkService;
|
||||
use App\Services\Uploads\UploadCancelService;
|
||||
use App\Services\Uploads\UploadAuditService;
|
||||
use App\Services\Uploads\UploadPipelineService;
|
||||
use App\Services\Uploads\UploadQuotaService;
|
||||
use App\Services\Uploads\UploadSessionStatus;
|
||||
use App\Services\Uploads\UploadStatusService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Throwable;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use App\Services\Upload\Contracts\UploadDraftServiceInterface;
|
||||
use Carbon\Carbon;
|
||||
use App\Uploads\Jobs\VirusScanJob;
|
||||
use App\Uploads\Services\PublishService;
|
||||
use App\Services\Activity\UserActivityService;
|
||||
use App\Services\ArtworkAttributionService;
|
||||
use App\Services\Maturity\ArtworkMaturityService;
|
||||
use App\Uploads\Exceptions\UploadNotFoundException;
|
||||
use App\Uploads\Exceptions\UploadOwnershipException;
|
||||
use App\Uploads\Exceptions\UploadPublishValidationException;
|
||||
use App\Uploads\Services\ArchiveInspectorService;
|
||||
use App\Uploads\Services\DraftQuotaService;
|
||||
use App\Uploads\Exceptions\DraftQuotaException;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Group;
|
||||
use App\Services\GroupArtworkReviewService;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class UploadController extends Controller
|
||||
{
|
||||
public function init(
|
||||
UploadInitRequest $request,
|
||||
UploadPipelineService $pipeline,
|
||||
UploadQuotaService $quota,
|
||||
UploadAuditService $audit
|
||||
)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
try {
|
||||
$quota->enforce($user->id);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => $e->getMessage(),
|
||||
], Response::HTTP_TOO_MANY_REQUESTS);
|
||||
}
|
||||
|
||||
$result = $pipeline->initSession($user->id, (string) $request->ip());
|
||||
|
||||
$audit->log($user->id, 'upload_init_issued', (string) $request->ip(), [
|
||||
'session_id' => $result->sessionId,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'session_id' => $result->sessionId,
|
||||
'upload_token' => $result->token,
|
||||
'status' => $result->status,
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
public function finish(
|
||||
UploadFinishRequest $request,
|
||||
UploadPipelineService $pipeline,
|
||||
UploadSessionRepository $sessions,
|
||||
UploadAuditService $audit
|
||||
) {
|
||||
$user = $request->user();
|
||||
$sessionId = (string) $request->validated('session_id');
|
||||
$artworkId = (int) $request->validated('artwork_id');
|
||||
$originalFileName = $request->validated('file_name');
|
||||
$archiveSessionId = $request->validated('archive_session_id');
|
||||
$archiveOriginalFileName = $request->validated('archive_file_name');
|
||||
$additionalScreenshotSessions = collect($request->validated('additional_screenshot_sessions', []))
|
||||
->filter(fn ($payload) => is_array($payload) && is_string($payload['session_id'] ?? null))
|
||||
->values();
|
||||
|
||||
$session = $sessions->getOrFail($sessionId);
|
||||
|
||||
$request->artwork();
|
||||
|
||||
$validated = $pipeline->validateAndHash($sessionId);
|
||||
if (! $validated->validation->ok || ! $validated->hash) {
|
||||
return response()->json([
|
||||
'message' => 'Upload validation failed.',
|
||||
'reason' => $validated->validation->reason,
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
if ($pipeline->originalHashExists($validated->hash)) {
|
||||
return response()->json([
|
||||
'message' => 'Duplicate upload is not allowed. This file already exists.',
|
||||
'reason' => 'duplicate_hash',
|
||||
'hash' => $validated->hash,
|
||||
], Response::HTTP_CONFLICT);
|
||||
}
|
||||
|
||||
$scan = $pipeline->scan($sessionId);
|
||||
if (! $scan->ok) {
|
||||
return response()->json([
|
||||
'message' => 'Upload scan failed.',
|
||||
'reason' => $scan->reason,
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$validatedArchive = null;
|
||||
if (is_string($archiveSessionId) && trim($archiveSessionId) !== '') {
|
||||
$validatedArchive = $pipeline->validateAndHashArchive($archiveSessionId);
|
||||
if (! $validatedArchive->validation->ok || ! $validatedArchive->hash) {
|
||||
return response()->json([
|
||||
'message' => 'Archive validation failed.',
|
||||
'reason' => $validatedArchive->validation->reason,
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$archiveScan = $pipeline->scan($archiveSessionId);
|
||||
if (! $archiveScan->ok) {
|
||||
return response()->json([
|
||||
'message' => 'Archive scan failed.',
|
||||
'reason' => $archiveScan->reason,
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
}
|
||||
|
||||
$validatedAdditionalScreenshots = [];
|
||||
foreach ($additionalScreenshotSessions as $payload) {
|
||||
$screenshotSessionId = (string) ($payload['session_id'] ?? '');
|
||||
if ($screenshotSessionId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$validatedScreenshot = $pipeline->validateAndHash($screenshotSessionId);
|
||||
if (! $validatedScreenshot->validation->ok || ! $validatedScreenshot->hash) {
|
||||
return response()->json([
|
||||
'message' => 'Screenshot validation failed.',
|
||||
'reason' => $validatedScreenshot->validation->reason,
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$screenshotScan = $pipeline->scan($screenshotSessionId);
|
||||
if (! $screenshotScan->ok) {
|
||||
return response()->json([
|
||||
'message' => 'Screenshot scan failed.',
|
||||
'reason' => $screenshotScan->reason,
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$validatedAdditionalScreenshots[] = [
|
||||
'session_id' => $screenshotSessionId,
|
||||
'hash' => $validatedScreenshot->hash,
|
||||
'file_name' => is_string($payload['file_name'] ?? null) ? $payload['file_name'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$status = DB::transaction(function () use ($pipeline, $sessionId, $validated, $artworkId, $originalFileName, $archiveSessionId, $validatedArchive, $archiveOriginalFileName, $validatedAdditionalScreenshots) {
|
||||
if ((bool) config('uploads.queue_derivatives', false)) {
|
||||
GenerateDerivativesJob::dispatch(
|
||||
$sessionId,
|
||||
$validated->hash,
|
||||
$artworkId,
|
||||
is_string($originalFileName) ? $originalFileName : null,
|
||||
is_string($archiveSessionId) ? $archiveSessionId : null,
|
||||
$validatedArchive?->hash,
|
||||
is_string($archiveOriginalFileName) ? $archiveOriginalFileName : null,
|
||||
$validatedAdditionalScreenshots
|
||||
)->afterCommit();
|
||||
return 'queued';
|
||||
}
|
||||
|
||||
$pipeline->processAndPublish(
|
||||
$sessionId,
|
||||
$validated->hash,
|
||||
$artworkId,
|
||||
is_string($originalFileName) ? $originalFileName : null,
|
||||
is_string($archiveSessionId) ? $archiveSessionId : null,
|
||||
$validatedArchive?->hash,
|
||||
is_string($archiveOriginalFileName) ? $archiveOriginalFileName : null,
|
||||
$validatedAdditionalScreenshots
|
||||
);
|
||||
|
||||
// Derivatives are available now; dispatch AI auto-tagging.
|
||||
AutoTagArtworkJob::dispatch($artworkId, $validated->hash)->afterCommit();
|
||||
GenerateArtworkEmbeddingJob::dispatch($artworkId, $validated->hash)->afterCommit();
|
||||
return UploadSessionStatus::PROCESSED;
|
||||
});
|
||||
|
||||
$audit->log($user->id, 'upload_finished', $session->ip, [
|
||||
'session_id' => $sessionId,
|
||||
'hash' => $validated->hash,
|
||||
'artwork_id' => $artworkId,
|
||||
'status' => $status,
|
||||
'archive_session_id' => is_string($archiveSessionId) ? $archiveSessionId : null,
|
||||
'additional_screenshot_session_ids' => array_values(array_map(static fn (array $payload): string => (string) $payload['session_id'], $validatedAdditionalScreenshots)),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'artwork_id' => $artworkId,
|
||||
'status' => $status,
|
||||
], Response::HTTP_OK);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('Upload finish failed', [
|
||||
'session_id' => $sessionId,
|
||||
'artwork_id' => $artworkId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Upload finish failed.',
|
||||
], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function chunk(UploadChunkRequest $request, UploadChunkService $chunks)
|
||||
{
|
||||
$user = $request->user();
|
||||
$chunkFile = $request->file('chunk');
|
||||
|
||||
// Debug: log uploaded file object details to help diagnose missing chunk
|
||||
try {
|
||||
if (! $chunkFile) {
|
||||
logger()->warning('Chunk upload: no file present on request', [
|
||||
'session_id' => (string) $request->input('session_id'),
|
||||
'headers' => $request->headers->all(),
|
||||
]);
|
||||
} else {
|
||||
logger()->warning('Chunk upload file details', [
|
||||
'session_id' => (string) $request->input('session_id'),
|
||||
'client_name' => $chunkFile->getClientOriginalName() ?? null,
|
||||
'client_size' => $chunkFile->getSize() ?? null,
|
||||
'error' => $chunkFile->getError(),
|
||||
'realpath' => $chunkFile->getRealPath(),
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
logger()->warning('Chunk upload debug logging failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
try {
|
||||
// Use getPathname() — this returns the PHP temp filename even when
|
||||
// getRealPath() may be false (platform/stream wrappers can cause
|
||||
// getRealPath() to return false). getPathname() is safe for reading
|
||||
// the uploaded chunk file.
|
||||
$chunkPath = $chunkFile ? $chunkFile->getPathname() : '';
|
||||
|
||||
$result = $chunks->appendChunk(
|
||||
(string) $request->input('session_id'),
|
||||
(string) $chunkPath,
|
||||
(int) $request->input('offset'),
|
||||
(int) $request->input('chunk_size'),
|
||||
(int) $request->input('total_size'),
|
||||
(int) $user->id,
|
||||
(string) $request->ip()
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'session_id' => $result->sessionId,
|
||||
'status' => $result->status,
|
||||
'received_bytes' => $result->receivedBytes,
|
||||
'total_bytes' => $result->totalBytes,
|
||||
'progress' => $result->progress,
|
||||
], Response::HTTP_OK);
|
||||
} catch (\Throwable $e) {
|
||||
logger()->warning('Upload chunk failed', [
|
||||
'session_id' => (string) $request->input('session_id'),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
// Include the underlying error message in the response during debugging
|
||||
// so the frontend can show a useful description. Remove or hide this
|
||||
// in production if you prefer more generic errors.
|
||||
return response()->json([
|
||||
'message' => 'Upload chunk failed.',
|
||||
'error' => $e->getMessage(),
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
}
|
||||
|
||||
public function status(string $id, UploadStatusRequest $request, UploadStatusService $statusService, UploadAuditService $audit)
|
||||
{
|
||||
$user = $request->user();
|
||||
$payload = $statusService->get($id);
|
||||
|
||||
$audit->log($user->id, 'upload_status_checked', (string) $request->ip(), [
|
||||
'session_id' => $id,
|
||||
'status' => $payload['status'],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'session_id' => $payload['session_id'],
|
||||
'status' => $payload['status'],
|
||||
'progress' => $payload['progress'],
|
||||
'failure_reason' => $payload['failure_reason'],
|
||||
'received_bytes' => $payload['received_bytes'] ?? 0,
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
public function cancel(UploadCancelRequest $request, UploadCancelService $cancel)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
try {
|
||||
$result = $cancel->cancel(
|
||||
(string) $request->input('session_id'),
|
||||
(int) $user->id,
|
||||
(string) $request->ip()
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'session_id' => $result['session_id'],
|
||||
'status' => $result['status'],
|
||||
], Response::HTTP_OK);
|
||||
} catch (\Throwable $e) {
|
||||
logger()->warning('Upload cancel failed', [
|
||||
'session_id' => (string) $request->input('session_id'),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Upload cancel failed.',
|
||||
], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload an upload draft: validate main file, create draft and store files.
|
||||
*
|
||||
* Returns JSON: { upload_id, status, expires_at }
|
||||
*/
|
||||
public function preload(Request $request, UploadDraftServiceInterface $draftService, ArchiveInspectorService $archiveInspector, DraftQuotaService $draftQuotaService)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$request->validate([
|
||||
'main' => ['required', 'file'],
|
||||
'screenshots' => ['sometimes', 'array'],
|
||||
'screenshots.*' => ['file', 'image', 'max:5120'],
|
||||
]);
|
||||
|
||||
$main = $request->file('main');
|
||||
|
||||
// Detect type from mime
|
||||
$mime = (string) $main->getClientMimeType();
|
||||
$type = null;
|
||||
if (str_starts_with($mime, 'image/')) {
|
||||
$type = 'image';
|
||||
} elseif (in_array($mime, ['application/zip', 'application/x-zip-compressed', 'application/x-tar', 'application/x-gzip', 'application/x-rar-compressed', 'application/octet-stream'])) {
|
||||
$type = 'archive';
|
||||
}
|
||||
|
||||
if ($type === null) {
|
||||
return response()->json([
|
||||
'message' => 'Invalid main file type.',
|
||||
'errors' => [
|
||||
'main' => ['The main file must be an image or archive.'],
|
||||
],
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
if ($type === 'archive') {
|
||||
$validator = Validator::make($request->all(), [
|
||||
'screenshots' => ['required', 'array', 'min:1'],
|
||||
'screenshots.*' => ['file', 'image', 'max:5120'],
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'The given data was invalid.',
|
||||
'errors' => $validator->errors(),
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$inspection = $archiveInspector->inspect((string) $main->getPathname());
|
||||
if (! $inspection->valid) {
|
||||
return response()->json([
|
||||
'message' => 'Archive inspection failed.',
|
||||
'reason' => $inspection->reason,
|
||||
'stats' => $inspection->stats,
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
}
|
||||
|
||||
$incomingFiles = [$main];
|
||||
if ($type === 'archive' && $request->hasFile('screenshots')) {
|
||||
foreach ($request->file('screenshots') as $screenshot) {
|
||||
$incomingFiles[] = $screenshot;
|
||||
}
|
||||
}
|
||||
|
||||
$mainHash = $draftService->calculateHash((string) $main->getPathname());
|
||||
|
||||
try {
|
||||
$warnings = $draftQuotaService->assertCanCreateDraft($user, [
|
||||
'files' => $incomingFiles,
|
||||
'main_hash' => $mainHash,
|
||||
]);
|
||||
} catch (DraftQuotaException $e) {
|
||||
return response()->json([
|
||||
'message' => $e->machineCode(),
|
||||
'code' => $e->machineCode(),
|
||||
], $e->httpStatus());
|
||||
}
|
||||
|
||||
// Create draft record (meta-only) and store main file via service
|
||||
$draft = $draftService->createDraft(['user_id' => $user->id, 'type' => $type]);
|
||||
|
||||
try {
|
||||
$mainInfo = $draftService->storeMainFile($draft['id'], $main);
|
||||
|
||||
// If archive, allow optional screenshots to be uploaded in the same request
|
||||
if ($type === 'archive' && $request->hasFile('screenshots')) {
|
||||
foreach ($request->file('screenshots') as $ss) {
|
||||
try {
|
||||
$draftService->storeScreenshot($draft['id'], $ss);
|
||||
} catch (Throwable $e) {
|
||||
// Keep controller thin: log and continue
|
||||
logger()->warning('Screenshot store failed during preload', ['error' => $e->getMessage(), 'draft' => $draft['id']]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set expiration (default 7 days) and return info
|
||||
$ttlDays = (int) config('uploads.draft_ttl_days', 7);
|
||||
$expiresAt = Carbon::now()->addDays($ttlDays);
|
||||
$draftService->setExpiration($draft['id'], $expiresAt);
|
||||
|
||||
VirusScanJob::dispatch($draft['id']);
|
||||
|
||||
$response = [
|
||||
'upload_id' => $draft['id'],
|
||||
'status' => 'draft',
|
||||
'expires_at' => $expiresAt->toISOString(),
|
||||
];
|
||||
|
||||
if (! empty($warnings)) {
|
||||
$response['warnings'] = array_values($warnings);
|
||||
}
|
||||
|
||||
return response()->json($response, Response::HTTP_OK);
|
||||
} catch (Throwable $e) {
|
||||
logger()->error('Upload preload failed', ['error' => $e->getMessage()]);
|
||||
return response()->json(['message' => 'Preload failed.'], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function autosave(string $id, Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$upload = DB::table('uploads')->where('id', $id)->first();
|
||||
if (! $upload) {
|
||||
return response()->json(['message' => 'Upload draft not found.'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
if ((int) $upload->user_id !== (int) $user->id) {
|
||||
return response()->json(['message' => 'Forbidden.'], Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
if ((string) $upload->status !== 'draft') {
|
||||
return response()->json([
|
||||
'message' => 'Only draft uploads can be autosaved.',
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => ['nullable', 'string', 'max:255'],
|
||||
'category_id' => ['nullable', 'exists:categories,id'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'tags' => ['nullable', 'array'],
|
||||
'license' => ['nullable', 'string'],
|
||||
'nsfw' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$updates = [];
|
||||
foreach (['title', 'category_id', 'description', 'tags', 'license', 'nsfw'] as $field) {
|
||||
if (array_key_exists($field, $validated)) {
|
||||
$updates[$field] = $validated[$field];
|
||||
}
|
||||
}
|
||||
|
||||
$dirty = [];
|
||||
foreach ($updates as $field => $value) {
|
||||
$current = $upload->{$field} ?? null;
|
||||
|
||||
if ($field === 'tags') {
|
||||
$current = $current ? json_decode((string) $current, true) : null;
|
||||
}
|
||||
|
||||
if ($field === 'nsfw') {
|
||||
$current = is_null($current) ? null : (bool) $current;
|
||||
$value = is_null($value) ? null : (bool) $value;
|
||||
}
|
||||
|
||||
if ($current !== $value) {
|
||||
$dirty[$field] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists('tags', $dirty)) {
|
||||
$dirty['tags'] = json_encode($dirty['tags']);
|
||||
}
|
||||
|
||||
if (! empty($dirty)) {
|
||||
$dirty['updated_at'] = now();
|
||||
DB::table('uploads')->where('id', $id)->update($dirty);
|
||||
$upload = DB::table('uploads')->where('id', $id)->first();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'updated_at' => (string) ($upload->updated_at ?? now()->toDateTimeString()),
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
public function processingStatus(string $id, Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$upload = DB::table('uploads')->where('id', $id)->first();
|
||||
if (! $upload) {
|
||||
return response()->json(['message' => 'Upload not found.'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
if ((int) $upload->user_id !== (int) $user->id) {
|
||||
return response()->json(['message' => 'Forbidden.'], Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
$status = (string) ($upload->status ?? 'draft');
|
||||
$isScanned = (bool) ($upload->is_scanned ?? false);
|
||||
$previewReady = ! empty($upload->preview_path);
|
||||
$hasTags = (bool) ($upload->has_tags ?? false);
|
||||
$processingState = (string) ($upload->processing_state ?? 'pending_scan');
|
||||
|
||||
return response()->json([
|
||||
'id' => (string) $upload->id,
|
||||
'status' => $status,
|
||||
'is_scanned' => $isScanned,
|
||||
'preview_ready' => $previewReady,
|
||||
'has_tags' => $hasTags,
|
||||
'processing_state' => $processingState,
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
public function publish(string $id, Request $request, PublishService $publishService, ArtworkAttributionService $attribution, ArtworkMaturityService $maturity)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => ['nullable', 'string', 'max:150'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'category' => ['nullable', 'integer', 'exists:categories,id'],
|
||||
'tags' => ['nullable', 'array', 'max:15'],
|
||||
'tags.*' => ['string', 'max:64'],
|
||||
'is_mature' => ['nullable', 'boolean'],
|
||||
'nsfw' => ['nullable', 'boolean'],
|
||||
// Scheduled-publishing fields
|
||||
'mode' => ['nullable', 'string', 'in:now,schedule'],
|
||||
'publish_at' => ['nullable', 'string', 'date'],
|
||||
'timezone' => ['nullable', 'string', 'max:64'],
|
||||
'visibility' => ['nullable', 'string', 'in:public,unlisted,private'],
|
||||
'group' => ['nullable', 'string', 'max:90'],
|
||||
'primary_author_user_id' => ['nullable', 'integer', 'min:1'],
|
||||
'contributor_user_ids' => ['nullable', 'array', 'max:20'],
|
||||
'contributor_user_ids.*' => ['integer', 'min:1'],
|
||||
'contributor_credits' => ['nullable', 'array', 'max:20'],
|
||||
'contributor_credits.*.user_id' => ['required', 'integer', 'min:1'],
|
||||
'contributor_credits.*.credit_role' => ['nullable', 'string', 'max:80'],
|
||||
'contributor_credits.*.is_primary' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$mode = $validated['mode'] ?? 'now';
|
||||
$visibility = $validated['visibility'] ?? 'public';
|
||||
|
||||
// Resolve the UTC publish_at datetime for schedule mode
|
||||
$publishAt = null;
|
||||
if ($mode === 'schedule' && ! empty($validated['publish_at'])) {
|
||||
try {
|
||||
$publishAt = \Carbon\Carbon::parse($validated['publish_at'])->utc();
|
||||
// Must be at least 1 minute in the future (server-side guard)
|
||||
if ($publishAt->lte(now()->addMinute())) {
|
||||
return response()->json([
|
||||
'message' => 'Scheduled publish time must be at least 1 minute in the future.',
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return response()->json(['message' => 'Invalid publish_at datetime.'], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
}
|
||||
|
||||
if (ctype_digit($id)) {
|
||||
$artworkId = (int) $id;
|
||||
$artwork = Artwork::query()->find($artworkId);
|
||||
if (! $artwork) {
|
||||
return response()->json(['message' => 'Artwork not found.'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
if ((int) $artwork->user_id !== (int) $user->id) {
|
||||
return response()->json(['message' => 'Forbidden.'], Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
$title = trim((string) ($validated['title'] ?? $artwork->title ?? ''));
|
||||
if ($title === '') {
|
||||
$title = 'Untitled artwork';
|
||||
}
|
||||
|
||||
$slugBase = Str::slug($title);
|
||||
if ($slugBase === '') {
|
||||
$slugBase = 'artwork';
|
||||
}
|
||||
|
||||
$artwork->title = $title;
|
||||
if (array_key_exists('description', $validated)) {
|
||||
$artwork->description = $validated['description'];
|
||||
}
|
||||
if (array_key_exists('is_mature', $validated) || array_key_exists('nsfw', $validated)) {
|
||||
$artwork->is_mature = (bool) ($validated['is_mature'] ?? $validated['nsfw'] ?? false);
|
||||
}
|
||||
$artwork->slug = Str::limit($slugBase, 160, '');
|
||||
$artwork->artwork_timezone = $validated['timezone'] ?? null;
|
||||
$artwork->uploaded_by_user_id = $artwork->uploaded_by_user_id ?: (int) $user->id;
|
||||
$artwork->primary_author_user_id = $artwork->primary_author_user_id ?: (int) $user->id;
|
||||
|
||||
// Sync category if provided
|
||||
$categoryId = isset($validated['category']) ? (int) $validated['category'] : null;
|
||||
if ($categoryId && \App\Models\Category::where('id', $categoryId)->exists()) {
|
||||
$artwork->categories()->sync([$categoryId]);
|
||||
}
|
||||
|
||||
// Sync tags if provided
|
||||
if (!empty($validated['tags']) && is_array($validated['tags'])) {
|
||||
$tagIds = [];
|
||||
foreach ($validated['tags'] as $tagSlug) {
|
||||
$tag = \App\Models\Tag::firstOrCreate(
|
||||
['slug' => Str::slug($tagSlug)],
|
||||
['name' => $tagSlug, 'is_active' => true, 'usage_count' => 0]
|
||||
);
|
||||
$tagIds[$tag->id] = ['source' => 'user', 'confidence' => 1.0];
|
||||
}
|
||||
$artwork->tags()->sync($tagIds);
|
||||
}
|
||||
|
||||
$artwork->save();
|
||||
$maturity->applyUploaderDeclaration($artwork, (bool) $artwork->is_mature);
|
||||
$artwork = $attribution->apply($artwork->fresh(['group.members']), $user, $validated);
|
||||
|
||||
if ($mode === 'schedule' && $publishAt) {
|
||||
// Scheduled: store publish_at but don't make public yet
|
||||
$artwork->visibility = $visibility;
|
||||
$artwork->is_public = false;
|
||||
$artwork->is_approved = true;
|
||||
$artwork->publish_at = $publishAt;
|
||||
$artwork->artwork_status = 'scheduled';
|
||||
$artwork->published_at = null;
|
||||
$artwork->save();
|
||||
|
||||
try {
|
||||
$artwork->unsearchable();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Failed to remove scheduled artwork from search index', [
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'status' => 'scheduled',
|
||||
'slug' => (string) $artwork->slug,
|
||||
'publish_at' => $publishAt->toISOString(),
|
||||
'published_at' => null,
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
// Publish immediately
|
||||
$artwork->visibility = $visibility;
|
||||
$artwork->is_public = ($visibility !== 'private');
|
||||
$artwork->is_approved = true;
|
||||
$artwork->published_at = now();
|
||||
$artwork->artwork_status = 'published';
|
||||
$artwork->publish_at = null;
|
||||
$artwork->save();
|
||||
|
||||
try {
|
||||
if ((bool) $artwork->is_public && (bool) $artwork->is_approved && !empty($artwork->published_at)) {
|
||||
$artwork->searchable();
|
||||
} else {
|
||||
$artwork->unsearchable();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Failed to sync artwork search index after publish', [
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Record upload activity event
|
||||
try {
|
||||
\App\Models\ActivityEvent::record(
|
||||
actorId: (int) $user->id,
|
||||
type: \App\Models\ActivityEvent::TYPE_UPLOAD,
|
||||
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
|
||||
targetId: (int) $artwork->id,
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
|
||||
try {
|
||||
app(UserActivityService::class)->logUpload((int) $user->id, (int) $artwork->id);
|
||||
} catch (\Throwable) {}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'status' => 'published',
|
||||
'slug' => (string) $artwork->slug,
|
||||
'published_at' => optional($artwork->published_at)->toISOString(),
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
try {
|
||||
$upload = $publishService->publish($id, $user);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'upload_id' => (string) $upload->id,
|
||||
'status' => (string) $upload->status,
|
||||
'published_at' => optional($upload->published_at)->toISOString(),
|
||||
'final_path' => (string) ($upload->final_path ?? ''),
|
||||
], Response::HTTP_OK);
|
||||
} catch (UploadOwnershipException $e) {
|
||||
return response()->json(['message' => $e->getMessage()], Response::HTTP_FORBIDDEN);
|
||||
} catch (UploadNotFoundException $e) {
|
||||
return response()->json(['message' => $e->getMessage()], Response::HTTP_NOT_FOUND);
|
||||
} catch (UploadPublishValidationException $e) {
|
||||
return response()->json(['message' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
}
|
||||
|
||||
public function submitForReview(string $id, Request $request, GroupArtworkReviewService $reviews)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => ['nullable', 'string', 'max:150'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'category' => ['nullable', 'integer', 'exists:categories,id'],
|
||||
'tags' => ['nullable', 'array', 'max:15'],
|
||||
'tags.*' => ['string', 'max:64'],
|
||||
'is_mature' => ['nullable', 'boolean'],
|
||||
'nsfw' => ['nullable', 'boolean'],
|
||||
'timezone' => ['nullable', 'string', 'max:64'],
|
||||
'visibility' => ['nullable', 'string', 'in:public,unlisted,private'],
|
||||
'group' => ['required', 'string', 'max:90'],
|
||||
'primary_author_user_id' => ['nullable', 'integer', 'min:1'],
|
||||
'contributor_user_ids' => ['nullable', 'array', 'max:20'],
|
||||
'contributor_user_ids.*' => ['integer', 'min:1'],
|
||||
'contributor_credits' => ['nullable', 'array', 'max:20'],
|
||||
'contributor_credits.*.user_id' => ['required', 'integer', 'min:1'],
|
||||
'contributor_credits.*.credit_role' => ['nullable', 'string', 'max:80'],
|
||||
'contributor_credits.*.is_primary' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
if (! ctype_digit($id)) {
|
||||
return response()->json(['message' => 'Artwork review submission requires an artwork draft id.'], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$artwork = Artwork::query()->find((int) $id);
|
||||
if (! $artwork) {
|
||||
return response()->json(['message' => 'Artwork not found.'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
if ((int) $artwork->user_id !== (int) $user->id && (int) ($artwork->uploaded_by_user_id ?? 0) !== (int) $user->id) {
|
||||
return response()->json(['message' => 'Forbidden.'], Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
$group = Group::query()->with('members')->where('slug', (string) $validated['group'])->first();
|
||||
if (! $group) {
|
||||
return response()->json(['message' => 'Group not found.'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$artwork = $reviews->submit($group, $artwork, $user, $validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'status' => 'submitted_for_review',
|
||||
'group_review_status' => (string) $artwork->group_review_status,
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\TagNormalizer;
|
||||
use App\Services\Vision\VisionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Synchronous Vision tag suggestions for the upload wizard.
|
||||
*
|
||||
* POST /api/uploads/{id}/vision-suggest
|
||||
*
|
||||
* Calls the Vision gateway (/analyze/all) synchronously and returns
|
||||
* normalised tag suggestions immediately — without going through the queue.
|
||||
* The queue-based AutoTagArtworkJob still runs in the background and writes
|
||||
* to the DB; this endpoint gives the user instant pre-fill on Step 2.
|
||||
*/
|
||||
final class UploadVisionSuggestController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly VisionService $vision,
|
||||
private readonly TagNormalizer $normalizer,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id, Request $request): JsonResponse
|
||||
{
|
||||
if (! $this->vision->isEnabled()) {
|
||||
return response()->json(['tags' => [], 'vision_enabled' => false]);
|
||||
}
|
||||
|
||||
$artwork = Artwork::query()->findOrFail($id);
|
||||
$this->authorizeOrNotFound($request->user(), $artwork);
|
||||
$limit = (int) $request->query('limit', 10);
|
||||
|
||||
return response()->json($this->vision->suggestTags($artwork, $this->normalizer, $limit));
|
||||
}
|
||||
|
||||
private function authorizeOrNotFound(mixed $user, Artwork $artwork): void
|
||||
{
|
||||
if (! $user) {
|
||||
abort(404);
|
||||
}
|
||||
if ((int) $artwork->user_id !== (int) $user->id) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\AchievementService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class UserAchievementsController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, AchievementService $achievements): JsonResponse
|
||||
{
|
||||
return response()->json($achievements->summary($request->user()->id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\UserSuggestionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class UserSuggestionsController extends Controller
|
||||
{
|
||||
public function __construct(private readonly UserSuggestionService $suggestions)
|
||||
{
|
||||
}
|
||||
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$limit = min((int) $request->query('limit', 8), 24);
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->suggestions->suggestFor($request->user(), $limit),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\XPService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class UserXpController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, XPService $xp): JsonResponse
|
||||
{
|
||||
return response()->json($xp->summary($request->user()->id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\UsernameRequest;
|
||||
use App\Models\User;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class UsernameAvailabilityController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$candidate = UsernamePolicy::normalize((string) $request->query('username', ''));
|
||||
|
||||
$validator = validator(
|
||||
['username' => $candidate],
|
||||
['username' => UsernameRequest::formatRules()]
|
||||
);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'available' => false,
|
||||
'normalized' => $candidate,
|
||||
'errors' => $validator->errors()->toArray(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$ignoreUserId = $request->user()?->id;
|
||||
$exists = User::query()
|
||||
->whereRaw('LOWER(username) = ?', [$candidate])
|
||||
->when($ignoreUserId !== null, fn ($q) => $q->where('id', '!=', (int) $ignoreUserId))
|
||||
->exists();
|
||||
|
||||
return response()->json([
|
||||
'available' => ! $exists,
|
||||
'normalized' => $candidate,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Web\BrowseGalleryController;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\ArtworkIndexRequest;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Services\ContentTypes\ContentTypeSlugResolver;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ArtworkController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ContentTypeSlugResolver $contentTypeResolver)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse artworks with optional category filtering.
|
||||
* Uses cursor pagination (no offset pagination) and only returns public, approved, not-deleted items.
|
||||
*/
|
||||
public function index(ArtworkIndexRequest $request, ?Category $category = null): View
|
||||
{
|
||||
$perPage = (int) ($request->get('per_page', 24));
|
||||
|
||||
$query = Artwork::public()->published();
|
||||
|
||||
if ($category) {
|
||||
$query->whereHas('categories', function ($q) use ($category) {
|
||||
$q->where('categories.id', $category->id);
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('q')) {
|
||||
$q = $request->get('q');
|
||||
$query->where(function ($sub) use ($q) {
|
||||
$sub->where('title', 'like', '%' . $q . '%')
|
||||
->orWhere('description', 'like', '%' . $q . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$sort = $request->get('sort', 'latest');
|
||||
if ($sort === 'oldest') {
|
||||
$query->orderBy('published_at', 'asc');
|
||||
} else {
|
||||
$query->orderByDesc('published_at');
|
||||
}
|
||||
|
||||
// Important: do NOT eager-load artwork_stats in listings
|
||||
$artworks = $query->cursorPaginate($perPage);
|
||||
|
||||
return view('artworks.index', [
|
||||
'artworks' => $artworks,
|
||||
'category' => $category,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a single artwork by slug. Resolve the slug manually to avoid implicit
|
||||
* route-model binding exceptions when the slug does not correspond to an artwork.
|
||||
*/
|
||||
public function show(Request $request, string $contentTypeSlug, string $categoryPath, $artwork = null)
|
||||
{
|
||||
$resolution = $this->contentTypeResolver->resolve($contentTypeSlug);
|
||||
if (! $resolution->found() || $resolution->contentType === null) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$resolvedContentTypeSlug = strtolower((string) $resolution->contentType->slug);
|
||||
|
||||
if ($resolution->requiresRedirect()) {
|
||||
return $this->redirectToCanonicalArtworkPath($request, $resolvedContentTypeSlug, $categoryPath, $artwork, 301);
|
||||
}
|
||||
|
||||
// Manually resolve artwork by slug when provided. The route may bind
|
||||
// the 'artwork' parameter to an Artwork model or pass the slug string.
|
||||
$foundArtwork = null;
|
||||
$artworkSlug = null;
|
||||
if ($artwork instanceof Artwork) {
|
||||
$foundArtwork = $artwork;
|
||||
$artworkSlug = $artwork->slug;
|
||||
} elseif ($artwork) {
|
||||
$artworkSlug = (string) $artwork;
|
||||
$foundArtwork = $this->findArtworkForCategoryPath($resolvedContentTypeSlug, $categoryPath, $artworkSlug);
|
||||
}
|
||||
|
||||
// When the URL can represent a nested category path (e.g. /skins/audio/winamp),
|
||||
// prefer category rendering over artwork slug collisions so same-level groups
|
||||
// behave consistently.
|
||||
if (! empty($artworkSlug)) {
|
||||
$combinedPath = trim($categoryPath . '/' . $artworkSlug, '/');
|
||||
$resolvedCategory = Category::findByPath($resolvedContentTypeSlug, $combinedPath);
|
||||
if ($resolvedCategory) {
|
||||
return app(BrowseGalleryController::class)->content(request(), $resolvedContentTypeSlug, $combinedPath);
|
||||
}
|
||||
}
|
||||
|
||||
// If no artwork was found, treat the request as a category path.
|
||||
// The route places the artwork slug in the last segment, so include it.
|
||||
// Delegate to BrowseGalleryController to render the same modern gallery
|
||||
// layout used by routes like /skins/audio.
|
||||
if (! $foundArtwork) {
|
||||
$combinedPath = $categoryPath;
|
||||
if ($artworkSlug) {
|
||||
$combinedPath = trim($categoryPath . '/' . $artworkSlug, '/');
|
||||
}
|
||||
return app(BrowseGalleryController::class)->content(request(), $resolvedContentTypeSlug, $combinedPath);
|
||||
}
|
||||
|
||||
if (! $foundArtwork->is_public || ! $foundArtwork->is_approved || $foundArtwork->trashed()) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// Delegate to the canonical ArtworkPageController which builds all
|
||||
// required view data ($meta, thumbnails, related items, comments, etc.)
|
||||
return app(\App\Http\Controllers\Web\ArtworkPageController::class)->show(
|
||||
$request,
|
||||
(int) $foundArtwork->id,
|
||||
$foundArtwork->slug,
|
||||
);
|
||||
}
|
||||
|
||||
private function findArtworkForCategoryPath(string $contentTypeSlug, string $categoryPath, string $artworkSlug): ?Artwork
|
||||
{
|
||||
$segments = array_values(array_filter(explode('/', trim($categoryPath, '/'))));
|
||||
$category = Category::findByPath(strtolower($contentTypeSlug), $segments);
|
||||
|
||||
$query = Artwork::query()->where('slug', $artworkSlug);
|
||||
|
||||
if ($category) {
|
||||
$query->whereHas('categories', function ($categoryQuery) use ($category): void {
|
||||
$categoryQuery->where('categories.id', $category->id);
|
||||
});
|
||||
}
|
||||
|
||||
return $query
|
||||
->orderByDesc('published_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function redirectToCanonicalArtworkPath(Request $request, string $contentTypeSlug, string $categoryPath, Artwork|string|null $artwork, int $status = 301): RedirectResponse
|
||||
{
|
||||
$artworkSlug = $artwork instanceof Artwork ? $artwork->slug : (string) $artwork;
|
||||
$target = url('/' . trim($contentTypeSlug . '/' . trim($categoryPath, '/') . '/' . trim($artworkSlug, '/'), '/'));
|
||||
$queryString = $request->getQueryString();
|
||||
|
||||
if ($queryString) {
|
||||
$target .= '?' . $queryString;
|
||||
}
|
||||
|
||||
return redirect()->to($target, $status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkDownload;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
final class ArtworkDownloadController extends Controller
|
||||
{
|
||||
/**
|
||||
* Allowed original file extensions for secure server-side download.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const ALLOWED_EXTENSIONS = [
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'gif',
|
||||
'webp',
|
||||
'bmp',
|
||||
'tiff',
|
||||
'zip',
|
||||
'rar',
|
||||
'7z',
|
||||
'tar',
|
||||
'gz',
|
||||
];
|
||||
|
||||
public function __invoke(Request $request, int $id): BinaryFileResponse
|
||||
{
|
||||
$artwork = Artwork::query()->find($id);
|
||||
if (! $artwork) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$filePath = $this->resolveOriginalPath($artwork);
|
||||
$ext = strtolower(ltrim((string) pathinfo($filePath, PATHINFO_EXTENSION), '.'));
|
||||
|
||||
if ($filePath === '' || ! in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->recordDownload($request, $artwork->id);
|
||||
$this->incrementDownloadCountIfAvailable($artwork->id);
|
||||
|
||||
if (! File::isFile($filePath)) {
|
||||
Log::warning('Artwork original file missing for download.', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'ext' => $ext,
|
||||
'resolved_path' => $filePath,
|
||||
]);
|
||||
|
||||
abort(404);
|
||||
}
|
||||
|
||||
|
||||
$downloadName = $this->buildDownloadFilename((string) $artwork->file_name, $ext);
|
||||
|
||||
return response()->download($filePath, $downloadName);
|
||||
}
|
||||
|
||||
private function resolveOriginalPath(Artwork $artwork): string
|
||||
{
|
||||
$relative = trim((string) $artwork->file_path, '/');
|
||||
$prefix = trim((string) config('uploads.object_storage.prefix', 'artworks'), '/') . '/original/';
|
||||
|
||||
if ($relative !== '' && str_starts_with($relative, $prefix)) {
|
||||
$suffix = substr($relative, strlen($prefix));
|
||||
$root = rtrim((string) config('uploads.local_originals_root'), DIRECTORY_SEPARATOR);
|
||||
|
||||
return $root . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, (string) $suffix);
|
||||
}
|
||||
|
||||
$hash = strtolower((string) $artwork->hash);
|
||||
$ext = strtolower(ltrim((string) $artwork->file_ext, '.'));
|
||||
if (! $this->isValidHash($hash) || $ext === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$root = rtrim((string) config('uploads.local_originals_root'), DIRECTORY_SEPARATOR);
|
||||
|
||||
return $root
|
||||
. DIRECTORY_SEPARATOR . substr($hash, 0, 2)
|
||||
. DIRECTORY_SEPARATOR . substr($hash, 2, 2)
|
||||
. DIRECTORY_SEPARATOR . $hash . '.' . $ext;
|
||||
}
|
||||
|
||||
private function recordDownload(Request $request, int $artworkId): void
|
||||
{
|
||||
try {
|
||||
$ipAddress = $request->ip();
|
||||
$ipBinary = $ipAddress ? @inet_pton($ipAddress) : false;
|
||||
|
||||
ArtworkDownload::query()->create([
|
||||
'artwork_id' => $artworkId,
|
||||
'user_id' => $request->user()?->id,
|
||||
'ip' => $ipBinary !== false ? $ipBinary : null,
|
||||
'ip_address' => $ipAddress,
|
||||
'user_agent' => mb_substr((string) $request->userAgent(), 0, 1024),
|
||||
'referer' => mb_substr((string) $request->headers->get('referer'), 0, 65535),
|
||||
]);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('Failed to record artwork download analytics.', [
|
||||
'artwork_id' => $artworkId,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function incrementDownloadCountIfAvailable(int $artworkId): void
|
||||
{
|
||||
if (! Schema::hasColumn('artworks', 'download_count')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Artwork::query()->whereKey($artworkId)->increment('download_count');
|
||||
}
|
||||
|
||||
private function isValidHash(string $hash): bool
|
||||
{
|
||||
return $hash !== '' && preg_match('/^[a-f0-9]+$/', $hash) === 1;
|
||||
}
|
||||
|
||||
private function buildDownloadFilename(string $fileName, string $ext): string
|
||||
{
|
||||
$name = trim($fileName);
|
||||
$name = str_replace(['/', '\\'], '-', $name);
|
||||
$name = preg_replace('/[\x00-\x1F\x7F]/', '', $name) ?? '';
|
||||
$name = preg_replace('/\s+/', ' ', $name) ?? '';
|
||||
$name = trim((string) $name, ". \t\n\r\0\x0B");
|
||||
|
||||
if ($name === '') {
|
||||
$name = 'artwork';
|
||||
}
|
||||
|
||||
if (strtolower((string) pathinfo($name, PATHINFO_EXTENSION)) !== $ext) {
|
||||
$name .= '.' . $ext;
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use App\Services\Security\CaptchaVerifier;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AuthenticatedSessionController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the login view.
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly CaptchaVerifier $captchaVerifier,
|
||||
) {
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.login', [
|
||||
'requiresCaptcha' => session('bot_captcha_required', false),
|
||||
'captcha' => $this->captchaVerifier->frontendConfig(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming authentication request.
|
||||
*/
|
||||
public function store(LoginRequest $request): RedirectResponse
|
||||
{
|
||||
$request->authenticate();
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('dashboard'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy an authenticated session.
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ConfirmablePasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the confirm password view.
|
||||
*/
|
||||
public function show(): View
|
||||
{
|
||||
return view('auth.confirm-password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the user's password.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if (! Auth::guard('web')->validate([
|
||||
'email' => $request->user()->email,
|
||||
'password' => $request->password,
|
||||
])) {
|
||||
throw ValidationException::withMessages([
|
||||
'password' => __('auth.password'),
|
||||
]);
|
||||
}
|
||||
|
||||
$request->session()->put('auth.password_confirmed_at', time());
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EmailVerificationNotificationController extends Controller
|
||||
{
|
||||
/**
|
||||
* Send a new email verification notification.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
$request->user()->sendEmailVerificationNotification();
|
||||
|
||||
return back()->with('status', 'verification-link-sent');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class EmailVerificationPromptController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the email verification prompt.
|
||||
*/
|
||||
public function __invoke(Request $request): RedirectResponse|View
|
||||
{
|
||||
return $request->user()->hasVerifiedEmail()
|
||||
? redirect()->intended(route('dashboard', absolute: false))
|
||||
: view('auth.verify-email');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NewPasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the password reset view.
|
||||
*/
|
||||
public function create(Request $request): View
|
||||
{
|
||||
return view('auth.reset-password', ['request' => $request]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming new password request.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'token' => ['required'],
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
// Here we will attempt to reset the user's password. If it is successful we
|
||||
// will update the password on an actual user model and persist it to the
|
||||
// database. Otherwise we will parse the error and return the response.
|
||||
$status = Password::reset(
|
||||
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||
function (User $user) use ($request) {
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($request->password),
|
||||
'remember_token' => Str::random(60),
|
||||
])->save();
|
||||
|
||||
event(new PasswordReset($user));
|
||||
}
|
||||
);
|
||||
|
||||
// If the password was successfully reset, we will redirect the user back to
|
||||
// the application's home authenticated view. If there is an error we can
|
||||
// redirect them back to where they came from with their error message.
|
||||
return $status == Password::PASSWORD_RESET
|
||||
? redirect()->route('login')->with('status', __($status))
|
||||
: back()->withInput($request->only('email'))
|
||||
->withErrors(['email' => __($status)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Socialite\Contracts\User as SocialiteUser;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Throwable;
|
||||
|
||||
class OAuthController extends Controller
|
||||
{
|
||||
/** Providers enabled for OAuth login. */
|
||||
private const ALLOWED_PROVIDERS = ['google', 'discord'];
|
||||
|
||||
/**
|
||||
* Redirect the user to the provider's OAuth page.
|
||||
*/
|
||||
public function redirectToProvider(string $provider): RedirectResponse
|
||||
{
|
||||
$this->abortIfInvalidProvider($provider);
|
||||
|
||||
return Socialite::driver($provider)->redirect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the provider callback and authenticate the user.
|
||||
*/
|
||||
public function handleProviderCallback(string $provider): RedirectResponse
|
||||
{
|
||||
$this->abortIfInvalidProvider($provider);
|
||||
|
||||
try {
|
||||
/** @var SocialiteUser $socialUser */
|
||||
$socialUser = Socialite::driver($provider)->user();
|
||||
} catch (Throwable) {
|
||||
return redirect()->route('login')
|
||||
->withErrors(['oauth' => 'Authentication failed. Please try again.']);
|
||||
}
|
||||
|
||||
$providerId = (string) $socialUser->getId();
|
||||
$providerEmail = $this->resolveEmail($socialUser);
|
||||
$verified = $this->isEmailVerifiedByProvider($provider, $socialUser);
|
||||
|
||||
// ── 1. Provider account already linked → login ───────────────────────
|
||||
$existing = SocialAccount::query()
|
||||
->where('provider', $provider)
|
||||
->where('provider_id', $providerId)
|
||||
->with('user')
|
||||
->first();
|
||||
|
||||
if ($existing !== null && $existing->user !== null) {
|
||||
return $this->loginAndRedirect($existing->user);
|
||||
}
|
||||
|
||||
// ── 2. Email match → link to existing account ────────────────────────
|
||||
// Covers both verified and unverified users: if the OAuth provider
|
||||
// has confirmed this email we can safely link it and mark it verified,
|
||||
// preventing a duplicate-email insert when the user had started
|
||||
// registration via email but never finished verification.
|
||||
if ($providerEmail !== null && $verified) {
|
||||
$userByEmail = User::query()
|
||||
->where('email', strtolower($providerEmail))
|
||||
->first();
|
||||
|
||||
if ($userByEmail !== null) {
|
||||
// If their email was not yet verified, promote it now — the
|
||||
// OAuth provider has already verified it on our behalf.
|
||||
if ($userByEmail->email_verified_at === null) {
|
||||
$userByEmail->forceFill([
|
||||
'email_verified_at' => now(),
|
||||
'is_active' => true,
|
||||
// Keep their onboarding step unless already complete
|
||||
'onboarding_step' => $userByEmail->onboarding_step === 'email'
|
||||
? 'username'
|
||||
: ($userByEmail->onboarding_step ?? 'username'),
|
||||
])->save();
|
||||
}
|
||||
|
||||
$this->createSocialAccount($userByEmail, $provider, $providerId, $providerEmail, $socialUser->getAvatar());
|
||||
|
||||
return $this->loginAndRedirect($userByEmail);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Provider email not verified → reject auto-link ────────────────
|
||||
if ($providerEmail !== null && ! $verified) {
|
||||
return redirect()->route('login')
|
||||
->withErrors(['oauth' => 'Your ' . ucfirst($provider) . ' email is not verified. Please verify it and try again.']);
|
||||
}
|
||||
|
||||
// ── 4. No email at all → cannot proceed ──────────────────────────────
|
||||
if ($providerEmail === null) {
|
||||
return redirect()->route('login')
|
||||
->withErrors(['oauth' => 'Could not retrieve your email address from ' . ucfirst($provider) . '. Please register with email.']);
|
||||
}
|
||||
|
||||
// ── 5. New user creation ──────────────────────────────────────────────
|
||||
try {
|
||||
$user = $this->createOAuthUser($socialUser, $provider, $providerId, $providerEmail);
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
// Race condition: another request inserted the same email between
|
||||
// the lookup above and this insert. Fetch and link instead.
|
||||
$user = User::query()->where('email', strtolower($providerEmail))->first();
|
||||
|
||||
if ($user === null) {
|
||||
return redirect()->route('login')
|
||||
->withErrors(['oauth' => 'An error occurred during sign in. Please try again.']);
|
||||
}
|
||||
|
||||
$this->createSocialAccount($user, $provider, $providerId, $providerEmail, $socialUser->getAvatar());
|
||||
}
|
||||
|
||||
return $this->loginAndRedirect($user);
|
||||
}
|
||||
|
||||
// ─── Private helpers ─────────────────────────────────────────────────────
|
||||
|
||||
private function abortIfInvalidProvider(string $provider): void
|
||||
{
|
||||
abort_unless(in_array($provider, self::ALLOWED_PROVIDERS, true), 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create social_accounts row linked to a user.
|
||||
*/
|
||||
private function createSocialAccount(
|
||||
User $user,
|
||||
string $provider,
|
||||
string $providerId,
|
||||
?string $providerEmail,
|
||||
?string $avatar
|
||||
): void {
|
||||
SocialAccount::query()->updateOrCreate(
|
||||
['provider' => $provider, 'provider_id' => $providerId],
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'provider_email' => $providerEmail !== null ? strtolower($providerEmail) : null,
|
||||
'avatar' => $avatar,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a brand-new user from OAuth data.
|
||||
*/
|
||||
private function createOAuthUser(
|
||||
SocialiteUser $socialUser,
|
||||
string $provider,
|
||||
string $providerId,
|
||||
string $providerEmail
|
||||
): User {
|
||||
$user = DB::transaction(function () use ($socialUser, $provider, $providerId, $providerEmail): User {
|
||||
$name = $this->resolveDisplayName($socialUser, $providerEmail);
|
||||
|
||||
$user = User::query()->create([
|
||||
'username' => null,
|
||||
'name' => $name,
|
||||
'email' => strtolower($providerEmail),
|
||||
'email_verified_at' => now(),
|
||||
'password' => Hash::make(Str::random(64)),
|
||||
'is_active' => true,
|
||||
'onboarding_step' => 'username',
|
||||
'username_changed_at' => now(),
|
||||
'last_username_change_at' => now(),
|
||||
]);
|
||||
|
||||
$this->createSocialAccount(
|
||||
$user,
|
||||
$provider,
|
||||
$providerId,
|
||||
$providerEmail,
|
||||
$socialUser->getAvatar()
|
||||
);
|
||||
|
||||
return $user;
|
||||
});
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login the user and redirect appropriately.
|
||||
*/
|
||||
private function loginAndRedirect(User $user): RedirectResponse
|
||||
{
|
||||
Auth::login($user, remember: true);
|
||||
|
||||
request()->session()->regenerate();
|
||||
|
||||
$step = strtolower((string) ($user->onboarding_step ?? ''));
|
||||
|
||||
if (in_array($step, ['username', 'password'], true)) {
|
||||
return redirect()->route('setup.username.create');
|
||||
}
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a usable display name from the social user.
|
||||
*/
|
||||
private function resolveDisplayName(SocialiteUser $socialUser, string $email): string
|
||||
{
|
||||
$name = trim((string) ($socialUser->getName() ?? ''));
|
||||
|
||||
if ($name !== '') {
|
||||
return $name;
|
||||
}
|
||||
|
||||
return Str::before($email, '@');
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort email resolution. Apple can return null email on repeat logins.
|
||||
*/
|
||||
private function resolveEmail(SocialiteUser $socialUser): ?string
|
||||
{
|
||||
$email = $socialUser->getEmail();
|
||||
|
||||
if ($email === null || $email === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return strtolower(trim($email));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the provider has verified the user's email.
|
||||
*
|
||||
* - Google: returns email_verified flag in raw data
|
||||
* - Discord: returns verified flag in raw data
|
||||
* - Apple: only issues tokens for verified Apple IDs
|
||||
*/
|
||||
private function isEmailVerifiedByProvider(string $provider, SocialiteUser $socialUser): bool
|
||||
{
|
||||
$raw = (array) ($socialUser->getRaw() ?? []);
|
||||
|
||||
return match ($provider) {
|
||||
'google' => filter_var($raw['email_verified'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
||||
'discord' => (bool) ($raw['verified'] ?? false),
|
||||
'apple' => true, // Apple only issues tokens for verified Apple IDs
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class PasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Update the user's password.
|
||||
*/
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validateWithBag('updatePassword', [
|
||||
'current_password' => ['required', 'current_password'],
|
||||
'password' => ['required', Password::defaults(), 'confirmed'],
|
||||
]);
|
||||
|
||||
$request->user()->update([
|
||||
'password' => Hash::make($validated['password']),
|
||||
]);
|
||||
|
||||
return back()->with('status', 'password-updated');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PasswordResetLinkController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the password reset link request view.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.forgot-password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming password reset link request.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
]);
|
||||
|
||||
// We will send the password reset link to this user. Once we have attempted
|
||||
// to send the link, we will examine the response then see the message we
|
||||
// need to show to the user. Finally, we'll send out a proper response.
|
||||
$status = Password::sendResetLink(
|
||||
$request->only('email')
|
||||
);
|
||||
|
||||
return $status == Password::RESET_LINK_SENT
|
||||
? back()->with('status', __($status))
|
||||
: back()->withInput($request->only('email'))
|
||||
->withErrors(['email' => __($status)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Jobs\SendVerificationEmailJob;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\EmailSendEvent;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\DisposableEmailService;
|
||||
use App\Services\Auth\RegistrationVerificationTokenService;
|
||||
use App\Services\Security\CaptchaVerifier;
|
||||
use App\Services\Security\TurnstileVerifier;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RegisteredUserController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CaptchaVerifier $captchaVerifier,
|
||||
private readonly TurnstileVerifier $turnstileVerifier,
|
||||
private readonly DisposableEmailService $disposableEmailService,
|
||||
private readonly RegistrationVerificationTokenService $verificationTokenService,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the registration view.
|
||||
*/
|
||||
public function create(Request $request): View
|
||||
{
|
||||
return view('auth.register', [
|
||||
'prefillEmail' => (string) $request->query('email', ''),
|
||||
'requiresCaptcha' => $this->shouldRequireCaptcha($request->ip()),
|
||||
'captcha' => $this->captchaVerifier->frontendConfig(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function notice(Request $request): View
|
||||
{
|
||||
$email = (string) session('registration_email', '');
|
||||
$remaining = $email === '' ? 0 : $this->resendRemainingSeconds($email);
|
||||
|
||||
return view('auth.register-notice', [
|
||||
'email' => $email,
|
||||
'resendSeconds' => $remaining,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming registration request.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$rules = [
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255'],
|
||||
'website' => ['nullable', 'max:0'],
|
||||
];
|
||||
$rules[$this->captchaVerifier->inputName()] = ['nullable', 'string'];
|
||||
|
||||
$validated = $request->validate($rules);
|
||||
|
||||
$email = strtolower(trim((string) $validated['email']));
|
||||
$ip = $request->ip();
|
||||
|
||||
$this->trackRegisterAttempt($ip);
|
||||
|
||||
if ($this->shouldRequireCaptcha($ip)) {
|
||||
$verified = $this->captchaVerifier->verify(
|
||||
(string) $request->input($this->captchaVerifier->inputName(), ''),
|
||||
$ip
|
||||
);
|
||||
|
||||
if ($this->turnstileVerifier->isEnabled()) {
|
||||
$verified = $this->turnstileVerifier->verify(
|
||||
(string) $request->input($this->captchaVerifier->inputName(), ''),
|
||||
$ip
|
||||
);
|
||||
}
|
||||
|
||||
if (! $verified) {
|
||||
return back()
|
||||
->withInput($request->except('website'))
|
||||
->withErrors(['captcha' => 'Captcha verification failed. Please try again.']);
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->disposableEmailService->isDisposableEmail($email)) {
|
||||
$this->logEmailEvent($email, $ip, null, 'blocked', 'disposable');
|
||||
|
||||
return back()
|
||||
->withInput($request->except('website'))
|
||||
->withErrors(['email' => 'Please use a real email provider.']);
|
||||
}
|
||||
|
||||
$user = User::query()->where('email', $email)->first();
|
||||
|
||||
if ($user && $user->email_verified_at !== null) {
|
||||
$this->logEmailEvent($email, $ip, (int) $user->id, 'blocked', 'already-verified');
|
||||
|
||||
return $this->redirectToRegisterNotice($email);
|
||||
}
|
||||
|
||||
if (! $user) {
|
||||
$user = User::query()->create([
|
||||
'username' => null,
|
||||
'name' => Str::before($email, '@'),
|
||||
'email' => $email,
|
||||
'password' => Hash::make(Str::random(64)),
|
||||
'is_active' => false,
|
||||
'onboarding_step' => 'email',
|
||||
'username_changed_at' => now(),
|
||||
'last_username_change_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($this->isWithinEmailCooldown($user)) {
|
||||
$this->logEmailEvent($email, $ip, (int) $user->id, 'blocked', 'cooldown');
|
||||
|
||||
return $this->redirectToRegisterNotice($email);
|
||||
}
|
||||
|
||||
$token = $this->verificationTokenService->createForUser((int) $user->id);
|
||||
$event = $this->logEmailEvent($email, $ip, (int) $user->id, 'queued', null);
|
||||
|
||||
SendVerificationEmailJob::dispatch(
|
||||
emailEventId: (int) $event->id,
|
||||
email: $email,
|
||||
token: $token,
|
||||
userId: (int) $user->id,
|
||||
ip: $ip
|
||||
);
|
||||
|
||||
$this->markVerificationEmailSent($user);
|
||||
|
||||
return $this->redirectToRegisterNotice($email);
|
||||
}
|
||||
|
||||
public function resendVerification(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255'],
|
||||
]);
|
||||
|
||||
$email = strtolower(trim((string) $validated['email']));
|
||||
$ip = $request->ip();
|
||||
|
||||
$user = User::query()
|
||||
->where('email', $email)
|
||||
->whereNull('email_verified_at')
|
||||
->where('onboarding_step', 'email')
|
||||
->first();
|
||||
|
||||
if (! $user) {
|
||||
$this->logEmailEvent($email, $ip, null, 'blocked', 'missing');
|
||||
|
||||
return $this->redirectToRegisterNotice($email);
|
||||
}
|
||||
|
||||
if ($this->isWithinEmailCooldown($user)) {
|
||||
$this->logEmailEvent($email, $ip, (int) $user->id, 'blocked', 'cooldown');
|
||||
|
||||
return $this->redirectToRegisterNotice($email);
|
||||
}
|
||||
|
||||
$token = $this->verificationTokenService->createForUser((int) $user->id);
|
||||
$event = $this->logEmailEvent($email, $ip, (int) $user->id, 'queued', null);
|
||||
|
||||
SendVerificationEmailJob::dispatch(
|
||||
emailEventId: (int) $event->id,
|
||||
email: $email,
|
||||
token: $token,
|
||||
userId: (int) $user->id,
|
||||
ip: $ip
|
||||
);
|
||||
|
||||
$this->markVerificationEmailSent($user);
|
||||
|
||||
return $this->redirectToRegisterNotice($email);
|
||||
}
|
||||
|
||||
private function redirectToRegisterNotice(string $email): RedirectResponse
|
||||
{
|
||||
return redirect(route('register.notice', absolute: false))
|
||||
->with('status', $this->genericSuccessMessage())
|
||||
->with('registration_email', $email);
|
||||
}
|
||||
|
||||
private function genericSuccessMessage(): string
|
||||
{
|
||||
return (string) config('registration.generic_success_message', 'If that email is valid, we sent a verification link.');
|
||||
}
|
||||
|
||||
private function logEmailEvent(string $email, ?string $ip, ?int $userId, string $status, ?string $reason): EmailSendEvent
|
||||
{
|
||||
return EmailSendEvent::query()->create([
|
||||
'type' => 'verify_email',
|
||||
'email' => $email,
|
||||
'ip' => $ip,
|
||||
'user_id' => $userId,
|
||||
'status' => $status,
|
||||
'reason' => $reason,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function shouldRequireCaptcha(?string $ip): bool
|
||||
{
|
||||
if (! $this->captchaVerifier->isEnabled()) {
|
||||
if (! $this->turnstileVerifier->isEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! (bool) config('registration.enable_turnstile', true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->turnstileVerifier->isEnabled() && $this->shouldRequireCaptchaForIp($ip);
|
||||
}
|
||||
|
||||
return $this->shouldRequireCaptchaForIp($ip);
|
||||
}
|
||||
|
||||
private function shouldRequireCaptchaForIp(?string $ip): bool
|
||||
{
|
||||
if (! $this->captchaVerifier->isEnabled() && ! $this->turnstileVerifier->isEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($ip === null || $ip === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$threshold = max(1, (int) config('registration.turnstile_suspicious_attempts', 2));
|
||||
$attempts = (int) cache()->get($this->registerAttemptCacheKey($ip), 0);
|
||||
|
||||
if ($attempts >= $threshold) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$minuteLimit = max(1, (int) config('registration.ip_per_minute_limit', 3));
|
||||
$dailyLimit = max(1, (int) config('registration.ip_per_day_limit', 20));
|
||||
|
||||
if (RateLimiter::tooManyAttempts($this->registerIpRateKey($ip), $minuteLimit)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return RateLimiter::tooManyAttempts($this->registerIpDailyRateKey($ip), $dailyLimit);
|
||||
}
|
||||
|
||||
private function trackRegisterAttempt(?string $ip): void
|
||||
{
|
||||
if ($ip === null || $ip === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$key = $this->registerAttemptCacheKey($ip);
|
||||
$windowMinutes = max(1, (int) config('registration.turnstile_attempt_window_minutes', 30));
|
||||
$seconds = $windowMinutes * 60;
|
||||
|
||||
$attempts = (int) cache()->get($key, 0);
|
||||
cache()->put($key, $attempts + 1, $seconds);
|
||||
}
|
||||
|
||||
private function registerAttemptCacheKey(string $ip): string
|
||||
{
|
||||
return 'register:attempts:' . sha1($ip);
|
||||
}
|
||||
|
||||
private function registerIpRateKey(string $ip): string
|
||||
{
|
||||
return 'register:ip:' . $ip;
|
||||
}
|
||||
|
||||
private function registerIpDailyRateKey(string $ip): string
|
||||
{
|
||||
return 'register:ip:daily:' . $ip;
|
||||
}
|
||||
|
||||
private function isWithinEmailCooldown(User $user): bool
|
||||
{
|
||||
if ($user->last_verification_sent_at === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$cooldownMinutes = max(1, (int) config('registration.email_cooldown_minutes', 30));
|
||||
|
||||
return $user->last_verification_sent_at->gt(now()->subMinutes($cooldownMinutes));
|
||||
}
|
||||
|
||||
private function markVerificationEmailSent(User $user): void
|
||||
{
|
||||
$now = now();
|
||||
|
||||
$windowStartedAt = $user->verification_send_window_started_at;
|
||||
if (! $windowStartedAt || $windowStartedAt->lt($now->copy()->subDay())) {
|
||||
$user->verification_send_window_started_at = $now;
|
||||
$user->verification_send_count_24h = 1;
|
||||
} else {
|
||||
$user->verification_send_count_24h = ((int) $user->verification_send_count_24h) + 1;
|
||||
}
|
||||
|
||||
$user->last_verification_sent_at = $now;
|
||||
$user->save();
|
||||
}
|
||||
|
||||
private function resendCooldownSeconds(): int
|
||||
{
|
||||
return max(60, ((int) config('registration.email_cooldown_minutes', 30)) * 60);
|
||||
}
|
||||
|
||||
private function resendRemainingSeconds(string $email): int
|
||||
{
|
||||
$user = User::query()
|
||||
->where('email', strtolower(trim($email)))
|
||||
->whereNull('email_verified_at')
|
||||
->first();
|
||||
|
||||
if (! $user || $user->last_verification_sent_at === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$remaining = $user->last_verification_sent_at
|
||||
->copy()
|
||||
->addSeconds($this->resendCooldownSeconds())
|
||||
->diffInSeconds(now(), false);
|
||||
|
||||
return $remaining >= 0 ? 0 : abs((int) $remaining);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\RegistrationVerificationTokenService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RegistrationVerificationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RegistrationVerificationTokenService $tokenService
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
public function __invoke(string $token): RedirectResponse
|
||||
{
|
||||
$record = $this->tokenService->findValidRecord($token);
|
||||
|
||||
if (! $record) {
|
||||
return redirect(route('login', absolute: false))
|
||||
->withErrors(['email' => 'Verification link is invalid.']);
|
||||
}
|
||||
|
||||
$user = User::query()->find((int) $record->user_id);
|
||||
if (! $user) {
|
||||
DB::table('user_verification_tokens')->where('id', $record->id)->delete();
|
||||
|
||||
return redirect(route('login', absolute: false))
|
||||
->withErrors(['email' => 'Verification link is invalid.']);
|
||||
}
|
||||
|
||||
$user->forceFill([
|
||||
'email_verified_at' => $user->email_verified_at ?? now(),
|
||||
'onboarding_step' => 'verified',
|
||||
'is_active' => true,
|
||||
])->save();
|
||||
|
||||
DB::table('user_verification_tokens')
|
||||
->where('id', $record->id)
|
||||
->delete();
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
return redirect('/setup/password')->with('status', 'Email verified. Continue with password setup.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SetupPasswordController extends Controller
|
||||
{
|
||||
public function create(Request $request): View
|
||||
{
|
||||
return view('auth.setup-password', [
|
||||
'email' => (string) ($request->user()?->email ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'password' => [
|
||||
'required',
|
||||
'string',
|
||||
'min:10',
|
||||
'regex:/\d/',
|
||||
'regex:/[^\w\s]/',
|
||||
'confirmed',
|
||||
],
|
||||
], [
|
||||
'password.min' => 'Your password must be at least 10 characters.',
|
||||
'password.regex' => 'Your password must include at least one number and one symbol.',
|
||||
'password.confirmed' => 'Password confirmation does not match.',
|
||||
]);
|
||||
|
||||
$request->user()->forceFill([
|
||||
'password' => Hash::make((string) $validated['password']),
|
||||
'onboarding_step' => 'password',
|
||||
'needs_password_reset' => false,
|
||||
])->save();
|
||||
|
||||
return redirect('/setup/username')->with('status', 'Password saved. Choose your public username to finish setup.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\UsernameRequest;
|
||||
use App\Services\UsernameApprovalService;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SetupUsernameController extends Controller
|
||||
{
|
||||
public function __construct(private readonly UsernameApprovalService $usernameApprovalService)
|
||||
{
|
||||
}
|
||||
|
||||
public function create(Request $request): View
|
||||
{
|
||||
return view('auth.setup-username', [
|
||||
'username' => (string) ($request->user()?->username ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$normalized = UsernamePolicy::normalize((string) $request->input('username', ''));
|
||||
$request->merge(['username' => $normalized]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'username' => UsernameRequest::rulesFor((int) $request->user()->id),
|
||||
], [
|
||||
'username.required' => 'Please choose a username to continue.',
|
||||
'username.unique' => 'This username is already taken.',
|
||||
'username.regex' => 'Use only letters, numbers, and underscores.',
|
||||
'username.min' => 'Username must be at least 3 characters.',
|
||||
'username.max' => 'Username must be at most 20 characters.',
|
||||
]);
|
||||
|
||||
$candidate = (string) $validated['username'];
|
||||
$user = $request->user();
|
||||
|
||||
$similar = UsernamePolicy::similarReserved($candidate);
|
||||
if ($similar !== null && ! UsernamePolicy::hasApprovedOverride($candidate, (int) $user->id)) {
|
||||
$this->usernameApprovalService->submit($user, $candidate, 'onboarding_username', [
|
||||
'current_username' => (string) ($user->username ?? ''),
|
||||
]);
|
||||
|
||||
return back()
|
||||
->withInput()
|
||||
->with('status', 'Your request has been submitted for manual username review.')
|
||||
->withErrors([
|
||||
'username' => 'This username is too similar to a reserved name and requires manual approval.',
|
||||
]);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($user, $candidate): void {
|
||||
$oldUsername = (string) ($user->username ?? '');
|
||||
|
||||
if ($oldUsername !== '' && strtolower($oldUsername) !== strtolower($candidate) && Schema::hasTable('username_history')) {
|
||||
DB::table('username_history')->insert([
|
||||
'user_id' => (int) $user->id,
|
||||
'old_username' => strtolower($oldUsername),
|
||||
'changed_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($oldUsername !== '' && strtolower($oldUsername) !== strtolower($candidate) && Schema::hasTable('username_redirects')) {
|
||||
DB::table('username_redirects')->updateOrInsert(
|
||||
['old_username' => strtolower($oldUsername)],
|
||||
[
|
||||
'new_username' => strtolower($candidate),
|
||||
'user_id' => (int) $user->id,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$user->forceFill([
|
||||
'username' => strtolower($candidate),
|
||||
'onboarding_step' => 'complete',
|
||||
'username_changed_at' => now(),
|
||||
'last_username_change_at' => now(),
|
||||
])->save();
|
||||
});
|
||||
|
||||
return redirect('/@' . strtolower($candidate));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class VerifyEmailController extends Controller
|
||||
{
|
||||
/**
|
||||
* Mark the authenticated user's email address as verified.
|
||||
*/
|
||||
public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
||||
{
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
|
||||
if ($request->user()->markEmailAsVerified()) {
|
||||
event(new Verified($request->user()));
|
||||
}
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Services\ThumbnailService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$search = trim((string) $request->query('q', ''));
|
||||
$sort = (string) $request->query('sort', 'popular');
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$perPage = min(60, max(12, (int) $request->query('per_page', 24)));
|
||||
|
||||
$categories = collect(Cache::remember('categories.directory.v1', 3600, function (): array {
|
||||
$publishedArtworkScope = DB::table('artwork_category as artwork_category')
|
||||
->join('artworks as artworks', 'artworks.id', '=', 'artwork_category.artwork_id')
|
||||
->leftJoin('artwork_stats as artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->whereColumn('artwork_category.category_id', 'categories.id')
|
||||
->where('artworks.is_public', true)
|
||||
->where('artworks.is_approved', true)
|
||||
->whereNull('artworks.deleted_at');
|
||||
|
||||
$categories = Category::query()
|
||||
->select([
|
||||
'categories.id',
|
||||
'categories.content_type_id',
|
||||
'categories.parent_id',
|
||||
'categories.name',
|
||||
'categories.slug',
|
||||
])
|
||||
->selectSub(
|
||||
(clone $publishedArtworkScope)->selectRaw('COUNT(DISTINCT artworks.id)'),
|
||||
'artwork_count'
|
||||
)
|
||||
->selectSub(
|
||||
(clone $publishedArtworkScope)
|
||||
->whereNotNull('artworks.hash')
|
||||
->whereNotNull('artworks.thumb_ext')
|
||||
->orderByDesc(DB::raw('COALESCE(artwork_stats.views, 0)'))
|
||||
->orderByDesc(DB::raw('COALESCE(artwork_stats.favorites, 0)'))
|
||||
->orderByDesc(DB::raw('COALESCE(artwork_stats.downloads, 0)'))
|
||||
->orderByDesc(DB::raw('COALESCE(artworks.published_at, artworks.created_at)'))
|
||||
->orderByDesc('artworks.id')
|
||||
->limit(1)
|
||||
->select('artworks.hash'),
|
||||
'cover_hash'
|
||||
)
|
||||
->selectSub(
|
||||
(clone $publishedArtworkScope)
|
||||
->whereNotNull('artworks.hash')
|
||||
->whereNotNull('artworks.thumb_ext')
|
||||
->orderByDesc(DB::raw('COALESCE(artwork_stats.views, 0)'))
|
||||
->orderByDesc(DB::raw('COALESCE(artwork_stats.favorites, 0)'))
|
||||
->orderByDesc(DB::raw('COALESCE(artwork_stats.downloads, 0)'))
|
||||
->orderByDesc(DB::raw('COALESCE(artworks.published_at, artworks.created_at)'))
|
||||
->orderByDesc('artworks.id')
|
||||
->limit(1)
|
||||
->select('artworks.thumb_ext'),
|
||||
'cover_ext'
|
||||
)
|
||||
->selectSub(
|
||||
(clone $publishedArtworkScope)
|
||||
->selectRaw('COALESCE(SUM(COALESCE(artwork_stats.views, 0) + (COALESCE(artwork_stats.favorites, 0) * 3) + (COALESCE(artwork_stats.downloads, 0) * 2)), 0)'),
|
||||
'popular_score'
|
||||
)
|
||||
->with(['contentType:id,name,slug'])
|
||||
->active()
|
||||
->orderBy('categories.name')
|
||||
->get();
|
||||
|
||||
return $this->transformCategories($categories);
|
||||
}));
|
||||
|
||||
$filtered = $this->filterAndSortCategories($categories, $search, $sort);
|
||||
$total = $filtered->count();
|
||||
$lastPage = max(1, (int) ceil($total / $perPage));
|
||||
$currentPage = min($page, $lastPage);
|
||||
$offset = ($currentPage - 1) * $perPage;
|
||||
$pageItems = $filtered->slice($offset, $perPage)->values();
|
||||
$popularCategories = $this->filterAndSortCategories($categories, '', 'popular')->take(4)->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $pageItems,
|
||||
'meta' => [
|
||||
'current_page' => $currentPage,
|
||||
'last_page' => $lastPage,
|
||||
'per_page' => $perPage,
|
||||
'total' => $total,
|
||||
],
|
||||
'summary' => [
|
||||
'total_categories' => $categories->count(),
|
||||
'total_artworks' => $categories->sum(fn (array $category): int => (int) ($category['artwork_count'] ?? 0)),
|
||||
],
|
||||
'popular_categories' => $search === '' ? $popularCategories : [],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, array<string, mixed>> $categories
|
||||
* @return Collection<int, array<string, mixed>>
|
||||
*/
|
||||
private function filterAndSortCategories(Collection $categories, string $search, string $sort): Collection
|
||||
{
|
||||
$filtered = $categories;
|
||||
|
||||
if ($search !== '') {
|
||||
$needle = mb_strtolower($search);
|
||||
|
||||
$filtered = $filtered->filter(function (array $category) use ($needle): bool {
|
||||
return str_contains(mb_strtolower((string) ($category['name'] ?? '')), $needle);
|
||||
});
|
||||
}
|
||||
|
||||
return $filtered->sort(function (array $left, array $right) use ($sort): int {
|
||||
if ($sort === 'az') {
|
||||
return strcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
|
||||
}
|
||||
|
||||
if ($sort === 'artworks') {
|
||||
$countCompare = ((int) ($right['artwork_count'] ?? 0)) <=> ((int) ($left['artwork_count'] ?? 0));
|
||||
|
||||
return $countCompare !== 0
|
||||
? $countCompare
|
||||
: strcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
|
||||
}
|
||||
|
||||
$scoreCompare = ((int) ($right['popular_score'] ?? 0)) <=> ((int) ($left['popular_score'] ?? 0));
|
||||
if ($scoreCompare !== 0) {
|
||||
return $scoreCompare;
|
||||
}
|
||||
|
||||
$countCompare = ((int) ($right['artwork_count'] ?? 0)) <=> ((int) ($left['artwork_count'] ?? 0));
|
||||
if ($countCompare !== 0) {
|
||||
return $countCompare;
|
||||
}
|
||||
|
||||
return strcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
|
||||
})->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Category> $categories
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function transformCategories(Collection $categories): array
|
||||
{
|
||||
$categoryMap = $categories->keyBy('id');
|
||||
$pathCache = [];
|
||||
|
||||
$buildPath = function (Category $category) use (&$buildPath, &$pathCache, $categoryMap): string {
|
||||
if (isset($pathCache[$category->id])) {
|
||||
return $pathCache[$category->id];
|
||||
}
|
||||
|
||||
if ($category->parent_id && $categoryMap->has($category->parent_id)) {
|
||||
$pathCache[$category->id] = $buildPath($categoryMap->get($category->parent_id)) . '/' . $category->slug;
|
||||
|
||||
return $pathCache[$category->id];
|
||||
}
|
||||
|
||||
$pathCache[$category->id] = $category->slug;
|
||||
|
||||
return $pathCache[$category->id];
|
||||
};
|
||||
|
||||
return $categories
|
||||
->map(function (Category $category) use ($buildPath): array {
|
||||
$contentTypeSlug = strtolower((string) ($category->contentType?->slug ?? 'categories'));
|
||||
$path = $buildPath($category);
|
||||
$coverImage = null;
|
||||
|
||||
if (! empty($category->cover_hash) && ! empty($category->cover_ext)) {
|
||||
$coverImage = ThumbnailService::fromHash((string) $category->cover_hash, (string) $category->cover_ext, 'md');
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int) $category->id,
|
||||
'name' => (string) $category->name,
|
||||
'slug' => (string) $category->slug,
|
||||
'url' => '/' . $contentTypeSlug . '/' . $path,
|
||||
'content_type' => [
|
||||
'name' => (string) ($category->contentType?->name ?? 'Categories'),
|
||||
'slug' => $contentTypeSlug,
|
||||
],
|
||||
'cover_image' => $coverImage ?: 'https://files.skinbase.org/default/missing_md.webp',
|
||||
'artwork_count' => (int) ($category->artwork_count ?? 0),
|
||||
'popular_score' => (int) ($category->popular_score ?? 0),
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Services\ArtworkService;
|
||||
use App\Services\ContentTypes\ContentTypeSlugResolver;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CategoryPageController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private ArtworkService $artworkService,
|
||||
private ContentTypeSlugResolver $contentTypeResolver,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
public function show(Request $request, string $contentTypeSlug, ?string $categoryPath = null)
|
||||
{
|
||||
$resolution = $this->contentTypeResolver->resolve($contentTypeSlug);
|
||||
if (! $resolution->found() || $resolution->contentType === null) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$contentType = $resolution->contentType;
|
||||
|
||||
if ($resolution->requiresRedirect()) {
|
||||
$target = url('/' . trim($contentType->slug . '/' . trim((string) $categoryPath, '/'), '/'));
|
||||
$queryString = $request->getQueryString();
|
||||
|
||||
if ($queryString) {
|
||||
$target .= '?' . $queryString;
|
||||
}
|
||||
|
||||
return redirect()->to($target, 301);
|
||||
}
|
||||
|
||||
$sort = (string) $request->get('sort', 'latest');
|
||||
|
||||
|
||||
if ($categoryPath === null || $categoryPath === '') {
|
||||
// No category path: show content-type landing page (e.g., /wallpapers)
|
||||
$rootCategories = $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get();
|
||||
$page_title = $contentType->name;
|
||||
$page_meta_description = $contentType->description ?? ($contentType->name . ' artworks on Skinbase');
|
||||
|
||||
// Load artworks for this content type (show gallery on the root page)
|
||||
$perPage = 40;
|
||||
$artworks = $this->artworkService->getArtworksByContentType($contentType->slug, $perPage, $sort);
|
||||
|
||||
return view('legacy::content-type', compact(
|
||||
'contentType',
|
||||
'rootCategories',
|
||||
'artworks',
|
||||
'page_title',
|
||||
'page_meta_description'
|
||||
));
|
||||
}
|
||||
|
||||
$segments = array_filter(explode('/', $categoryPath));
|
||||
$slugs = array_values(array_map('strtolower', $segments));
|
||||
if (empty($slugs)) {
|
||||
return redirect('/browse-categories');
|
||||
}
|
||||
|
||||
// If the first slug exists but under a different content type, redirect to its canonical URL
|
||||
$firstSlug = $slugs[0];
|
||||
$globalRoot = Category::whereNull('parent_id')->where('slug', $firstSlug)->first();
|
||||
if ($globalRoot && $globalRoot->contentType && $globalRoot->contentType->slug !== strtolower($contentType->slug)) {
|
||||
$redirectPath = '/' . $globalRoot->contentType->slug . '/' . implode('/', $slugs);
|
||||
return redirect($redirectPath, 301);
|
||||
}
|
||||
|
||||
// Resolve category by path using the helper that validates parent chain and content type
|
||||
$category = Category::findByPath($contentType->slug, $slugs);
|
||||
if (! $category) {
|
||||
abort(404);
|
||||
}
|
||||
$subcategories = $category->children()->orderBy('sort_order')->orderBy('name')->get();
|
||||
$rootCategories = $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get();
|
||||
|
||||
// Collect category ids for the category + all descendants recursively
|
||||
$collected = [];
|
||||
$gather = function (Category $cat) use (&$gather, &$collected) {
|
||||
$collected[] = $cat->id;
|
||||
foreach ($cat->children as $child) {
|
||||
$gather($child);
|
||||
}
|
||||
};
|
||||
// Ensure children relation is loaded to avoid N+1 recursion
|
||||
$category->load('children');
|
||||
$gather($category);
|
||||
|
||||
// Load artworks via ArtworkService to support arbitrary-depth category paths
|
||||
$perPage = 40;
|
||||
try {
|
||||
// service expects an array with contentType slug first, then category slugs
|
||||
$pathSlugs = array_merge([strtolower($contentTypeSlug)], $slugs);
|
||||
$artworks = $this->artworkService->getArtworksByCategoryPath($pathSlugs, $perPage, $sort);
|
||||
} catch (\Throwable $e) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$page_title = $category->name;
|
||||
$page_meta_description = $category->description ?? ($contentType->name . ' artworks on Skinbase');
|
||||
$page_meta_keywords = strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography';
|
||||
|
||||
// resolved category and breadcrumbs are used by the view
|
||||
|
||||
return view('legacy::category-slug', compact(
|
||||
'contentType',
|
||||
'category',
|
||||
'subcategories',
|
||||
'rootCategories',
|
||||
'artworks',
|
||||
'page_title',
|
||||
'page_meta_description',
|
||||
'page_meta_keywords'
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Collection;
|
||||
use App\Models\CollectionMember;
|
||||
use App\Models\User;
|
||||
use App\Services\CollectionCollaborationService;
|
||||
use App\Services\CollectionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CollectionCollaborationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CollectionCollaborationService $collaborators,
|
||||
private readonly CollectionService $collections,
|
||||
) {
|
||||
}
|
||||
|
||||
public function store(Request $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('manageMembers', $collection);
|
||||
|
||||
$data = $request->validate([
|
||||
'username' => ['required', 'string', 'min:3', 'max:20'],
|
||||
'role' => ['required', 'in:editor,contributor,viewer'],
|
||||
'note' => ['nullable', 'string', 'max:320'],
|
||||
'expires_in_days' => ['nullable', 'integer', 'min:1', 'max:90'],
|
||||
'expires_at' => ['nullable', 'date', 'after:now'],
|
||||
]);
|
||||
|
||||
$invitee = User::query()
|
||||
->whereRaw('LOWER(username) = ?', [strtolower((string) $data['username'])])
|
||||
->firstOrFail();
|
||||
|
||||
$member = $this->collaborators->inviteMember(
|
||||
$collection,
|
||||
$request->user(),
|
||||
$invitee,
|
||||
(string) $data['role'],
|
||||
$data['note'] ?? null,
|
||||
isset($data['expires_in_days']) ? (int) $data['expires_in_days'] : null,
|
||||
$data['expires_at'] ?? null,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'member' => $member,
|
||||
'members' => $this->collaborators->mapMembers($collection, $request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function transfer(Request $request, Collection $collection, CollectionMember $member): JsonResponse
|
||||
{
|
||||
$collection = $this->collaborators->transferOwnership($collection, $member, $request->user());
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
|
||||
'members' => $this->collaborators->mapMembers($collection, $request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Collection $collection, CollectionMember $member): JsonResponse
|
||||
{
|
||||
$this->authorize('manageMembers', $collection);
|
||||
abort_unless((int) $member->collection_id === (int) $collection->id, 404);
|
||||
|
||||
$data = $request->validate([
|
||||
'role' => ['required', 'in:editor,contributor,viewer'],
|
||||
]);
|
||||
|
||||
$this->collaborators->updateMemberRole($member, $request->user(), (string) $data['role']);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'members' => $this->collaborators->mapMembers($collection, $request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Collection $collection, CollectionMember $member): JsonResponse
|
||||
{
|
||||
$this->authorize('manageMembers', $collection);
|
||||
abort_unless((int) $member->collection_id === (int) $collection->id, 404);
|
||||
|
||||
$this->collaborators->revokeMember($member, $request->user());
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'members' => $this->collaborators->mapMembers($collection, $request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function accept(Request $request, CollectionMember $member): JsonResponse
|
||||
{
|
||||
$collection = $member->collection;
|
||||
$this->collaborators->acceptInvite($member, $request->user());
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'members' => $this->collaborators->mapMembers($collection, $request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function decline(Request $request, CollectionMember $member): JsonResponse
|
||||
{
|
||||
$collection = $member->collection;
|
||||
$this->collaborators->declineInvite($member, $request->user());
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'members' => $this->collaborators->mapMembers($collection, $request->user()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Collection;
|
||||
use App\Models\CollectionComment;
|
||||
use App\Services\CollectionCommentService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CollectionCommentController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CollectionCommentService $comments,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request, Collection $collection): JsonResponse
|
||||
{
|
||||
abort_unless($collection->canBeViewedBy($request->user()), 404);
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->comments->mapComments($collection, $request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$this->authorize('comment', $collection);
|
||||
|
||||
$data = $request->validate([
|
||||
'body' => ['required', 'string', 'min:2', 'max:4000'],
|
||||
'parent_id' => ['nullable', 'integer', 'min:1'],
|
||||
]);
|
||||
|
||||
$parent = null;
|
||||
if (! empty($data['parent_id'])) {
|
||||
$parent = CollectionComment::query()->findOrFail((int) $data['parent_id']);
|
||||
abort_unless((int) $parent->collection_id === (int) $collection->id, 404);
|
||||
}
|
||||
|
||||
$this->comments->create($collection->loadMissing('user'), $request->user(), (string) $data['body'], $parent);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'comments' => $this->comments->mapComments($collection, $request->user()),
|
||||
'comments_count' => (int) $collection->fresh()->comments_count,
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Collection $collection, CollectionComment $comment): JsonResponse
|
||||
{
|
||||
abort_unless((int) $comment->collection_id === (int) $collection->id, 404);
|
||||
|
||||
$this->comments->delete($comment->load('collection'), $request->user());
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'comments' => $this->comments->mapComments($collection, $request->user()),
|
||||
'comments_count' => (int) $collection->fresh()->comments_count,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user