optimizations
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -3,23 +3,231 @@
|
||||
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', '');
|
||||
|
||||
$items = Report::query()
|
||||
->with('reporter:id,username')
|
||||
$query = Report::query()
|
||||
->with(['reporter:id,username', 'lastModeratedBy:id,username', 'historyEntries.actor:id,username'])
|
||||
->where('status', $status)
|
||||
->orderByDesc('id')
|
||||
->paginate(30);
|
||||
->orderByDesc('id');
|
||||
|
||||
return response()->json($items);
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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;
|
||||
@@ -128,6 +129,15 @@ class ArtworkCommentController extends Controller
|
||||
);
|
||||
} 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,8 @@ class ArtworkController extends Controller
|
||||
(int) $user->id,
|
||||
(string) $data['title'],
|
||||
isset($data['description']) ? (string) $data['description'] : null,
|
||||
$categoryId
|
||||
$categoryId,
|
||||
(bool) ($data['is_mature'] ?? false)
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
|
||||
@@ -9,6 +9,7 @@ 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;
|
||||
@@ -64,6 +65,10 @@ final class ArtworkInteractionController extends Controller
|
||||
targetId: $artworkId,
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
|
||||
try {
|
||||
app(UserActivityService::class)->logFavourite((int) $request->user()->id, $artworkId);
|
||||
} catch (\Throwable) {}
|
||||
} elseif (! $state && $changed) {
|
||||
$svc->decrementFavoritesReceived($creatorId);
|
||||
}
|
||||
@@ -88,6 +93,9 @@ final class ArtworkInteractionController extends Controller
|
||||
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);
|
||||
|
||||
@@ -17,7 +17,7 @@ final class DiscoveryEventController extends Controller
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'event_id' => ['nullable', 'uuid'],
|
||||
'event_type' => ['required', 'string', 'in:view,click,favorite,download'],
|
||||
'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'],
|
||||
|
||||
206
app/Http/Controllers/Api/DiscoveryNegativeSignalController.php
Normal file
206
app/Http/Controllers/Api/DiscoveryNegativeSignalController.php
Normal file
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,13 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Recommendations\PersonalizedFeedService;
|
||||
use App\Services\Recommendations\RecommendationFeedResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class FeedController extends Controller
|
||||
{
|
||||
public function __construct(private readonly PersonalizedFeedService $feedService)
|
||||
public function __construct(private readonly RecommendationFeedResolver $feedResolver)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ final class FeedController extends Controller
|
||||
'algo_version' => ['nullable', 'string', 'max:64'],
|
||||
]);
|
||||
|
||||
$result = $this->feedService->getFeed(
|
||||
$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,
|
||||
|
||||
@@ -44,6 +44,8 @@ final class FollowController extends Controller
|
||||
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),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -59,6 +61,8 @@ final class FollowController extends Controller
|
||||
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),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
53
app/Http/Controllers/Api/ImageSearchController.php
Normal file
53
app/Http/Controllers/Api/ImageSearchController.php
Normal file
@@ -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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ 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;
|
||||
@@ -31,6 +32,7 @@ class MessageController extends Controller
|
||||
private readonly ConversationStateService $conversationState,
|
||||
private readonly MessagingPayloadFactory $payloadFactory,
|
||||
private readonly SendMessageAction $sendMessage,
|
||||
private readonly UnreadCounterService $unreadCounters,
|
||||
) {}
|
||||
|
||||
// ── GET /api/messages/{conversation_id} ──────────────────────────────────
|
||||
@@ -80,6 +82,10 @@ class MessageController extends Controller
|
||||
|
||||
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()),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
161
app/Http/Controllers/Api/NovaCards/NovaCardDraftController.php
Normal file
161
app/Http/Controllers/Api/NovaCards/NovaCardDraftController.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?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 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);
|
||||
|
||||
if ($request->validated() !== []) {
|
||||
$card = $this->drafts->autosave($card, $request->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);
|
||||
}
|
||||
|
||||
$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);
|
||||
}
|
||||
}
|
||||
131
app/Http/Controllers/Api/NovaCards/NovaCardPresetController.php
Normal file
131
app/Http/Controllers/Api/NovaCards/NovaCardPresetController.php
Normal file
@@ -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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
38
app/Http/Controllers/Api/ProfileActivityController.php
Normal file
38
app/Http/Controllers/Api/ProfileActivityController.php
Normal file
@@ -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),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,22 +3,22 @@
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ConversationParticipant;
|
||||
use App\Models\Message;
|
||||
use App\Models\Report;
|
||||
use App\Models\Story;
|
||||
use App\Models\User;
|
||||
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|in:message,conversation,user,story',
|
||||
'target_type' => ['required', Rule::in($this->targets->supportedTargetTypes())],
|
||||
'target_id' => 'required|integer|min:1',
|
||||
'reason' => 'required|string|max:120',
|
||||
'details' => 'nullable|string|max:4000',
|
||||
@@ -27,32 +27,7 @@ class ReportController extends Controller
|
||||
$targetType = $data['target_type'];
|
||||
$targetId = (int) $data['target_id'];
|
||||
|
||||
if ($targetType === 'message') {
|
||||
$message = Message::query()->findOrFail($targetId);
|
||||
$allowed = ConversationParticipant::query()
|
||||
->where('conversation_id', $message->conversation_id)
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('left_at')
|
||||
->exists();
|
||||
abort_unless($allowed, 403, 'You are not allowed to report this message.');
|
||||
}
|
||||
|
||||
if ($targetType === 'conversation') {
|
||||
$allowed = ConversationParticipant::query()
|
||||
->where('conversation_id', $targetId)
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('left_at')
|
||||
->exists();
|
||||
abort_unless($allowed, 403, 'You are not allowed to report this conversation.');
|
||||
}
|
||||
|
||||
if ($targetType === 'user') {
|
||||
User::query()->findOrFail($targetId);
|
||||
}
|
||||
|
||||
if ($targetType === 'story') {
|
||||
Story::query()->findOrFail($targetId);
|
||||
}
|
||||
$this->targets->validateForReporter($user, $targetType, $targetId);
|
||||
|
||||
$report = Report::query()->create([
|
||||
'reporter_id' => $user->id,
|
||||
|
||||
55
app/Http/Controllers/Api/SimilarAiArtworksController.php
Normal file
55
app/Http/Controllers/Api/SimilarAiArtworksController.php
Normal file
@@ -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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -22,11 +22,14 @@ final class TagController extends Controller
|
||||
// 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]);
|
||||
}
|
||||
|
||||
@@ -34,11 +37,14 @@ final class TagController extends Controller
|
||||
{
|
||||
$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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ 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\Uploads\Exceptions\UploadNotFoundException;
|
||||
use App\Uploads\Exceptions\UploadOwnershipException;
|
||||
use App\Uploads\Exceptions\UploadPublishValidationException;
|
||||
@@ -490,6 +491,8 @@ final class UploadController extends Controller
|
||||
'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'],
|
||||
@@ -548,6 +551,9 @@ final class UploadController extends Controller
|
||||
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 = $slug;
|
||||
$artwork->artwork_timezone = $validated['timezone'] ?? null;
|
||||
|
||||
@@ -572,6 +578,7 @@ final class UploadController extends Controller
|
||||
|
||||
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;
|
||||
@@ -599,6 +606,7 @@ final class UploadController extends Controller
|
||||
}
|
||||
|
||||
// Publish immediately
|
||||
$artwork->visibility = $visibility;
|
||||
$artwork->is_public = ($visibility !== 'private');
|
||||
$artwork->is_approved = true;
|
||||
$artwork->published_at = now();
|
||||
@@ -629,6 +637,10 @@ final class UploadController extends Controller
|
||||
);
|
||||
} 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,
|
||||
|
||||
@@ -7,11 +7,9 @@ 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;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Synchronous Vision tag suggestions for the upload wizard.
|
||||
@@ -26,178 +24,21 @@ use Illuminate\Support\Str;
|
||||
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 (! (bool) config('vision.enabled', true)) {
|
||||
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);
|
||||
|
||||
$imageUrl = $this->buildImageUrl((string) $artwork->hash);
|
||||
if ($imageUrl === null) {
|
||||
return response()->json([
|
||||
'tags' => [],
|
||||
'vision_enabled' => true,
|
||||
'reason' => 'image_url_unavailable',
|
||||
]);
|
||||
}
|
||||
|
||||
$gatewayBase = trim((string) config('vision.gateway.base_url', config('vision.clip.base_url', '')));
|
||||
if ($gatewayBase === '') {
|
||||
return response()->json([
|
||||
'tags' => [],
|
||||
'vision_enabled' => true,
|
||||
'reason' => 'gateway_not_configured',
|
||||
]);
|
||||
}
|
||||
|
||||
$url = rtrim($gatewayBase, '/') . '/analyze/all';
|
||||
$limit = min(20, max(5, (int) ($request->query('limit', 10))));
|
||||
$timeout = (int) config('vision.gateway.timeout_seconds', 10);
|
||||
$cTimeout = (int) config('vision.gateway.connect_timeout_seconds', 3);
|
||||
$ref = (string) Str::uuid();
|
||||
|
||||
try {
|
||||
/** @var \Illuminate\Http\Client\Response $response */
|
||||
$response = Http::acceptJson()
|
||||
->connectTimeout(max(1, $cTimeout))
|
||||
->timeout(max(1, $timeout))
|
||||
->withHeaders(['X-Request-ID' => $ref])
|
||||
->post($url, [
|
||||
'url' => $imageUrl,
|
||||
'limit' => $limit,
|
||||
]);
|
||||
|
||||
if (! $response->ok()) {
|
||||
Log::warning('vision-suggest: non-ok response', [
|
||||
'ref' => $ref,
|
||||
'artwork_id' => $id,
|
||||
'status' => $response->status(),
|
||||
'body' => Str::limit($response->body(), 400),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'tags' => [],
|
||||
'vision_enabled' => true,
|
||||
'reason' => 'gateway_error_' . $response->status(),
|
||||
]);
|
||||
}
|
||||
|
||||
$tags = $this->parseGatewayResponse($response->json());
|
||||
|
||||
return response()->json([
|
||||
'tags' => $tags,
|
||||
'vision_enabled' => true,
|
||||
'source' => 'gateway_sync',
|
||||
]);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('vision-suggest: request failed', [
|
||||
'ref' => $ref,
|
||||
'artwork_id' => $id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'tags' => [],
|
||||
'vision_enabled' => true,
|
||||
'reason' => 'gateway_exception',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private function buildImageUrl(string $hash): ?string
|
||||
{
|
||||
$base = rtrim((string) config('cdn.files_url', ''), '/');
|
||||
if ($base === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$clean = strtolower((string) preg_replace('/[^a-z0-9]/', '', $hash));
|
||||
$clean = str_pad($clean, 6, '0');
|
||||
$seg = [substr($clean, 0, 2) ?: '00', substr($clean, 2, 2) ?: '00', substr($clean, 4, 2) ?: '00'];
|
||||
$variant = (string) config('vision.image_variant', 'md');
|
||||
|
||||
return $base . '/img/' . implode('/', $seg) . '/' . $variant . '.webp';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the /analyze/all gateway response.
|
||||
*
|
||||
* The gateway returns a unified object:
|
||||
* { clip: [{tag, confidence}], blip: ["caption1"], yolo: [{tag, confidence}] }
|
||||
* or a flat list of tags directly.
|
||||
*
|
||||
* @param mixed $json
|
||||
* @return array<int, array{name: string, slug: string, confidence: float|null, source: string, is_ai: true}>
|
||||
*/
|
||||
private function parseGatewayResponse(mixed $json): array
|
||||
{
|
||||
$raw = [];
|
||||
|
||||
if (! is_array($json)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Unified gateway response
|
||||
if (isset($json['clip']) && is_array($json['clip'])) {
|
||||
foreach ($json['clip'] as $item) {
|
||||
$raw[] = ['tag' => $item['tag'] ?? $item['name'] ?? '', 'confidence' => $item['confidence'] ?? null, 'source' => 'clip'];
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($json['yolo']) && is_array($json['yolo'])) {
|
||||
foreach ($json['yolo'] as $item) {
|
||||
$raw[] = ['tag' => $item['tag'] ?? $item['label'] ?? $item['name'] ?? '', 'confidence' => $item['confidence'] ?? null, 'source' => 'yolo'];
|
||||
}
|
||||
}
|
||||
|
||||
// Flat lists
|
||||
if ($raw === []) {
|
||||
$list = $json['tags'] ?? $json['data'] ?? $json;
|
||||
if (is_array($list)) {
|
||||
foreach ($list as $item) {
|
||||
if (is_array($item)) {
|
||||
$raw[] = ['tag' => $item['tag'] ?? $item['name'] ?? $item['label'] ?? '', 'confidence' => $item['confidence'] ?? null, 'source' => 'vision'];
|
||||
} elseif (is_string($item)) {
|
||||
$raw[] = ['tag' => $item, 'confidence' => null, 'source' => 'vision'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate by slug, keep highest confidence
|
||||
$bySlug = [];
|
||||
foreach ($raw as $r) {
|
||||
$slug = $this->normalizer->normalize((string) ($r['tag'] ?? ''));
|
||||
if ($slug === '') {
|
||||
continue;
|
||||
}
|
||||
$conf = isset($r['confidence']) && is_numeric($r['confidence']) ? (float) $r['confidence'] : null;
|
||||
|
||||
if (! isset($bySlug[$slug]) || ($conf !== null && $conf > (float) ($bySlug[$slug]['confidence'] ?? 0))) {
|
||||
$bySlug[$slug] = [
|
||||
'name' => ucwords(str_replace(['-', '_'], ' ', $slug)),
|
||||
'slug' => $slug,
|
||||
'confidence' => $conf,
|
||||
'source' => $r['source'] ?? 'vision',
|
||||
'is_ai' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by confidence desc
|
||||
$sorted = array_values($bySlug);
|
||||
usort($sorted, static fn ($a, $b) => ($b['confidence'] ?? 0) <=> ($a['confidence'] ?? 0));
|
||||
|
||||
return $sorted;
|
||||
return response()->json($this->vision->suggestTags($artwork, $this->normalizer, $limit));
|
||||
}
|
||||
|
||||
private function authorizeOrNotFound(mixed $user, Artwork $artwork): void
|
||||
|
||||
26
app/Http/Controllers/Api/UserSuggestionsController.php
Normal file
26
app/Http/Controllers/Api/UserSuggestionsController.php
Normal file
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user