feat: add integrations

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2026-03-09 22:34:04 +01:00
parent 646581ff44
commit a6401e3b79
26 changed files with 3207 additions and 244 deletions

View File

@@ -1,6 +1,7 @@
use forage_core::integrations::{
CreateIntegrationInput, DeliveryStatus, Integration, IntegrationConfig, IntegrationError,
IntegrationStore, IntegrationType, NotificationDelivery, NotificationRule, NOTIFICATION_TYPES,
IntegrationStore, IntegrationType, NotificationDelivery, NotificationRule, SlackMessageRef,
SlackUserLink, NOTIFICATION_TYPES,
};
use sqlx::PgPool;
use uuid::Uuid;
@@ -221,6 +222,36 @@ impl IntegrationStore for PgIntegrationStore {
Ok(())
}
async fn update_integration_config(
&self,
organisation: &str,
id: &str,
name: &str,
config: &IntegrationConfig,
) -> Result<(), IntegrationError> {
let uuid: Uuid = id
.parse()
.map_err(|_| IntegrationError::NotFound(id.to_string()))?;
let encrypted = self.encrypt_config(config)?;
let result = sqlx::query(
"UPDATE integrations SET name = $1, config_encrypted = $2, updated_at = NOW()
WHERE id = $3 AND organisation = $4",
)
.bind(name)
.bind(&encrypted)
.bind(uuid)
.bind(organisation)
.execute(&self.pool)
.await
.map_err(|e| IntegrationError::Store(e.to_string()))?;
if result.rows_affected() == 0 {
return Err(IntegrationError::NotFound(id.to_string()));
}
Ok(())
}
async fn list_rules(
&self,
integration_id: &str,
@@ -392,6 +423,189 @@ impl IntegrationStore for PgIntegrationStore {
self.row_to_integration(row)
}
// ── Slack user links ─────────────────────────────────────────
async fn get_slack_user_link(
&self,
user_id: &str,
team_id: &str,
) -> Result<Option<SlackUserLink>, IntegrationError> {
let row: Option<SlackUserLinkRow> = sqlx::query_as(
"SELECT id, user_id, team_id, team_name, slack_user_id, slack_username, created_at
FROM slack_user_links WHERE user_id = $1 AND team_id = $2",
)
.bind(user_id)
.bind(team_id)
.fetch_optional(&self.pool)
.await
.map_err(|e| IntegrationError::Store(e.to_string()))?;
Ok(row.map(|r| SlackUserLink {
id: r.id.to_string(),
user_id: r.user_id,
team_id: r.team_id,
team_name: r.team_name,
slack_user_id: r.slack_user_id,
slack_username: r.slack_username,
created_at: r.created_at.to_rfc3339(),
}))
}
async fn upsert_slack_user_link(
&self,
link: &SlackUserLink,
) -> Result<(), IntegrationError> {
sqlx::query(
"INSERT INTO slack_user_links (id, user_id, team_id, team_name, slack_user_id, slack_username, created_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
ON CONFLICT (user_id, team_id) DO UPDATE SET
slack_user_id = EXCLUDED.slack_user_id,
slack_username = EXCLUDED.slack_username,
team_name = EXCLUDED.team_name",
)
.bind(Uuid::parse_str(&link.id).map_err(|e| IntegrationError::Store(e.to_string()))?)
.bind(&link.user_id)
.bind(&link.team_id)
.bind(&link.team_name)
.bind(&link.slack_user_id)
.bind(&link.slack_username)
.execute(&self.pool)
.await
.map_err(|e| IntegrationError::Store(e.to_string()))?;
Ok(())
}
async fn delete_slack_user_link(
&self,
user_id: &str,
team_id: &str,
) -> Result<(), IntegrationError> {
sqlx::query("DELETE FROM slack_user_links WHERE user_id = $1 AND team_id = $2")
.bind(user_id)
.bind(team_id)
.execute(&self.pool)
.await
.map_err(|e| IntegrationError::Store(e.to_string()))?;
Ok(())
}
async fn list_slack_user_links(
&self,
user_id: &str,
) -> Result<Vec<SlackUserLink>, IntegrationError> {
let rows: Vec<SlackUserLinkRow> = sqlx::query_as(
"SELECT id, user_id, team_id, team_name, slack_user_id, slack_username, created_at
FROM slack_user_links WHERE user_id = $1 ORDER BY created_at",
)
.bind(user_id)
.fetch_all(&self.pool)
.await
.map_err(|e| IntegrationError::Store(e.to_string()))?;
Ok(rows
.into_iter()
.map(|r| SlackUserLink {
id: r.id.to_string(),
user_id: r.user_id,
team_id: r.team_id,
team_name: r.team_name,
slack_user_id: r.slack_user_id,
slack_username: r.slack_username,
created_at: r.created_at.to_rfc3339(),
})
.collect())
}
// ── Slack message refs ───────────────────────────────────────
async fn get_slack_message_ref(
&self,
integration_id: &str,
release_id: &str,
) -> Result<Option<SlackMessageRef>, IntegrationError> {
let iid =
Uuid::parse_str(integration_id).map_err(|e| IntegrationError::Store(e.to_string()))?;
let row: Option<SlackMessageRefRow> = sqlx::query_as(
"SELECT id, integration_id, release_id, channel_id, message_ts, last_event_type, destinations, release_title, created_at, updated_at
FROM slack_message_refs WHERE integration_id = $1 AND release_id = $2",
)
.bind(iid)
.bind(release_id)
.fetch_optional(&self.pool)
.await
.map_err(|e| IntegrationError::Store(e.to_string()))?;
Ok(row.map(|r| SlackMessageRef {
id: r.id.to_string(),
integration_id: r.integration_id.to_string(),
release_id: r.release_id,
channel_id: r.channel_id,
message_ts: r.message_ts,
last_event_type: r.last_event_type,
destinations: serde_json::from_value(r.destinations).unwrap_or_default(),
release_title: r.release_title,
created_at: r.created_at.to_rfc3339(),
updated_at: r.updated_at.to_rfc3339(),
}))
}
async fn upsert_slack_message_ref(
&self,
msg_ref: &SlackMessageRef,
) -> Result<(), IntegrationError> {
let iid = Uuid::parse_str(&msg_ref.integration_id)
.map_err(|e| IntegrationError::Store(e.to_string()))?;
let destinations_json = serde_json::to_value(&msg_ref.destinations)
.map_err(|e| IntegrationError::Store(e.to_string()))?;
sqlx::query(
"INSERT INTO slack_message_refs (id, integration_id, release_id, channel_id, message_ts, last_event_type, destinations, release_title, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
ON CONFLICT (integration_id, release_id) DO UPDATE SET
message_ts = EXCLUDED.message_ts,
last_event_type = EXCLUDED.last_event_type,
destinations = EXCLUDED.destinations,
release_title = EXCLUDED.release_title,
updated_at = NOW()",
)
.bind(Uuid::parse_str(&msg_ref.id).map_err(|e| IntegrationError::Store(e.to_string()))?)
.bind(iid)
.bind(&msg_ref.release_id)
.bind(&msg_ref.channel_id)
.bind(&msg_ref.message_ts)
.bind(&msg_ref.last_event_type)
.bind(destinations_json)
.bind(&msg_ref.release_title)
.execute(&self.pool)
.await
.map_err(|e| IntegrationError::Store(e.to_string()))?;
Ok(())
}
}
#[derive(sqlx::FromRow)]
struct SlackUserLinkRow {
id: Uuid,
user_id: String,
team_id: String,
team_name: String,
slack_user_id: String,
slack_username: String,
created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(sqlx::FromRow)]
struct SlackMessageRefRow {
id: Uuid,
integration_id: Uuid,
release_id: String,
channel_id: String,
message_ts: String,
last_event_type: String,
destinations: serde_json::Value,
release_title: String,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(sqlx::FromRow)]

View File

@@ -0,0 +1,29 @@
-- Slack user identity links (user-level "Sign in with Slack")
CREATE TABLE IF NOT EXISTS slack_user_links (
id UUID PRIMARY KEY,
user_id TEXT NOT NULL,
team_id TEXT NOT NULL,
team_name TEXT NOT NULL DEFAULT '',
slack_user_id TEXT NOT NULL,
slack_username TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, team_id)
);
CREATE INDEX idx_slack_user_links_user ON slack_user_links(user_id);
CREATE INDEX idx_slack_user_links_team_slack ON slack_user_links(team_id, slack_user_id);
-- Slack message refs for update-in-place pattern (one message per release)
CREATE TABLE IF NOT EXISTS slack_message_refs (
id UUID PRIMARY KEY,
integration_id UUID NOT NULL REFERENCES integrations(id) ON DELETE CASCADE,
release_id TEXT NOT NULL,
channel_id TEXT NOT NULL,
message_ts TEXT NOT NULL,
last_event_type TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (integration_id, release_id)
);
CREATE INDEX idx_slack_message_refs_lookup ON slack_message_refs(integration_id, release_id);

View File

@@ -0,0 +1,3 @@
-- Add per-destination status tracking and release title to slack_message_refs
ALTER TABLE slack_message_refs ADD COLUMN IF NOT EXISTS destinations JSONB NOT NULL DEFAULT '{}';
ALTER TABLE slack_message_refs ADD COLUMN IF NOT EXISTS release_title TEXT NOT NULL DEFAULT '';