Skip to content

Commit 7b3acde

Browse files
feat: Update LinkedIn configuration to use LINKEDIN_AUTHOR_URN for improved clarity and backward compatibility
1 parent 92d5bc8 commit 7b3acde

7 files changed

Lines changed: 163 additions & 55 deletions

File tree

.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ CLAUDE_MODEL=claude-sonnet-4-6
1111

1212
# LinkedIn publishing
1313
LINKEDIN_ACCESS_TOKEN=your_linkedin_access_token
14-
LINKEDIN_PERSON_URN=urn:li:person:xxxxxxxx
14+
LINKEDIN_AUTHOR_URN=urn:li:person:xxxxxxxx
1515
# or: urn:li:organization:xxxxxxxx (company page posting)
1616

1717
# ─── Optional In Production ──────────────────────────────────────────────────

README.md

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,18 +81,28 @@ Persist .last-post-date only if at least one platform actually published
8181

8282
**Step 2 — Get your access token**
8383
1. Go to [linkedin.com/developers/tools/oauth](https://www.linkedin.com/developers/tools/oauth)
84-
2. Select your app, tick scopes: `w_member_social`, `openid`, `profile`
85-
3. Tick the redirect URL checkbox → **Request access token** → copy it → `LINKEDIN_ACCESS_TOKEN`
84+
2. If you are posting to your personal profile, tick scopes: `w_member_social`, `openid`, `profile`
85+
3. If you are posting to a company page, your app must have organization posting access and the authenticated member must consent to `w_organization_social`
86+
4. Tick the redirect URL checkbox → **Request access token** → copy it → `LINKEDIN_ACCESS_TOKEN`
8687

8788
> ⚠️ LinkedIn tokens **expire after 60 days**. Set a calendar reminder to regenerate before expiry.
8889
89-
**Step 3 — Find your Person URN**
90+
**Step 3 — Choose your author URN**
91+
92+
For a personal profile:
9093
```bash
9194
curl -H "Authorization: Bearer YOUR_TOKEN" https://api.linkedin.com/v2/userinfo
9295
```
93-
Copy the `sub` value → your URN is `urn:li:person:THAT_VALUE``LINKEDIN_PERSON_URN`
96+
Copy the `sub` value → your URN is `urn:li:person:THAT_VALUE``LINKEDIN_AUTHOR_URN`
97+
98+
For a company page:
99+
1. Use the company page's numeric organization URN: `urn:li:organization:xxxxxxxx`
100+
2. Set that value as `LINKEDIN_AUTHOR_URN`
101+
3. Make sure the LinkedIn member who generated the token is allowed to post for that page
94102

95-
> To post as a company page instead: `urn:li:organization:xxxxxxxx`
103+
> Backward compatibility: `LINKEDIN_PERSON_URN` still works, but `LINKEDIN_AUTHOR_URN` is the clearer setting going forward.
104+
105+
> If you use an organization URN with a token that only has `w_member_social`, LinkedIn will reject the publish request with a 403-class authorization error.
96106
97107
---
98108

@@ -203,7 +213,7 @@ npm install
203213

204214
# 2. Configure environment
205215
cp .env.example .env
206-
# Fill in at minimum: ANTHROPIC_API_KEY, LINKEDIN_ACCESS_TOKEN, LINKEDIN_PERSON_URN
216+
# Fill in at minimum: ANTHROPIC_API_KEY, LINKEDIN_ACCESS_TOKEN, LINKEDIN_AUTHOR_URN
207217
# Recommended for first run: NEWS_NICHE=business-architect, POSTING_STYLE=business-architect
208218

209219
# 3. Test LinkedIn credentials first (no Claude credits needed)
@@ -233,7 +243,7 @@ Go to repo → **Settings → Secrets and variables → Actions → New reposito
233243
|---|---|
234244
| `ANTHROPIC_API_KEY` | Claude API key from console.anthropic.com |
235245
| `LINKEDIN_ACCESS_TOKEN` | LinkedIn OAuth access token |
236-
| `LINKEDIN_PERSON_URN` | `urn:li:person:xxxxxx` from `/v2/userinfo` |
246+
| `LINKEDIN_AUTHOR_URN` | `urn:li:person:xxxxxx` for a member profile or `urn:li:organization:xxxxxx` for a company page |
237247

238248
#### Recommended
239249

@@ -347,6 +357,9 @@ Set the `POST_INTERVAL_DAYS` GitHub Secret to any number of days. No code change
347357
**Posts stopped after ~60 days**
348358
→ LinkedIn token expired. Regenerate at [linkedin.com/developers/tools/oauth](https://www.linkedin.com/developers/tools/oauth) and update `LINKEDIN_ACCESS_TOKEN`.
349359

360+
**LinkedIn company-page post fails with HTTP 403**
361+
`LINKEDIN_AUTHOR_URN` is set to `urn:li:organization:...` but the access token is missing org-posting permission or the authenticated member is not allowed to post for that page. Regenerate the token with `w_organization_social` and confirm the member has a valid page role.
362+
350363
**`AuthenticationError: invalid x-api-key`**
351364
→ Check `ANTHROPIC_API_KEY` — must start with `sk-ant-`, no extra spaces.
352365

prompts/business-architect-post.prompt.ts

Lines changed: 109 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,24 @@
1-
import { PostPromptParams } from './prompt-params.interface';
2-
3-
export function businessArchitectLinkedInPrompt(params: PostPromptParams): string {
4-
const { topStory, otherHeadlines, date, factualAnchors, postLengthHint } = params;
5-
const lengthRule = postLengthHint ?? '750–1000 characters';
1+
import { PostPromptParams } from "./prompt-params.interface";
2+
3+
export function businessArchitectLinkedInPrompt(
4+
params: PostPromptParams,
5+
): string {
6+
const { topStory, otherHeadlines, date, factualAnchors, postLengthHint } =
7+
params;
8+
const lengthRule = postLengthHint ?? "750–1000 characters";
69
const anchorSection = factualAnchors
710
? `\nFACTUAL ANCHORS:\n${factualAnchors}\n`
811
: "";
912

10-
return `You are writing a LinkedIn post on behalf of a senior software architect who BUILDS custom software systems for ecommerce, healthcare, and hospitality businesses. This is their core identity — they design and deliver the technology that solves operational problems in these three industries.
11-
12-
CRITICAL — every post must reflect this identity:
13-
- The author is not a commentator or analyst — they are a builder and technology partner
14-
- Frame every insight from the perspective of: "this is why businesses in [ecommerce/healthcare/hospitality] need better software — and this is what I build to solve it"
15-
- The closing must always position the author as someone who solves this problem through software, not just someone observing it
16-
17-
The audience is: ecommerce founders, healthcare executives, hospitality operators, and investors evaluating technology capabilities.
18-
19-
The tone must be:
20-
- Authoritative: the author has built these systems and knows the trade-offs
21-
- Outcome-focused: connect every technology point to a business result
22-
- Client-facing: the reader should think "this person understands my industry and could build this for us"
13+
return `<role>
14+
You are writing a LinkedIn post on behalf of a senior software architect who BUILDS custom software systems for ecommerce, healthcare, and hospitality businesses. This is their core identity — they design and deliver the technology that solves operational problems in these three industries. Voice: direct, no buzzwords, authoritative from hands-on experience.
15+
</role>
2316
24-
Write ONLY from what you read below — never add facts not present in the content.
25-
Write in clear natural English only. If the source material is in another language, translate the meaning into English without adding or changing facts.
17+
<audience>
18+
Ecommerce founders, healthcare executives, hospitality operators, and investors evaluating technology capabilities.
19+
</audience>
2620
21+
<source_material>
2722
TODAY'S TRENDING TOPIC:
2823
Title: ${topStory.title}
2924
Source: ${topStory.source}
@@ -32,18 +27,34 @@ URL: ${topStory.url}
3227
ARTICLE CONTENT:
3328
${topStory.articleText}
3429
${anchorSection}
35-
3630
OTHER TRENDING:
3731
${otherHeadlines}
32+
</source_material>
33+
34+
<tone>
35+
- Authoritative: the author has built these systems and knows the trade-offs first-hand
36+
- Outcome-focused: connect every technology point to a specific business result
37+
- Client-facing: the reader should think "this person understands my industry and could build this for us"
38+
- No filler phrases ("game-changing", "data silos", "seamless integration") unless those exact words appear in the article
39+
</tone>
40+
41+
<grounding>
42+
Write ONLY from what you read in source_material above — the author's credibility with ecommerce founders and healthcare executives depends on every claim being verifiable. Invented facts would directly damage the trust the post is trying to build.
3843
39-
Before writing, identify from the content above:
40-
- The specific product name, feature name, or technology being announced (e.g. "Locale Aware Catalogs", "GPT-4o", "Kubernetes 1.30")
41-
- One concrete statistic, number, or company name from the article (e.g. "118 of the Top 2000 retailers", "Reebok Europe")
42-
- The exact business problem this addresses (use the article's framing, not your own)
44+
Write in clear natural English only. If the source material is in another language, translate the meaning without adding or changing facts.
45+
</grounding>
4346
44-
These 3 anchors MUST appear in your post. Do not replace them with generic phrases.
47+
<pre_write>
48+
Before drafting, identify these 3 anchors from source_material (do not output this section — internal reasoning only):
49+
1. The specific product name, feature name, or technology being announced (e.g. "Locale Aware Catalogs", "Cosmos Data Platform", "OPERA Cloud 24.2")
50+
2. One concrete statistic, number, or company name from the article (e.g. "118 of the Top 2000 retailers", "Reebok Europe", "40% reduction in manual reconciliation")
51+
3. The exact business problem this addresses — use the article's framing, not your own
4552
46-
Write a LinkedIn post using this EXACT structure:
53+
After drafting, verify: do all 3 anchors appear verbatim in your post? If any is missing, revise before outputting.
54+
</pre_write>
55+
56+
<output_format>
57+
Use this exact structure:
4758
4859
💡 Industry Insight — ${date}
4960
@@ -52,28 +63,90 @@ Write a LinkedIn post using this EXACT structure:
5263
[Body: 2 sentences. Sentence 1: what specifically is happening (name the feature/product). Sentence 2: what this means for operations. Both must be traceable directly to the article content.]
5364
5465
What this means for your business:
55-
• [Specific impact — must reference something named in the article, not generic "data silos" or "API complexity"]
66+
• [Specific impact — must reference something named in the article, not generic industry buzzwords]
5667
• [Risk or cost of inaction — what the article says breaks or falls behind]
5768
• [What the author builds to solve exactly this problem — this bullet can reflect the author's expertise]
5869
5970
[Closing: a direct question positioning the author as someone who solves this — e.g. "If your [ecommerce/healthcare/hospitality] platform isn't built to handle this, let's talk."]
6071
6172
Source: ${topStory.url}
73+
</output_format>
6274
63-
STRICT RULES:
64-
- Every claim must be supported by the article content above — nothing invented
75+
<strict_rules>
76+
- Every claim must be supported by the article content — nothing invented
6577
- The specific product/feature name from the article MUST appear in the post
6678
- At least one concrete statistic or company name from the article MUST appear in the post
67-
- Use the factual anchors above as hard constraints, not inspiration
68-
- Do NOT use generic phrases ("data silos", "fragmented builds", "evolving APIs") unless those exact words appear in the article
79+
- Use the factual anchors as hard constraints, not inspiration
6980
- Total post length: ${lengthRule}
70-
- Hashtags: generate 6–10 hashtags relevant to the actual article topic and specific industry. Mix broad (#SoftwareDevelopment #DigitalTransformation) with specific topic tags. Place at the end.
71-
- Output ONLY the post text`;
81+
- Hashtags: generate 6–10 hashtags relevant to the actual article topic and industry. Mix broad (#SoftwareDevelopment #DigitalTransformation) with specific topic tags. Place at the end.
82+
- Output ONLY the post text
83+
</strict_rules>
84+
85+
<examples>
86+
<example label="ecommerce">
87+
💡 Industry Insight — March 15, 2025
88+
89+
Reebok Europe's catalog management broke under 14-language localization — Shopware's new Locale Aware Catalogs solve exactly what custom middleware can't.
90+
91+
Shopware 6.7 introduces Locale Aware Catalogs, decoupling product data from regional pricing and language rules. For multi-market retailers, this eliminates the sync failures that cause overselling and missed promotions across regions.
92+
93+
What this means for your business:
94+
• Reebok Europe reduced catalog-sync errors by removing manual locale overrides — now automated per-market
95+
• Retailers without this architecture face inventory drift during peak events like Black Friday and regional sales windows
96+
• I build catalog and pricing layers for ecommerce platforms that handle this at scale without custom middleware
97+
98+
If your ecommerce platform serves more than one market and you're still patching locale mismatches manually, let's talk.
99+
100+
Source: https://shopware.com/en/news/shopware-6-7/
101+
102+
#Ecommerce #Shopware #RetailTech #CatalogManagement #DigitalCommerce #SoftwareArchitecture #MultiMarket #EcommerceOps
103+
</example>
104+
105+
<example label="healthcare">
106+
💡 Industry Insight — February 3, 2025
107+
108+
Epic's Cosmos Data Platform now aggregates de-identified records from 260 million patients — and most health systems still can't query their own data without a 3-week IT ticket.
109+
110+
Epic released a federated query layer inside Cosmos that lets clinical teams run population-level cohort searches without exporting to a separate analytics warehouse. For mid-size hospital networks, this closes the gap between EHR data and actual operational decisions.
111+
112+
What this means for your business:
113+
• Health systems in the Cosmos network can now identify high-risk readmission cohorts in hours, not weeks — without touching raw HL7 feeds
114+
• Facilities locked into siloed departmental reporting miss the CMS quality benchmarks tied to population health metrics
115+
• I build clinical data integration layers for healthcare operators that surface this kind of insight without requiring a full Epic implementation or a separate data warehouse
116+
117+
If your care teams are still waiting on IT to pull patient cohort reports, let's talk.
118+
119+
Source: https://www.epic.com/cosmos
120+
121+
#HealthTech #EpicSystems #ClinicalData #PopulationHealth #HealthcareIT #EHR #SoftwareArchitecture #HealthcareOperations
122+
</example>
123+
124+
<example label="hospitality">
125+
💡 Industry Insight — January 22, 2025
126+
127+
Oracle OPERA Cloud 24.4 introduced real-time rate parity enforcement across OTA channels — and hotel groups running legacy PMS systems just lost another margin point without knowing it.
128+
129+
OPERA Cloud 24.4 added a native channel manager sync that pushes rate changes to Booking.com, Expedia, and direct booking simultaneously within 90 seconds. For full-service hotel groups, this eliminates the 4–6 hour lag where OTAs undercut direct rates during demand spikes.
130+
131+
What this means for your business:
132+
• Hotel groups on OPERA Cloud 24.4 recovered an average of 1.8% direct booking share in Q4 2024 by closing the rate-update window
133+
• Properties still running batch-sync PMS integrations are silently leaking direct revenue every time a revenue manager updates rates manually
134+
• I build PMS integration layers for hospitality operators that replicate this channel-sync behavior without a full OPERA Cloud migration
135+
136+
If your revenue management system still syncs rates on a schedule instead of in real time, let's talk.
137+
138+
Source: https://www.oracle.com/hospitality/opera-cloud/
139+
140+
#Hospitality #HotelTech #OPERACloud #RevenueManagement #HospitalityTech #SoftwareArchitecture #DirectBooking #PMS
141+
</example>
142+
</examples>`;
72143
}
73144

74-
export function businessArchitectTwitterPrompt(params: PostPromptParams): string {
145+
export function businessArchitectTwitterPrompt(
146+
params: PostPromptParams,
147+
): string {
75148
const { topStory, date, factualAnchors, postLengthHint } = params;
76-
const lengthRule = postLengthHint ?? '200 and 240 characters';
149+
const lengthRule = postLengthHint ?? "200 and 240 characters";
77150
const anchorSection = factualAnchors
78151
? `\nFACTUAL ANCHORS:\n${factualAnchors}\n`
79152
: "";

src/core/config/app.config.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export default registerAs("app", () => ({
1515

1616
linkedin: {
1717
accessToken: process.env.LINKEDIN_ACCESS_TOKEN,
18-
personUrn: process.env.LINKEDIN_PERSON_URN,
18+
authorUrn: process.env.LINKEDIN_AUTHOR_URN || process.env.LINKEDIN_PERSON_URN,
1919
},
2020

2121
twitter: {
@@ -48,7 +48,6 @@ export default registerAs("app", () => ({
4848
prompts: {
4949
postingStyle: process.env.POSTING_STYLE ?? '',
5050
defaultTone: process.env.DEFAULT_TONE ?? '',
51-
promptVersion: process.env.PROMPT_VERSION ?? '',
5251
linkedInPostLength: process.env.LINKEDIN_POST_LENGTH || '',
5352
twitterPostLength: process.env.TWITTER_POST_LENGTH || '',
5453
},

src/core/config/config.validation.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const configValidationSchema = Joi.object({
1616

1717
// LinkedIn — optional, but at least one platform must be configured
1818
LINKEDIN_ACCESS_TOKEN: Joi.string().allow("").optional(),
19+
LINKEDIN_AUTHOR_URN: Joi.string().allow("").optional(),
1920
LINKEDIN_PERSON_URN: Joi.string().allow("").optional(),
2021

2122
// Twitter / X — optional, but at least one platform must be configured
@@ -50,7 +51,6 @@ export const configValidationSchema = Joi.object({
5051
.allow("")
5152
.optional(),
5253
DEFAULT_TONE: Joi.string().allow("").optional(),
53-
PROMPT_VERSION: Joi.string().allow("").optional(),
5454

5555
// Slack approval gate (optional — if not set, posts publish immediately)
5656
SLACK_BOT_TOKEN: Joi.string().allow("").optional(),

src/modules/linkedin/linkedin.service.ts

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import { Injectable, OnModuleInit } from "@nestjs/common";
22
import { ConfigService } from "@nestjs/config";
33
import { HttpService } from "@nestjs/axios";
4+
import { AxiosError } from "axios";
45
import { firstValueFrom } from "rxjs";
56
import { AppLoggerService } from "../../core/logger/logger.service";
67
import { SocialPostResult } from "../../shared/interfaces/social-post.interface";
78

89
@Injectable()
910
export class LinkedInService implements OnModuleInit {
1011
private accessToken: string;
11-
private personUrn: string;
12+
private authorUrn: string;
1213
private readonly apiBase = "https://api.linkedin.com/v2";
1314
private publishRetryAttempts: number;
1415

@@ -22,27 +23,35 @@ export class LinkedInService implements OnModuleInit {
2223

2324
onModuleInit(): void {
2425
const accessToken = this.config.get<string>("app.linkedin.accessToken");
25-
const personUrn = this.config.get<string>("app.linkedin.personUrn");
26+
const authorUrn = this.config.get<string>("app.linkedin.authorUrn");
2627

27-
if (!accessToken || !personUrn) {
28+
if (!accessToken || !authorUrn) {
2829
this.logger.warn(
2930
"LinkedIn credentials not configured — skipping client init",
3031
);
3132
return;
3233
}
3334

3435
this.accessToken = accessToken;
35-
this.personUrn = personUrn;
36+
this.authorUrn = authorUrn;
3637
this.publishRetryAttempts =
3738
this.config.get<number>("app.scheduler.publishRetryAttempts") ?? 3;
38-
this.logger.log("LinkedIn service initialized");
39+
const authorType = this.authorUrn.startsWith("urn:li:organization:")
40+
? "organization"
41+
: "member";
42+
this.logger.log(`LinkedIn service initialized for ${authorType} author`);
43+
if (authorType === "organization") {
44+
this.logger.log(
45+
"Organization posting requires org-scoped LinkedIn permissions and a page role for the authenticated member",
46+
);
47+
}
3948
}
4049

4150
async createPost(content: string): Promise<SocialPostResult> {
4251
this.logger.log("Publishing post to LinkedIn...");
4352

4453
const body = {
45-
author: this.personUrn,
54+
author: this.authorUrn,
4655
lifecycleState: "PUBLISHED",
4756
specificContent: {
4857
"com.linkedin.ugc.ShareContent": {
@@ -81,7 +90,7 @@ export class LinkedInService implements OnModuleInit {
8190
postedAt: new Date(),
8291
};
8392
} catch (error) {
84-
lastError = error instanceof Error ? error.message : String(error);
93+
lastError = this.describePublishError(error);
8594
this.logger.warn(
8695
`LinkedIn publish attempt ${attempt}/${this.publishRetryAttempts} failed: ${lastError}`,
8796
);
@@ -103,4 +112,18 @@ export class LinkedInService implements OnModuleInit {
103112
private delay(ms: number): Promise<void> {
104113
return new Promise((resolve) => setTimeout(resolve, ms));
105114
}
115+
116+
private describePublishError(error: unknown): string {
117+
if (error instanceof AxiosError) {
118+
const status = error.response?.status;
119+
const details =
120+
typeof error.response?.data === "string"
121+
? error.response.data
122+
: JSON.stringify(error.response?.data);
123+
124+
return status ? `HTTP ${status}${details ? ` — ${details}` : ""}` : error.message;
125+
}
126+
127+
return error instanceof Error ? error.message : String(error);
128+
}
106129
}

src/modules/scheduler/scheduler.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ export class SchedulerService {
6363

6464
this.linkedinEnabled = !!(
6565
this.config.get("app.linkedin.accessToken") &&
66-
this.config.get("app.linkedin.personUrn")
66+
this.config.get("app.linkedin.authorUrn")
6767
);
6868

6969
this.twitterEnabled = !!(

0 commit comments

Comments
 (0)