Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
cd19d84
fix(identity): remove references to deleted Information and Address m…
danielhe4rt May 25, 2026
d35e217
chore(identity): remove 3Pontos tenant and set default domain in Base…
danielhe4rt May 25, 2026
7900307
feat(identity): refactor OAuth flow with intent-based routing
danielhe4rt May 25, 2026
c8c20e6
feat(panel-app): add Discord and GitHub social login buttons
danielhe4rt May 25, 2026
2dd429e
fix(integration): use APP_URL for OAuth callback URIs
danielhe4rt May 25, 2026
c87a9d1
fix(panel-admin): ConnectionHub polymorphic owner for tenant providers
danielhe4rt May 25, 2026
98e4382
feat(identity): convert tenant_id from bigint to UUID
danielhe4rt May 25, 2026
6aad7d3
feat(integration-github): GitHub OAuth client with Saloon
danielhe4rt May 25, 2026
00160ee
fix(identity): compact ConnectionHub layout for narrow sidebar
danielhe4rt May 26, 2026
379394b
fix(identity): handle username collision with sequential suffix on OA…
danielhe4rt May 26, 2026
7c23a00
feat(identity): add first_login_at and enrich user on first OAuth login
danielhe4rt May 26, 2026
aa0e7a1
refactor(identity): use match expression and expressive variable in F…
danielhe4rt May 26, 2026
d75a825
feat(identity): detect merge conflict on Link flow and store in session
danielhe4rt May 26, 2026
e2e3d16
feat(panel-app): merge confirmation modal in ConnectionHub with Merge…
danielhe4rt May 26, 2026
9d5741d
fix(panel-app): use panel URL instead of request()->url() for merge r…
danielhe4rt May 26, 2026
3ed0b40
feat(panel-app): OAuth login page with Discord/GitHub buttons and pro…
danielhe4rt May 26, 2026
c9604b7
fix(identity): resolve PHPStan errors — nullable email PHPDoc and ten…
danielhe4rt May 26, 2026
1f6d352
fix(tests): resolve CI failures from tenant_id UUID migration
danielhe4rt May 26, 2026
ad134f7
wip
danielhe4rt May 26, 2026
1682268
fix(tests): resolve remaining CI failures from UUID migration
danielhe4rt May 26, 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
2 changes: 1 addition & 1 deletion app-modules/activity/src/Message/DTOs/NewMessageDTO.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
final class NewMessageDTO
{
public function __construct(
public int $tenantId,
public string $tenantId,
public IdentityProvider $provider,
public string $providerUsername,
public string $externalAccountId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

/**
* @property string $id
* @property int $tenant_id
* @property string $tenant_id
* @property string $external_identity_id
* @property string $kind
* @property Carbon $occurred_at
Expand Down
2 changes: 1 addition & 1 deletion app-modules/activity/src/Message/Models/Message.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
* @property string $content
* @property int $obtained_experience
* @property Carbon|null $sent_at
* @property int $tenant_id
* @property string $tenant_id
* @property array<string, mixed>|null $metadata
* @property int $reactions_count
* @property int $reactions_total
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

/**
* @property string $id
* @property int $tenant_id
* @property string $tenant_id
* @property string $message_id
* @property string|null $provider_attachment_id
* @property string $url
Expand Down
2 changes: 1 addition & 1 deletion app-modules/activity/src/Message/Models/MessageEmbed.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

/**
* @property string $id
* @property int $tenant_id
* @property string $tenant_id
* @property string $message_id
* @property string|null $url
* @property string|null $title
Expand Down
2 changes: 1 addition & 1 deletion app-modules/activity/src/Message/Models/MessageMention.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

/**
* @property string $id
* @property int $tenant_id
* @property string $tenant_id
* @property string $message_id
* @property string|null $mentioned_identity_id
* @property string $mentioned_provider_account_id
Expand Down
2 changes: 1 addition & 1 deletion app-modules/activity/src/Message/Models/MessageThread.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

/**
* @property string $id
* @property int $tenant_id
* @property string $tenant_id
* @property string $message_id
* @property string $provider_thread_id
* @property string|null $name
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

/**
* @property string $id
* @property int $tenant_id
* @property string $tenant_id
* @property string|null $external_identity_id
* @property string|null $moderator_identity_id
* @property ModerationType $type
Expand Down
2 changes: 1 addition & 1 deletion app-modules/activity/src/Reaction/Models/Reaction.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

/**
* @property string $id
* @property int $tenant_id
* @property string $tenant_id
* @property string $reactable_type
* @property string $reactable_id
* @property string $emoji_key
Expand Down
2 changes: 1 addition & 1 deletion app-modules/activity/src/Timeline/DTOs/CreatePostDTO.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/
public function __construct(
public string $userId,
public int $tenantId,
public string $tenantId,
public string $content,
public array $images = [],
) {}
Expand Down
2 changes: 1 addition & 1 deletion app-modules/activity/src/Timeline/DTOs/CreateReplyDTO.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/
public function __construct(
public string $userId,
public int $tenantId,
public string $tenantId,
public string $parentTimelineId,
public string $content,
public array $images = [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public function handle(ActionExecuted $event): void
]);
}

private function resolveIdentity(?string $userId, int $tenantId): ?string
private function resolveIdentity(?string $userId, string $tenantId): ?string
{
if ($userId === null) {
return null;
Expand Down
2 changes: 1 addition & 1 deletion app-modules/activity/src/Timeline/Queries/TimelineFeed.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
final readonly class TimelineFeed
{
public function __construct(
private int $tenantId,
private string $tenantId,
) {}

/** @return Builder<Timeline> */
Expand Down
4 changes: 2 additions & 2 deletions app-modules/activity/src/Timeline/Timeline.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
/**
* @property string $id
* @property string $user_id
* @property int $tenant_id
* @property string $tenant_id
* @property string $postable_type
* @property string $postable_id
* @property string|null $root_id
Expand Down Expand Up @@ -85,7 +85,7 @@ protected function casts(): array
{
return [
'user_id' => 'string',
'tenant_id' => 'integer',
'tenant_id' => 'string',
'root_id' => 'string',
'parent_id' => 'string',
'is_ignored' => 'boolean',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
{
public function __construct(
public string $characterId,
public int $tenantId,
public string $tenantId,
public ActivityType $type,
public IdentityProvider $provider,
public DateTimeImmutable $occurredAt,
Expand Down
2 changes: 1 addition & 1 deletion app-modules/activity/src/Tracking/Models/Interaction.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
/**
* @property string $id
* @property string $character_id
* @property int $tenant_id
* @property string $tenant_id
* @property ActivityType $type
* @property IdentityProvider $provider
* @property ValueTier $value_tier
Expand Down
2 changes: 1 addition & 1 deletion app-modules/activity/src/Voice/Models/Voice.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
/**
* @property int $id
* @property string $external_identity_id
* @property int $tenant_id
* @property string $tenant_id
* @property string|null $provider_message_id
* @property string $channel_name
* @property string $state
Expand Down
2 changes: 1 addition & 1 deletion app-modules/community/src/Feedback/Models/Feedback.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

/**
* @property string $id
* @property int $tenant_id
* @property string $tenant_id
* @property string $sender_id
* @property string $target_id
* @property string $type
Expand Down
2 changes: 1 addition & 1 deletion app-modules/community/src/Feedback/Models/Review.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

/**
* @property string $id
* @property int $tenant_id
* @property string $tenant_id
* @property string $feedback_id
* @property string $staff_id
* @property ReviewTypeEnum $status
Expand Down
2 changes: 1 addition & 1 deletion app-modules/community/src/Meeting/Models/Meeting.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

/**
* @property string $id
* @property int $tenant_id
* @property string $tenant_id
* @property string $admin_id
* @property string|null $content
* @property int $meeting_type_id
Expand Down
2 changes: 1 addition & 1 deletion app-modules/gamification/src/Badge/DTOs/NewBadgeDTO.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public function __construct(
private string $description,
private string $redeemCode,
private bool $active,
private int $tenant_id
private string $tenant_id
) {}

/**
Expand Down
2 changes: 1 addition & 1 deletion app-modules/gamification/src/Badge/Models/Badge.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

/**
* @property int $id
* @property int $tenant_id
* @property string $tenant_id
* @property IdentityProvider $provider
* @property string $name
* @property string $description
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

final class CharacterInitializerAction
{
public function ensure(User $user, int $tenantId): Character
public function ensure(User $user, string $tenantId): Character
{
return Character::query()->firstOrCreate(
[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
* @property int $level
* @property float $percentage_experience
* @property bool $can_claim_daily_bonus
* @property int|null $tenant_id
* @property string|null $tenant_id
*/
#[Appends([
'ranking',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

/**
* @property string $id
* @property int $tenant_id
* @property string $tenant_id
* @property string $season_id
* @property string $character_id
* @property int $ranking_position
Expand Down
2 changes: 1 addition & 1 deletion app-modules/gamification/src/Season/Models/Season.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
use Illuminate\Support\Facades\Date;

/**
* @property int $tenant_id
* @property string $tenant_id
* @property string $name
* @property string $description
* @property int $messages_count
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->timestamp('first_login_at')->nullable()->after('banned_at');
});
}
};
Comment thread
danielhe4rt marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# ADR-0001: OAuth User Resolution and Account Merge Strategy

**Status:** Accepted
**Date:** 2026-05-25
**Deciders:** danielhe4rt

## Context

The platform has multiple user creation paths that lead to duplicate User records for the same physical person:

1. **Discord ETL** — imports guild members as Users with ExternalIdentity (`model_type=user`). These users have no email and a username derived from Discord.
2. **OAuth Web Login** — creates Users when authenticating via GitHub/Discord/Twitch. Uses `FindOrCreateUserByProvider` which looks up by `(provider, external_account_id, tenant_id)` then by email.
3. **Admin Panel (tenant connections)** — creates ExternalIdentity with `model_type=tenant` for infrastructure (bot credentials, channel access).

The collision scenarios:

- **Unique violation**: ETL creates `danielhe4rt` (no email). Same person authenticates via GitHub (username=`danielhe4rt`) → INSERT fails on `users_username_unique`.
- **Duplicate users**: ETL creates User A (Discord). Same person authenticates via GitHub → lookup fails (different provider, no email match) → creates User B. Now two Users exist for the same person.
- **Cross-provider blindness**: `FindOrCreateUserByProvider` only searches by the current provider's `external_account_id`. It cannot correlate Discord identity with GitHub identity.

### Domain distinction: model_type semantics

- `model_type = user` → personal identity (the person's account in the app)
- `model_type = tenant` → infrastructure (Discord server credentials, Twitch channel OAuth, GitHub App tokens)

These are fundamentally different domains sharing the same table. A tenant-owned ExternalIdentity does NOT represent a person's identity — it represents organizational infrastructure.

## Decision

### 1. User Resolution on Login (intent=Login)

`FindOrCreateUserByProvider` resolves users with this priority:

1. **ExternalIdentity match** — `(provider, external_account_id, model_type=user)` without tenant filter (cross-tenant)
2. **Email match** — `User.email = oauth.email` (when email is not null)
3. **Create new user** — if username collides, append sequential suffix (`-2`, `-3`, ...)

### 2. First Login Enrichment

New column `first_login_at` (timestamp, nullable) on `users`. When null, the user was created by ETL and never authenticated directly.

On first real login (`first_login_at IS NULL`):

- Update `email` from OAuth provider
- Update `name` from OAuth provider
- Attempt `username` update — skip silently if unique constraint would violate
- Set `first_login_at = now()`

Subsequent logins: no User field updates.

### 3. Account Merge on Link (intent=Link, ConnectionHub)

When a logged-in user connects a provider via ConnectionHub and the `external_account_id` already belongs to a **different** User (`model_type=user`, cross-tenant lookup):

| Step | Action |
| -------- | -------------------------------------------------------------------------------------- |
| Detect | OAuth callback finds conflicting ExternalIdentity owned by another User |
| Store | Save conflict state in session (OAuth credentials, conflicting user ID, provider data) |
| Confirm | ConnectionHub displays modal: old user's username, `created_at`, message count |
| Execute | Synchronous `DB::transaction` on user confirmation |
| Re-login | `Auth::login($oldUser)` |

#### Merge transaction (confirmed by user):

1. **Move ExternalIdentities** — update `model_id` on new user's identities → old user
2. **Sync tenant memberships** — `$oldUser->tenants()->syncWithoutDetaching($newUser->tenants)`
3. **Update old user info** — if `first_login_at` is null: update email, name, attempt username
4. **Set `first_login_at`** — on old user if null
5. **Delete new user** — hard delete (new user has no heavy relations)
6. **Re-login** — authenticate as old user

#### Merge direction rationale:

Always keep the **old** user (the one that owns the conflicting `external_account_id`). The old user carries heavy relations (100k+ messages, Character, activity history). The new user is lightweight (just created via OAuth). Transferring 2-3 records from new→old is trivial; transferring 100k messages would be catastrophic overhead.

### 4. Conflict detection scope

- **Cross-tenant**: `external_account_id` is globally unique per provider (Discord user ID, GitHub user ID). Conflict detection queries without `tenant_id` filter.
- **Only `model_type=user`**: tenant-owned identities are infrastructure, not personal identity. They are never considered for merge.

### 5. Username suffix generation

When creating a new User and `username` collides:

```sql
SELECT username FROM users WHERE username LIKE 'danielhe4rt-%' ORDER BY username DESC LIMIT 1
```

Extract the numeric suffix, increment. If none exists, use `-2`.

## Alternatives Considered

### A — Match by username across providers

Use username collision as proof of identity (same username = same person). **Rejected**: security risk — anyone could register `torvalds` on GitHub and impersonate a user imported from Discord ETL.

### B — Single ExternalIdentity per (provider, external_account_id) with ownership pivot

Redesign ExternalIdentity to be unique per physical account with a separate ownership table. **Rejected**: over-engineering for the current problem. The `model_type` split (user vs tenant) serves a valid domain distinction and the merge strategy handles conflicts without schema redesign.

### C — Transfer old user's data to new user (merge direction: old→new)

Keep the new user, transfer all history from old. **Rejected**: prohibitively expensive. Users can have 100k+ messages, years of activity. Moving 2-3 lightweight records (identities + tenant pivot) from new→old is orders of magnitude cheaper.

### D — Automatic merge without confirmation

Merge silently when conflict is detected. **Rejected**: user should understand what's happening. They might have connected the wrong provider account accidentally. Confirmation modal shows the target account's details (username, creation date, message count) so the user can make an informed decision.

## Consequences

### Positive

- Eliminates duplicate Users for the same physical person
- ETL-created users seamlessly transition to full accounts on first login
- No data loss — heavy history stays in place, lightweight data moves to it
- User has explicit control over merge (confirmation modal)
- Cross-tenant detection prevents duplicates across multi-server setups

### Negative

- Session-stored merge state can expire if user doesn't confirm promptly
- Sequential username suffix (`-2`) creates temporary "ugly" usernames until merge happens
- `first_login_at` adds a column that's only relevant during the ETL→OAuth transition period

### Risks

- Race condition: two concurrent OAuth callbacks for the same `external_account_id` could both pass the lookup and try to create. Mitigated by: `UniqueConstraintViolationException` catch with retry/fallback to existing record.
- Orphaned merge state in session if user navigates away. Acceptable: state expires naturally, no side effects.
Loading