{
	"name": "Outstand - scoped Instagram and Facebook comment-to-DM",
	"active": false,
	"nodes": [
		{
			"id": "setup",
			"name": "READ ME before activation",
			"type": "n8n-nodes-base.stickyNote",
			"typeVersion": 1,
			"position": [0, -380],
			"parameters": {
				"width": 960,
				"height": 300,
				"content": "## Setup required: fail closed\n1. Read comment-to-dm-n8n.README.md beside this download. Create the PostgreSQL ledger table.\n2. Put a signature-verifying gateway in front. Verify X-Outstand-Signature over raw bytes with the Outstand endpoint secret, then forward authenticated requests. Never connect Outstand directly to this webhook.\n3. Select Header Auth on the Webhook: X-Verified-Gateway, a strong random secret held only by your gateway. Select PostgreSQL credentials on both database nodes.\n4. Edit CONFIG in Scope and build payload: org, network/account/platform-post allowlist, keyword and DM. Defaults deliberately send nothing.\n5. Select a separate Header Auth credential on Send private reply: Authorization = Bearer <Outstand API key>, conversations:send permission.\n6. No automatic send retries. A stale claimed row or needs_review requires reconciliation, NOT deletion and replay. accepted is NOT delivery.\nTested node/import details and limitations are in the companion README. Import inactive; test before publishing."
			}
		},
		{
			"id": "webhook",
			"name": "Verified gateway only",
			"type": "n8n-nodes-base.webhook",
			"typeVersion": 2,
			"position": [0, 0],
			"webhookId": "outstand-comment-dm-verified-v1",
			"parameters": {
				"httpMethod": "POST",
				"path": "outstand-comment-dm-verified",
				"authentication": "headerAuth",
				"responseMode": "responseNode",
				"options": {}
			}
		},
		{
			"id": "scope",
			"name": "Scope and build payload",
			"type": "n8n-nodes-base.code",
			"typeVersion": 2,
			"position": [240, 0],
			"parameters": {
				"mode": "runOnceForAllItems",
				"jsCode": "// Replace these non-secret campaign settings. Never put API keys here.\nconst CONFIG = { orgId: 'REPLACE_ORG_ID', keyword: 'guide', content: 'Here is the guide you requested: https://example.com/guide', targets: [\n  { network: 'instagram', accountId: 'REPLACE_IG_ACCOUNT_ID', platformPostId: 'REPLACE_IG_PLATFORM_POST_ID' },\n  { network: 'facebook', accountId: 'REPLACE_FB_ACCOUNT_ID', platformPostId: 'REPLACE_FB_PLATFORM_POST_ID' },\n] };\nconst input = $input.first().json;\nconst e = input.body;\nconst d = e?.data;\nconst ignore = () => [{ json: { eligible: false, dedupKey: 'ignored', payload: {} } }];\nconst validId = v => typeof v === 'string' && v.length > 0 && v.length <= 256;\nif (!e || e.event !== 'comment.received' || !d || CONFIG.orgId.startsWith('REPLACE_')) return ignore();\nif (d.orgId !== CONFIG.orgId || !['instagram', 'facebook'].includes(d.network)) return ignore();\nif (!validId(d.accountId) || !validId(d.commentId) || !validId(d.platformPostId)) return ignore();\nif (d.isLive !== false || d.parentCommentId !== null) return ignore(); // Feed top-level comments only.\nif (!CONFIG.targets.some(t => t.network === d.network && t.accountId === d.accountId && t.platformPostId === d.platformPostId)) return ignore();\nif (typeof d.text !== 'string' || d.text.length > 20000) return ignore();\nconst words = d.text.normalize('NFKC').toLowerCase().split(/[^\\p{L}\\p{N}_]+/u);\nif (!words.includes(CONFIG.keyword.normalize('NFKC').toLowerCase())) return ignore();\nif (d.network === 'instagram' && !validId(d.authorId)) return ignore();\nif (typeof CONFIG.content !== 'string' || !CONFIG.content.trim()) return ignore();\nconst payload = { social_account_id: d.accountId, content: CONFIG.content };\nif (d.network === 'instagram') payload.instagram = { comment_id: d.commentId, author_id: d.authorId };\nelse payload.facebook = { comment_id: d.commentId };\n// Campaign-independent key: at most one automatic attempt per account/comment.\nconst dedupKey = JSON.stringify([d.orgId, d.network, d.accountId, d.commentId]);\nreturn [{ json: { eligible: true, dedupKey, payload } }];"
			}
		},
		{
			"id": "claim",
			"name": "Claim once in PostgreSQL",
			"type": "n8n-nodes-base.postgres",
			"typeVersion": 2.6,
			"position": [480, 0],
			"parameters": {
				"operation": "executeQuery",
				"query": "WITH claimed AS (\n INSERT INTO public.outstand_comment_dm_ledger (dedup_key, payload, status)\n SELECT $1::text, $2::jsonb, 'claimed' WHERE $3::boolean\n ON CONFLICT (dedup_key) DO NOTHING RETURNING dedup_key\n) SELECT EXISTS(SELECT 1 FROM claimed) AS claimed, $1::text AS dedup_key, $2::jsonb AS payload;",
				"options": { "queryReplacement": "={{ [$json.dedupKey, JSON.stringify($json.payload), $json.eligible] }}" }
			}
		},
		{
			"id": "ack",
			"name": "Acknowledge after durable claim",
			"type": "n8n-nodes-base.respondToWebhook",
			"typeVersion": 1.4,
			"position": [720, 0],
			"parameters": {
				"respondWith": "json",
				"responseBody": "={{ { accepted: true, claimed: $json.claimed } }}",
				"options": { "responseCode": 202 }
			}
		},
		{
			"id": "if",
			"name": "Only new claims send",
			"type": "n8n-nodes-base.if",
			"typeVersion": 2.2,
			"position": [960, 0],
			"parameters": {
				"conditions": {
					"options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 },
					"conditions": [
						{
							"id": "claimed",
							"leftValue": "={{ $json.claimed }}",
							"rightValue": true,
							"operator": { "type": "boolean", "operation": "true", "singleValue": true }
						}
					],
					"combinator": "and"
				},
				"options": {}
			}
		},
		{
			"id": "send",
			"name": "Send private reply",
			"type": "n8n-nodes-base.httpRequest",
			"typeVersion": 4.2,
			"position": [1200, 0],
			"retryOnFail": false,
			"onError": "continueRegularOutput",
			"parameters": {
				"method": "POST",
				"url": "https://api.outstand.so/v1/conversations",
				"authentication": "genericCredentialType",
				"genericAuthType": "httpHeaderAuth",
				"sendBody": true,
				"contentType": "json",
				"specifyBody": "json",
				"jsonBody": "={{ $json.payload }}",
				"options": {
					"timeout": 15000,
					"redirect": { "redirect": { "followRedirects": false } },
					"response": { "response": { "fullResponse": true, "neverError": true, "responseFormat": "json" } }
				}
			}
		},
		{
			"id": "classify",
			"name": "Classify without retry",
			"type": "n8n-nodes-base.code",
			"typeVersion": 2,
			"position": [1440, 0],
			"parameters": {
				"mode": "runOnceForAllItems",
				"jsCode": "const r = $input.first().json;\nconst claim = $('Claim once in PostgreSQL').first().json;\nconst b = r.body;\nconst success = Number(r.statusCode) >= 200 && Number(r.statusCode) < 300 && b?.success === true && typeof b?.message?.id === 'string' && typeof b?.conversation?.id === 'string';\nlet status = 'needs_review';\nif (success && b.message.status === 'sent') status = 'sent';\nelse if (success && b.message.status === 'pending') status = 'pending';\nreturn [{ json: { dedup_key: claim.dedup_key, status, http_status: Number.isInteger(r.statusCode) ? r.statusCode : null, conversation_id: success ? b.conversation.id : null, message_id: success ? b.message.id : null } }];"
			}
		},
		{
			"id": "record",
			"name": "Record outcome in PostgreSQL",
			"type": "n8n-nodes-base.postgres",
			"typeVersion": 2.6,
			"position": [1680, 0],
			"parameters": {
				"operation": "executeQuery",
				"query": "UPDATE public.outstand_comment_dm_ledger SET status = $2::text, http_status = $3::integer, conversation_id = $4::text, message_id = $5::text, updated_at = now() WHERE dedup_key = $1::text RETURNING dedup_key, status, http_status, conversation_id, message_id;",
				"options": {
					"queryReplacement": "={{ [$json.dedup_key, $json.status, $json.http_status, $json.conversation_id, $json.message_id] }}"
				}
			}
		}
	],
	"connections": {
		"Verified gateway only": { "main": [[{ "node": "Scope and build payload", "type": "main", "index": 0 }]] },
		"Scope and build payload": { "main": [[{ "node": "Claim once in PostgreSQL", "type": "main", "index": 0 }]] },
		"Claim once in PostgreSQL": { "main": [[{ "node": "Acknowledge after durable claim", "type": "main", "index": 0 }]] },
		"Acknowledge after durable claim": { "main": [[{ "node": "Only new claims send", "type": "main", "index": 0 }]] },
		"Only new claims send": { "main": [[{ "node": "Send private reply", "type": "main", "index": 0 }], []] },
		"Send private reply": { "main": [[{ "node": "Classify without retry", "type": "main", "index": 0 }]] },
		"Classify without retry": { "main": [[{ "node": "Record outcome in PostgreSQL", "type": "main", "index": 0 }]] }
	},
	"settings": {
		"executionOrder": "v1",
		"saveDataErrorExecution": "all",
		"saveDataSuccessExecution": "all",
		"saveExecutionProgress": true,
		"executionTimeout": 120
	},
	"pinData": {},
	"tags": []
}
