Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/lfx-one/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,11 @@ CHANGELOG_PRODUCT_ID=your-changelog-product-uuid
LINKEDIN_ACCESS_TOKEN=your-linkedin-access-token
LINKEDIN_AD_ACCOUNT_ID=your-linkedin-ad-account-id
LINKEDIN_ORG_ID=your-linkedin-org-id
# Path to the runtime LinkedIn config file (account/org/targeting/exclusion data).
# In production this is mounted from the staticConfigMaps.linkedin-config ConfigMap.
# For local dev, point this at a JSON file matching the LinkedInRuntimeConfig shape
# (defaultAccountId, defaultOrgId, accounts[], employerExclusions[], targetingProfiles[]).
# LINKEDIN_CONFIG_PATH=/etc/lfx-self-serve/linkedin/linkedin.json

# Stripe publishable key
# This value can be obtained from our AWS Systems Manager Parameter Store:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,12 +189,12 @@ <h3 class="text-sm font-semibold text-gray-900">LinkedIn Sponsored Content</h3>
(change)="onLinkedInAccountChange($event)"
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
data-testid="implementation-linkedin-account">
@for (account of linkedInAccounts; track account.accountId) {
@for (account of linkedInAccounts(); track account.accountId) {
<option [value]="account.accountId">{{ account.label }} ({{ account.accountId }})</option>
}
</select>
@if (selectedLinkedInAccount()) {
<p class="mt-1 text-xs text-gray-400">Org: {{ selectedLinkedInAccount().organizationId }} · Status: {{ selectedLinkedInAccount().status }}</p>
<p class="mt-1 text-xs text-gray-400">Org: {{ selectedLinkedInAccount().orgId }} · Status: {{ selectedLinkedInAccount().status }}</p>
}
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,15 @@
// SPDX-License-Identifier: MIT

import { SlicePipe } from '@angular/common';
import { Component, computed, DestroyRef, effect, inject, input, signal } from '@angular/core';
import { Component, computed, DestroyRef, effect, inject, input, OnInit, signal } from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { FormArray, FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ButtonComponent } from '@components/button/button.component';
import {
CAMPAIGN_BUDGET_DEFAULTS,
CAMPAIGN_CHAR_LIMITS,
CAMPAIGN_JOB_POLL_INTERVAL_MS,
LINKEDIN_AD_ACCOUNTS,
LINKEDIN_CHAR_LIMITS,
LINKEDIN_DEFAULT_ACCOUNT_ID,
LINKEDIN_GEO_RESOLVE_MAP,
} from '@lfx-one/shared/constants';
import { CampaignService } from '@services/campaign.service';
Expand All @@ -26,7 +24,7 @@ import type {
CampaignKeyword,
CampaignPlatform,
CampaignType,
LinkedInAdAccount,
LinkedInAccount,
LinkedInCreativeVariant,
LinkedInGeoTarget,
LinkedInTargetingProfile,
Expand All @@ -41,7 +39,7 @@ type ImplementationStep = 'form' | 'creating' | 'results';
templateUrl: './implementation-tab.component.html',
styleUrl: './implementation-tab.component.scss',
})
export class ImplementationTabComponent {
export class ImplementationTabComponent implements OnInit {
// === Services ===
private readonly campaignService = inject(CampaignService);
private readonly fb = inject(FormBuilder);
Expand All @@ -53,7 +51,6 @@ export class ImplementationTabComponent {
// === Constants ===
protected readonly charLimits = CAMPAIGN_CHAR_LIMITS;
protected readonly linkedInCharLimits = LINKEDIN_CHAR_LIMITS;
protected readonly linkedInAccounts: readonly LinkedInAdAccount[] = LINKEDIN_AD_ACCOUNTS;
protected readonly allKnownGeos: LinkedInGeoTarget[] = [...new Map(Object.values(LINKEDIN_GEO_RESOLVE_MAP).map((g) => [g.urn, g])).values()];
protected readonly todayDate = new Date().toISOString().split('T')[0];
protected readonly defaultEndDate = new Date(Date.now() + 30 * 86_400_000).toISOString().split('T')[0];
Expand Down Expand Up @@ -88,7 +85,9 @@ export class ImplementationTabComponent {
protected readonly linkedInVariants = signal<LinkedInCreativeVariant[]>([]);
protected readonly linkedInBudgetUsd = signal(500);
protected readonly linkedInLifetimeBudget = signal(false);
protected readonly linkedInAccountId = signal<string>(LINKEDIN_DEFAULT_ACCOUNT_ID);
protected readonly linkedInAccounts = signal<LinkedInAccount[]>([]);
protected readonly linkedInAccountsLoading = signal(false);
protected readonly linkedInAccountId = signal<string>('');
protected readonly redditVariants = signal<RedditAdVariant[]>([]);
protected readonly redditSubreddits = signal<string[]>([]);
protected readonly redditInterests = signal<string[]>([]);
Expand All @@ -100,9 +99,10 @@ export class ImplementationTabComponent {
protected readonly showGoogleSection = computed(() => this.selectedPlatforms().includes('google-ads'));
protected readonly showLinkedInSection = computed(() => this.selectedPlatforms().includes('linkedin-ads'));
protected readonly showRedditSection = computed(() => this.selectedPlatforms().includes('reddit-ads'));
protected readonly selectedLinkedInAccount = computed(
() => this.linkedInAccounts.find((a) => a.accountId === this.linkedInAccountId()) ?? this.linkedInAccounts[0]
);
protected readonly selectedLinkedInAccount = computed(() => {
const accounts = this.linkedInAccounts();
return accounts.find((a) => a.accountId === this.linkedInAccountId()) ?? accounts[0];
});

protected readonly canSubmit = computed(() => {
const platforms = this.selectedPlatforms();
Expand Down Expand Up @@ -153,6 +153,25 @@ export class ImplementationTabComponent {
});
}

public ngOnInit(): void {
this.linkedInAccountsLoading.set(true);
this.campaignService
.getLinkedInAccounts()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (accounts) => {
this.linkedInAccounts.set(accounts);
if (accounts.length > 0 && !this.linkedInAccountId()) {
this.linkedInAccountId.set(accounts[0].accountId);
}
this.linkedInAccountsLoading.set(false);
},
error: () => {
this.linkedInAccountsLoading.set(false);
},
});
}

// === Public Methods ===
public reset(): void {
this.jobSubscription?.unsubscribe();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -371,8 +371,8 @@ <h4 class="text-sm font-semibold text-gray-900">Keyword Performance</h4>
aria-label="LinkedIn ad account"
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
data-testid="linkedin-account-select">
@for (acct of linkedInAccountOptions(); track acct.key) {
<option [value]="acct.key">{{ acct.label }}</option>
@for (acct of linkedInAccountOptions(); track acct.accountId) {
<option [value]="acct.accountId">{{ acct.label }}</option>
}
</select>
<div class="flex items-center gap-3">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type {
CampaignMonitorResponse,
KeywordMetrics,
KeywordMetricsResponse,
LinkedInAccountOption,
LinkedInAccount,
LinkedInMonitorResponse,
LinkedInPacingLabel,
RedditAccountOption,
Expand Down Expand Up @@ -56,7 +56,7 @@ export class MonitoringTabComponent implements OnInit {

// Platform switcher
protected readonly selectedPlatform = signal<PlatformType>('google');
protected readonly linkedInAccountOptions = signal<LinkedInAccountOption[]>([]);
protected readonly linkedInAccountOptions = signal<LinkedInAccount[]>([]);
protected readonly selectedLinkedInAccountKey = signal<string>('');
protected readonly linkedInLoading = signal(false);
protected readonly linkedInData = signal<LinkedInMonitorResponse | null>(null);
Expand Down Expand Up @@ -121,7 +121,7 @@ export class MonitoringTabComponent implements OnInit {
next: (accounts) => {
this.linkedInAccountOptions.set(accounts);
if (accounts.length > 0 && !this.selectedLinkedInAccountKey()) {
this.selectedLinkedInAccountKey.set(accounts[0].key);
this.selectedLinkedInAccountKey.set(accounts[0].accountId);
if (this.selectedPlatform() === 'linkedin') {
this.fetchLinkedInData();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -411,8 +411,8 @@ <h3 class="flex items-center gap-2 text-sm font-semibold text-gray-800">
aria-label="LinkedIn ad account"
class="rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
data-testid="linkedin-account-select-opt">
@for (acct of linkedInAccountOptions(); track acct.key) {
<option [value]="acct.key">{{ acct.label }}</option>
@for (acct of linkedInAccountOptions(); track acct.accountId) {
<option [value]="acct.accountId">{{ acct.label }}</option>
}
</select>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type {
KeywordActionType,
KeywordMetrics,
KeywordMetricsResponse,
LinkedInAccountOption,
LinkedInAccount,
LinkedInActionItem,
LinkedInMonitorResponse,
RedditAccountOption,
Expand Down Expand Up @@ -77,7 +77,7 @@ export class OptimizationTabComponent implements OnInit {
protected readonly hasDisplayCampaigns = computed(() => this.displayCampaigns().length > 0);

// LinkedIn optimization
protected readonly linkedInAccountOptions = signal<LinkedInAccountOption[]>([]);
protected readonly linkedInAccountOptions = signal<LinkedInAccount[]>([]);
protected readonly selectedLinkedInAccountKey = signal<string>('');
protected readonly linkedInLoading = signal(false);
protected readonly linkedInData = signal<LinkedInMonitorResponse | null>(null);
Expand All @@ -104,7 +104,7 @@ export class OptimizationTabComponent implements OnInit {
next: (accounts) => {
this.linkedInAccountOptions.set(accounts);
if (accounts.length > 0) {
this.selectedLinkedInAccountKey.set(accounts[0].key);
this.selectedLinkedInAccountKey.set(accounts[0].accountId);
this.fetchLinkedInOptimization();
}
},
Expand Down
6 changes: 3 additions & 3 deletions apps/lfx-one/src/app/shared/services/campaign.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
HubSpotUtmCreateResult,
HubSpotUtmLookupResult,
KeywordMetricsResponse,
LinkedInAccountOption,
LinkedInAccount,
LinkedInMonitorResponse,
RedditAccountOption,
RedditMonitorResponse,
Expand Down Expand Up @@ -71,8 +71,8 @@ export class CampaignService {
return this.http.get<CampaignMonitorResponse>('/api/campaigns/monitor', { params: { days } });
}

public getLinkedInAccounts(): Observable<LinkedInAccountOption[]> {
return this.http.get<LinkedInAccountOption[]>('/api/campaigns/linkedin/accounts');
public getLinkedInAccounts(): Observable<LinkedInAccount[]> {
return this.http.get<LinkedInAccount[]>('/api/campaigns/linkedin/accounts');
}

public getLinkedInMonitorData(accountKey: string, days: number = 30): Observable<LinkedInMonitorResponse> {
Expand Down
1 change: 0 additions & 1 deletion apps/lfx-one/src/server/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,5 @@
// SPDX-License-Identifier: MIT

export * from './gateway.constants';
export * from './linkedin.constants';
export * from './reddit.constants';
export * from './rewards.constants';
Comment thread
dealako marked this conversation as resolved.
80 changes: 0 additions & 80 deletions apps/lfx-one/src/server/constants/linkedin.constants.ts

This file was deleted.

15 changes: 11 additions & 4 deletions apps/lfx-one/src/server/controllers/campaign.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ import type {
FlushableResponse,
} from '@lfx-one/shared/interfaces';

import { LINKEDIN_ACCOUNTS, REDDIT_ACCOUNTS } from '../constants';
import { REDDIT_ACCOUNTS } from '../constants';
import { ServiceValidationError } from '../errors';
import { CampaignMetricsService, LinkedInMetricsService, RedditMetricsService } from '../services/campaign-metrics.service';
import { validateScrapeUrl } from '../helpers/url-validation';
import { getLinkedInConfig } from '../services/linkedin-ads.service';
import { CampaignProxyService } from '../services/campaign-proxy.service';
import { logger } from '../services/logger.service';
import { addShutdownHook, isShuttingDown } from '../utils/shutdown';
Expand Down Expand Up @@ -314,16 +315,22 @@ export class CampaignController {
}

public getLinkedInAccounts(_req: Request, res: Response): void {
const accounts = LINKEDIN_ACCOUNTS.map((a) => ({ key: a.accountId, label: a.label }));
res.json(accounts);
const config = getLinkedInConfig();
// Return default account first so clients defaulting to accounts[0] honour the configured default.
const sorted = [
...config.accounts.filter((a) => a.accountId === config.defaultAccountId),
...config.accounts.filter((a) => a.accountId !== config.defaultAccountId),
];
res.json(sorted);
}

public async getLinkedInMonitor(req: Request, res: Response, next: NextFunction): Promise<void> {
const rawDays = String(req.query['days'] ?? '30');
const parsedDays = /^\d+$/.test(rawDays) ? Number(rawDays) : NaN;
const days = Number.isFinite(parsedDays) ? Math.min(Math.max(parsedDays, 7), 90) : 30;
const rawKey = String(req.query['accountKey'] ?? '');
const account = LINKEDIN_ACCOUNTS.find((a) => a.accountId === rawKey) ?? LINKEDIN_ACCOUNTS[0];
const config = getLinkedInConfig();
const account = config.accounts.find((a) => a.accountId === rawKey) ?? config.accounts[0];
if (!account) {
next(
ServiceValidationError.forField('accountKey', 'Invalid LinkedIn account key', {
Expand Down
Loading
Loading