Skip to content

feat(publishing): LinkedIn integration publication#99

Merged
yacosta738 merged 5 commits into
mainfrom
feature/linkedin-integration-publication
Jun 16, 2026
Merged

feat(publishing): LinkedIn integration publication#99
yacosta738 merged 5 commits into
mainfrom
feature/linkedin-integration-publication

Conversation

@yacosta738

@yacosta738 yacosta738 commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

This pull request introduces a comprehensive set of features and improvements for LinkedIn integration within the publishing system, focusing on robust publication lifecycle management, enhanced credential handling, and improved user feedback.

Key changes include:

  • Enhanced LinkedIn Integration Model: Implements a capability-bundled model for LinkedIn publishing, allowing the system to gate features (e.g., personal profile text/image publishing, organization page publishing, video) based on granted OAuth scopes and product access. Initial support focuses on personal profile text and image publishing.
  • Advanced Publication Lifecycle States: Introduces a new BLOCKED status for publications. Publications are now marked BLOCKED if their target social account is DISABLED, REQUIRES_RECONNECT, or PENDING. BLOCKED publications are automatically retried with exponential backoff once the associated account's status is restored to ACTIVE. Publications targeting DELETED accounts will fail terminally.
  • Robust LinkedIn Credential Management: Implements a refresh-aware credential resolver that automatically refreshes LinkedIn access tokens before they expire. It also detects refresh token expiry or revocation, marking the social account as REQUIRES_RECONNECT and preventing further publishing attempts until re-authentication. Optimistic locking is used to prevent concurrent token refresh attempts.
  • Durable Notification System: Introduces a new notification_events table and associated domain/infrastructure to record durable events for critical publication and connection outcomes (e.g., success, failure, blocked, reconnect required). This lays the groundwork for future user notification features.
  • Scheduler UI Enhancements:
    • Adds a prominent "Reconnect Required" prompt in the scheduler view when LinkedIn accounts need re-authentication.
    • Displays a distinct BLOCKED status indicator on individual publications in both calendar and list views, along with a direct reconnect action.
    • Refines the weekly calendar view to display a single, centralized time-axis column on the left.
  • New API for Listing Publications: Introduces a new API endpoint (/publications) to list publications, supporting filtering by status, social account, and date range, enabling more flexible display of publication history in the UI.
  • Database Migrations: Adds new columns to the publications table (public_url, blocked_at, blocked_reason, retry_count) and the secure_credentials table (version for optimistic locking), and creates the new notification_events table.

…ment

- Add support for new publication statuses: PENDING, DISABLED, REQUIRES_RECONNECT, DELETED, and BLOCKED.
- Introduce NotificationEvent model and repository for tracking LinkedIn publication outcomes.
- Enhance LinkedInCredentialGateway with refresh token metadata.
- Implement ListPublicationsHandler for querying publications with optional filters.
- Update API to include new endpoints for managing publication statuses and notifications.
- Create Liquibase migration for new database columns related to publications and notifications.
@kody-ai

This comment has been minimized.

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@yacosta738, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 39 minutes and 55 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9df9d084-af81-471e-9c32-0c93b957113c

📥 Commits

Reviewing files that changed from the base of the PR and between b2a397a and 041ac48.

📒 Files selected for processing (10)
  • apps/web/app/src/stores/publishing.ts
  • openspec/changes/archive/2026-06-16-linkedin-integration-publication/verify-report.md
  • server/smp/src/main/kotlin/com/profiletailors/smp/publishing/domain/PublishingPolicies.kt
  • server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/http/PublishingControllers.kt
  • server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/persistence/R2dbcPublishingRepositories.kt
  • server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/scheduling/PublishingSchedulingConfiguration.kt
  • server/smp/src/main/resources/db/changelog/publishing/013-linkedin-integration-publication.yaml
  • server/smp/src/test/kotlin/com/profiletailors/smp/publishing/application/PublishingHandlersTest.kt
  • server/smp/src/test/kotlin/com/profiletailors/smp/publishing/domain/PublicationLifecyclePolicyTest.kt
  • server/smp/src/test/kotlin/com/profiletailors/smp/publishing/infrastructure/http/PublishingControllersTest.kt
📝 Walkthrough

Walkthrough

Implements LinkedIn publication MVP as a BLOCKED lifecycle extension to the existing publishing bounded context. Adds refresh-aware credential resolution with token refresh and reconnect flows, a periodic blocked-recovery worker scan, durable notification events, a list-publications API endpoint, and frontend reconnect and BLOCKED status UI prompts. Includes Liquibase schema migrations and a full OpenSpec documentation archive covering exploration, proposal, risk analysis, specifications, design, tasks, and verification.

Changes

LinkedIn Integration Publication

Layer / File(s) Summary
Domain model expansion
server/.../domain/PublishingModels.kt, server/.../domain/NotificationEvent.kt, server/.../domain/PublishingProviderPorts.kt, server/.../domain/PublishingRepositories.kt, server/.../domain/PublishingPolicies.kt
Expands SocialConnectionStatus enum with PENDING, DISABLED, REQUIRES_RECONNECT, DELETED, ERROR states. Adds BLOCKED to PublicationStatus. Extends PublicationDraft with publicUrl, blockedAt, blockedReason, retryCount fields. Introduces LinkedinCapabilityBundle and GrantedScopeBundle for capability gating. Creates NotificationEvent domain model and NotificationCategory enum (7 categories). Adds RefreshAwareCredentialResolver port and ReconnectRequiredException with ReconnectReason. Extends PublicationRepository with markBlocked() and findBlockedForRecovery(). Adds NotificationEventRepository interface. Implements markBlocked(), prepareBlockedRetry() with exponential backoff, and blockedRetryDelay() in PublicationLifecyclePolicy.
Refresh-aware credential resolver
server/.../credentials/LinkedInCredentialGateway.kt, server/.../credentials/RefreshAwareCredentialResolverImpl.kt
Adds four token metadata fields to LinkedInCredentials (refresh expiry, last attempt time, last status, granted scopes). Implements RefreshAwareCredentialResolverImpl with 5-minute refresh-ahead window check, refresh token absolute expiry validation, HTTP token endpoint call, and optimistic-credential persistence. Handles missing/expired refresh tokens by throwing ReconnectRequiredException with specific reasons. Tests cover valid token return, missing/expired refresh scenarios, refresh success with partial responses, and failure handling (non-2xx, timeout, I/O, unexpected exceptions).
LinkedIn adapter updates
server/.../linkedin/LinkedInAssetUploaderAdapters.kt, server/.../linkedin/LinkedInPublishingAdapters.kt
Updates asset uploader to use /rest/images endpoints instead of /rest/assets. Replaces resolveAccessToken() helper in RealLinkedInPublisher with injected RefreshAwareCredentialResolver. Adds enabledBundles parameter to LinkedInCapabilityValidator (defaults to personal text/image). Rewires Spring beans for socialPublisher and providerCapabilityValidator.
Schema migrations
server/.../db/changelog/publishing/013-linkedin-integration-publication.yaml, server/.../db/changelog-master.yaml
Adds public_url, blocked_at, blocked_reason, retry_count columns to publications table. Creates notification_events table with workspace FK, provider/category/message fields, optional suggested action and public URL, timestamps, and composite indexes. Adds version int column to secure_credentials. Registers changelog in master file.
R2DBC persistence
server/.../persistence/R2dbcPublishingRepositories.kt, server/.../persistence/R2dbcNotificationEventRepository.kt
Extends R2dbcPublicationRepository read queries to select and map blocked fields. Implements markBlocked() update. Implements findBlockedForRecovery() with social_accounts join filtered to active, retry budget check, FOR UPDATE SKIP LOCKED, and asset link hydration. Introduces R2dbcNotificationEventRepository with record() insert and findByWorkspace() with dynamic filter assembly. Promotes bindNullable helpers from private to internal and adds Integer? overload.
Publishing worker and scheduling
server/.../scheduling/PublishingWorker.kt, server/.../scheduling/PublishingSchedulingConfiguration.kt
Adds SocialConnectionStatus preflight gate in PublishingJobExecutor that blocks/fails publications without calling provider for non-publishable accounts. Handles ReconnectRequiredException to mark blocked and emit notifications. Extends PublishingWorker with scanBlockedForRecovery() method. Schedules recovery scan in PublishingWorkerLifecycle at blockedRecoveryInterval. Wires NotificationEventRepository and blockedRecoveryInterval Duration into Spring configuration.
Application API, handlers, and HTTP controller
server/.../application/PublishingApi.kt, server/.../application/PublishingHandlers.kt, server/.../http/PublishingControllers.kt
Changes ListConnectedChannelsQuery default status filter to null (all statuses). Adds ListPublicationsQuery/Response/Item and notification event DTOs. Implements ListPublicationsHandler with closed-range exact query and open-range 90-day-past / 30-day-future branching. Replaces placeholder list-publications endpoint with listPublications accepting filters and pagination parameters. Returns ListPublicationsResponse via mediator.
Domain policy and worker tests
server/.../domain/PublicationLifecyclePolicyTest.kt, server/.../scheduling/PublishingWorkerTest.kt
Adds 12 lifecycle policy tests: markBlocked transitions from QUEUED/PROCESSING/SCHEDULED to BLOCKED, rejects terminal states, allows re-blocking. Tests prepareBlockedRetry with backoff doubling, cap at 60 minutes, FAILED transition at max retries. Adds three preflight-gate tests verifying publisher not invoked for DISABLED/REQUIRES_RECONNECT/DELETED accounts. Extends InMemory test doubles for blocked-recovery support.
API model and notification event tests
server/.../application/PublishingApiTest.kt, server/.../domain/NotificationEventTest.kt, server/.../credentials/RefreshAwareCredentialResolverTest.kt, server/.../linkedin/LinkedInPublishingAdaptersTest.kt
Adds 30+ API model construction tests covering all command/query/response/result DTOs. Adds 10 notification event tests for field storage, category-specific behavior, equality, copy independence. Adds 12 credential resolver tests covering token expiry windows, refresh success/failures, and partial response handling. Updates adapter tests with TestCredentialResolver and gated capability scenarios.
Frontend store and scheduler UI
apps/web/app/src/stores/publishing.ts, apps/web/app/src/views/SchedulerView.vue
Expands Channel.status and Publication.status unions with BLOCKED, REQUIRES_RECONNECT, DISABLED, DELETED, PENDING, ERROR. Adds blockedReason to Publication. Adds hasReconnectRequiredChannels boolean and reconnectRequiredChannels array computed fields. Passes backend status directly instead of coercing. Adds handleReconnect() helper. Adds reconnect banner with button. Adds BLOCKED badge and reason text with reconnect button in week view. Updates list-mode status styling and adds reconnect button. Restructures week-view grid with fixed left time-axis column.
Configuration, environment, and seed data
.env.example, server/.../application.yaml, server/.../db/changelog/dev/001-seed-test-data.yaml, .codegraph/.gitignore
Removes DEV_PASSWORD_HASH environment variable and hardcodes bcrypt hash in Liquibase seed changelog. Adds publishing.blocked-recovery-interval and spring.linkedin.api-version to application.yaml. Simplifies .codegraph/.gitignore to blanket * rule with !.gitignore exception.
OpenSpec documentation archive
openspec/changes/archive/2026-06-16-linkedin-integration-publication/*, openspec/specs/publishing/spec.md
Adds full lifecycle archive: exploration (PRD goals, current architecture, design direction), proposal (prerequisites, standards, operational guidance), risk analysis (9 decision areas), delta spec (726 lines: MVP scope, capability bundling, OAuth, token lifecycle, REST contract, media uploads, privacy retention, scheduler UI), design (architecture decisions, worker/refresh flows), tasks (phased plan), state.yaml (lifecycle tracking), verify-report (compliance matrix, assessment, PASS-with-warnings). Extends canonical spec with 698 lines of LinkedIn-specific requirements.

Sequence Diagram(s)

sequenceDiagram
  participant Worker as PublishingWorker
  participant Executor as PublishingJobExecutor
  participant PubRepo as PublicationRepository
  participant Resolver as RefreshAwareCredentialResolverImpl
  participant LinkedIn as LinkedIn REST API
  participant NotifRepo as NotificationEventRepository
  
  Worker->>Executor: executeClaim(job)
  Executor->>PubRepo: load SocialConnection status
  alt DISABLED or REQUIRES_RECONNECT
    Executor->>PubRepo: markBlocked(publication)
    Executor->>NotifRepo: record(PUBLICATION_BLOCKED)
    Executor-->>Worker: job completed
  else DELETED
    Executor->>PubRepo: markFailed(publication)
    Executor->>NotifRepo: record(PUBLICATION_FAILED)
    Executor-->>Worker: job completed
  else ACTIVE
    Executor->>Resolver: resolve(account)
    alt token expired or approaching refresh-ahead
      Resolver->>LinkedIn: POST /oauth/v2/accessToken
      LinkedIn-->>Resolver: new access token
      Resolver->>PubRepo: storeForOwner(credentials)
    end
    Resolver-->>Executor: access token
    Executor->>LinkedIn: POST /rest/posts (publish)
    LinkedIn-->>Executor: postUrn
    Executor->>PubRepo: markPublished(publicUrl)
    Executor->>NotifRepo: record(PUBLICATION_SUCCESS)
    Executor-->>Worker: job completed
  end
  
  Note over Worker: Later... scheduled blockedRecoveryInterval
  Worker->>Worker: scanBlockedForRecovery()
  Worker->>PubRepo: findBlockedForRecovery(maxRetries)
  loop each eligible blocked publication
    Worker->>Worker: prepareBlockedRetry (exponential backoff)
    Worker->>PubRepo: updateEditableDraft
    Worker->>Worker: replaceForPublication (new PENDING job)
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Poem

🐰 Hop hop! The bunny refreshes LinkedIn tokens so fine,
With BLOCKED publications retrying on a schedule divine.
Reconnect banners prompt users to re-authenticate with glee,
While notifications durable ensure nothing is lost, you see.
The worker scans blocked, prepares exponential backoff with care,
Publishing MVP complete—the future's in the air! 🌟

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/linkedin-integration-publication

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 16, 2026

Copy link
Copy Markdown

Deploying profiletailors with  Cloudflare Pages  Cloudflare Pages

Latest commit: 041ac48
Status: ✅  Deploy successful!
Preview URL: https://4ed0196a.profiletailors-com.pages.dev
Branch Preview URL: https://feature-linkedin-integration.profiletailors-com.pages.dev

View logs

@github-actions github-actions Bot added area:backend Changes in backend code (server/smp/** or shared/**) area:docs Documentation changes (docs/**, *.md, *.mdx) type:test Test files or test infrastructure changes labels Jun 16, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 17

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (6)
server/smp/src/main/resources/db/changelog/dev/001-seed-test-data.yaml (1)

3-5: ⚠️ Potential issue | 🔴 Critical

Configure Liquibase to activate the dev context only when the dev profile is active.

The changeset specifies context: dev (line 5), but the Liquibase configuration in application.yaml does not set a contexts property. Without this configuration, the context: dev specification in the changeset is ignored, and the seed data will execute in all environments including production.

Add the following to the Liquibase configuration in application.yaml:

Required configuration change
spring:
  liquibase:
    enabled: ${SMP_LIQUIBASE_ENABLED:true}
    change-log: classpath:db/changelog/db.changelog-master.yaml
    url: ${SMP_LIQUIBASE_JDBC_URL:jdbc:postgresql://localhost:5432/profiletailors_smp}
    user: ${SMP_DB_USERNAME:profiletailors}
    password: ${SMP_DB_PASSWORD:CHANGE_ME}
    contexts: ${SMP_LIQUIBASE_CONTEXTS:}

Then, in the dev profile section (after line 116), add:

spring:
  liquibase:
    contexts: dev
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/smp/src/main/resources/db/changelog/dev/001-seed-test-data.yaml`
around lines 3 - 5, The changeset with id dev-001-seed-test-data specifies
context: dev but the Liquibase configuration in application.yaml does not define
a contexts property, causing the context specification to be ignored and seed
data to execute in all environments including production. Add a contexts
property to the main Liquibase configuration in application.yaml that uses a
parameterized default (empty), then override this in the dev profile section of
application.yaml to set contexts: dev. This ensures that the dev context is only
activated when the dev profile is active, preventing unwanted seed data
execution in production environments.
openspec/specs/publishing/spec.md (1)

211-225: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Unify the LinkedIn media upload contract across both spec copies.

Both spec files still carry the archived /assets upload flow, while the new delta below moves media ingestion to /rest/images//rest/posts. Please either mark the older block as historical/non-normative or update it in both places; otherwise the archive and canonical spec tell implementers to use incompatible upload APIs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openspec/specs/publishing/spec.md` around lines 211 - 225, The LinkedIn asset
upload contract is defined inconsistently across two spec files: the anchor in
openspec/specs/publishing/spec.md (lines 211-225) and the sibling in
openspec/changes/archive/2026-06-16-linkedin-integration-publication/specs/publishing/spec.md
(lines 211-225). Both currently document the older POST /assets and PUT upload
URL flow, but the new implementation uses POST /rest/images and /rest/posts
endpoints. Either mark the archived `/assets` flow documentation as historical
and non-normative in both locations to clarify it is legacy, or update both spec
files to document the current `/rest/images`/`/rest/posts` API contract so
implementers receive consistent guidance on which LinkedIn API to use.
openspec/changes/archive/2026-06-16-linkedin-integration-publication/design.md (1)

320-333: ⚠️ Potential issue | 🟡 Minor

Code implementation is correct; operational runbook documentation missing.

The LinkedIn-Version header is properly configurable:

  • ✓ Configured in application.yaml via api-version: ${SMP_LINKEDIN_API_VERSION:202606}
  • ✓ Read from LinkedInPublishingProperties.apiVersion in the adapter
  • ✓ Applied to outbound requests: RealLinkedInPublisher (line 245) and RealLinkedInAssetUploader (lines 100, 139)
  • ✓ No hardcoded version strings in the LinkedIn adapter code

However, the operational runbook documented in the design (quarterly review cadence, deprecation monitoring, deployment procedure) is not present in the repository. Create the operational runbook per design requirements before deployment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@openspec/changes/archive/2026-06-16-linkedin-integration-publication/design.md`
around lines 320 - 333, The code implementation correctly makes the
LinkedIn-Version header configurable through application.yaml and
LinkedInPublishingProperties without hardcoding, but the operational runbook
specified in the design document is missing from the repository. Create an
operational runbook document that includes the quarterly review cadence for
checking LinkedIn API version deprecations, the process for updating the version
value when new LinkedIn API versions are released, monitoring procedures for
LinkedIn API response headers and deprecation warnings, and the deployment
procedure for updating the version without downtime. This runbook should be
added to the repository as part of the deployment documentation to fulfill the
design requirements before this feature goes live.
server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/scheduling/PublishingSchedulingConfiguration.kt (1)

35-37: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Increase scheduler pool size for dual-loop execution.

Line 35 sets poolSize = 1, but the lifecycle now runs both polling and blocked-recovery fixed-rate tasks. A long poll cycle can starve recovery scans. Use at least 2 threads (or separate schedulers) to avoid starvation.

Suggested patch
-        poolSize = 1
+        poolSize = 2
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/scheduling/PublishingSchedulingConfiguration.kt`
around lines 35 - 37, The poolSize is currently set to 1 in the
PublishingSchedulingConfiguration, but the scheduler now executes both polling
and blocked-recovery fixed-rate tasks. With only one thread, a long poll cycle
will starve the recovery scans from executing. Increase the poolSize value to at
least 2 threads to allow concurrent execution of both tasks, preventing
starvation of the recovery scan operations.
server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/scheduling/PublishingWorker.kt (1)

349-356: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Emit a notification event on terminal publish failure.

The terminal failure path marks publication/job failed but does not record a NotificationEvent. This leaves durable outcome tracking incomplete for provider failures and retry exhaustion.

Suggested patch
         } else {
             publicationRepository.markFailed(
                 publication.id,
                 now,
                 exception::class.simpleName,
                 exception.message
             )
             publicationJobRepository.fail(claim.jobId, now)
+            recordNotificationEvent(
+                workspaceId = publication.workspaceId,
+                socialAccountId = publication.socialAccountId,
+                publicationId = publication.id,
+                category = NotificationCategory.PUBLICATION_FAILED,
+                message = exception.message ?: "Publication failed",
+                now = now,
+                provider = publication.provider,
+            )
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/scheduling/PublishingWorker.kt`
around lines 349 - 356, The terminal failure path in PublishingWorker marks the
publication and job as failed using publicationRepository.markFailed() and
publicationJobRepository.fail() but does not emit a NotificationEvent to
complete durable outcome tracking. After both repository calls in this failure
block (around lines 349-356), add a call to emit a NotificationEvent that
captures the terminal failure state, ensuring the outcome is durably recorded
for provider failures and retry exhaustion scenarios.
server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/linkedin/LinkedInPublishingAdapters.kt (1)

164-188: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

enabledBundles is currently a no-op, so capability gating is bypassed.

LinkedInCapabilityValidator accepts enabledBundles but never checks them in validate(...). That allows gated capabilities (e.g., video) to pass validation even when only personal text/image bundles are configured.

Proposed fix
 class LinkedInCapabilityValidator(
     private val enabledBundles: Set<com.profiletailors.smp.publishing.domain.LinkedinCapabilityBundle> = setOf(
         com.profiletailors.smp.publishing.domain.LinkedinCapabilityBundle.PERSONAL_PROFILE_TEXT,
         com.profiletailors.smp.publishing.domain.LinkedinCapabilityBundle.PERSONAL_PROFILE_IMAGE,
     ),
 ) : ProviderCapabilityValidator {
     override fun validate(input: ProviderCapabilityValidationInput) {
@@
         validateMediaTypes(input)
+        validateEnabledCapability(input)
         validateFileSizes(input)
     }
+
+    private fun validateEnabledCapability(input: ProviderCapabilityValidationInput) {
+        val requiredBundle = when {
+            input.assets.isEmpty() ->
+                com.profiletailors.smp.publishing.domain.LinkedinCapabilityBundle.PERSONAL_PROFILE_TEXT
+            input.assets.any { it.mediaType.equals("video/mp4", ignoreCase = true) } ->
+                com.profiletailors.smp.publishing.domain.LinkedinCapabilityBundle.VIDEO
+            input.assets.all { it.mediaType.startsWith("image/", ignoreCase = true) } ->
+                com.profiletailors.smp.publishing.domain.LinkedinCapabilityBundle.PERSONAL_PROFILE_IMAGE
+            else -> return
+        }
+        if (requiredBundle !in enabledBundles) {
+            throw PublicationValidationException("LinkedIn capability not enabled: $requiredBundle")
+        }
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/linkedin/LinkedInPublishingAdapters.kt`
around lines 164 - 188, The LinkedInCapabilityValidator class accepts an
enabledBundles parameter but does not use it in the validate method, which
bypasses capability gating and allows unsupported asset types to pass
validation. Add validation logic within the validate method that checks each
asset in input.assets against the enabledBundles collection and throws a
PublicationValidationException if any asset type is not supported by the
currently enabled bundles. Map the asset media types to their corresponding
LinkedinCapabilityBundle types and verify they are present in the enabledBundles
set before allowing validation to proceed.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web/app/src/stores/publishing.ts`:
- Line 33: The Channel.status type definition is missing legacy reconnect
statuses (REVOKED and EXPIRED) that the backend still emits. Add REVOKED and
EXPIRED to the Channel.status union type on line 33. Additionally, update the
reconnect detection logic in the selector around lines 265-270 to check for
these legacy statuses (REVOKED and EXPIRED) in addition to REQUIRES_RECONNECT
when determining if reconnection is needed. The type assertion on line 155 will
be automatically corrected once the Channel.status type properly includes these
statuses.
- Around line 51-52: The Publication interface declares the blockedReason field
but the calendar mapper that hydrates Publication objects from API responses
does not extract and populate this field. Locate the calendar mapper function
that transforms API response data into Publication objects (likely in a mapping
or transformation utility), and add the mapping logic to populate the
blockedReason field from the corresponding API response field whenever a
publication has a BLOCKED status. This ensures that server-loaded BLOCKED
publications retain the blockedReason information needed by dependent UI logic.

In
`@openspec/changes/archive/2026-06-16-linkedin-integration-publication/verify-report.md`:
- Around line 69-70: The verification report marks the "Expiring access token
refreshes automatically through resolver" row as COMPLIANT, but the cited test
RefreshAwareCredentialResolverTest.returns access token when token is not
expired only validates the non-expired path, not an actual automatic refresh
scenario. Change the compliance status from COMPLIANT to PARTIAL or UNTESTED for
this row unless a test exists that actually exercises the refresh path, and
update the final verdict wording to accurately reflect that automatic refresh
behavior is not verified by the current test evidence. This same alignment issue
also affects lines 159-163, so apply the same corrections there as well.

In
`@server/smp/src/main/kotlin/com/profiletailors/smp/publishing/application/PublishingHandlers.kt`:
- Around line 624-635: The pagination is currently being performed in memory
after fetching all matching publications, which makes list operations O(N) and
creates a performance bottleneck for large workspaces. Modify the
`publicationRepository.findInDateRange()` method signature to accept offset and
limit parameters (or create a separate paginated variant), pass `query.offset`
and `query.limit` directly to the repository call instead of using in-memory
`.drop()` and `.take()` operations, and ensure the method returns both the
paginated results and the total count of all matching records separately so only
the requested page of data is fetched from the database. Apply this same pattern
to the other affected location mentioned in the comment.
- Around line 639-641: The code instantiating the `now` variable uses
`java.time.Clock.systemUTC().instant()` directly instead of leveraging a
dependency-injected clock, reducing testability and determinism. Replace the
direct Clock.systemUTC() call with the injected clock instance that should be
available in the PublishingHandlers class (likely as a constructor parameter or
class property), then call .instant() on that injected clock to maintain the
same assignment pattern for the `now` variable.

In
`@server/smp/src/main/kotlin/com/profiletailors/smp/publishing/domain/PublishingPolicies.kt`:
- Around line 153-160: The `markBlocked` function in PublishingPolicies.kt
currently only validates that the publication is not in terminal states, but it
should restrict transitions to only in-flight publishing states. The current
check allows invalid state transitions from DRAFT and FAILED states into
BLOCKED. Add an additional validation condition that ensures the publication
status is in an in-flight publish state (such as actively publishing or
in-progress states) before allowing the transition to BLOCKED. This will prevent
the lifecycle from accepting invalid state paths outside the normal publish
pipeline.

In
`@server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/credentials/RefreshAwareCredentialResolverImpl.kt`:
- Around line 105-109: The issue is that executeRefresh() returns null for both
transient failures (timeouts, IO errors, interruptions) and permanent failures
(invalid credentials), but attemptRefresh() treats all null returns as
INVALID_GRANT errors, forcing unnecessary reconnect flows. To fix this, modify
the error handling logic in the executeRefresh() method to distinguish between
transient and permanent failures by either catching and re-raising specific
exceptions for transient errors, or by having executeRefresh() return an error
type (like a Result/Either wrapper) that captures whether the failure is
transient or permanent, then update the code that calls executeRefresh() to
route transient failures to retry/backoff logic instead of immediately throwing
ReconnectRequiredException with INVALID_GRANT. This applies to all call sites
where executeRefresh() is used and its null return is converted to
ReconnectRequiredException.
- Around line 104-123: The credentialReference (treated as a credential row id
at the start of the method in RefreshAwareCredentialResolverImpl) is being
incorrectly passed as an ownerId to credentialGateway.storeForOwner() at the
end. Since storeForOwner() upserts by (owner_type, owner_id) and generates a new
random id on insert, this breaks the optimistic locking pattern and can create
divergent credential records. Replace the storeForOwner call with a method that
updates by the actual credential row id (credentialReference) and includes
version-based optimistic locking to ensure you are updating the correct row that
was read at the beginning of the refresh operation.

In
`@server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/http/PublishingControllers.kt`:
- Around line 285-286: The limit and offset request parameters in this
controller method lack validation, allowing arbitrary integers including
negative values or unbounded sizes that can cause expensive queries and
downstream failures. Add validation at the controller boundary (within the
method containing these parameters) to enforce reasonable bounds: ensure limit
is positive and within a maximum threshold (for example, reject values less than
or equal to 0 or greater than 1000), and ensure offset is non-negative (greater
than or equal to 0). Apply these validations immediately after the parameters
are received but before they are used in any service calls.

In
`@server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/persistence/R2dbcPublishingRepositories.kt`:
- Around line 284-310: The findBlockedForRecovery method returns
PublicationDraft objects through toPublicationDraft() which sets assetIds to an
empty list, causing data loss when these drafts are later passed to
updateEditableDraft where replaceAssetLinks deletes and reinserts asset links
based on the empty assetIds. Load and hydrate the asset IDs for each
PublicationDraft returned by findBlockedForRecovery before returning them to the
caller, ensuring the assetIds field is populated with actual linked asset
identifiers so that replaceAssetLinks preserves the existing asset links during
recovery.

In
`@server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/scheduling/PublishingWorker.kt`:
- Around line 402-414: The current code makes two separate repository calls
where publicationRepository.updateEditableDraft(prepared) is executed before
publicationJobRepository.replaceForPublication(), which violates atomicity. If
the second call fails, the draft is left updated with no corresponding job,
stranding the publication. Create a new atomic repository method that performs
both the draft update and job replacement in a single transaction, then replace
these two separate calls with this single atomic operation. This ensures either
both mutations succeed together or both fail together, preventing inconsistent
state.
- Around line 387-389: At
server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/scheduling/PublishingWorker.kt
lines 387-389, wrap the requeueBlockedPublication(publication) call inside the
forEach iteration with a try-catch block to handle exceptions per-publication,
ensuring the loop continues processing remaining publications instead of
aborting on the first error. Apply the same per-item exception handling pattern
at lines 436-443 for the scheduled task callbacks with coroutines to prevent
uncaught exceptions from halting the scheduler. Additionally, configure an error
handler on the ThreadPoolTaskScheduler bean to gracefully handle any uncaught
exceptions in scheduled executions and prevent executor shutdown.

In
`@server/smp/src/main/resources/db/changelog/publishing/013-linkedin-integration-publication.yaml`:
- Around line 6-41: The changeSet with id
publishing-013-add-publications-columns adds columns to the publications table
but does not create an index to support the findBlockedForRecovery query. Add a
new createIndex change within this same changeSet to create an index on the
publications table covering the status, retry_count, and blocked_at columns (in
that order) to prevent full-table scans. Name the index
idx_publications_blocked_recovery. Also add the corresponding dropIndex to the
rollback section to drop this index when the migration is rolled back.

In
`@server/smp/src/test/kotlin/com/profiletailors/smp/publishing/domain/PublicationLifecyclePolicyTest.kt`:
- Around line 293-305: The test function prepareBlockedRetry caps delay at 60
minutes has a misleading name that does not match its actual assertions. The
test currently uses retryCount = 4, which produces a 16-minute delay that is
under the 60-minute cap, not testing the cap itself. Either rename the test
function to accurately reflect that it tests a delay under the cap (such as
prepareBlockedRetry uses exponential backoff under cap), or change the
retryCount to a higher value (such as 10 or more) that would trigger the
60-minute cap behavior and update the assertion to expect 60 minutes instead of
16 minutes.

In
`@server/smp/src/test/kotlin/com/profiletailors/smp/publishing/infrastructure/credentials/RefreshAwareCredentialResolverTest.kt`:
- Around line 223-226: The StubLinkedInHttpTransport class always throws an
exception, preventing any testing of the actual refresh logic in executeRefresh
and attemptRefresh methods. Create new test cases that use a controllable stub
(that can return different HTTP responses instead of always throwing) to
validate three key scenarios: successful refresh response parsing, transient
transport failure mapping, and non-2xx response handling. This ensures the
refresh execution outcomes are properly validated in the test suite.

In
`@server/smp/src/test/kotlin/com/profiletailors/smp/publishing/infrastructure/http/PublishingControllersTest.kt`:
- Around line 302-313: The test method `list publications delegates to mediator`
only verifies the response returned by the controller but doesn't assert that
the correct query was dispatched to the mediator. Add an assertion after the
response is obtained to verify that the CapturingMediator captured the expected
ListPublicationsQuery object. This ensures the controller properly maps the
request to the appropriate query command sent to the mediator, rather than just
relying on the canned response the mediator returns.

In
`@server/smp/src/test/kotlin/com/profiletailors/smp/publishing/infrastructure/scheduling/PublishingWorkerTest.kt`:
- Around line 340-444: Replace the `notificationEventRepository = null`
parameter with an in-memory implementation (like
InMemoryNotificationEventRepository) in all three test methods: the DISABLED
account test, the REQUIRES_RECONNECT account test, and the DELETED account test.
Then add assertions after each worker.pollOnce() call to verify that the correct
notification events were emitted — assert for PUBLICATION_BLOCKED events in the
first two tests and PUBLICATION_FAILED events in the third test. This ensures
regressions in event emission for each account status path will be caught by the
tests.

---

Outside diff comments:
In
`@openspec/changes/archive/2026-06-16-linkedin-integration-publication/design.md`:
- Around line 320-333: The code implementation correctly makes the
LinkedIn-Version header configurable through application.yaml and
LinkedInPublishingProperties without hardcoding, but the operational runbook
specified in the design document is missing from the repository. Create an
operational runbook document that includes the quarterly review cadence for
checking LinkedIn API version deprecations, the process for updating the version
value when new LinkedIn API versions are released, monitoring procedures for
LinkedIn API response headers and deprecation warnings, and the deployment
procedure for updating the version without downtime. This runbook should be
added to the repository as part of the deployment documentation to fulfill the
design requirements before this feature goes live.

In `@openspec/specs/publishing/spec.md`:
- Around line 211-225: The LinkedIn asset upload contract is defined
inconsistently across two spec files: the anchor in
openspec/specs/publishing/spec.md (lines 211-225) and the sibling in
openspec/changes/archive/2026-06-16-linkedin-integration-publication/specs/publishing/spec.md
(lines 211-225). Both currently document the older POST /assets and PUT upload
URL flow, but the new implementation uses POST /rest/images and /rest/posts
endpoints. Either mark the archived `/assets` flow documentation as historical
and non-normative in both locations to clarify it is legacy, or update both spec
files to document the current `/rest/images`/`/rest/posts` API contract so
implementers receive consistent guidance on which LinkedIn API to use.

In
`@server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/linkedin/LinkedInPublishingAdapters.kt`:
- Around line 164-188: The LinkedInCapabilityValidator class accepts an
enabledBundles parameter but does not use it in the validate method, which
bypasses capability gating and allows unsupported asset types to pass
validation. Add validation logic within the validate method that checks each
asset in input.assets against the enabledBundles collection and throws a
PublicationValidationException if any asset type is not supported by the
currently enabled bundles. Map the asset media types to their corresponding
LinkedinCapabilityBundle types and verify they are present in the enabledBundles
set before allowing validation to proceed.

In
`@server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/scheduling/PublishingSchedulingConfiguration.kt`:
- Around line 35-37: The poolSize is currently set to 1 in the
PublishingSchedulingConfiguration, but the scheduler now executes both polling
and blocked-recovery fixed-rate tasks. With only one thread, a long poll cycle
will starve the recovery scans from executing. Increase the poolSize value to at
least 2 threads to allow concurrent execution of both tasks, preventing
starvation of the recovery scan operations.

In
`@server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/scheduling/PublishingWorker.kt`:
- Around line 349-356: The terminal failure path in PublishingWorker marks the
publication and job as failed using publicationRepository.markFailed() and
publicationJobRepository.fail() but does not emit a NotificationEvent to
complete durable outcome tracking. After both repository calls in this failure
block (around lines 349-356), add a call to emit a NotificationEvent that
captures the terminal failure state, ensuring the outcome is durably recorded
for provider failures and retry exhaustion scenarios.

In `@server/smp/src/main/resources/db/changelog/dev/001-seed-test-data.yaml`:
- Around line 3-5: The changeset with id dev-001-seed-test-data specifies
context: dev but the Liquibase configuration in application.yaml does not define
a contexts property, causing the context specification to be ignored and seed
data to execute in all environments including production. Add a contexts
property to the main Liquibase configuration in application.yaml that uses a
parameterized default (empty), then override this in the dev profile section of
application.yaml to set contexts: dev. This ensures that the dev context is only
activated when the dev profile is active, preventing unwanted seed data
execution in production environments.
🪄 Autofix (Beta)

❌ Autofix failed (check again to retry)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: b5f7a7f0-b00a-499b-98f1-accbda9a4862

📥 Commits

Reviewing files that changed from the base of the PR and between 68aaf63 and 6832ac9.

⛔ Files ignored due to path filters (1)
  • server/smp/src/main/resources/db/changelog/data/dev/local_password_credentials_dev.csv is excluded by !**/*.csv
📒 Files selected for processing (40)
  • .codegraph/.gitignore
  • .env.example
  • apps/web/app/src/stores/publishing.ts
  • apps/web/app/src/views/SchedulerView.vue
  • openspec/changes/archive/2026-06-16-linkedin-integration-publication/design.md
  • openspec/changes/archive/2026-06-16-linkedin-integration-publication/exploration.md
  • openspec/changes/archive/2026-06-16-linkedin-integration-publication/proposal.md
  • openspec/changes/archive/2026-06-16-linkedin-integration-publication/risk-analysis.md
  • openspec/changes/archive/2026-06-16-linkedin-integration-publication/specs/publishing/spec.md
  • openspec/changes/archive/2026-06-16-linkedin-integration-publication/state.yaml
  • openspec/changes/archive/2026-06-16-linkedin-integration-publication/tasks.md
  • openspec/changes/archive/2026-06-16-linkedin-integration-publication/verify-report.md
  • openspec/specs/publishing/spec.md
  • server/smp/src/main/kotlin/com/profiletailors/smp/publishing/application/PublishingApi.kt
  • server/smp/src/main/kotlin/com/profiletailors/smp/publishing/application/PublishingHandlers.kt
  • server/smp/src/main/kotlin/com/profiletailors/smp/publishing/domain/NotificationEvent.kt
  • server/smp/src/main/kotlin/com/profiletailors/smp/publishing/domain/PublishingModels.kt
  • server/smp/src/main/kotlin/com/profiletailors/smp/publishing/domain/PublishingPolicies.kt
  • server/smp/src/main/kotlin/com/profiletailors/smp/publishing/domain/PublishingProviderPorts.kt
  • server/smp/src/main/kotlin/com/profiletailors/smp/publishing/domain/PublishingRepositories.kt
  • server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/credentials/LinkedInCredentialGateway.kt
  • server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/credentials/RefreshAwareCredentialResolverImpl.kt
  • server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/http/PublishingControllers.kt
  • server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/linkedin/LinkedInAssetUploaderAdapters.kt
  • server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/linkedin/LinkedInPublishingAdapters.kt
  • server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/persistence/R2dbcNotificationEventRepository.kt
  • server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/persistence/R2dbcPublishingConnectionRepositories.kt
  • server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/persistence/R2dbcPublishingRepositories.kt
  • server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/scheduling/PublishingSchedulingConfiguration.kt
  • server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/scheduling/PublishingWorker.kt
  • server/smp/src/main/resources/application.yaml
  • server/smp/src/main/resources/db/changelog/db.changelog-master.yaml
  • server/smp/src/main/resources/db/changelog/dev/001-seed-test-data.yaml
  • server/smp/src/main/resources/db/changelog/publishing/013-linkedin-integration-publication.yaml
  • server/smp/src/test/kotlin/com/profiletailors/smp/publishing/application/PublishingHandlersTest.kt
  • server/smp/src/test/kotlin/com/profiletailors/smp/publishing/domain/PublicationLifecyclePolicyTest.kt
  • server/smp/src/test/kotlin/com/profiletailors/smp/publishing/infrastructure/credentials/RefreshAwareCredentialResolverTest.kt
  • server/smp/src/test/kotlin/com/profiletailors/smp/publishing/infrastructure/http/PublishingControllersTest.kt
  • server/smp/src/test/kotlin/com/profiletailors/smp/publishing/infrastructure/linkedin/LinkedInPublishingAdaptersTest.kt
  • server/smp/src/test/kotlin/com/profiletailors/smp/publishing/infrastructure/scheduling/PublishingWorkerTest.kt
💤 Files with no reviewable changes (1)
  • server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/persistence/R2dbcPublishingConnectionRepositories.kt

Comment thread apps/web/app/src/stores/publishing.ts Outdated
Comment on lines +51 to +52
/** Reason the publication was blocked (e.g., account DISABLED or REQUIRES_RECONNECT). */
blockedReason?: string

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

blockedReason is declared but not populated from API results.

Lines 51-52 add Publication.blockedReason, but the calendar mapper never hydrates it. Server-loaded BLOCKED publications therefore lose this field and dependent UI logic won’t receive the reason.

Proposed fix
 export interface CalendarPublicationResult {
   id: string
   workspaceId: string
   socialAccountId: string
   provider: string
   status: string
   scheduleMode: string
   priority: boolean
   title: string | null
   bodyText: string | null
   scheduledFor: string | null
+  blockedReason?: string | null
   hasConflict: boolean
   conflictingPublicationIds: string[]
 }

 function apiResultToPublication(api: CalendarPublicationResult): Publication {
   return {
@@
     hasConflict: api.hasConflict,
     conflictingPublicationIds: api.conflictingPublicationIds,
     accountId: api.socialAccountId,
+    blockedReason: api.blockedReason ?? undefined,
   }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/app/src/stores/publishing.ts` around lines 51 - 52, The Publication
interface declares the blockedReason field but the calendar mapper that hydrates
Publication objects from API responses does not extract and populate this field.
Locate the calendar mapper function that transforms API response data into
Publication objects (likely in a mapping or transformation utility), and add the
mapping logic to populate the blockedReason field from the corresponding API
response field whenever a publication has a BLOCKED status. This ensures that
server-loaded BLOCKED publications retain the blockedReason information needed
by dependent UI logic.

Comment on lines +624 to +635
val publications = publicationRepository.findInDateRange(
workspaceId = workspaceId,
from = from,
to = to,
statuses = statuses,
socialAccountIds = accountIds,
)
val items = publications.drop(query.offset).take(query.limit).map { it.toListItem() }
return ListPublicationsResponse(
publications = items,
total = publications.size,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Push pagination/counting into the repository instead of paginating in memory.

Line 624 and Line 644 fetch the full range and only then apply offset/limit. That makes list calls O(N) per request and can become a latency/memory hotspot for large workspaces.

Suggested direction
- val publications = publicationRepository.findInDateRange(...)
- val items = publications.drop(query.offset).take(query.limit).map { it.toListItem() }
- return ListPublicationsResponse(publications = items, total = publications.size)
+ val total = publicationRepository.countInDateRange(...)
+ val publications = publicationRepository.findInDateRangePaged(
+     ...,
+     limit = query.limit,
+     offset = query.offset,
+ )
+ return ListPublicationsResponse(
+     publications = publications.map { it.toListItem() },
+     total = total,
+ )

Also applies to: 644-655

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@server/smp/src/main/kotlin/com/profiletailors/smp/publishing/application/PublishingHandlers.kt`
around lines 624 - 635, The pagination is currently being performed in memory
after fetching all matching publications, which makes list operations O(N) and
creates a performance bottleneck for large workspaces. Modify the
`publicationRepository.findInDateRange()` method signature to accept offset and
limit parameters (or create a separate paginated variant), pass `query.offset`
and `query.limit` directly to the repository call instead of using in-memory
`.drop()` and `.take()` operations, and ensure the method returns both the
paginated results and the total count of all matching records separately so only
the requested page of data is fetched from the database. Apply this same pattern
to the other affected location mentioned in the comment.

Comment on lines +639 to +641
val now = java.time.Clock.systemUTC().instant()
val broadFrom = from ?: now.minus(java.time.Duration.ofDays(90))
val broadTo = to ?: now.plus(java.time.Duration.ofDays(30))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Use the injected clock here instead of Clock.systemUTC().

Line 639 bypasses dependency-injected time, making behavior less deterministic and harder to test consistently with the rest of the handlers.

Proposed adjustment
 internal class ListPublicationsHandler(
     private val resourceContextProvider: ResourceContextProvider,
     private val publicationRepository: PublicationRepository,
+    private val clock: Clock,
 ) : QueryHandler<ListPublicationsQuery, ListPublicationsResponse> {

-    val now = java.time.Clock.systemUTC().instant()
+    val now = clock.instant()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@server/smp/src/main/kotlin/com/profiletailors/smp/publishing/application/PublishingHandlers.kt`
around lines 639 - 641, The code instantiating the `now` variable uses
`java.time.Clock.systemUTC().instant()` directly instead of leveraging a
dependency-injected clock, reducing testability and determinism. Replace the
direct Clock.systemUTC() call with the injected clock instance that should be
available in the PublishingHandlers class (likely as a constructor parameter or
class property), then call .instant() on that injected clock to maintain the
same assignment pattern for the `now` variable.

Comment on lines +340 to +444
fun `preflight blocks DISABLED account without calling publisher`() = runTest {
val disabledAccount = successAccount().copy(status = SocialConnectionStatus.DISABLED)
val publication = successPublication()
val publicationRepository = InMemoryPublicationRepository(publication)
val jobRepository = InMemoryJobRepository(PublicationJobClaim("job-1", "pub-1", "workspace-1", 1, fixedClock.instant()))
val publisher = NeverPublishesPublisher()
val executor = PublishingJobExecutor(
publicationJobRepository = jobRepository,
publicationRepository = publicationRepository,
socialAccountRepository = InMemoryAccountRepository(disabledAccount),
publicationAssetRepository = InMemoryAssetRepository(emptyList()),
deliveryAttemptRepository = InMemoryAttemptRepository(),
notificationEventRepository = null,
providerCapabilityValidator = AcceptingCapabilityValidator(),
socialPublisher = publisher,
retryPolicy = DeliveryRetryPolicy(3, Duration.ofMinutes(5)),
clock = fixedClock,
)
val worker = PublishingWorker(
publicationJobRepository = jobRepository,
publicationRepository = publicationRepository,
executor = executor,
clock = fixedClock,
workerId = "worker-1",
)

worker.pollOnce()

// Publisher was never called
assertEquals(false, publisher.called)
// Publication was marked blocked
assertEquals("pub-1", publicationRepository.blockedPublicationId)
// Job was completed (not left hanging)
assertEquals("job-1", jobRepository.completedJobId)
}

@Test
fun `preflight blocks REQUIRES_RECONNECT account without calling publisher`() = runTest {
val reconnectAccount = successAccount().copy(status = SocialConnectionStatus.REQUIRES_RECONNECT)
val publication = successPublication()
val publicationRepository = InMemoryPublicationRepository(publication)
val jobRepository = InMemoryJobRepository(PublicationJobClaim("job-1", "pub-1", "workspace-1", 1, fixedClock.instant()))
val publisher = NeverPublishesPublisher()
val executor = PublishingJobExecutor(
publicationJobRepository = jobRepository,
publicationRepository = publicationRepository,
socialAccountRepository = InMemoryAccountRepository(reconnectAccount),
publicationAssetRepository = InMemoryAssetRepository(emptyList()),
deliveryAttemptRepository = InMemoryAttemptRepository(),
notificationEventRepository = null,
providerCapabilityValidator = AcceptingCapabilityValidator(),
socialPublisher = publisher,
retryPolicy = DeliveryRetryPolicy(3, Duration.ofMinutes(5)),
clock = fixedClock,
)
val worker = PublishingWorker(
publicationJobRepository = jobRepository,
publicationRepository = publicationRepository,
executor = executor,
clock = fixedClock,
workerId = "worker-1",
)

worker.pollOnce()

assertEquals(false, publisher.called)
assertEquals("pub-1", publicationRepository.blockedPublicationId)
assertEquals("job-1", jobRepository.completedJobId)
}

@Test
fun `preflight fails DELETED account terminally without calling publisher`() = runTest {
val deletedAccount = successAccount().copy(status = SocialConnectionStatus.DELETED)
val publication = successPublication()
val publicationRepository = InMemoryPublicationRepository(publication)
val jobRepository = InMemoryJobRepository(PublicationJobClaim("job-1", "pub-1", "workspace-1", 1, fixedClock.instant()))
val publisher = NeverPublishesPublisher()
val executor = PublishingJobExecutor(
publicationJobRepository = jobRepository,
publicationRepository = publicationRepository,
socialAccountRepository = InMemoryAccountRepository(deletedAccount),
publicationAssetRepository = InMemoryAssetRepository(emptyList()),
deliveryAttemptRepository = InMemoryAttemptRepository(),
notificationEventRepository = null,
providerCapabilityValidator = AcceptingCapabilityValidator(),
socialPublisher = publisher,
retryPolicy = DeliveryRetryPolicy(3, Duration.ofMinutes(5)),
clock = fixedClock,
)
val worker = PublishingWorker(
publicationJobRepository = jobRepository,
publicationRepository = publicationRepository,
executor = executor,
clock = fixedClock,
workerId = "worker-1",
)

worker.pollOnce()

// Publisher was never called
assertEquals(false, publisher.called)
// Publication was failed terminally (not blocked — DELETED is terminal)
assertEquals("pub-1", publicationRepository.failedPublicationId)
assertEquals("job-1", jobRepository.failedJobId)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Cover notification-event emission in preflight tests.

These tests validate publication/job state transitions, but they pass notificationEventRepository = null, so regressions in emitted categories/messages (PUBLICATION_BLOCKED / PUBLICATION_FAILED) won’t be caught. Add an in-memory event repository and assert emitted events per status path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@server/smp/src/test/kotlin/com/profiletailors/smp/publishing/infrastructure/scheduling/PublishingWorkerTest.kt`
around lines 340 - 444, Replace the `notificationEventRepository = null`
parameter with an in-memory implementation (like
InMemoryNotificationEventRepository) in all three test methods: the DISABLED
account test, the REQUIRES_RECONNECT account test, and the DELETED account test.
Then add assertions after each worker.pollOnce() call to verify that the correct
notification events were emitted — assert for PUBLICATION_BLOCKED events in the
first two tests and PUBLICATION_FAILED events in the third test. This ensures
regressions in event emission for each account status path will be caught by the
tests.

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Note

Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it.

⚠️ Branch updated during autofix.

The branch was updated while autofix was in progress. Please try again.

Comment thread apps/web/app/src/views/SchedulerView.vue
Comment on lines +35 to +36
# Dev-only: hardcoded hash for "S3cr3tP@ssw0rd*123" — do not use in production.
- column: { name: password_hash, value: "$2b$12$Lh38sBH4uUHd6hKDM4dTreK7l/KaMo9wQKZijLzfvDWVic7qZS2Z2" }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules critical

Hardcoded secret in 001-seed-test-data.yaml, RefreshAwareCredentialResolverTest.kt, and LinkedInPublishingAdapters.kt replaces the ${dev-password-hash} parameter with a literal bcrypt hash. Revert to the parameterized value to comply with the prohibition of hardcoded secrets in source control.

Kody rule violation: Prohibit hardcoded secrets in source code

- column: { name: password_hash, value: "${dev-password-hash}" }
Prompt for LLM

File server/smp/src/main/resources/db/changelog/dev/001-seed-test-data.yaml:

Line 35 to 36:

Hardcoded password hash committed in source control violates 'Prohibit hardcoded secrets in source code'. The previous parameterized value `${dev-password-hash}` was replaced with a literal bcrypt hash.

Suggested Code:

- column: { name: password_hash, value: "${dev-password-hash}" }

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

@kody-ai kody-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found critical issues please review the requested changes

1. SchedulerView.vue: handle async reconnect with try/catch
   - Unhandled promise rejection on network failure when reconnecting
   - Wrapped connectLinkedInPersonalProfile() in handleReconnect()

2. R2dbcPublishingRepositories.kt: add row locking to recovery scan
   - Race condition: multiple workers could claim same BLOCKED rows
   - Added FOR UPDATE SKIP LOCKED to findBlockedForRecovery query

3. PublishingWorker.kt: prevent orphaned QUEUED publications
   - Non-atomic requeue: if job creation failed, publication left without job
   - Added try/catch with rollback to BLOCKED on failure
   - Also added missing notification event on terminal failure
@kody-ai

This comment has been minimized.

kody-ai[bot]
kody-ai Bot previously approved these changes Jun 16, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/persistence/R2dbcPublishingRepositories.kt (2)

287-301: ⚠️ Potential issue | 🔴 Critical

Move the row-lock transaction boundary to include the entire requeue loop.

The FOR UPDATE OF p SKIP LOCKED at line 301 holds row locks only for the duration of the SELECT statement's transaction. Once findBlockedForRecovery() returns and the transaction ends, the locks are released. The subsequent requeueBlockedPublication() calls in scanBlockedForRecovery() (PublishingWorker.kt:392–403) execute in separate transactions without transaction boundaries, creating a race window where a second worker can invoke findBlockedForRecovery() and claim the same BLOCKED rows. This results in duplicate recovery jobs enqueued for the same publications.

Wrap the entire recovery workflow—from findBlockedForRecovery() through the completion of all requeueBlockedPublication() calls—in a single transaction to ensure the row locks persist across the select-and-requeue cycle.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/persistence/R2dbcPublishingRepositories.kt`
around lines 287 - 301, The row locks acquired by the FOR UPDATE OF p SKIP
LOCKED clause in the SQL query are released immediately after
findBlockedForRecovery() returns, allowing concurrent workers to claim the same
rows. Extend the transaction boundary to encompass the entire recovery workflow
including both the findBlockedForRecovery() call and all subsequent
requeueBlockedPublication() invocations in the scanBlockedForRecovery() method
in PublishingWorker.kt. Ensure that the transaction scope wraps the
select-and-requeue cycle as a single atomic operation so that row locks persist
throughout the entire recovery process, preventing duplicate job enqueuing.

293-300: ⚠️ Potential issue | 🟠 Major

Add a scan to terminally fail BLOCKED publications whose accounts transition to DELETED.

The existing findBlockedForRecovery recovery query only returns BLOCKED publications with ACTIVE accounts. This means BLOCKED publications targeting DELETED accounts are never retrieved by the recovery scan and remain BLOCKED indefinitely instead of terminally failing as required.

While the preflight validation does terminally fail publications when their account is DELETED (via failPublicationTerminal in PublishingWorker), this check only applies to publications in claimable job states (PENDING, RETRY_WAITING). BLOCKED publications have completed jobs and are never reclaimed by normal polling, so they bypass the preflight check entirely.

A separate scan or query is needed to find BLOCKED publications with DELETED accounts and transition them to FAILED status with terminal failure notification.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/persistence/R2dbcPublishingRepositories.kt`
around lines 293 - 300, The current `findBlockedForRecovery` query only
retrieves BLOCKED publications with ACTIVE accounts due to the filter condition
`a.status = :activeStatus`, which means BLOCKED publications associated with
DELETED accounts are never found by the recovery scan and remain stuck in
BLOCKED status instead of failing terminally as required. Create a separate
query method that specifically finds BLOCKED publications linked to DELETED
accounts, then implement a corresponding scan or recovery handler that
transitions these publications to FAILED status and sends the appropriate
terminal failure notification. This ensures BLOCKED publications whose accounts
have been deleted are properly cleaned up by the recovery mechanism rather than
being ignored indefinitely.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/persistence/R2dbcPublishingRepositories.kt`:
- Around line 315-334: The SQL query for assetLinks does not specify an
ordering, causing asset links to be returned in arbitrary database row order.
Modify the SELECT query in the databaseClient.sql() call to include ORDER BY
position_index at the end of the statement, ensuring that when
assetsByPublication groups these links by publication_id, the asset ordering is
preserved according to their position index, preventing asset reordering when
recovery drafts are saved or retried.

In
`@server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/scheduling/PublishingWorker.kt`:
- Around line 414-435: The updateEditableDraft method is called before
replaceForPublication, which persists the incremented retryCount and updated
schedule to the database. If the replaceForPublication call fails and
markBlocked is invoked, the retry metadata has already been persisted but no job
was created, effectively consuming a retry attempt without scheduling any work.
To fix this, either wrap the updateEditableDraft and replaceForPublication calls
in a database transaction so both operations atomically succeed or fail
together, or alternatively, before calling markBlocked in the exception handler,
explicitly restore the original publication's retryCount and schedule fields to
their pre-update state so that the failed retry attempt does not consume the
retry budget.

In
`@server/smp/src/test/kotlin/com/profiletailors/smp/publishing/application/PublishingHandlersTest.kt`:
- Around line 1217-1233: The test `list publications open-ended uses broad date
range` uses hardcoded fixture dates in June 2026, but the
ListPublicationsHandler.handle method derives its date window from
Clock.systemUTC().instant() at runtime, causing the test to fail once the actual
current date drifts beyond those fixed dates. Either replace the hardcoded
calendarPublication timestamps with runtime-relative dates (e.g., derived from
the current system clock), or capture and assert on the actual from/to query
bounds that the publicationRepository receives when handle is called, rather
than just asserting the result count. This ensures the test remains
deterministic and won't become flaky as real time progresses.

In
`@server/smp/src/test/kotlin/com/profiletailors/smp/publishing/infrastructure/credentials/RefreshAwareCredentialResolverTest.kt`:
- Around line 238-280: The tests in RefreshAwareCredentialResolverTest are
incorrectly treating all refresh failures uniformly as requiring reconnect
(INVALID_GRANT). Update the test methods to distinguish between different
failure types: only actual OAuth invalid_grant errors (expired/revoked refresh
tokens) should throw ReconnectRequiredException with INVALID_GRANT reason, while
other failures like invalid_client, transport/IO errors, timeouts, and HTTP
5xx/429 responses should be tested as retryable failures that do not require
reconnect. Specifically, for the test starting at line 238 (testing
invalid_client error with 401 status), change the assertion from expecting
ReconnectRequiredException to expecting a retryable error response instead.
Apply the same distinction across all affected test methods in the file to
properly separate transient/configuration failures from actual token revocation
scenarios.

---

Outside diff comments:
In
`@server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/persistence/R2dbcPublishingRepositories.kt`:
- Around line 287-301: The row locks acquired by the FOR UPDATE OF p SKIP LOCKED
clause in the SQL query are released immediately after findBlockedForRecovery()
returns, allowing concurrent workers to claim the same rows. Extend the
transaction boundary to encompass the entire recovery workflow including both
the findBlockedForRecovery() call and all subsequent requeueBlockedPublication()
invocations in the scanBlockedForRecovery() method in PublishingWorker.kt.
Ensure that the transaction scope wraps the select-and-requeue cycle as a single
atomic operation so that row locks persist throughout the entire recovery
process, preventing duplicate job enqueuing.
- Around line 293-300: The current `findBlockedForRecovery` query only retrieves
BLOCKED publications with ACTIVE accounts due to the filter condition `a.status
= :activeStatus`, which means BLOCKED publications associated with DELETED
accounts are never found by the recovery scan and remain stuck in BLOCKED status
instead of failing terminally as required. Create a separate query method that
specifically finds BLOCKED publications linked to DELETED accounts, then
implement a corresponding scan or recovery handler that transitions these
publications to FAILED status and sends the appropriate terminal failure
notification. This ensures BLOCKED publications whose accounts have been deleted
are properly cleaned up by the recovery mechanism rather than being ignored
indefinitely.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7041a7bd-0e39-4862-8c22-ff81923b3cd7

📥 Commits

Reviewing files that changed from the base of the PR and between 6832ac9 and b2a397a.

📒 Files selected for processing (7)
  • apps/web/app/src/views/SchedulerView.vue
  • server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/persistence/R2dbcPublishingRepositories.kt
  • server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/scheduling/PublishingWorker.kt
  • server/smp/src/test/kotlin/com/profiletailors/smp/publishing/application/PublishingApiTest.kt
  • server/smp/src/test/kotlin/com/profiletailors/smp/publishing/application/PublishingHandlersTest.kt
  • server/smp/src/test/kotlin/com/profiletailors/smp/publishing/domain/NotificationEventTest.kt
  • server/smp/src/test/kotlin/com/profiletailors/smp/publishing/infrastructure/credentials/RefreshAwareCredentialResolverTest.kt

Comment on lines +414 to +435
// Update publication first; if job replacement fails, revert to BLOCKED to avoid orphaning
// a QUEUED publication with no matching job (recovery scans filter by BLOCKED).
publicationRepository.updateEditableDraft(prepared)
try {
publicationJobRepository.replaceForPublication(
com.profiletailors.smp.publishing.domain.PublicationJob(
id = "pjob-${UUID.randomUUID()}",
publicationId = prepared.id,
workspaceId = prepared.workspaceId,
status = com.profiletailors.smp.publishing.domain.JobStatus.PENDING,
dueAt = prepared.scheduledFor ?: now,
priorityRank = 0,
attemptCount = 0,
maxAttempts = 1,
),
)
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
// Revert publication to BLOCKED so next recovery scan can retry
log.warn("Failed to replace job for blocked publication ${publication.id}, reverting to BLOCKED", e)
publicationRepository.markBlocked(publication.id, now, "Retry failed: ${e.message}")
return
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Rollback must restore retry metadata or be transactional.

updateEditableDraft(prepared) persists the incremented retryCount and retry schedule before job replacement. If replaceForPublication fails, markBlocked only flips status/reason, so failed requeue attempts still consume the max retry budget and can leave a BLOCKED publication with no retry ever scheduled. Put the draft update and job replacement in one transaction, or restore the original draft’s retry/schedule fields on failure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/scheduling/PublishingWorker.kt`
around lines 414 - 435, The updateEditableDraft method is called before
replaceForPublication, which persists the incremented retryCount and updated
schedule to the database. If the replaceForPublication call fails and
markBlocked is invoked, the retry metadata has already been persisted but no job
was created, effectively consuming a retry attempt without scheduling any work.
To fix this, either wrap the updateEditableDraft and replaceForPublication calls
in a database transaction so both operations atomically succeed or fail
together, or alternatively, before calling markBlocked in the exception handler,
explicitly restore the original publication's retryCount and schedule fields to
their pre-update state so that the failed retry attempt does not consume the
retry budget.

Comment on lines +238 to +280
@Test
fun `executeRefresh returns null on non-2xx causing ReconnectRequiredException`() = runTest {
val account = testAccount()
val connectionId = UUID.randomUUID()
val expiredCredentials = LinkedInCredentials(
accessToken = "old-token",
refreshToken = "refresh-token-123",
expiresAtEpochSeconds = fixedNow.epochSecond - 100,
scope = "w_member_social",
)
fakeConnectionRepository.addConnection(
SocialConnection(
id = "conn-1",
workspaceId = "ws-1",
provider = SocialProvider.LINKEDIN,
providerConnectionRef = "linkedin-member-123",
status = SocialConnectionStatus.ACTIVE,
credentialReference = connectionId.toString(),
),
)
fakeCredentialGateway.store("linkedin:user", connectionId, expiredCredentials)

val fakeTransport = ControllableFakeLinkedInHttpTransport(
response = com.profiletailors.smp.publishing.infrastructure.linkedin.LinkedInHttpResponse(
statusCode = 401,
headers = java.net.http.HttpHeaders.of(emptyMap()) { _, _ -> true },
body = """{"error":"invalid_client"}""",
),
)
resolver = RefreshAwareCredentialResolverImpl(
credentialGateway = fakeCredentialGateway,
socialConnectionRepository = fakeConnectionRepository,
properties = properties,
httpTransport = fakeTransport,
objectMapper = objectMapper,
clock = clock,
)

val exception = assertThrows(ReconnectRequiredException::class.java) {
kotlinx.coroutines.runBlocking { resolver.resolve(account) }
}
assertEquals(ReconnectReason.INVALID_GRANT, exception.reason)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Classify refresh failures instead of asserting all failures require reconnect.

These tests map invalid_client, timeouts, IOException, and unexpected exceptions to ReconnectReason.INVALID_GRANT. That would force user reconnects for transient LinkedIn/network failures or app misconfiguration; reserve reconnect for expired/revoked refresh tokens such as OAuth invalid_grant, and surface transport/5xx/429 failures as retryable refresh/publish failures.

Also applies to: 282-400

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@server/smp/src/test/kotlin/com/profiletailors/smp/publishing/infrastructure/credentials/RefreshAwareCredentialResolverTest.kt`
around lines 238 - 280, The tests in RefreshAwareCredentialResolverTest are
incorrectly treating all refresh failures uniformly as requiring reconnect
(INVALID_GRANT). Update the test methods to distinguish between different
failure types: only actual OAuth invalid_grant errors (expired/revoked refresh
tokens) should throw ReconnectRequiredException with INVALID_GRANT reason, while
other failures like invalid_client, transport/IO errors, timeouts, and HTTP
5xx/429 responses should be tested as retryable failures that do not require
reconnect. Specifically, for the test starting at line 238 (testing
invalid_client error with 401 status), change the assertion from expecting
ReconnectRequiredException to expecting a retryable error response instead.
Apply the same distinction across all affected test methods in the file to
properly separate transient/configuration failures from actual token revocation
scenarios.

- Add ORDER BY publication_id, position_index ASC to findBlockedForRecovery
  asset links query to preserve asset ordering (matching findByWorkspaceAndId pattern)
- Replace hardcoded June 2026 dates in open-ended list test with runtime-relative
  dates to prevent flakiness as time progresses
@kody-ai

kody-ai Bot commented Jun 16, 2026

Copy link
Copy Markdown

Code Review Completed! 🔥

The code review was successfully completed based on your current configurations.

Kody Guide: Usage and Configuration
Interacting with Kody
  • Request a Review: Ask Kody to review your PR manually by adding a comment with the @kody start-review command at the root of your PR.

  • Validate Business Logic: Ask Kody to validate your code against business rules by adding a comment with the @kody -v business-logic command.

  • Provide Feedback: Help Kody learn and improve by reacting to its comments with a 👍 for helpful suggestions or a 👎 if improvements are needed.

Current Kody Configuration
Review Options

The following review options are enabled or disabled:

Options Enabled
Bug
Performance
Security
Business Logic

Access your configuration settings here.

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
0.0% Coverage on New Code (required ≥ 80%)
E Security Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@kody-ai

kody-ai Bot commented Jun 16, 2026

Copy link
Copy Markdown

kody code-review Kody Rules high

PR title violation of the ': ' pattern occurs when using the conventional-commit 'feat(publishing):' format. Remove the 'feat(' prefix and ')' characters so the scope appears directly before the colon.

Kody rule violation: Write a clear, scoped PR title

Comment on lines +288 to +291
require(limit in MIN_LIMIT..MAX_LIMIT) {
"limit must be between $MIN_LIMIT and $MAX_LIMIT, got $limit"
}
require(offset >= 0) { "offset must be non-negative, got $offset" }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Bug medium

IllegalArgumentException from require() causes HTTP 500 errors for out-of-range limit or negative offset, breaking the API contract by masking client errors. Replace require() with explicit ResponseStatusException(HttpStatus.BAD_REQUEST, ...) checks to match the timezone validation pattern already used in the same controller.

if (limit !in MIN_LIMIT..MAX_LIMIT) {
    throw ResponseStatusException(
        HttpStatus.BAD_REQUEST,
        "limit must be between $MIN_LIMIT and $MAX_LIMIT, got $limit",
    )
}
if (offset < 0) {
    throw ResponseStatusException(
        HttpStatus.BAD_REQUEST,
        "offset must be non-negative, got $offset",
    )
}
Prompt for LLM

File server/smp/src/main/kotlin/com/profiletailors/smp/publishing/infrastructure/http/PublishingControllers.kt:

Line 288 to 291:

WHAT: require() throws IllegalArgumentException that is not mapped to an HTTP status, causing Spring to return HTTP 500 for out-of-range limit or negative offset. WHY: Clients sending invalid pagination parameters receive 500 instead of 400, breaking the API contract and masking client errors as server failures. HOW: Replace require() with explicit ResponseStatusException(HttpStatus.BAD_REQUEST, ...) checks, matching the timezone validation pattern already used in the same controller.

Suggested Code:

        if (limit !in MIN_LIMIT..MAX_LIMIT) {
            throw ResponseStatusException(
                HttpStatus.BAD_REQUEST,
                "limit must be between $MIN_LIMIT and $MAX_LIMIT, got $limit",
            )
        }
        if (offset < 0) {
            throw ResponseStatusException(
                HttpStatus.BAD_REQUEST,
                "offset must be non-negative, got $offset",
            )
        }

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

@yacosta738 yacosta738 merged commit 5f15d0a into main Jun 16, 2026
27 of 30 checks passed
@yacosta738 yacosta738 deleted the feature/linkedin-integration-publication branch June 16, 2026 15:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:backend Changes in backend code (server/smp/** or shared/**) area:docs Documentation changes (docs/**, *.md, *.mdx) type:test Test files or test infrastructure changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant