···11+<?php
22+33+namespace SocialDept\AtpParity\Concerns;
44+55+use SocialDept\AtpParity\Publish\PublishService;
66+77+/**
88+ * Trait for Eloquent models that automatically publish to AT Protocol.
99+ *
1010+ * This trait sets up model observers to automatically publish, update,
1111+ * and unpublish records when the model is created, updated, or deleted.
1212+ *
1313+ * Override shouldAutoPublish() and shouldAutoUnpublish() to customize
1414+ * the conditions under which auto-publishing occurs.
1515+ *
1616+ * @mixin \Illuminate\Database\Eloquent\Model
1717+ */
1818+trait AutoPublish
1919+{
2020+ use PublishesRecords;
2121+2222+ /**
2323+ * Boot the AutoPublish trait.
2424+ */
2525+ public static function bootAutoPublish(): void
2626+ {
2727+ static::created(function ($model) {
2828+ if ($model->shouldAutoPublish()) {
2929+ app(PublishService::class)->publish($model);
3030+ }
3131+ });
3232+3333+ static::updated(function ($model) {
3434+ if ($model->isPublished() && $model->shouldAutoPublish()) {
3535+ app(PublishService::class)->update($model);
3636+ }
3737+ });
3838+3939+ static::deleted(function ($model) {
4040+ if ($model->isPublished() && $model->shouldAutoUnpublish()) {
4141+ app(PublishService::class)->delete($model);
4242+ }
4343+ });
4444+ }
4545+4646+ /**
4747+ * Determine if the model should be auto-published.
4848+ *
4949+ * Override this method to add custom conditions.
5050+ */
5151+ public function shouldAutoPublish(): bool
5252+ {
5353+ return true;
5454+ }
5555+5656+ /**
5757+ * Determine if the model should be auto-unpublished when deleted.
5858+ *
5959+ * Override this method to add custom conditions.
6060+ */
6161+ public function shouldAutoUnpublish(): bool
6262+ {
6363+ return true;
6464+ }
6565+6666+ /**
6767+ * Get the DID to use for auto-publishing.
6868+ *
6969+ * Override this method to customize DID resolution.
7070+ */
7171+ public function getAutoPublishDid(): ?string
7272+ {
7373+ // Check for did column
7474+ if (isset($this->did)) {
7575+ return $this->did;
7676+ }
7777+7878+ // Check for user relationship with did
7979+ if (method_exists($this, 'user') && $this->user?->did) {
8080+ return $this->user->did;
8181+ }
8282+8383+ // Check for author relationship with did
8484+ if (method_exists($this, 'author') && $this->author?->did) {
8585+ return $this->author->did;
8686+ }
8787+8888+ return null;
8989+ }
9090+}
+152
src/Concerns/HasAtpRecord.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Concerns;
44+55+use SocialDept\AtpParity\Contracts\RecordMapper;
66+use SocialDept\AtpParity\MapperRegistry;
77+use SocialDept\AtpSchema\Data\Data;
88+99+/**
1010+ * Trait for Eloquent models that map to AT Protocol records.
1111+ *
1212+ * @mixin \Illuminate\Database\Eloquent\Model
1313+ */
1414+trait HasAtpRecord
1515+{
1616+ /**
1717+ * Get the AT Protocol URI for this model.
1818+ */
1919+ public function getAtpUri(): ?string
2020+ {
2121+ $column = config('parity.columns.uri', 'atp_uri');
2222+2323+ return $this->getAttribute($column);
2424+ }
2525+2626+ /**
2727+ * Get the AT Protocol CID for this model.
2828+ */
2929+ public function getAtpCid(): ?string
3030+ {
3131+ $column = config('parity.columns.cid', 'atp_cid');
3232+3333+ return $this->getAttribute($column);
3434+ }
3535+3636+ /**
3737+ * Get the DID from the AT Protocol URI.
3838+ */
3939+ public function getAtpDid(): ?string
4040+ {
4141+ $uri = $this->getAtpUri();
4242+4343+ if (! $uri) {
4444+ return null;
4545+ }
4646+4747+ // at://did:plc:xxx/app.bsky.feed.post/rkey
4848+ if (preg_match('#^at://([^/]+)/#', $uri, $matches)) {
4949+ return $matches[1];
5050+ }
5151+5252+ return null;
5353+ }
5454+5555+ /**
5656+ * Get the collection (lexicon NSID) from the AT Protocol URI.
5757+ */
5858+ public function getAtpCollection(): ?string
5959+ {
6060+ $uri = $this->getAtpUri();
6161+6262+ if (! $uri) {
6363+ return null;
6464+ }
6565+6666+ // at://did:plc:xxx/app.bsky.feed.post/rkey
6767+ if (preg_match('#^at://[^/]+/([^/]+)/#', $uri, $matches)) {
6868+ return $matches[1];
6969+ }
7070+7171+ return null;
7272+ }
7373+7474+ /**
7575+ * Get the rkey from the AT Protocol URI.
7676+ */
7777+ public function getAtpRkey(): ?string
7878+ {
7979+ $uri = $this->getAtpUri();
8080+8181+ if (! $uri) {
8282+ return null;
8383+ }
8484+8585+ // at://did:plc:xxx/app.bsky.feed.post/rkey
8686+ if (preg_match('#^at://[^/]+/[^/]+/([^/]+)$#', $uri, $matches)) {
8787+ return $matches[1];
8888+ }
8989+9090+ return null;
9191+ }
9292+9393+ /**
9494+ * Check if this model has been synced to AT Protocol.
9595+ */
9696+ public function hasAtpRecord(): bool
9797+ {
9898+ return $this->getAtpUri() !== null;
9999+ }
100100+101101+ /**
102102+ * Get the mapper for this model.
103103+ */
104104+ public function getAtpMapper(): ?RecordMapper
105105+ {
106106+ return app(MapperRegistry::class)->forModel(static::class);
107107+ }
108108+109109+ /**
110110+ * Convert this model to an AT Protocol record DTO.
111111+ */
112112+ public function toAtpRecord(): ?Data
113113+ {
114114+ $mapper = $this->getAtpMapper();
115115+116116+ if (! $mapper) {
117117+ return null;
118118+ }
119119+120120+ return $mapper->toRecord($this);
121121+ }
122122+123123+ /**
124124+ * Scope to query models that have been synced to AT Protocol.
125125+ */
126126+ public function scopeWithAtpRecord($query)
127127+ {
128128+ $column = config('parity.columns.uri', 'atp_uri');
129129+130130+ return $query->whereNotNull($column);
131131+ }
132132+133133+ /**
134134+ * Scope to query models that have not been synced to AT Protocol.
135135+ */
136136+ public function scopeWithoutAtpRecord($query)
137137+ {
138138+ $column = config('parity.columns.uri', 'atp_uri');
139139+140140+ return $query->whereNull($column);
141141+ }
142142+143143+ /**
144144+ * Scope to find by AT Protocol URI.
145145+ */
146146+ public function scopeWhereAtpUri($query, string $uri)
147147+ {
148148+ $column = config('parity.columns.uri', 'atp_uri');
149149+150150+ return $query->where($column, $uri);
151151+ }
152152+}
+127
src/Concerns/HasAtpRelationships.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Concerns;
44+55+use Illuminate\Database\Eloquent\Relations\BelongsTo;
66+use Illuminate\Database\Eloquent\Relations\HasMany;
77+88+/**
99+ * Trait for Eloquent models with AT Protocol relationships.
1010+ *
1111+ * Provides helpers for defining relationships based on AT Protocol URI references.
1212+ * Common relationship patterns:
1313+ *
1414+ * - reply.parent -> parent_uri column
1515+ * - reply.root -> root_uri column
1616+ * - embed.record (quote) -> quoted_uri column
1717+ * - like.subject -> subject_uri column
1818+ * - follow.subject -> subject_did column
1919+ * - repost.subject -> subject_uri column
2020+ *
2121+ * @mixin \Illuminate\Database\Eloquent\Model
2222+ */
2323+trait HasAtpRelationships
2424+{
2525+ /**
2626+ * Define an AT Protocol relationship via URI reference.
2727+ *
2828+ * This creates a BelongsTo relationship where the foreign key is an AT Protocol URI
2929+ * stored in the specified column, matched against the related model's atp_uri column.
3030+ *
3131+ * Example:
3232+ * ```php
3333+ * public function parent(): BelongsTo
3434+ * {
3535+ * return $this->atpBelongsTo(Post::class, 'parent_uri');
3636+ * }
3737+ * ```
3838+ *
3939+ * @param class-string<\Illuminate\Database\Eloquent\Model> $related
4040+ */
4141+ public function atpBelongsTo(string $related, string $uriColumn, ?string $ownerKey = null): BelongsTo
4242+ {
4343+ $ownerKey = $ownerKey ?? config('parity.columns.uri', 'atp_uri');
4444+4545+ // Create a custom BelongsTo that uses URI matching
4646+ return $this->belongsTo($related, $uriColumn, $ownerKey);
4747+ }
4848+4949+ /**
5050+ * Define an inverse AT Protocol relationship via URI reference.
5151+ *
5252+ * This creates a HasMany relationship where related models have a column
5353+ * containing this model's AT Protocol URI.
5454+ *
5555+ * Example:
5656+ * ```php
5757+ * public function replies(): HasMany
5858+ * {
5959+ * return $this->atpHasMany(Post::class, 'parent_uri');
6060+ * }
6161+ * ```
6262+ *
6363+ * @param class-string<\Illuminate\Database\Eloquent\Model> $related
6464+ */
6565+ public function atpHasMany(string $related, string $foreignKey, ?string $localKey = null): HasMany
6666+ {
6767+ $localKey = $localKey ?? config('parity.columns.uri', 'atp_uri');
6868+6969+ return $this->hasMany($related, $foreignKey, $localKey);
7070+ }
7171+7272+ /**
7373+ * Define an AT Protocol relationship via DID reference.
7474+ *
7575+ * This creates a BelongsTo relationship where the foreign key is a DID
7676+ * stored in the specified column, matched against a did column on the related model.
7777+ *
7878+ * Example:
7979+ * ```php
8080+ * public function subject(): BelongsTo
8181+ * {
8282+ * return $this->atpBelongsToByDid(User::class, 'subject_did');
8383+ * }
8484+ * ```
8585+ *
8686+ * @param class-string<\Illuminate\Database\Eloquent\Model> $related
8787+ */
8888+ public function atpBelongsToByDid(string $related, string $didColumn, string $ownerKey = 'did'): BelongsTo
8989+ {
9090+ return $this->belongsTo($related, $didColumn, $ownerKey);
9191+ }
9292+9393+ /**
9494+ * Define an inverse AT Protocol relationship via DID reference.
9595+ *
9696+ * Example:
9797+ * ```php
9898+ * public function followers(): HasMany
9999+ * {
100100+ * return $this->atpHasManyByDid(Follow::class, 'subject_did');
101101+ * }
102102+ * ```
103103+ *
104104+ * @param class-string<\Illuminate\Database\Eloquent\Model> $related
105105+ */
106106+ public function atpHasManyByDid(string $related, string $foreignKey, string $localKey = 'did'): HasMany
107107+ {
108108+ return $this->hasMany($related, $foreignKey, $localKey);
109109+ }
110110+111111+ /**
112112+ * Get a related model by AT Protocol URI.
113113+ *
114114+ * @param class-string<\Illuminate\Database\Eloquent\Model> $modelClass
115115+ * @return \Illuminate\Database\Eloquent\Model|null
116116+ */
117117+ public function findByAtpUri(string $modelClass, ?string $uri)
118118+ {
119119+ if (! $uri) {
120120+ return null;
121121+ }
122122+123123+ $column = config('parity.columns.uri', 'atp_uri');
124124+125125+ return $modelClass::where($column, $uri)->first();
126126+ }
127127+}
+59
src/Concerns/PublishesRecords.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Concerns;
44+55+use SocialDept\AtpParity\Publish\PublishResult;
66+use SocialDept\AtpParity\Publish\PublishService;
77+88+/**
99+ * Trait for Eloquent models that can be manually published to AT Protocol.
1010+ *
1111+ * @mixin \Illuminate\Database\Eloquent\Model
1212+ */
1313+trait PublishesRecords
1414+{
1515+ use HasAtpRecord;
1616+1717+ /**
1818+ * Publish this model to AT Protocol.
1919+ *
2020+ * If the model has a DID association (via did column or relationship),
2121+ * it will be used. Otherwise, use publishAs() to specify the DID.
2222+ */
2323+ public function publish(): PublishResult
2424+ {
2525+ return app(PublishService::class)->publish($this);
2626+ }
2727+2828+ /**
2929+ * Publish this model as a specific user.
3030+ */
3131+ public function publishAs(string $did): PublishResult
3232+ {
3333+ return app(PublishService::class)->publishAs($did, $this);
3434+ }
3535+3636+ /**
3737+ * Update the published record on AT Protocol.
3838+ */
3939+ public function republish(): PublishResult
4040+ {
4141+ return app(PublishService::class)->update($this);
4242+ }
4343+4444+ /**
4545+ * Delete the record from AT Protocol.
4646+ */
4747+ public function unpublish(): bool
4848+ {
4949+ return app(PublishService::class)->delete($this);
5050+ }
5151+5252+ /**
5353+ * Check if this model has been published to AT Protocol.
5454+ */
5555+ public function isPublished(): bool
5656+ {
5757+ return $this->hasAtpRecord();
5858+ }
5959+}
+96
src/Concerns/SyncsWithAtp.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Concerns;
44+55+use SocialDept\AtpSchema\Data\Data;
66+77+/**
88+ * Trait for models that sync bidirectionally with AT Protocol.
99+ *
1010+ * Extends HasAtpRecord with additional sync tracking and conflict handling.
1111+ *
1212+ * @mixin \Illuminate\Database\Eloquent\Model
1313+ */
1414+trait SyncsWithAtp
1515+{
1616+ use HasAtpRecord;
1717+1818+ /**
1919+ * Get the column name for tracking the last sync timestamp.
2020+ */
2121+ public function getAtpSyncedAtColumn(): string
2222+ {
2323+ return 'atp_synced_at';
2424+ }
2525+2626+ /**
2727+ * Get the timestamp of the last sync.
2828+ */
2929+ public function getAtpSyncedAt(): ?\DateTimeInterface
3030+ {
3131+ $column = $this->getAtpSyncedAtColumn();
3232+3333+ return $this->getAttribute($column);
3434+ }
3535+3636+ /**
3737+ * Mark the model as synced with the given metadata.
3838+ */
3939+ public function markAsSynced(string $uri, string $cid): void
4040+ {
4141+ $uriColumn = config('parity.columns.uri', 'atp_uri');
4242+ $cidColumn = config('parity.columns.cid', 'atp_cid');
4343+ $syncColumn = $this->getAtpSyncedAtColumn();
4444+4545+ $this->setAttribute($uriColumn, $uri);
4646+ $this->setAttribute($cidColumn, $cid);
4747+ $this->setAttribute($syncColumn, now());
4848+ }
4949+5050+ /**
5151+ * Check if the model has local changes since last sync.
5252+ */
5353+ public function hasLocalChanges(): bool
5454+ {
5555+ $syncedAt = $this->getAtpSyncedAt();
5656+5757+ if (! $syncedAt) {
5858+ return true;
5959+ }
6060+6161+ $updatedAt = $this->getAttribute('updated_at');
6262+6363+ if (! $updatedAt) {
6464+ return false;
6565+ }
6666+6767+ return $updatedAt > $syncedAt;
6868+ }
6969+7070+ /**
7171+ * Update the model from a remote record.
7272+ */
7373+ public function updateFromRecord(Data $record, string $uri, string $cid): void
7474+ {
7575+ $mapper = $this->getAtpMapper();
7676+7777+ if (! $mapper) {
7878+ return;
7979+ }
8080+8181+ $mapper->updateModel($this, $record, [
8282+ 'uri' => $uri,
8383+ 'cid' => $cid,
8484+ ]);
8585+8686+ $this->setAttribute($this->getAtpSyncedAtColumn(), now());
8787+ }
8888+8989+ /**
9090+ * Boot the trait.
9191+ */
9292+ public static function bootSyncsWithAtp(): void
9393+ {
9494+ // Hook into model events if needed
9595+ }
9696+}