wip: delphi trace transform rules

This commit is contained in:
aecsocket
2026-07-19 09:46:12 +01:00
parent 6b569e4ee4
commit 023c789a31
20 changed files with 1376 additions and 5 deletions
+23 -2
View File
@@ -15,7 +15,14 @@
</template>
<script setup lang="ts">
import { FolderIcon, GlobeIcon, HashIcon, ReportIcon, ShieldCheckIcon } from '@modrinth/assets'
import {
FolderIcon,
GlobeIcon,
HashIcon,
ReportIcon,
SettingsIcon,
ShieldCheckIcon,
} from '@modrinth/assets'
import { Chips, defineMessages, NavTabs, useVIntl } from '@modrinth/ui'
definePageMeta({
@@ -51,6 +58,10 @@ const messages = defineMessages({
id: 'moderation.page.global-detail-traces',
defaultMessage: 'Global traces',
},
delphiRulesTitle: {
id: 'moderation.page.delphi-rules',
defaultMessage: 'Delphi rules',
},
})
const moderationLinks = [
@@ -71,6 +82,11 @@ const moderationLinks = [
href: '/moderation/global-traces',
icon: HashIcon,
},
{
label: formatMessage(messages.delphiRulesTitle),
href: '/moderation/technical-review/rules',
icon: SettingsIcon,
},
]
const mobileNavOptions = [
@@ -79,12 +95,15 @@ const mobileNavOptions = [
formatMessage(messages.reportsTitle),
formatMessage(messages.externalFilesTitle),
formatMessage(messages.globalDetailTracesTitle),
formatMessage(messages.delphiRulesTitle),
]
const selectedChip = computed({
get() {
const path = route.path
if (path.startsWith('/moderation/technical-review')) {
if (path.startsWith('/moderation/technical-review/rules')) {
return formatMessage(messages.delphiRulesTitle)
} else if (path.startsWith('/moderation/technical-review')) {
return formatMessage(messages.technicalReviewTitle)
} else if (path.startsWith('/moderation/reports')) {
return formatMessage(messages.reportsTitle)
@@ -110,6 +129,8 @@ function navigateToPage(selectedOption: string) {
router.push('/moderation/external-projects')
} else if (selectedOption === formatMessage(messages.globalDetailTracesTitle)) {
router.push('/moderation/global-traces')
} else if (selectedOption === formatMessage(messages.delphiRulesTitle)) {
router.push('/moderation/technical-review/rules')
} else {
router.push('/moderation')
}
@@ -5,10 +5,12 @@ import {
ListFilterIcon,
LoaderCircleIcon,
SearchIcon,
SettingsIcon,
SortAscIcon,
SortDescIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
Combobox,
type ComboboxOption,
commonMessages,
@@ -607,6 +609,13 @@ watch(totalPages, (pages) => {
<div
class="flex flex-col items-stretch justify-end gap-2 sm:flex-row sm:items-center lg:flex-shrink-0"
>
<ButtonStyled>
<NuxtLink to="/moderation/technical-review/rules">
<SettingsIcon class="size-5" />
Rules
</NuxtLink>
</ButtonStyled>
<Combobox
v-model="currentResponseFilter"
class="!w-full flex-grow sm:!w-[120px] sm:flex-grow-0"
@@ -0,0 +1,288 @@
<template>
<NewModal ref="ruleModal" :header="modalTitle">
<form class="flex w-[36rem] max-w-full flex-col gap-3" @submit.prevent="saveRule">
<label class="font-semibold text-contrast" for="rule-name">Name</label>
<StyledInput
id="rule-name"
v-model="form.name"
type="text"
maxlength="128"
placeholder="Known-safe obfuscated bootstrap"
/>
<label class="font-semibold text-contrast" for="rule-priority">Priority</label>
<StyledInput id="rule-priority" v-model="form.priority" type="number" placeholder="0" />
<p class="m-0 text-sm text-secondary">Higher-priority rules are evaluated first.</p>
<label class="font-semibold text-contrast" for="rule-expression">CEL expression</label>
<StyledInput
id="rule-expression"
v-model="form.expression"
type="text"
multiline
resize="vertical"
class="font-mono"
input-class="min-h-64 font-mono"
/>
<p class="m-0 text-sm text-secondary">
Return <code>null</code> when the rule does not match, or a map containing
<code>severity</code> and/or <code>hidden</code> when it does.
</p>
<div class="flex justify-end gap-2">
<ButtonStyled>
<button type="button" @click="closeRuleModal">Cancel</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button type="submit" :disabled="isSaving">
{{ isSaving ? 'Saving...' : 'Save rule' }}
</button>
</ButtonStyled>
</div>
</form>
</NewModal>
<ConfirmModal
ref="deleteModal"
:title="`Delete ${ruleToDelete?.name ?? 'rule'}?`"
description="This permanently deletes the rule, its revisions, and its materialized effects."
:markdown="false"
proceed-label="Delete rule"
@proceed="deleteRule"
/>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<ButtonStyled circular type="transparent">
<NuxtLink to="/moderation/technical-review" aria-label="Back to tech review queue">
<ArrowLeftIcon />
</NuxtLink>
</ButtonStyled>
<div>
<h1 class="m-0 text-2xl font-bold text-contrast">Delphi rules</h1>
<p class="m-0 text-secondary">Transform or hide Delphi issue traces.</p>
</div>
</div>
<ButtonStyled color="brand">
<button type="button" @click="openCreateModal">
<PlusIcon />
Create rule
</button>
</ButtonStyled>
</div>
<details class="universal-card text-sm">
<summary class="cursor-pointer font-semibold text-contrast">CEL contract and input</summary>
<div class="mt-3 flex flex-col gap-2 text-secondary">
<p class="m-0">
Effects are maps such as
<code>{ "severity": "low", "hidden": false }</code>. Severity can be <code>low</code>,
<code>medium</code>, <code>high</code>, or <code>severe</code>.
</p>
<p class="m-0">
The <code>input</code> object contains <code>schema_version</code>,
<code>trace</code> (<code>key</code>, <code>issue_type</code>, <code>severity</code>,
<code>jar</code>, <code>file_path</code>, <code>data</code>),
<code>scan.delphi_version</code>, <code>artifact</code> (<code>size</code>,
<code>hashes</code>), and stable IDs under <code>scope</code> (<code>project_id</code>,
<code>version_id</code>, <code>file_id</code>).
</p>
</div>
</details>
<div v-if="isLoading" class="universal-card flex h-32 items-center justify-center">
<LoaderCircleIcon class="size-8 animate-spin text-secondary" />
</div>
<div v-else-if="loadFailed" class="universal-card flex flex-col items-center gap-3 py-8">
<p class="m-0 text-secondary">Failed to load Delphi rules.</p>
<ButtonStyled>
<button type="button" @click="loadRules">Try again</button>
</ButtonStyled>
</div>
<EmptyState
v-else-if="rules.length === 0"
type="no-search-result"
heading="No Delphi rules"
description="Create a rule to transform matching issue traces."
/>
<div v-else class="flex flex-col gap-3">
<article v-for="rule in rules" :key="rule.id" class="universal-card flex flex-col gap-3">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 class="m-0 text-lg font-bold text-contrast">{{ rule.name }}</h2>
<p class="m-0 text-sm text-secondary">
Priority {{ rule.priority }} · revision {{ rule.revision_id }}
</p>
</div>
<div class="flex gap-2">
<ButtonStyled>
<button type="button" @click="openEditModal(rule)">
<EditIcon />
Edit
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button type="button" @click="openDeleteModal(rule)">
<TrashIcon />
Delete
</button>
</ButtonStyled>
</div>
</div>
<pre
class="m-0 overflow-x-auto rounded-lg bg-bg-raised p-3 text-sm"
><code>{{ rule.expression }}</code></pre>
</article>
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ArrowLeftIcon, EditIcon, LoaderCircleIcon, PlusIcon, TrashIcon } from '@modrinth/assets'
import {
ButtonStyled,
ConfirmModal,
EmptyState,
injectModrinthClient,
injectNotificationManager,
NewModal,
StyledInput,
} from '@modrinth/ui'
const DEFAULT_EXPRESSION = `input.trace.issue_type == "OBFUSCATED_NAMES"
? {"severity": "low", "hidden": false}
: null`
useHead({ title: 'Delphi rules - Modrinth' })
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const ruleModal = useTemplateRef<InstanceType<typeof NewModal>>('ruleModal')
const deleteModal = useTemplateRef<InstanceType<typeof ConfirmModal>>('deleteModal')
const rules = ref<Labrinth.TechReview.Internal.DelphiRule[]>([])
const isLoading = ref(true)
const isSaving = ref(false)
const loadFailed = ref(false)
const editingRuleId = ref<number | null>(null)
const ruleToDelete = ref<Labrinth.TechReview.Internal.DelphiRule | null>(null)
const form = reactive({
name: '',
priority: '0',
expression: DEFAULT_EXPRESSION,
})
const modalTitle = computed(() => (editingRuleId.value === null ? 'Create rule' : 'Edit rule'))
async function loadRules() {
isLoading.value = true
loadFailed.value = false
try {
rules.value = await client.labrinth.tech_review_internal.getRules()
} catch (error) {
console.error('Failed to load Delphi rules', error)
loadFailed.value = true
} finally {
isLoading.value = false
}
}
function openCreateModal() {
editingRuleId.value = null
form.name = ''
form.priority = '0'
form.expression = DEFAULT_EXPRESSION
ruleModal.value?.show()
}
function openEditModal(rule: Labrinth.TechReview.Internal.DelphiRule) {
editingRuleId.value = rule.id
form.name = rule.name
form.priority = String(rule.priority)
form.expression = rule.expression
ruleModal.value?.show()
}
function closeRuleModal() {
ruleModal.value?.hide()
}
async function saveRule() {
if (isSaving.value) return
const priority = Number(form.priority)
if (!form.name.trim() || !form.expression.trim() || !Number.isInteger(priority)) {
addNotification({
type: 'error',
title: 'Invalid rule',
text: 'Enter a name, a CEL expression, and an integer priority.',
})
return
}
isSaving.value = true
const payload = {
name: form.name,
priority,
expression: form.expression,
}
try {
if (editingRuleId.value === null) {
await client.labrinth.tech_review_internal.createRule(payload)
} else {
await client.labrinth.tech_review_internal.updateRule(editingRuleId.value, payload)
}
closeRuleModal()
addNotification({
type: 'success',
title: 'Rule saved',
text: 'The Delphi rule revision was saved.',
})
await loadRules()
} catch (error) {
console.error('Failed to save Delphi rule', error)
addNotification({
type: 'error',
title: 'Failed to save rule',
text: 'Check the CEL expression and try again.',
})
} finally {
isSaving.value = false
}
}
function openDeleteModal(rule: Labrinth.TechReview.Internal.DelphiRule) {
ruleToDelete.value = rule
deleteModal.value?.show()
}
async function deleteRule() {
const rule = ruleToDelete.value
if (!rule) return
try {
await client.labrinth.tech_review_internal.deleteRule(rule.id)
addNotification({
type: 'success',
title: 'Rule deleted',
text: `${rule.name} was deleted.`,
})
await loadRules()
} catch (error) {
console.error('Failed to delete Delphi rule', error)
addNotification({
type: 'error',
title: 'Failed to delete rule',
text: 'The Delphi rule could not be deleted.',
})
} finally {
ruleToDelete.value = null
}
}
onMounted(loadRules)
</script>
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\tUPDATE delphi_rule_revisions\n\t\tSET active = FALSE\n\t\tWHERE rule_id = $1 AND active\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "2dd8132c42c883381fe2782b28e1078cb48ccd645f059e257cd204de024955b8"
}
@@ -0,0 +1,60 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\tINSERT INTO delphi_rules (\n\t\t\tname,\n\t\t\tpriority,\n\t\t\tcreated_by,\n\t\t\tupdated_by\n\t\t)\n\t\tVALUES ($1, $2, $3, $3)\n\t\tRETURNING id, name, priority, created, updated, created_by, updated_by\n\t\t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "priority",
"type_info": "Int4"
},
{
"ordinal": 3,
"name": "created",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "updated",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "created_by",
"type_info": "Int8"
},
{
"ordinal": 6,
"name": "updated_by",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Int4",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true
]
},
"hash": "3e6c4e0a778f7f06bace43cbce705f834dbb9b88b834186b1353f116a07c3421"
}
@@ -0,0 +1,61 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\tUPDATE delphi_rules\n\t\tSET\n\t\t\tname = $2,\n\t\t\tpriority = $3,\n\t\t\tupdated = CURRENT_TIMESTAMP,\n\t\t\tupdated_by = $4\n\t\tWHERE id = $1\n\t\tRETURNING id, name, priority, created, updated, created_by, updated_by\n\t\t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "priority",
"type_info": "Int4"
},
{
"ordinal": 3,
"name": "created",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "updated",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "created_by",
"type_info": "Int8"
},
{
"ordinal": 6,
"name": "updated_by",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8",
"Text",
"Int4",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true
]
},
"hash": "3fd6d574c4feaec4b5116a43432a9ff55fc6dda4b154dc3136478d3772e06020"
}
@@ -0,0 +1,42 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\tINSERT INTO delphi_rule_revisions (rule_id, expression, created_by)\n\t\tVALUES ($1, $2, $3)\n\t\tRETURNING id, expression, created, created_by\n\t\t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "expression",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "created",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "created_by",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8",
"Text",
"Int8"
]
},
"nullable": [
false,
false,
false,
true
]
},
"hash": "882090c323a15f717e9481a84ae6f7de5ddc18e2014470d2b88626a9b37d30fe"
}
@@ -0,0 +1,80 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\tSELECT\n\t\t\tr.id,\n\t\t\tr.name,\n\t\t\tr.priority,\n\t\t\tr.created,\n\t\t\tr.updated,\n\t\t\tr.created_by,\n\t\t\tr.updated_by,\n\t\t\trr.id AS revision_id,\n\t\t\trr.expression,\n\t\t\trr.created AS revision_created,\n\t\t\trr.created_by AS revision_created_by\n\t\tFROM delphi_rules r\n\t\tINNER JOIN delphi_rule_revisions rr\n\t\t\tON rr.rule_id = r.id\n\t\t\tAND rr.active\n\t\tORDER BY r.priority DESC, r.id\n\t\t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "priority",
"type_info": "Int4"
},
{
"ordinal": 3,
"name": "created",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "updated",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "created_by",
"type_info": "Int8"
},
{
"ordinal": 6,
"name": "updated_by",
"type_info": "Int8"
},
{
"ordinal": 7,
"name": "revision_id",
"type_info": "Int8"
},
{
"ordinal": 8,
"name": "expression",
"type_info": "Text"
},
{
"ordinal": 9,
"name": "revision_created",
"type_info": "Timestamptz"
},
{
"ordinal": 10,
"name": "revision_created_by",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
false,
false,
false,
true,
true,
false,
false,
false,
true
]
},
"hash": "90f61453157c8a9cec0a360430170a71b32d7fe35bf5f11654e1653c63245078"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM delphi_rules WHERE id = $1 RETURNING id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false
]
},
"hash": "9476659afe6432102c6c35cf04b37aa1023b436d06b4412a559553dd5452029e"
}
+1
View File
@@ -34,6 +34,7 @@ base64 = { workspace = true }
bitflags = { workspace = true }
bytes = { workspace = true }
censor = { workspace = true }
cel = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
clap = { workspace = true, features = ["derive"] }
clickhouse = { workspace = true, features = ["time", "uuid"] }
@@ -0,0 +1,50 @@
CREATE TABLE delphi_rules (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
name TEXT NOT NULL CHECK (BTRIM(name) <> ''),
priority INTEGER NOT NULL DEFAULT 0,
created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by BIGINT REFERENCES users (id)
ON DELETE SET NULL
ON UPDATE CASCADE,
updated_by BIGINT REFERENCES users (id)
ON DELETE SET NULL
ON UPDATE CASCADE
);
CREATE TABLE delphi_rule_revisions (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
rule_id BIGINT NOT NULL REFERENCES delphi_rules (id)
ON DELETE CASCADE
ON UPDATE CASCADE,
expression TEXT NOT NULL CHECK (BTRIM(expression) <> ''),
active BOOLEAN NOT NULL DEFAULT TRUE,
created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by BIGINT REFERENCES users (id)
ON DELETE SET NULL
ON UPDATE CASCADE
);
CREATE UNIQUE INDEX delphi_rule_revisions_active
ON delphi_rule_revisions (rule_id)
WHERE active;
CREATE INDEX delphi_rule_revisions_rule_id
ON delphi_rule_revisions (rule_id, created DESC);
CREATE TABLE delphi_rule_effects (
rule_revision_id BIGINT NOT NULL REFERENCES delphi_rule_revisions (id)
ON DELETE CASCADE
ON UPDATE CASCADE,
issue_detail_id BIGINT NOT NULL REFERENCES delphi_report_issue_details (id)
ON DELETE CASCADE
ON UPDATE CASCADE,
severity delphi_severity,
hidden BOOLEAN NOT NULL DEFAULT FALSE,
created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (rule_revision_id, issue_detail_id),
CHECK (severity IS NOT NULL OR hidden)
);
CREATE INDEX delphi_rule_effects_issue_detail_id
ON delphi_rule_effects (issue_detail_id);
+4
View File
@@ -115,6 +115,10 @@ pub fn config(cfg: &mut web::ServiceConfig) {
moderation::tech_review::search_projects,
moderation::tech_review::global::search_global_issue_details,
moderation::tech_review::global::get_global_issue_detail,
moderation::tech_review::rules::get_rules,
moderation::tech_review::rules::create_rule,
moderation::tech_review::rules::update_rule,
moderation::tech_review::rules::delete_rule,
moderation::tech_review::get_project_report,
moderation::tech_review::submit_report,
moderation::tech_review::update_issue_details,
@@ -44,10 +44,12 @@ use crate::{
use eyre::eyre;
pub mod global;
pub mod rules;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(search_projects)
.configure(global::config)
.configure(rules::config)
.service(get_project_report)
.service(get_report)
.service(get_issue)
@@ -0,0 +1,379 @@
use actix_web::{HttpRequest, delete, get, post, put, web};
use chrono::{DateTime, Utc};
use eyre::eyre;
use serde::{Deserialize, Serialize};
use crate::{
auth::check_is_moderator_from_headers,
database::{PgPool, ReadOnlyPgPool, redis::RedisPool},
models::pats::Scopes,
queue::session::AuthQueue,
routes::ApiError,
util::error::Context,
};
const MAX_RULE_NAME_LENGTH: usize = 128;
const MAX_RULE_EXPRESSION_LENGTH: usize = 65_536;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(get_rules)
.service(create_rule)
.service(update_rule)
.service(delete_rule);
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct DelphiRule {
pub id: i64,
pub name: String,
pub priority: i32,
pub expression: String,
pub revision_id: i64,
pub created: DateTime<Utc>,
pub updated: DateTime<Utc>,
pub revision_created: DateTime<Utc>,
pub created_by: Option<i64>,
pub updated_by: Option<i64>,
pub revision_created_by: Option<i64>,
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct WriteDelphiRule {
pub name: String,
pub priority: i32,
pub expression: String,
}
struct ValidatedRule {
name: String,
priority: i32,
expression: String,
}
impl WriteDelphiRule {
fn validate(self) -> Result<ValidatedRule, ApiError> {
let name = self.name.trim().to_string();
if name.is_empty() {
return Err(ApiError::Request(eyre!("rule name cannot be empty")));
}
if name.chars().count() > MAX_RULE_NAME_LENGTH {
return Err(ApiError::Request(eyre!(
"rule name cannot exceed {MAX_RULE_NAME_LENGTH} characters"
)));
}
let expression = self.expression.trim().to_string();
if expression.is_empty() {
return Err(ApiError::Request(eyre!(
"rule expression cannot be empty"
)));
}
if expression.len() > MAX_RULE_EXPRESSION_LENGTH {
return Err(ApiError::Request(eyre!(
"rule expression cannot exceed {MAX_RULE_EXPRESSION_LENGTH} bytes"
)));
}
cel::Program::compile(&expression).map_err(|error| {
ApiError::Request(eyre!("invalid cel expression: {error}"))
})?;
Ok(ValidatedRule {
name,
priority: self.priority,
expression,
})
}
}
/// List the current revision of every Delphi rule.
#[utoipa::path(
context_path = "/moderation/tech-review",
tag = "moderation",
security(("bearer_auth" = [])),
responses((status = OK, body = Vec<DelphiRule>))
)]
#[get("/rules")]
pub async fn get_rules(
req: HttpRequest,
pool: web::Data<PgPool>,
ro_pool: web::Data<ReadOnlyPgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<web::Json<Vec<DelphiRule>>, ApiError> {
check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_READ,
)
.await?;
let rows = sqlx::query!(
r#"
SELECT
r.id,
r.name,
r.priority,
r.created,
r.updated,
r.created_by,
r.updated_by,
rr.id AS revision_id,
rr.expression,
rr.created AS revision_created,
rr.created_by AS revision_created_by
FROM delphi_rules r
INNER JOIN delphi_rule_revisions rr
ON rr.rule_id = r.id
AND rr.active
ORDER BY r.priority DESC, r.id
"#,
)
.fetch_all(&***ro_pool)
.await
.wrap_internal_err("failed to fetch delphi rules")?;
Ok(web::Json(
rows.into_iter()
.map(|row| DelphiRule {
id: row.id,
name: row.name,
priority: row.priority,
expression: row.expression,
revision_id: row.revision_id,
created: row.created,
updated: row.updated,
revision_created: row.revision_created,
created_by: row.created_by,
updated_by: row.updated_by,
revision_created_by: row.revision_created_by,
})
.collect(),
))
}
/// Create a Delphi rule and its first revision.
#[utoipa::path(
context_path = "/moderation/tech-review",
tag = "moderation",
security(("bearer_auth" = [])),
request_body = WriteDelphiRule,
responses((status = OK, body = DelphiRule))
)]
#[post("/rules")]
pub async fn create_rule(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
body: web::Json<WriteDelphiRule>,
) -> Result<web::Json<DelphiRule>, ApiError> {
let user = check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_WRITE,
)
.await?;
let rule = body.into_inner().validate()?;
let user_id = user.id.0 as i64;
let mut transaction = pool
.begin()
.await
.wrap_internal_err("failed to begin delphi rule transaction")?;
let row = sqlx::query!(
r#"
INSERT INTO delphi_rules (
name,
priority,
created_by,
updated_by
)
VALUES ($1, $2, $3, $3)
RETURNING id, name, priority, created, updated, created_by, updated_by
"#,
rule.name,
rule.priority,
user_id,
)
.fetch_one(&mut transaction)
.await
.wrap_internal_err("failed to create delphi rule")?;
let revision = sqlx::query!(
r#"
INSERT INTO delphi_rule_revisions (rule_id, expression, created_by)
VALUES ($1, $2, $3)
RETURNING id, expression, created, created_by
"#,
row.id,
rule.expression,
user_id,
)
.fetch_one(&mut transaction)
.await
.wrap_internal_err("failed to create delphi rule revision")?;
transaction
.commit()
.await
.wrap_internal_err("failed to commit delphi rule transaction")?;
Ok(web::Json(DelphiRule {
id: row.id,
name: row.name,
priority: row.priority,
expression: revision.expression,
revision_id: revision.id,
created: row.created,
updated: row.updated,
revision_created: revision.created,
created_by: row.created_by,
updated_by: row.updated_by,
revision_created_by: revision.created_by,
}))
}
/// Replace a Delphi rule and create a new current revision.
#[utoipa::path(
context_path = "/moderation/tech-review",
tag = "moderation",
security(("bearer_auth" = [])),
request_body = WriteDelphiRule,
responses((status = OK, body = DelphiRule), (status = NOT_FOUND))
)]
#[put("/rules/{id}")]
pub async fn update_rule(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
path: web::Path<(i64,)>,
body: web::Json<WriteDelphiRule>,
) -> Result<web::Json<DelphiRule>, ApiError> {
let user = check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_WRITE,
)
.await?;
let (id,) = path.into_inner();
let rule = body.into_inner().validate()?;
let user_id = user.id.0 as i64;
let mut transaction = pool
.begin()
.await
.wrap_internal_err("failed to begin delphi rule transaction")?;
let row = sqlx::query!(
r#"
UPDATE delphi_rules
SET
name = $2,
priority = $3,
updated = CURRENT_TIMESTAMP,
updated_by = $4
WHERE id = $1
RETURNING id, name, priority, created, updated, created_by, updated_by
"#,
id,
rule.name,
rule.priority,
user_id,
)
.fetch_optional(&mut transaction)
.await
.wrap_internal_err("failed to update delphi rule")?
.ok_or(ApiError::NotFound)?;
sqlx::query!(
r#"
UPDATE delphi_rule_revisions
SET active = FALSE
WHERE rule_id = $1 AND active
"#,
id,
)
.execute(&mut transaction)
.await
.wrap_internal_err("failed to deactivate delphi rule revision")?;
let revision = sqlx::query!(
r#"
INSERT INTO delphi_rule_revisions (rule_id, expression, created_by)
VALUES ($1, $2, $3)
RETURNING id, expression, created, created_by
"#,
id,
rule.expression,
user_id,
)
.fetch_one(&mut transaction)
.await
.wrap_internal_err("failed to create delphi rule revision")?;
transaction
.commit()
.await
.wrap_internal_err("failed to commit delphi rule transaction")?;
Ok(web::Json(DelphiRule {
id: row.id,
name: row.name,
priority: row.priority,
expression: revision.expression,
revision_id: revision.id,
created: row.created,
updated: row.updated,
revision_created: revision.created,
created_by: row.created_by,
updated_by: row.updated_by,
revision_created_by: revision.created_by,
}))
}
/// Delete a Delphi rule and all its revisions and materialized effects.
#[utoipa::path(
context_path = "/moderation/tech-review",
tag = "moderation",
security(("bearer_auth" = [])),
responses((status = OK), (status = NOT_FOUND))
)]
#[delete("/rules/{id}")]
pub async fn delete_rule(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
path: web::Path<(i64,)>,
) -> Result<(), ApiError> {
check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_WRITE,
)
.await?;
let (id,) = path.into_inner();
let deleted = sqlx::query!(
"DELETE FROM delphi_rules WHERE id = $1 RETURNING id",
id,
)
.fetch_optional(&**pool)
.await
.wrap_internal_err("failed to delete delphi rule")?;
if deleted.is_none() {
return Err(ApiError::NotFound);
}
Ok(())
}