Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
115 commits
Select commit Hold shift + click to select a range
f5eaddc
Add repurpose schema, models and source fetchers
paulocastellano Sep 5, 2026
e5817ec
Poll repurpose sources and turn new videos into posts
paulocastellano Sep 5, 2026
4ae35e1
Add repurpose actions, policy, validation rules and templates
paulocastellano Sep 5, 2026
3828dbf
Add the repurpose web surface, pages and translations
paulocastellano Sep 5, 2026
39b1ca6
Match the accounts screen design on the repurpose pages
paulocastellano Sep 5, 2026
14660ac
Configure which format a repurpose watches and how each destination p…
paulocastellano Sep 5, 2026
4a626ac
Expose repurposes over the REST API and MCP
paulocastellano Sep 5, 2026
9c64a9b
Name the account behind every logo in the repurpose flow
paulocastellano Sep 5, 2026
a9bf1a3
Match the post editor's account tooltip in the repurpose flow
paulocastellano Sep 5, 2026
a6c1ece
Drop the repurpose unique in an order MySQL accepts, and finish the plan
paulocastellano Sep 5, 2026
17e0aa5
Configure repurpose destinations with the post editor's own components
paulocastellano Sep 5, 2026
a206b55
Split the repurpose configuration into source and destination columns
paulocastellano Sep 5, 2026
ae8e870
Lead the repurpose page with what it actually does
paulocastellano Sep 5, 2026
ceb16a5
Keep the activity table readable and name where each video landed
paulocastellano Sep 5, 2026
54a84b6
Use the app's own button styles for the repurpose destructive actions
paulocastellano Sep 5, 2026
a7727c1
Strip the explanatory comments from the repurpose module
paulocastellano Sep 5, 2026
5a94d05
Scope repurpose accounts to the caller's workspace and make processin…
paulocastellano Sep 5, 2026
a706313
Fix the defects a deep review of the repurpose module surfaced
paulocastellano Sep 5, 2026
e5e23be
Trim the repurpose module after the review
paulocastellano Sep 5, 2026
9203be3
Close the holes the second review opened up
paulocastellano Sep 5, 2026
efa3e17
Hand repurposed posts to the scheduler instead of racing it
paulocastellano Sep 5, 2026
64a9a2f
Measure the caption against the text that actually gets posted
paulocastellano Sep 5, 2026
3da4e1a
Stop the Instagram source from hanging on a field Meta may not send
paulocastellano Sep 5, 2026
8808095
Fail the build when an enum value has no string behind it
paulocastellano Sep 5, 2026
ff08556
Keep the posts' status changes observable and the poll inside its worker
paulocastellano Sep 5, 2026
9acbbcd
Say outright that a failed download does not return
paulocastellano Sep 5, 2026
41c867d
Page the repurpose list and survive fields Meta will not hand over
paulocastellano Sep 6, 2026
de485ff
Decide the lifecycle transition while holding the row
paulocastellano Sep 6, 2026
566f273
Fold source_format into the table it belongs to
paulocastellano Sep 6, 2026
317aa02
Merge remote-tracking branch 'origin/main' into repurpose-module
paulocastellano Sep 6, 2026
0b0a2e6
Stop tracking the local Codex config
paulocastellano Sep 6, 2026
c1ff7fb
Give each surface its own repurpose validation
paulocastellano Sep 6, 2026
4083069
Read the Graph values as enums and stop guessing the page size
paulocastellano Sep 6, 2026
1509db6
Cut the caption at a word boundary with the idiom that exists
paulocastellano Sep 6, 2026
c2a88b0
Share the platform tile instead of keeping two copies of it
paulocastellano Sep 6, 2026
a2ee688
Use the app's empty state on the repurpose index
paulocastellano Sep 6, 2026
3faac69
Say what the truncation loop does instead of computing it inline
paulocastellano Sep 6, 2026
10e4fb3
Name the repurpose statuses instead of quoting them
paulocastellano Sep 6, 2026
1b83649
Show the validation error where the field is
paulocastellano Sep 6, 2026
be9ca77
Send the starting content type instead of a list to index into
paulocastellano Sep 6, 2026
e679e4c
Fix the shortener prompt and give the model room under the cap
paulocastellano Sep 6, 2026
6375a20
Count the YouTube title in characters
paulocastellano Sep 6, 2026
844e711
Let a repurpose stop at a draft instead of publishing
paulocastellano Sep 6, 2026
2c5106c
Assemble the repurpose update the way the other updates do
paulocastellano Sep 6, 2026
ca7b344
Declare publish_mode on the table that this branch creates
paulocastellano Sep 6, 2026
243ac9e
Answer both source invariants in validation, not in the constraint
paulocastellano Sep 6, 2026
1c6e6b3
Pick the source account from a searchable list
paulocastellano Sep 6, 2026
063967e
Shrink the closed source picker to the network mark
paulocastellano Sep 6, 2026
342267d
Judge the destination against the video a repurpose will attach
paulocastellano Sep 6, 2026
6476a2e
Let the source account be changed from the page
paulocastellano Sep 6, 2026
a8cb1fe
Free the old source as a destination the moment it stops being the so…
paulocastellano Sep 6, 2026
bc97cd1
Draw the pipeline the page configures
paulocastellano Sep 6, 2026
322860a
Point the header at the source being chosen
paulocastellano Sep 6, 2026
123eb03
Make the activity read like a log of what happened
paulocastellano Sep 6, 2026
741d713
Make the replicated posts look like the links they are
paulocastellano Sep 6, 2026
89f2565
Let a repurpose outlive its source account
paulocastellano Sep 6, 2026
2f27b18
Add a non-throwing repurpose transition for system paths
paulocastellano Sep 6, 2026
1f7b696
Require one usable destination, not every destination
paulocastellano Sep 6, 2026
ae6975a
Start a system-paused repurpose from now, not from its backlog
paulocastellano Sep 6, 2026
da8e98c
Pause a repurpose when its source account stops working
paulocastellano Sep 6, 2026
554603a
Prune and realign repurpose destinations when their account changes
paulocastellano Sep 6, 2026
53849cf
Resume a repurpose when its source account recovers
paulocastellano Sep 6, 2026
422187f
Promote an account back to connected when its token refreshes
paulocastellano Sep 6, 2026
66ef8d2
Record why an item had nowhere to publish
paulocastellano Sep 6, 2026
80f8aac
Show the real status of each replicated post
paulocastellano Sep 6, 2026
77cfeed
Show why a repurpose stopped and what fixes it
paulocastellano Sep 6, 2026
768d67f
Say which automations a disconnect paused
paulocastellano Sep 6, 2026
d32b03b
Record the repurpose account-health decisions
paulocastellano Sep 6, 2026
481c8e8
Use the status enum in the health banner instead of a literal
paulocastellano Sep 6, 2026
4ab2439
Close the gaps the post-implementation review found
paulocastellano Sep 6, 2026
632dbbd
Report a paused automation when the deleted account was a destination
paulocastellano Sep 6, 2026
9a04d39
Stop a page watched for feed videos from replicating its reels
paulocastellano Sep 7, 2026
2824ecb
Cover the account-health surface on the API and MCP
paulocastellano Sep 7, 2026
4ed6e7a
Serve the activity list from one query instead of three
paulocastellano Sep 7, 2026
606e926
Serve the repurpose list from its action too
paulocastellano Sep 7, 2026
d65dbcf
Stop an exhausted item leaving orphan drafts in the calendar
paulocastellano Sep 7, 2026
abf1a66
Keep the signed source URL out of the stored item error
paulocastellano Sep 7, 2026
94c8632
Stop an orphaned repurpose claiming it watches LinkedIn
paulocastellano Sep 7, 2026
fa0a12a
Stop the editor erasing a destination the user only paused
paulocastellano Sep 7, 2026
7e647eb
Pin that an orphaned repurpose never reaches the poll dispatcher
paulocastellano Sep 7, 2026
fe0e04c
Pin the recovery path after a source account is deleted
paulocastellano Sep 7, 2026
ba4dbb6
Align the MCP requests with the web and API on paused destinations
paulocastellano Sep 7, 2026
9b078cb
Cover the guard that keeps this module out of the accounts flow
paulocastellano Sep 7, 2026
546093b
Cover the terminal guard that stops a skipped item being replicated
paulocastellano Sep 7, 2026
4497599
Pin that repeated polls queue a video once
paulocastellano Sep 7, 2026
f1b475b
Cover clearing the pause reason on the other two exits
paulocastellano Sep 7, 2026
fc98f1c
Say which of the two guards actually protects a user pause
paulocastellano Sep 7, 2026
68786f4
Take the page size out of the actions, and the noise out of the commands
paulocastellano Sep 7, 2026
f7d6c07
Paginate the public API from the same config as everything else
paulocastellano Sep 7, 2026
ada47b7
Ask Eloquent whether the repurpose is watching something else
paulocastellano Sep 7, 2026
9094195
Validate the source-is-not-a-destination rule after the field rules
paulocastellano Sep 7, 2026
73b0c00
Fold the account-health columns into the create migration
paulocastellano Sep 7, 2026
045bf84
Strip the commentary out of the repurpose module
paulocastellano Sep 7, 2026
5f30b9e
Finish moving the cross-field checks out of rules()
paulocastellano Sep 7, 2026
429fff2
Drop the unreachable content-type fallback
paulocastellano Sep 7, 2026
8aa74c5
Split the polling job into steps that each do one thing
paulocastellano Sep 7, 2026
80023d1
Simplify the caption adapter and the Facebook fetcher
paulocastellano Sep 7, 2026
7a89de4
Let the sync decide which account changes concern it
paulocastellano Sep 7, 2026
1049f88
Name the reel subtraction in the Facebook fetcher
paulocastellano Sep 7, 2026
42d8726
Let a repurpose answer which accounts it depends on
paulocastellano Sep 7, 2026
ac5ea66
Move the DTOs into app/Dto
paulocastellano Sep 7, 2026
5afed23
Name the watermark check after what it can actually prove
paulocastellano Sep 7, 2026
d0d13f2
Search by words so the caption never needs a second cut
paulocastellano Sep 7, 2026
e2d8ab0
Drop a word until the caption fits, instead of binary searching for it
paulocastellano Sep 7, 2026
a306c97
Reach for the framework's helpers in the caption adapter
paulocastellano Sep 7, 2026
ccad6aa
Validate destination meta when the repurpose is saved, not only when …
paulocastellano Sep 7, 2026
b6e230f
Remove the repurpose templates
paulocastellano Sep 7, 2026
033a92d
Leave the empty state with one call to action
paulocastellano Sep 7, 2026
de7ea96
Edit a repurpose the way the post editor edits a post
paulocastellano Sep 7, 2026
fad5725
Stop the poll job from shadowing Laravel's job hooks
paulocastellano Sep 7, 2026
0c45a07
Catch the poll job's dispatch failures with a real dispatch
paulocastellano Sep 7, 2026
a20c0a4
Read the repurpose payload rules from one place
paulocastellano Sep 7, 2026
a1ca83a
Close the review findings and tidy the repurpose index
paulocastellano Sep 7, 2026
1a67cfe
Translate the repurpose delete dialog and finish the pt-BR rename
paulocastellano Sep 7, 2026
d3845f8
Stop the repurpose copy from promising only videos
paulocastellano Sep 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .claude/release-assets/render-thumbnail.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@
// Playwright is resolved from the repo's node_modules, so the script works
// regardless of where it is invoked.

import { createRequire } from 'module';
import { readFileSync, writeFileSync, unlinkSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { createRequire } from 'module';
import { tmpdir } from 'os';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';

const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(here, '..', '..');
Expand Down
7 changes: 0 additions & 7 deletions .codex/config.toml

This file was deleted.

3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -300,3 +300,6 @@ VITE_REVERB_SCHEME="${REVERB_SCHEME}"
VITE_POSTHOG_ENABLED="${POSTHOG_ENABLED}"
VITE_POSTHOG_API_KEY="${POSTHOG_API_KEY}"
VITE_POSTHOG_HOST="${POSTHOG_HOST}"

REPURPOSE_POLL_INTERVAL_MINUTES=15
REPURPOSE_BACKOFF_MINUTES=60
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Homestead.yaml
npm-debug.log
yarn-error.log
/auth.json
/.codex
/.fleet
/.idea
/.nova
Expand Down
47 changes: 47 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,3 +286,50 @@ Standing constraints:
- The editor counts characters and renders the X preview client-side, so the rewrite is mirrored in `resources/js/lib/defuseXLinks.ts`. The TLD list is NOT duplicated there: `PostController@edit` sends `App\Support\LinkTlds::all()` as the `xLinkTlds` page prop, and only when defusing is on — an empty set means the feature is off, since without the list a bare host cannot be told from `Node.js`. Do not move it to the Inertia shared props; only the editor needs it. Two tests keep the mirror honest: `XLinkDefusingParityTest` runs a shared corpus through both engines over the same list and diffs the output, and `tests/Browser/XLinkDefusingTest.php` drives the real editor.
- Neither expression may use lookbehind. Safari only understands it from 16.4, esbuild cannot transpile it, and a `SyntaxError` there takes down the whole chunk — the character before a candidate URL is consumed and put back instead.

## Repurpose account health

A repurpose depends on social accounts it does not own the lifecycle of. Three
decisions govern how it reacts, and each exists because the obvious alternative
was tried and was wrong.

- **A switched-off destination is skipped, never an error.** Deactivating an
account means "don't post here", which `ProcessRepurposeItem` already honours.
So `ActivateRepurpose::assertDestinationsPublishable()` requires **one** usable
destination, not all of them, and the destination rule in the repurpose
FormRequests carries **no** `is_active` clause. Requiring either is what used
to block editing *and* resuming any repurpose that listed a paused account.
Keep the `workspace_id` clause — that is tenancy, not health. The
`source_social_account_id` rules stay strict: a source genuinely must work.
- **`repurposes.paused_reason` is not UI copy.** NULL means the user paused it.
Its only two jobs are deciding the watermark on resume (a system pause starts
from `now()`, a user pause keeps its place) and deciding whether the system may
auto-resume. Banners derive from current account health instead, so they can
say "ready to resume" once the cause is fixed. **Never clear it in
`UpdateRepurpose`** — that destroys the record that the pause was systemic, and
the next Resume replays the entire backlog.
- **Source and destination are deliberately asymmetric.** A dead source stops the
automation; a dead destination keeps flowing to the publisher, which fails the
post visibly and lets the user retry it after reconnecting. Skipping a
destination at job time would be permanent for that item, since items are never
retried.

`RepurposeAccountSync` runs from `SocialAccountObserver` and must never throw:
`deleting` runs inside `$account->delete()`, and `persistIdentity()` wraps a
reconnect in a transaction, so an exception there would 500 a disconnect or roll
back a reconnect. It reads account health **from the database**, not from the
model it was handed — `is_active` is absent from `SocialAccountFactory`, and
strict mode exempts recently-created models from the missing-attribute
exception, so a healthy account read back as `null` and silently skipped
auto-resume.

No email is sent when a repurpose stops. `markAsTokenExpired()` and
`VerifyWorkspaceConnections` already email about the account, and reconnecting is
what auto-resumes the repurpose; deleting or switching an account off is
something the user just did, so the flash on the accounts page reports the count
instead.

`VerifyWorkspaceConnections` is the **only** thing that promotes an account back
to `Connected`, because it does so after a real `verify()` call. A successful
token refresh is not that proof — the refresh token being valid says nothing
about whether publishing still works — so `RefreshSocialToken` must not promote,
even though it would let a paused repurpose resume sooner.
50 changes: 49 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ Self-hosted compose / `.env.example` set this `true`. When the env is unset, the
- Always use normal pagination (`->paginate()`). NEVER use cursor pagination (`->cursorPaginate()`).
- All paginated lists must use Inertia's scroll pagination (`Inertia::scroll()` on the backend with `<InfiniteScroll>` on the frontend). NEVER use traditional page-based pagination with page links/buttons.
- The page size ALWAYS comes from `config('app.pagination.default')` — never a magic number, and never a `perPage`/`per_page` value supplied by the request or frontend. Action/service list methods must NOT accept a `$perPage` parameter; call `->paginate((int) config('app.pagination.default'))` directly.
- The only exception is the public REST API (`app/Http/Controllers/Api`), which uses its own fixed, documented page size (15) as a stable API contract.
- **This includes the public REST API** (`app/Http/Controllers/Api`). It used to pin its own page size of 15 as a stable contract; that exception is gone, so a list endpoint reads the same config as everything else. Changing `app.pagination.default` therefore changes the API's page size too — deliberate, and the reason a list response always carries `meta.per_page` for clients to read rather than assume.

## Form Validation

Expand Down Expand Up @@ -434,3 +434,51 @@ Standing constraints:
- NEVER add `Co-Authored-By` lines to commit messages.
- NEVER commit, push, or open PRs unless explicitly asked by the user.
- Always create a new branch for feature work before making changes.

## Repurpose account health

A repurpose depends on social accounts it does not own the lifecycle of. Three
decisions govern how it reacts, and each exists because the obvious alternative
was tried and was wrong.

- **A switched-off destination is skipped, never an error.** Deactivating an
account means "don't post here", which `ProcessRepurposeItem` already honours.
So `ActivateRepurpose::assertDestinationsPublishable()` requires **one** usable
destination, not all of them, and the destination rule in the repurpose
FormRequests carries **no** `is_active` clause. Requiring either is what used
to block editing *and* resuming any repurpose that listed a paused account.
Keep the `workspace_id` clause — that is tenancy, not health. The
`source_social_account_id` rules stay strict: a source genuinely must work.
- **`repurposes.paused_reason` is not UI copy.** NULL means the user paused it.
Its only two jobs are deciding the watermark on resume (a system pause starts
from `now()`, a user pause keeps its place) and deciding whether the system may
auto-resume. Banners derive from current account health instead, so they can
say "ready to resume" once the cause is fixed. **Never clear it in
`UpdateRepurpose`** — that destroys the record that the pause was systemic, and
the next Resume replays the entire backlog.
- **Source and destination are deliberately asymmetric.** A dead source stops the
automation; a dead destination keeps flowing to the publisher, which fails the
post visibly and lets the user retry it after reconnecting. Skipping a
destination at job time would be permanent for that item, since items are never
retried.

`RepurposeAccountSync` runs from `SocialAccountObserver` and must never throw:
`deleting` runs inside `$account->delete()`, and `persistIdentity()` wraps a
reconnect in a transaction, so an exception there would 500 a disconnect or roll
back a reconnect. It reads account health **from the database**, not from the
model it was handed — `is_active` is absent from `SocialAccountFactory`, and
strict mode exempts recently-created models from the missing-attribute
exception, so a healthy account read back as `null` and silently skipped
auto-resume.

No email is sent when a repurpose stops. `markAsTokenExpired()` and
`VerifyWorkspaceConnections` already email about the account, and reconnecting is
what auto-resumes the repurpose; deleting or switching an account off is
something the user just did, so the flash on the accounts page reports the count
instead.

`VerifyWorkspaceConnections` is the **only** thing that promotes an account back
to `Connected`, because it does so after a real `verify()` call. A successful
token refresh is not that proof — the refresh token being valid says nothing
about whether publishing still works — so `RefreshSocialToken` must not promote,
even though it would let a paused repurpose resume sooner.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
| **AI generate &amp; review** | Draft from a prompt, get inline feedback before you publish. |
| **AI carousel builder** | Prompt to a multi-slide carousel with images, on-brand. |
| **Brand profile** | Tone, voice, language, and colors applied to every AI call. |
| **Repurpose** | Auto-replicate the videos you post outside TryPost to your other networks. |
| **Asset library** | Reusable workspace media, plus Unsplash and Giphy search built in. |
| **Signatures &amp; labels** | Reusable hashtag and CTA blocks, color-coded post tags. |
| **Team collaboration** | Owner / Admin / Member roles, comments with @mentions on drafts. |
Expand Down
105 changes: 105 additions & 0 deletions app/Actions/Repurpose/ActivateRepurpose.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php

declare(strict_types=1);

namespace App\Actions\Repurpose;

use App\Enums\Repurpose\Status;
use App\Enums\SocialAccount\Status as AccountStatus;
use App\Models\Repurpose;
use App\Models\SocialAccount;
use App\Support\PostPlatformMetaRules;
use App\Support\Repurpose\RepurposeTransition;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Validation\ValidationException;

class ActivateRepurpose
{
public static function execute(Repurpose $repurpose): Repurpose
{
return RepurposeTransition::apply(
$repurpose,
[Status::Draft, Status::Disabled],
__('repurposes.errors.only_idle_activates'),
function (Repurpose $locked): void {
self::assertSourceUsable($locked);
self::assertHasUsableDestination($locked);
self::assertDestinationsCarryRequiredMeta($locked);

$locked->update([
'status' => Status::Active,
'activated_at' => now(),
'paused_reason' => null,
'next_poll_at' => null,
'last_error' => null,
]);
},
);
}

public static function assertSourceUsable(Repurpose $repurpose): void
{
$account = $repurpose->loadMissing('sourceAccount')->sourceAccount;

if ($account === null) {
throw ValidationException::withMessages([
'source_social_account_id' => __('repurposes.errors.source_missing'),
]);
}

if (! $account->is_active || $account->status !== AccountStatus::Connected) {
throw ValidationException::withMessages([
'source_social_account_id' => __('repurposes.errors.source_unusable'),
]);
}
}

public static function assertHasUsableDestination(Repurpose $repurpose): void
{
if ($repurpose->destinations === []) {
throw ValidationException::withMessages([
'destinations' => __('repurposes.errors.destinations_required'),
]);
}

if (self::usableAccounts($repurpose)->isEmpty()) {
throw ValidationException::withMessages([
'destinations' => __('repurposes.errors.destination_unavailable'),
]);
}
}

public static function assertDestinationsCarryRequiredMeta(Repurpose $repurpose): void
{
$accounts = self::usableAccounts($repurpose);

foreach ($repurpose->destinations as $destination) {
$account = $accounts->get(data_get($destination, 'social_account_id'));

if ($account === null) {
continue;
}

$violation = PostPlatformMetaRules::requiredMetaViolation($account->platform, data_get($destination, 'meta'));

if ($violation !== null) {
throw ValidationException::withMessages(['destinations' => $violation[1]]);
}
}
}

/**
* @return Collection<string, SocialAccount>
*/
private static function usableAccounts(Repurpose $repurpose): Collection
{
return SocialAccount::query()
->where('workspace_id', $repurpose->workspace_id)
->where('is_active', true)
->findMany(array_map(
fn (array $destination): mixed => data_get($destination, 'social_account_id'),
$repurpose->destinations,
))
->keyBy('id');
}
}
57 changes: 57 additions & 0 deletions app/Actions/Repurpose/CreateRepurpose.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);

namespace App\Actions\Repurpose;

use App\Enums\Repurpose\PublishMode;
use App\Enums\Repurpose\SourceFormat;
use App\Enums\Repurpose\Status;
use App\Models\Repurpose;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Validation\ValidationException;

class CreateRepurpose
{
/**
* @param array<string, mixed> $data
*/
public static function execute(Workspace $workspace, User $user, array $data): Repurpose
{
$sourceAccountId = data_get($data, 'source_social_account_id');
$sourceFormat = SourceFormat::from(data_get($data, 'source_format', SourceFormat::Reel->value));

if (self::existingFor($workspace, $sourceAccountId, $sourceFormat) !== null) {
throw ValidationException::withMessages([
'source_social_account_id' => __('repurposes.errors.source_already_used'),
]);
}

try {
return Repurpose::query()->create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
'source_social_account_id' => $sourceAccountId,
'source_format' => $sourceFormat,
'publish_mode' => PublishMode::from(data_get($data, 'publish_mode', PublishMode::Publish->value)),
'destinations' => data_get($data, 'destinations', []),
'status' => Status::Draft,
]);
} catch (UniqueConstraintViolationException) {
throw ValidationException::withMessages([
'source_social_account_id' => __('repurposes.errors.source_already_used'),
]);
}
}

public static function existingFor(Workspace $workspace, string $sourceAccountId, SourceFormat $format): ?Repurpose
{
return Repurpose::query()
->where('workspace_id', $workspace->id)
->where('source_social_account_id', $sourceAccountId)
->where('source_format', $format)
->first();
}
}
15 changes: 15 additions & 0 deletions app/Actions/Repurpose/DeleteRepurpose.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace App\Actions\Repurpose;

use App\Models\Repurpose;

class DeleteRepurpose
{
public static function execute(Repurpose $repurpose): void
{
$repurpose->delete();
}
}
27 changes: 27 additions & 0 deletions app/Actions/Repurpose/DisableRepurpose.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace App\Actions\Repurpose;

use App\Enums\Repurpose\Status;
use App\Models\Repurpose;
use App\Support\Repurpose\RepurposeTransition;

class DisableRepurpose
{
public static function execute(Repurpose $repurpose): Repurpose
{
return RepurposeTransition::apply(
$repurpose,
[Status::Active, Status::Paused],
__('repurposes.errors.only_running_disables'),
fn (Repurpose $locked) => $locked->update([
'status' => Status::Disabled,
'activated_at' => null,
'paused_reason' => null,
'next_poll_at' => null,
]),
);
}
}
24 changes: 24 additions & 0 deletions app/Actions/Repurpose/ListRepurposeItems.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace App\Actions\Repurpose;

use App\Models\Repurpose;
use App\Models\RepurposeItem;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;

class ListRepurposeItems
{
/**
* @return LengthAwarePaginator<int, RepurposeItem>
*/
public static function execute(Repurpose $repurpose, ?int $page = null): LengthAwarePaginator
{
return $repurpose->items()
->with('posts.postPlatforms:id,post_id,platform,enabled,status')
->orderByDesc(DB::raw('coalesce(source_created_at, created_at)'))
->paginate((int) config('app.pagination.default'), page: $page);
}
}
Loading