Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 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
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
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 & 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 & labels** | Reusable hashtag and CTA blocks, color-coded post tags. |
| **Team collaboration** | Owner / Admin / Member roles, comments with @mentions on drafts. |
Expand Down
62 changes: 62 additions & 0 deletions app/Actions/Repurpose/ActivateRepurpose.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

declare(strict_types=1);

namespace App\Actions\Repurpose;

use App\Enums\Repurpose\Status;
use App\Models\Repurpose;
use App\Models\SocialAccount;
use App\Support\PostPlatformMetaRules;
use App\Support\Repurpose\RepurposeTransition;
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::assertPublishable($locked);

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

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

foreach ($repurpose->destinations as $destination) {
$account = SocialAccount::query()
->where('workspace_id', $repurpose->workspace_id)
->where('is_active', true)
->find(data_get($destination, 'social_account_id'));

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

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

if ($violation !== null) {
throw ValidationException::withMessages(['destinations' => $violation[1]]);
}
}
}
}
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 = (string) data_get($data, 'source_social_account_id');
$sourceFormat = SourceFormat::tryFrom((string) data_get($data, 'source_format')) ?? SourceFormat::Reel;

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::tryFrom((string) data_get($data, 'publish_mode')) ?? PublishMode::Publish,
'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();
}
}
26 changes: 26 additions & 0 deletions app/Actions/Repurpose/DisableRepurpose.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?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,
'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): LengthAwarePaginator
{
return $repurpose->items()
->with('posts.postPlatforms:id,post_id,platform,enabled')
->orderByDesc(DB::raw('coalesce(source_created_at, created_at)'))
->paginate((int) config('app.pagination.default'));
}
}
26 changes: 26 additions & 0 deletions app/Actions/Repurpose/ListRepurposes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace App\Actions\Repurpose;

use App\Enums\Repurpose\ItemStatus;
use App\Models\Repurpose;
use App\Models\Workspace;
use Illuminate\Pagination\LengthAwarePaginator;

class ListRepurposes
{
/**
* @return LengthAwarePaginator<int, Repurpose>
*/
public static function execute(Workspace $workspace, ?int $page = null): LengthAwarePaginator
{
return Repurpose::query()
->where('workspace_id', $workspace->id)
->with('sourceAccount')
->withCount(['items as published_items_count' => fn ($query) => $query->where('status', ItemStatus::Published)])
->latest()
->paginate((int) config('app.pagination.default'), page: $page);
}
}
22 changes: 22 additions & 0 deletions app/Actions/Repurpose/PauseRepurpose.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace App\Actions\Repurpose;

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

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

declare(strict_types=1);

namespace App\Actions\Repurpose;

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

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

declare(strict_types=1);

namespace App\Actions\Repurpose;

use App\Enums\Repurpose\Status;
use App\Models\Repurpose;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;

class UpdateRepurpose
{
/**
* @param array<string, mixed> $data
*/
public static function execute(Repurpose $repurpose, array $data): Repurpose
{
$attributes = Arr::only($data, [
'source_social_account_id',
'source_format',
'publish_mode',
'destinations',
]);

if (self::watchesSomethingElse($repurpose, $attributes)) {
$attributes['activated_at'] = $repurpose->activated_at === null ? null : now();
}

try {
return DB::transaction(function () use ($repurpose, $attributes): Repurpose {
$locked = Repurpose::query()->whereKey($repurpose->id)->lockForUpdate()->firstOrFail();

$locked->update($attributes);
$locked = $locked->fresh();

if ($locked->status === Status::Active) {
ActivateRepurpose::assertPublishable($locked);
}

return $locked;
});
} catch (UniqueConstraintViolationException) {
throw ValidationException::withMessages([
'source_social_account_id' => __('repurposes.errors.source_already_used'),
]);
}
}

/**
* A repurpose aimed at another account or another format has a back catalogue
* behind it that was never meant for these destinations, so the watermark
* moves to now instead of replaying it.
*
* @param array<string, mixed> $attributes
*/
private static function watchesSomethingElse(Repurpose $repurpose, array $attributes): bool
{
return data_get($attributes, 'source_social_account_id', $repurpose->source_social_account_id) !== $repurpose->source_social_account_id
|| data_get($attributes, 'source_format', $repurpose->source_format->value) !== $repurpose->source_format->value;
}
}
Loading