optimizations

This commit is contained in:
2026-03-28 19:15:39 +01:00
parent 0b25d9570a
commit cab4fbd83e
509 changed files with 1016804 additions and 1605 deletions

View File

@@ -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()),
]);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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(),
];
}
}

View File

@@ -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);
}

View File

@@ -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([

View File

@@ -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);

View File

@@ -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'],

View 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(),
]);
}
}

View File

@@ -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,

View File

@@ -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),
]);
}

View 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,
],
]);
}
}

View File

@@ -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()),
],
]);
}

View File

@@ -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,
]);
}
}

View 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);
}
}

View File

@@ -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;
}
}

View File

@@ -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),
]);
}
}

View File

@@ -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);
}
}

View 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,
],
]);
}
}

View 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),
)
);
}
}

View File

@@ -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,

View 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,
],
]);
}
}

View File

@@ -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]);
}
}

View File

@@ -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,

View File

@@ -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

View 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),
]);
}
}