Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ A member of an organization can **Claim** its page, which lists them as its admi

After that, members are managed on /dev/null, not mirrored from GitHub. An organization's members open its **Settings** from its page, where the **Members** tab lists everyone, each an **admin** or a **member**. Admins add people by their /dev/null handle, searching by handle or name as they type, except people they blocked or who blocked them, make them admins or members, and remove them. Any member can leave. Its last admin can't leave or become a member. Only its members see who its members are; its page lists its admins to everyone. Your organizations and your role in each are in your data export and go when you delete your account.

Its admins and members can **post as the organization**: in the composer, and in a reply's, they open their avatar and pick the organization, then switch back to themselves the same way. Every commit and reply starts as yourself. A commit from an organization shows the organization alone, with its square avatar and color, at `/@{handle}/commits/{id}`: nobody else sees which member posted it. It belongs to the organization: any of its members can delete it, and none can star it, report it, vote in its poll or give it pastéis. It counts toward no one's level, and blocking or muting the person who posted it doesn't hide it. Moderation still holds the person who posted it to account, and if they delete their account, the organization's commits they posted stay. Its admins and members hear about the organization in their own notifications, all except whoever did it: replies to its commits, quotes of them, stars on them and commits that mention it, said as the organization's, like **replied to @laravel's commit** or **mentioned @laravel**. They all see the organization's pending and rejected commits, like whoever posted them, and all hear when one is rejected, with the reason. Someone removed from the organization keeps what they already heard and hears nothing new, and no longer sees its rejected commits or what they heard about them. Organizations can't schedule commits yet, and their own commits, replies and quotes don't notify anyone yet. Apps can post as an organization through the API too, by sending its handle as `author`. Posting as an organization is still being rolled out: until administrators switch it on for everyone, the **Post as** menu doesn't show and the API refuses `author`.
Its admins and members can **post as the organization**: in the composer, and in a reply's, they open their avatar and pick the organization, then switch back to themselves the same way. Every commit and reply starts as yourself. A commit from an organization shows the organization alone, with its square avatar and color, at `/@{handle}/commits/{id}`: nobody else sees which member posted it. It belongs to the organization: any of its members can delete it, and none can star it, report it, vote in its poll or give it pastéis. It counts toward no one's level, and blocking or muting the person who posted it doesn't hide it. Moderation still holds the person who posted it to account, and if they delete their account, the organization's commits they posted stay. Its admins and members hear about the organization in their own notifications, all except whoever did it: replies to its commits, quotes of them, stars on them and commits that mention it, said as the organization's, like **replied to @laravel's commit** or **mentioned @laravel**. They all see the organization's pending and rejected commits, like whoever posted them, and all hear when one is rejected, with the reason. Someone removed from the organization keeps what they already heard and hears nothing new, and no longer sees its rejected commits or what they heard about them. They can schedule commits as the organization too, the same way: if they are no longer a member when it is due, it is canceled. The organization's own commits, replies and quotes don't notify anyone yet. Apps can post as an organization through the API too, by sending its handle as `author`. Posting as an organization is still being rolled out: until administrators switch it on for everyone, the **Post as** menu doesn't show and the API refuses `author`.

Every organization has a color, /dev/null's blue until an admin chooses another in the **Profile** tab of its **Settings**, where its admins land: one of the same six lane colors as developers, or a custom one. It rings the organization's avatar, leads its handle, and colors its link preview.

Expand Down Expand Up @@ -310,7 +310,7 @@ Sources: [poll editor](resources/js/components/poll-editor.tsx), [poll results](

### Scheduled commits

Use the composer's scheduling control to submit a top-level commit later. Pick a future time within **30 days**, in five-minute steps; you can have at most **10 outstanding scheduled commits**. Scheduled posts can carry images or a poll under the normal attachment rules, but replies and quotes cannot be scheduled. Scheduling counts against the 10-per-minute submission limit. The scheduled-commits page, linked from the scheduling control, is private to you: it lists queued posts, which you can cancel, and already submitted ones with their outcome.
Use the composer's scheduling control to submit a top-level commit later. Pick a future time within **30 days**, in five-minute steps; you can have at most **10 outstanding scheduled commits**. Scheduled posts can carry images or a poll under the normal attachment rules, but replies and quotes cannot be scheduled. Scheduling counts against the 10-per-minute submission limit. The scheduled-commits page, linked from the scheduling control, is private to you: it lists queued posts, which you can cancel, and already submitted ones with their outcome. Members of an organization can schedule a commit as the organization, by picking it from their avatar first; the page shows which ones go out as an organization.

The scheduler checks due posts every minute. Scheduling submits the post for the normal publishing and moderation flow; it is not a promise that an approved public post will appear at the exact second selected. To change a queued post, cancel it and schedule a replacement.

Expand Down
4 changes: 3 additions & 1 deletion app/Actions/CreateScheduledCommit.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace App\Actions;

use App\Dto\NewPoll;
use App\Models\Organization;
use App\Models\ScheduledCommit;
use App\Models\User;
use App\Services\Urls;
Expand All @@ -26,12 +27,13 @@ public function __construct(private CreateImageFiles $createImageFiles, private
* @param list<UploadedFile> $images
* @param array<int, string|null> $imageDescriptions
*/
public function handle(User $user, string $body, CarbonImmutable $scheduledAt, array $images = [], array $imageDescriptions = [], ?NewPoll $poll = null, bool|string $linkPreview = true): ScheduledCommit
public function handle(User $user, string $body, CarbonImmutable $scheduledAt, array $images = [], array $imageDescriptions = [], ?NewPoll $poll = null, bool|string $linkPreview = true, ?Organization $organization = null): ScheduledCommit
{
$body = mb_trim($body);

return DB::transaction(fn (): ScheduledCommit => ScheduledCommit::query()->create([
'user_id' => $user->id,
'author_id' => $organization?->id,
'body' => $body,
'poll' => $poll instanceof NewPoll ? ['options' => $poll->options, 'duration' => $poll->duration->value] : null,
'images' => $images === [] ? null : $this->createImageFiles->handle($images, $imageDescriptions),
Expand Down
15 changes: 13 additions & 2 deletions app/Actions/DeleteOrganization.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,30 @@
use App\Models\Commit;
use App\Models\Notification;
use App\Models\Organization;
use App\Models\ScheduledCommit;
use Illuminate\Support\Facades\DB;

final readonly class DeleteOrganization
{
public function __construct(private DeleteCommit $deleteCommit)
public function __construct(private DeleteCommit $deleteCommit, private DeleteStoredFiles $deleteStoredFiles)
{
//
}

/**
* Deletes an organization GitHub no longer has, with its members and followers and what they heard about it, and
* unlinks the commits that mention it, so none links to a page that is gone. Its own commits are deleted as their
* posters would, leaving tombstones for the replies under them, and lose their author with its profile.
* posters would, leaving tombstones for the replies under them, and lose their author with its profile. What its
* members scheduled as it goes too, images included.
*/
public function handle(Organization $organization): void
{
DB::transaction(function () use ($organization): void {
// Locked before the profile, in the order a scheduled commit being posted takes them: it holds its row, then
// waits on the profile, so taking them the other way round would deadlock.
$scheduled = ScheduledCommit::query()->where('author_id', $organization->id);
$scheduled->clone()->orderBy('id')->lockForUpdate()->pluck('id');

// A commit posted for it meanwhile waits on the profile, and one restored meanwhile on its own row, then
// fails or stays deleted, rather than slipping past the deletion and losing its author while still live.
$organization->profile()->lockForUpdate()->first();
Expand All @@ -33,6 +40,10 @@ public function handle(Organization $organization): void
->each(fn (Commit $commit): Commit => $this->deleteCommit->handle($commit));
$organization->profile->commits()->withTrashed()->update(['author_id' => null]);

$this->deleteStoredFiles->retain($scheduled->clone()->whereNotNull('images')->get(['images'])
->flatMap(fn (ScheduledCommit $scheduled): array => array_column($scheduled->images ?? [], 'path'))->all());
$scheduled->delete();

DB::table('commit_topics')
->where('topic_type', TopicType::Organization->value)
->where('topic_id', $organization->id)
Expand Down
25 changes: 21 additions & 4 deletions app/Actions/UpdateScheduledCommitSubmittedAt.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,34 @@
namespace App\Actions;

use App\Dto\NewPoll;
use App\Enums\Feature;
use App\Enums\PollDuration;
use App\Models\ScheduledCommit;
use App\Services\Features;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Support\Facades\DB;

final readonly class UpdateScheduledCommitSubmittedAt
{
public function __construct(private CreateCommit $createCommit)
public function __construct(private CreateCommit $createCommit, private DeleteScheduledCommit $deleteScheduledCommit, private Features $features)
{
//
}

/**
* The author can commit it now, before its scheduled time.
* The author can commit it now, before its scheduled time. One scheduled as an organization its user is no longer
* a member of, or while posting as organizations is switched off, is canceled instead, as a ban cancels it.
*/
public function handle(string $id, bool $early = false): bool
{
return DB::transaction(function () use ($id, $early): bool {
$postable = fn (Builder $query): Builder => $query->whereNull('author_id')
->when($this->features->enabled(Feature::OrganizationCommits), fn (Builder $query): Builder => $query->orWhereExists(fn (QueryBuilder $members): QueryBuilder => $members
->from('organization_members')
->whereColumn('organization_members.organization_id', 'scheduled_commits.author_id')
->whereColumn('organization_members.user_id', 'scheduled_commits.user_id')));

// Claim with a conditional write before reading.
// Creation and the claim roll back together if either operation fails.
// Moderation is checked in the claim too, since a timeout or ban can land after the batch was selected...
Expand All @@ -31,16 +41,23 @@ public function handle(string $id, bool $early = false): bool
->unless($early, fn (Builder $query): Builder => $query->where('scheduled_at', '<=', now()))
->whereDoesntHave('user.timeouts', fn (Builder $query): Builder => $query->where('ends_at', '>', now()))
->whereDoesntHave('user.ban')
->where($postable)
->update(['submitted_at' => now()]);

if ($claimed === 0) {
$lost = ScheduledCommit::query()->with('user')->whereKey($id)->whereNot($postable)->first();

if ($lost instanceof ScheduledCommit) {
$this->deleteScheduledCommit->handle($lost->user, $lost);
}

return false;
}

$scheduled = ScheduledCommit::query()->with('user')->findOrFail($id);
$scheduled = ScheduledCommit::query()->with(['user', 'author'])->findOrFail($id);
assert(is_string($scheduled->body));
$poll = $scheduled->poll === null ? null : new NewPoll($scheduled->poll['options'], PollDuration::from($scheduled->poll['duration']));
$commit = $this->createCommit->handle($scheduled->user, $scheduled->body, linkPreview: $scheduled->link_preview_url ?? false, poll: $poll);
$commit = $this->createCommit->handle($scheduled->user, $scheduled->body, linkPreview: $scheduled->link_preview_url ?? false, poll: $poll, organization: $scheduled->author?->organization);
$commit->images()->createMany($scheduled->images ?? []);

$scheduled->forceFill(['commit_id' => $commit->id, 'body' => null, 'poll' => null, 'images' => null, 'link_preview_url' => null])->save();
Expand Down
2 changes: 1 addition & 1 deletion app/Http/Controllers/Api/ScheduledCommitController.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public function index(#[CurrentUser] User $user, ScheduledCommitsQuery $schedule
*/
public function store(CreateApiScheduledCommitRequest $request, #[CurrentUser] User $user, CreateScheduledCommit $action): JsonResponse
{
$scheduled = $action->handle($user, $request->string('body')->value(), CarbonImmutable::parse($request->string('scheduled_at')->value())->utc(), $request->images(), $request->imageDescriptions(), $request->poll(), $request->linkPreview());
$scheduled = $action->handle($user, $request->string('body')->value(), CarbonImmutable::parse($request->string('scheduled_at')->value())->utc(), $request->images(), $request->imageDescriptions(), $request->poll(), $request->linkPreview(), $request->organization());

return ScheduledCommitResource::make($scheduled)
->response()
Expand Down
2 changes: 1 addition & 1 deletion app/Http/Controllers/ScheduledCommitController.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public function index(Request $request, #[CurrentUser] User $user, ScheduledComm

public function store(CreateScheduledCommitRequest $request, #[CurrentUser] User $user, CreateScheduledCommit $action): RedirectResponse
{
$action->handle($user, $request->string('body')->value(), CarbonImmutable::parse($request->string('scheduled_at')->value())->utc(), $request->images(), $request->imageDescriptions(), $request->poll(), $request->linkPreview());
$action->handle($user, $request->string('body')->value(), CarbonImmutable::parse($request->string('scheduled_at')->value())->utc(), $request->images(), $request->imageDescriptions(), $request->poll(), $request->linkPreview(), $request->organization());

Inertia::flash('scheduled', true);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Commit scheduled. Only you can see it until it is submitted.']);
Expand Down
14 changes: 14 additions & 0 deletions app/Http/Requests/CreateApiScheduledCommitRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@

use App\Dto\NewPoll;
use App\Enums\PollDuration;
use App\Models\Organization;
use App\Models\User;
use App\Rules\OrganizationToPostAs;
use App\Rules\ScheduledCommitLimit;
use App\Services\Urls;
use Illuminate\Contracts\Validation\ValidationRule;
Expand Down Expand Up @@ -46,6 +48,7 @@ public function rules(): array

return [
'body' => CreateCommitRequest::bodyRules($this->hasFile('images')),
'author' => ['nullable', 'string', new OrganizationToPostAs($user)],
'scheduled_at' => ['required', 'date_format:Y-m-d\TH:i:sP', 'after:now', 'before_or_equal:'.now()->addDays(30)->toIso8601String(), new ScheduledCommitLimit($user)],
'parent_id' => ['prohibited'],
'quoted_commit_id' => ['prohibited'],
Expand Down Expand Up @@ -86,6 +89,17 @@ public function linkPreview(): bool|string
return $this->filled('link_preview_url') ? $this->string('link_preview_url')->value() : false;
}

/**
* The organization the commit goes out as, or null when it goes out as the user.
*/
public function organization(): ?Organization
{
$user = $this->user();
assert($user instanceof User);

return OrganizationToPostAs::of($user, $this->input('author'));
}

public function poll(): ?NewPoll
{
if (! $this->filled('poll')) {
Expand Down
14 changes: 14 additions & 0 deletions app/Http/Requests/CreateScheduledCommitRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@

use App\Dto\NewPoll;
use App\Enums\PollDuration;
use App\Models\Organization;
use App\Models\User;
use App\Rules\OrganizationToPostAs;
use App\Rules\ScheduledCommitLimit;
use App\Services\Urls;
use Illuminate\Contracts\Validation\ValidationRule;
Expand Down Expand Up @@ -34,6 +36,7 @@ public function rules(): array

return [
'body' => CreateCommitRequest::bodyRules($this->hasFile('images')),
'author' => ['nullable', 'string', new OrganizationToPostAs($user)],
'scheduled_at' => ['required', 'date_format:Y-m-d\TH:i:sP', 'after:now', 'before_or_equal:'.now()->addDays(30)->toIso8601String(), new ScheduledCommitLimit($user)],
'parent_id' => ['prohibited'],
'link_preview_url' => ['nullable', 'string', Rule::in(is_string($body) ? resolve(Urls::class)->all($body) : [])],
Expand Down Expand Up @@ -73,6 +76,17 @@ public function linkPreview(): bool|string
return $this->filled('link_preview_url') ? $this->string('link_preview_url')->value() : false;
}

/**
* The organization the commit goes out as, or null when it goes out as the user.
*/
public function organization(): ?Organization
{
$user = $this->user();
assert($user instanceof User);

return OrganizationToPostAs::of($user, $this->input('author'));
}

public function poll(): ?NewPoll
{
if (! $this->filled('poll')) {
Expand Down
4 changes: 3 additions & 1 deletion app/Http/Resources/ScheduledCommitResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@

/**
* A commit the user queued, as the web's Scheduled page shows it. Once submitted, its body lives on the commit and
* only the status says what became of it: scheduled, the commit's moderation status, or deleted.
* only the status says what became of it: scheduled, the commit's moderation status, or deleted. Its author is the
* organization it goes out as, or null when it goes out as the user.
*
* @mixin ScheduledCommit
*/
Expand All @@ -24,6 +25,7 @@ public function toArray(Request $request): array
{
return [
'id' => $this->id,
'author' => $this->author === null ? null : ProfileResource::make($this->author)->resolve($request),
...($this->submitted_at === null && $this->body !== null
? ['body' => $this->body, ...resolve(CommitBodies::class)->segments($this->body)]
: ['body' => null, 'body_segments' => [], 'preview_segments' => null]),
Expand Down
Loading
Loading