Merge branch 'pr-478'

# Conflicts:
#	apps/aether-gateway/src/data/state/mod.rs
#	apps/aether-gateway/src/handlers/admin/mod.rs
#	apps/aether-gateway/src/handlers/admin/routes.rs
#	crates/aether-data/src/lifecycle/bootstrap/postgres.rs
#	crates/aether-data/src/lifecycle/migrate/tests.rs
#	crates/aether-data/src/repository/announcements/postgres.rs
#	frontend/src/features/auth/components/RegisterDialog.vue
This commit is contained in:
fawney19
2026-05-19 01:22:14 +08:00
56 changed files with 6283 additions and 52 deletions

View File

@@ -0,0 +1,62 @@
ALTER TABLE users
ADD COLUMN privacy_policy_accepted_version VARCHAR(64),
ADD COLUMN privacy_policy_accepted_at BIGINT;
ALTER TABLE announcements
ADD COLUMN requires_ack BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE IF NOT EXISTS user_invite_codes (
user_id VARCHAR(64) PRIMARY KEY,
invite_code VARCHAR(64) NOT NULL UNIQUE,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
CONSTRAINT user_invite_codes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_referrals (
id VARCHAR(64) PRIMARY KEY,
inviter_user_id VARCHAR(64) NOT NULL,
invitee_user_id VARCHAR(64) NOT NULL UNIQUE,
invite_code_snapshot VARCHAR(64) NOT NULL,
source_json TEXT,
first_paid_order_id VARCHAR(64),
first_paid_at BIGINT,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
KEY idx_user_referrals_inviter (inviter_user_id, created_at),
KEY idx_user_referrals_created (created_at),
KEY idx_user_referrals_invite_code (invite_code_snapshot),
CONSTRAINT user_referrals_inviter_user_id_fkey FOREIGN KEY (inviter_user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT user_referrals_invitee_user_id_fkey FOREIGN KEY (invitee_user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT user_referrals_first_paid_order_fkey FOREIGN KEY (first_paid_order_id) REFERENCES payment_orders(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS referral_rewards (
id VARCHAR(64) PRIMARY KEY,
referral_id VARCHAR(64) NOT NULL,
inviter_user_id VARCHAR(64) NOT NULL,
invitee_user_id VARCHAR(64) NOT NULL,
reward_type VARCHAR(32) NOT NULL,
trigger_point VARCHAR(64) NOT NULL,
source_order_id VARCHAR(64),
idempotency_key VARCHAR(128) NOT NULL UNIQUE,
amount_usd DOUBLE NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
wallet_transaction_id VARCHAR(64),
reversed_amount_usd DOUBLE NOT NULL DEFAULT 0,
pending_reversal_amount_usd DOUBLE NOT NULL DEFAULT 0,
failure_reason TEXT,
admin_operator_id VARCHAR(64),
admin_note TEXT,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
KEY idx_referral_rewards_inviter_status (inviter_user_id, status, created_at),
KEY idx_referral_rewards_inviter_created (inviter_user_id, created_at),
KEY idx_referral_rewards_created (created_at),
KEY idx_referral_rewards_source_order (source_order_id),
CONSTRAINT referral_rewards_referral_id_fkey FOREIGN KEY (referral_id) REFERENCES user_referrals(id) ON DELETE CASCADE,
CONSTRAINT referral_rewards_inviter_user_id_fkey FOREIGN KEY (inviter_user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT referral_rewards_invitee_user_id_fkey FOREIGN KEY (invitee_user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT referral_rewards_source_order_fkey FOREIGN KEY (source_order_id) REFERENCES payment_orders(id) ON DELETE SET NULL
);

View File

@@ -0,0 +1,63 @@
ALTER TABLE public.users
ADD COLUMN privacy_policy_accepted_version character varying(64),
ADD COLUMN privacy_policy_accepted_at timestamp with time zone;
ALTER TABLE public.announcements
ADD COLUMN requires_ack boolean NOT NULL DEFAULT false;
CREATE TABLE IF NOT EXISTS public.user_invite_codes (
user_id character varying(64) PRIMARY KEY REFERENCES public.users(id) ON DELETE CASCADE,
invite_code character varying(64) NOT NULL UNIQUE,
active boolean NOT NULL DEFAULT true,
created_at timestamp with time zone NOT NULL DEFAULT NOW(),
updated_at timestamp with time zone NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS public.user_referrals (
id character varying(64) PRIMARY KEY,
inviter_user_id character varying(64) NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
invitee_user_id character varying(64) NOT NULL UNIQUE REFERENCES public.users(id) ON DELETE CASCADE,
invite_code_snapshot character varying(64) NOT NULL,
source_json jsonb,
first_paid_order_id character varying(64) REFERENCES public.payment_orders(id) ON DELETE SET NULL,
first_paid_at timestamp with time zone,
created_at timestamp with time zone NOT NULL DEFAULT NOW(),
updated_at timestamp with time zone NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_user_referrals_inviter
ON public.user_referrals USING btree (inviter_user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_user_referrals_created
ON public.user_referrals USING btree (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_user_referrals_invite_code
ON public.user_referrals USING btree (invite_code_snapshot);
CREATE TABLE IF NOT EXISTS public.referral_rewards (
id character varying(64) PRIMARY KEY,
referral_id character varying(64) NOT NULL REFERENCES public.user_referrals(id) ON DELETE CASCADE,
inviter_user_id character varying(64) NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
invitee_user_id character varying(64) NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
reward_type character varying(32) NOT NULL,
trigger_point character varying(64) NOT NULL,
source_order_id character varying(64) REFERENCES public.payment_orders(id) ON DELETE SET NULL,
idempotency_key character varying(128) NOT NULL UNIQUE,
amount_usd numeric(20,8) NOT NULL,
status character varying(32) NOT NULL DEFAULT 'pending',
wallet_transaction_id character varying(64),
reversed_amount_usd numeric(20,8) NOT NULL DEFAULT 0,
pending_reversal_amount_usd numeric(20,8) NOT NULL DEFAULT 0,
failure_reason text,
admin_operator_id character varying(64),
admin_note text,
created_at timestamp with time zone NOT NULL DEFAULT NOW(),
updated_at timestamp with time zone NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_referral_rewards_inviter_status
ON public.referral_rewards USING btree (inviter_user_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_referral_rewards_inviter_created
ON public.referral_rewards USING btree (inviter_user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_referral_rewards_created
ON public.referral_rewards USING btree (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_referral_rewards_source_order
ON public.referral_rewards USING btree (source_order_id);

View File

@@ -0,0 +1,69 @@
ALTER TABLE users ADD COLUMN privacy_policy_accepted_version TEXT;
ALTER TABLE users ADD COLUMN privacy_policy_accepted_at INTEGER;
ALTER TABLE announcements ADD COLUMN requires_ack INTEGER NOT NULL DEFAULT 0;
CREATE TABLE IF NOT EXISTS user_invite_codes (
user_id TEXT PRIMARY KEY,
invite_code TEXT NOT NULL UNIQUE,
active INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_referrals (
id TEXT PRIMARY KEY,
inviter_user_id TEXT NOT NULL,
invitee_user_id TEXT NOT NULL UNIQUE,
invite_code_snapshot TEXT NOT NULL,
source_json TEXT,
first_paid_order_id TEXT,
first_paid_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY(inviter_user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(invitee_user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(first_paid_order_id) REFERENCES payment_orders(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_user_referrals_inviter
ON user_referrals (inviter_user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_user_referrals_created
ON user_referrals (created_at);
CREATE INDEX IF NOT EXISTS idx_user_referrals_invite_code
ON user_referrals (invite_code_snapshot);
CREATE TABLE IF NOT EXISTS referral_rewards (
id TEXT PRIMARY KEY,
referral_id TEXT NOT NULL,
inviter_user_id TEXT NOT NULL,
invitee_user_id TEXT NOT NULL,
reward_type TEXT NOT NULL,
trigger_point TEXT NOT NULL,
source_order_id TEXT,
idempotency_key TEXT NOT NULL UNIQUE,
amount_usd REAL NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
wallet_transaction_id TEXT,
reversed_amount_usd REAL NOT NULL DEFAULT 0,
pending_reversal_amount_usd REAL NOT NULL DEFAULT 0,
failure_reason TEXT,
admin_operator_id TEXT,
admin_note TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY(referral_id) REFERENCES user_referrals(id) ON DELETE CASCADE,
FOREIGN KEY(inviter_user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(invitee_user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(source_order_id) REFERENCES payment_orders(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_referral_rewards_inviter_status
ON referral_rewards (inviter_user_id, status, created_at);
CREATE INDEX IF NOT EXISTS idx_referral_rewards_inviter_created
ON referral_rewards (inviter_user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_referral_rewards_created
ON referral_rewards (created_at);
CREATE INDEX IF NOT EXISTS idx_referral_rewards_source_order
ON referral_rewards (source_order_id);

View File

@@ -131,6 +131,7 @@ CREATE TABLE IF NOT EXISTS public.announcements (
author_id character varying(36),
is_active boolean DEFAULT true,
is_pinned boolean DEFAULT false,
requires_ack boolean DEFAULT false NOT NULL,
start_time timestamp with time zone,
end_time timestamp with time zone,
created_at timestamp with time zone DEFAULT now() NOT NULL,
@@ -1412,11 +1413,72 @@ CREATE TABLE IF NOT EXISTS public.users (
email_verified boolean NOT NULL,
rate_limit integer,
rate_limit_mode text DEFAULT 'system'::text NOT NULL,
privacy_policy_accepted_version character varying(64),
privacy_policy_accepted_at timestamp with time zone,
metadata json
);
--
-- Name: user_invite_codes; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE IF NOT EXISTS public.user_invite_codes (
user_id character varying(64) NOT NULL,
invite_code character varying(64) NOT NULL,
active boolean DEFAULT true NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
--
-- Name: user_referrals; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE IF NOT EXISTS public.user_referrals (
id character varying(64) NOT NULL,
inviter_user_id character varying(64) NOT NULL,
invitee_user_id character varying(64) NOT NULL,
invite_code_snapshot character varying(64) NOT NULL,
source_json jsonb,
first_paid_order_id character varying(64),
first_paid_at timestamp with time zone,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
--
-- Name: referral_rewards; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE IF NOT EXISTS public.referral_rewards (
id character varying(64) NOT NULL,
referral_id character varying(64) NOT NULL,
inviter_user_id character varying(64) NOT NULL,
invitee_user_id character varying(64) NOT NULL,
reward_type character varying(32) NOT NULL,
trigger_point character varying(64) NOT NULL,
source_order_id character varying(64),
idempotency_key character varying(128) NOT NULL,
amount_usd numeric(20,8) NOT NULL,
status character varying(32) DEFAULT 'pending'::character varying NOT NULL,
wallet_transaction_id character varying(64),
reversed_amount_usd numeric(20,8) DEFAULT 0 NOT NULL,
pending_reversal_amount_usd numeric(20,8) DEFAULT 0 NOT NULL,
failure_reason text,
admin_operator_id character varying(64),
admin_note text,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
--
-- Name: user_groups; Type: TABLE; Schema: public; Owner: -
--

View File

@@ -1227,6 +1227,96 @@ END $mig$;
--
-- Name: user_invite_codes user_invite_codes_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.user_invite_codes
ADD CONSTRAINT user_invite_codes_pkey PRIMARY KEY (user_id);
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: user_invite_codes user_invite_codes_invite_code_key; Type: CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.user_invite_codes
ADD CONSTRAINT user_invite_codes_invite_code_key UNIQUE (invite_code);
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: user_referrals user_referrals_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.user_referrals
ADD CONSTRAINT user_referrals_pkey PRIMARY KEY (id);
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: user_referrals user_referrals_invitee_user_id_key; Type: CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.user_referrals
ADD CONSTRAINT user_referrals_invitee_user_id_key UNIQUE (invitee_user_id);
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: referral_rewards referral_rewards_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.referral_rewards
ADD CONSTRAINT referral_rewards_pkey PRIMARY KEY (id);
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: referral_rewards referral_rewards_idempotency_key_key; Type: CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.referral_rewards
ADD CONSTRAINT referral_rewards_idempotency_key_key UNIQUE (idempotency_key);
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: user_plan_entitlements user_plan_entitlements_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--

View File

@@ -613,6 +613,62 @@ CREATE INDEX IF NOT EXISTS idx_user_sessions_user_device ON public.user_sessions
--
-- Name: idx_user_referrals_inviter; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX IF NOT EXISTS idx_user_referrals_inviter ON public.user_referrals USING btree (inviter_user_id, created_at DESC);
--
-- Name: idx_user_referrals_created; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX IF NOT EXISTS idx_user_referrals_created ON public.user_referrals USING btree (created_at DESC);
--
-- Name: idx_user_referrals_invite_code; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX IF NOT EXISTS idx_user_referrals_invite_code ON public.user_referrals USING btree (invite_code_snapshot);
--
-- Name: idx_referral_rewards_inviter_status; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX IF NOT EXISTS idx_referral_rewards_inviter_status ON public.referral_rewards USING btree (inviter_user_id, status, created_at DESC);
--
-- Name: idx_referral_rewards_inviter_created; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX IF NOT EXISTS idx_referral_rewards_inviter_created ON public.referral_rewards USING btree (inviter_user_id, created_at DESC);
--
-- Name: idx_referral_rewards_created; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX IF NOT EXISTS idx_referral_rewards_created ON public.referral_rewards USING btree (created_at DESC);
--
-- Name: idx_referral_rewards_source_order; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX IF NOT EXISTS idx_referral_rewards_source_order ON public.referral_rewards USING btree (source_order_id);
--
-- Name: idx_video_tasks_external_id; Type: INDEX; Schema: public; Owner: -
--

View File

@@ -792,6 +792,126 @@ END $mig$;
--
-- Name: user_invite_codes user_invite_codes_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.user_invite_codes
ADD CONSTRAINT user_invite_codes_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: user_referrals user_referrals_inviter_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.user_referrals
ADD CONSTRAINT user_referrals_inviter_user_id_fkey FOREIGN KEY (inviter_user_id) REFERENCES public.users(id) ON DELETE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: user_referrals user_referrals_invitee_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.user_referrals
ADD CONSTRAINT user_referrals_invitee_user_id_fkey FOREIGN KEY (invitee_user_id) REFERENCES public.users(id) ON DELETE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: user_referrals user_referrals_first_paid_order_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.user_referrals
ADD CONSTRAINT user_referrals_first_paid_order_id_fkey FOREIGN KEY (first_paid_order_id) REFERENCES public.payment_orders(id) ON DELETE SET NULL;
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: referral_rewards referral_rewards_referral_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.referral_rewards
ADD CONSTRAINT referral_rewards_referral_id_fkey FOREIGN KEY (referral_id) REFERENCES public.user_referrals(id) ON DELETE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: referral_rewards referral_rewards_inviter_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.referral_rewards
ADD CONSTRAINT referral_rewards_inviter_user_id_fkey FOREIGN KEY (inviter_user_id) REFERENCES public.users(id) ON DELETE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: referral_rewards referral_rewards_invitee_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.referral_rewards
ADD CONSTRAINT referral_rewards_invitee_user_id_fkey FOREIGN KEY (invitee_user_id) REFERENCES public.users(id) ON DELETE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: referral_rewards referral_rewards_source_order_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.referral_rewards
ADD CONSTRAINT referral_rewards_source_order_id_fkey FOREIGN KEY (source_order_id) REFERENCES public.payment_orders(id) ON DELETE SET NULL;
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: video_tasks video_tasks_api_key_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--

View File

@@ -7,7 +7,7 @@ use tracing::info;
// Generated by build.rs from schema/bootstrap/postgres.
pub(crate) static EMPTY_DATABASE_SNAPSHOT_SQL: &str =
include_str!(concat!(env!("OUT_DIR"), "/empty_database_snapshot.sql"));
pub(crate) const EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION: i64 = 20260518000000;
pub(crate) const EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION: i64 = 20260519000000;
const PUBLIC_BASE_TABLE_COUNT_SQL: &str = r#"
SELECT COUNT(*)::BIGINT

View File

@@ -307,6 +307,7 @@ fn empty_database_snapshot_covers_current_cutoff_versions() {
20260515000000,
20260516000000,
20260518000000,
20260519000000,
]
);
}
@@ -597,6 +598,7 @@ fn mysql_and_sqlite_migrations_include_enabled_incrementals() {
20260512090000,
20260512110000,
20260516000000,
20260519000000,
]
);
assert_eq!(
@@ -614,6 +616,7 @@ fn mysql_and_sqlite_migrations_include_enabled_incrementals() {
20260512090000,
20260512110000,
20260516000000,
20260519000000,
]
);
}
@@ -1132,6 +1135,7 @@ fn pending_migrations_from_applied_skips_versions_already_applied() {
20260515000000,
20260516000000,
20260518000000,
20260519000000,
]
);
}

View File

@@ -135,6 +135,48 @@ impl AnnouncementReadRepository for InMemoryAnnouncementReadRepository {
Ok(total)
}
async fn list_required_unread_active_announcements(
&self,
user_id: &str,
now_unix_secs: u64,
limit: usize,
) -> Result<Vec<StoredAnnouncement>, DataLayerError> {
let announcements = self
.announcements
.read()
.expect("announcement repository lock");
let reads = self
.announcement_reads
.read()
.expect("announcement reads repository lock");
let mut items = announcements
.iter()
.filter(|announcement| {
announcement.requires_ack
&& announcement.is_active
&& announcement
.start_time_unix_secs
.is_none_or(|value| value <= now_unix_secs)
&& announcement
.end_time_unix_secs
.is_none_or(|value| value >= now_unix_secs)
&& !reads.contains(&(user_id.to_string(), announcement.id.clone()))
})
.cloned()
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.is_pinned
.cmp(&left.is_pinned)
.then_with(|| right.priority.cmp(&left.priority))
.then_with(|| right.created_at_unix_ms.cmp(&left.created_at_unix_ms))
.then_with(|| left.id.cmp(&right.id))
});
items.truncate(limit);
Ok(items)
}
}
#[async_trait]
@@ -153,6 +195,7 @@ impl AnnouncementWriteRepository for InMemoryAnnouncementReadRepository {
record.priority,
true,
record.is_pinned,
record.requires_ack,
Some(record.author_id),
None,
record.start_time_unix_secs.map(|value| value as i64),
@@ -201,6 +244,9 @@ impl AnnouncementWriteRepository for InMemoryAnnouncementReadRepository {
if let Some(is_pinned) = record.is_pinned {
announcement.is_pinned = is_pinned;
}
if let Some(requires_ack) = record.requires_ack {
announcement.requires_ack = requires_ack;
}
if let Some(start_time_unix_secs) = record.start_time_unix_secs {
announcement.start_time_unix_secs = Some(start_time_unix_secs);
}
@@ -261,6 +307,7 @@ mod tests {
10,
true,
true,
false,
Some("admin-1".to_string()),
Some("admin".to_string()),
None,
@@ -291,6 +338,7 @@ mod tests {
kind: "maintenance".to_string(),
priority: 10,
is_pinned: true,
requires_ack: false,
author_id: "admin-1".to_string(),
start_time_unix_secs: None,
end_time_unix_secs: None,
@@ -308,6 +356,7 @@ mod tests {
priority: Some(99),
is_active: Some(false),
is_pinned: Some(false),
requires_ack: Some(true),
start_time_unix_secs: None,
end_time_unix_secs: None,
})
@@ -337,6 +386,7 @@ mod tests {
10,
true,
true,
false,
Some("admin-1".to_string()),
Some("admin".to_string()),
None,
@@ -353,6 +403,7 @@ mod tests {
5,
true,
false,
false,
Some("admin-1".to_string()),
Some("admin".to_string()),
None,

View File

@@ -18,6 +18,7 @@ SELECT
a.priority,
a.is_active,
a.is_pinned,
a.requires_ack,
a.author_id,
u.username AS author_username,
a.start_time AS start_time_unix_secs,
@@ -144,6 +145,39 @@ WHERE a.is_active = 1
.map_sql_err()?;
Ok(row.try_get::<i64, _>("total").map_sql_err()?.max(0) as u64)
}
async fn list_required_unread_active_announcements(
&self,
user_id: &str,
now_unix_secs: u64,
limit: usize,
) -> Result<Vec<StoredAnnouncement>, DataLayerError> {
let rows = sqlx::query(&format!(
r#"
{ANNOUNCEMENT_SELECT}
WHERE a.is_active = 1
AND a.requires_ack = 1
AND (a.start_time IS NULL OR a.start_time <= ?)
AND (a.end_time IS NULL OR a.end_time >= ?)
AND NOT EXISTS (
SELECT 1
FROM announcement_reads r
WHERE r.user_id = ?
AND r.announcement_id = a.id
)
ORDER BY a.is_pinned DESC, a.priority DESC, a.created_at DESC, a.id ASC
LIMIT ?
"#
))
.bind(now_unix_secs as i64)
.bind(now_unix_secs as i64)
.bind(user_id)
.bind(limit as i64)
.fetch_all(&self.pool)
.await
.map_sql_err()?;
rows.iter().map(map_announcement_row).collect()
}
}
#[async_trait]
@@ -159,9 +193,9 @@ impl AnnouncementWriteRepository for MysqlAnnouncementRepository {
r#"
INSERT INTO announcements (
id, title, content, `type`, priority, author_id, is_active, is_pinned,
start_time, end_time, created_at, updated_at
requires_ack, start_time, end_time, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(&id)
@@ -171,6 +205,7 @@ VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)
.bind(record.priority)
.bind(record.author_id)
.bind(record.is_pinned)
.bind(record.requires_ack)
.bind(optional_i64_from_u64(
record.start_time_unix_secs,
"announcements.start_time",
@@ -204,6 +239,7 @@ SET title = COALESCE(?, title),
priority = COALESCE(?, priority),
is_active = COALESCE(?, is_active),
is_pinned = COALESCE(?, is_pinned),
requires_ack = COALESCE(?, requires_ack),
start_time = COALESCE(?, start_time),
end_time = COALESCE(?, end_time),
updated_at = ?
@@ -216,6 +252,7 @@ WHERE id = ?
.bind(record.priority)
.bind(record.is_active)
.bind(record.is_pinned)
.bind(record.requires_ack)
.bind(optional_i64_from_u64(
record.start_time_unix_secs,
"announcements.start_time",
@@ -303,6 +340,7 @@ fn map_announcement_row(row: &MySqlRow) -> Result<StoredAnnouncement, DataLayerE
row.try_get("priority").map_sql_err()?,
row.try_get("is_active").map_sql_err()?,
row.try_get("is_pinned").map_sql_err()?,
row.try_get("requires_ack").map_sql_err()?,
row.try_get("author_id").map_sql_err()?,
row.try_get("author_username").map_sql_err()?,
row.try_get("start_time_unix_secs").map_sql_err()?,

View File

@@ -18,6 +18,7 @@ SELECT
a.priority,
a.is_active,
a.is_pinned,
a.requires_ack,
a.author_id,
u.username AS author_username,
EXTRACT(EPOCH FROM a.start_time)::bigint AS start_time_unix_secs,
@@ -28,6 +29,38 @@ FROM announcements a
LEFT JOIN users u ON u.id = a.author_id
"#;
const LIST_REQUIRED_UNREAD_ACTIVE_ANNOUNCEMENTS_SQL: &str = r#"
SELECT
a.id,
a.title,
a.content,
a.type,
a.priority,
a.is_active,
a.is_pinned,
a.requires_ack,
a.author_id,
u.username AS author_username,
EXTRACT(EPOCH FROM a.start_time)::bigint AS start_time_unix_secs,
EXTRACT(EPOCH FROM a.end_time)::bigint AS end_time_unix_secs,
EXTRACT(EPOCH FROM a.created_at)::bigint AS created_at_unix_ms,
EXTRACT(EPOCH FROM a.updated_at)::bigint AS updated_at_unix_secs
FROM announcements a
LEFT JOIN users u ON u.id = a.author_id
WHERE a.is_active = TRUE
AND a.requires_ack = TRUE
AND (a.start_time IS NULL OR a.start_time <= TO_TIMESTAMP($2::double precision))
AND (a.end_time IS NULL OR a.end_time >= TO_TIMESTAMP($2::double precision))
AND NOT EXISTS (
SELECT 1
FROM announcement_reads r
WHERE r.user_id = $1
AND r.announcement_id = a.id
)
ORDER BY a.is_pinned DESC, a.priority DESC, a.created_at DESC, a.id ASC
LIMIT $3
"#;
const CREATE_ANNOUNCEMENT_SQL: &str = r#"
INSERT INTO announcements (
id,
@@ -38,6 +71,7 @@ INSERT INTO announcements (
author_id,
is_active,
is_pinned,
requires_ack,
start_time,
end_time,
created_at,
@@ -54,6 +88,7 @@ VALUES (
$7,
$8,
$9,
$10,
NOW(),
NOW()
)
@@ -65,6 +100,7 @@ RETURNING
priority,
is_active,
is_pinned,
requires_ack,
author_id,
(SELECT username FROM users WHERE id = announcements.author_id) AS author_username,
EXTRACT(EPOCH FROM start_time)::bigint AS start_time_unix_secs,
@@ -82,8 +118,9 @@ SET
priority = COALESCE($5, priority),
is_active = COALESCE($6, is_active),
is_pinned = COALESCE($7, is_pinned),
start_time = COALESCE($8, start_time),
end_time = COALESCE($9, end_time),
requires_ack = COALESCE($8, requires_ack),
start_time = COALESCE($9, start_time),
end_time = COALESCE($10, end_time),
updated_at = NOW()
WHERE id = $1
RETURNING
@@ -94,6 +131,7 @@ RETURNING
priority,
is_active,
is_pinned,
requires_ack,
author_id,
(SELECT username FROM users WHERE id = announcements.author_id) AS author_username,
EXTRACT(EPOCH FROM start_time)::bigint AS start_time_unix_secs,
@@ -247,6 +285,22 @@ impl AnnouncementReadRepository for SqlxAnnouncementReadRepository {
.max(0) as u64;
Ok(total)
}
async fn list_required_unread_active_announcements(
&self,
user_id: &str,
now_unix_secs: u64,
limit: usize,
) -> Result<Vec<StoredAnnouncement>, DataLayerError> {
let rows = sqlx::query(LIST_REQUIRED_UNREAD_ACTIVE_ANNOUNCEMENTS_SQL)
.bind(user_id)
.bind(now_unix_secs as f64)
.bind(limit as i64)
.fetch_all(&self.pool)
.await
.map_postgres_err()?;
rows.iter().map(map_announcement_row).collect()
}
}
#[async_trait]
@@ -264,6 +318,7 @@ impl AnnouncementWriteRepository for SqlxAnnouncementReadRepository {
.bind(record.priority)
.bind(record.author_id)
.bind(record.is_pinned)
.bind(record.requires_ack)
.bind(optional_datetime(record.start_time_unix_secs))
.bind(optional_datetime(record.end_time_unix_secs))
.fetch_one(&self.pool)
@@ -285,6 +340,7 @@ impl AnnouncementWriteRepository for SqlxAnnouncementReadRepository {
.bind(record.priority)
.bind(record.is_active)
.bind(record.is_pinned)
.bind(record.requires_ack)
.bind(optional_datetime(record.start_time_unix_secs))
.bind(optional_datetime(record.end_time_unix_secs))
.fetch_optional(&self.pool)
@@ -351,6 +407,7 @@ fn map_announcement_row(row: &PgRow) -> Result<StoredAnnouncement, DataLayerErro
row.try_get("priority").map_postgres_err()?,
row.try_get("is_active").map_postgres_err()?,
row.try_get("is_pinned").map_postgres_err()?,
row.try_get("requires_ack").map_postgres_err()?,
row.try_get("author_id").map_postgres_err()?,
row.try_get("author_username").map_postgres_err()?,
row.try_get("start_time_unix_secs").map_postgres_err()?,

View File

@@ -19,6 +19,7 @@ SELECT
a.priority,
a.is_active,
a.is_pinned,
a.requires_ack,
a.author_id,
u.username AS author_username,
a.start_time AS start_time_unix_secs,
@@ -158,6 +159,39 @@ impl AnnouncementReadRepository for SqliteAnnouncementRepository {
.max(0) as u64;
Ok(total)
}
async fn list_required_unread_active_announcements(
&self,
user_id: &str,
now_unix_secs: u64,
limit: usize,
) -> Result<Vec<StoredAnnouncement>, DataLayerError> {
let rows = sqlx::query(&format!(
r#"
{ANNOUNCEMENT_SELECT}
WHERE a.is_active = 1
AND a.requires_ack = 1
AND (a.start_time IS NULL OR a.start_time <= ?)
AND (a.end_time IS NULL OR a.end_time >= ?)
AND NOT EXISTS (
SELECT 1
FROM announcement_reads r
WHERE r.user_id = ?
AND r.announcement_id = a.id
)
ORDER BY a.is_pinned DESC, a.priority DESC, a.created_at DESC, a.id ASC
LIMIT ?
"#
))
.bind(now_unix_secs as i64)
.bind(now_unix_secs as i64)
.bind(user_id)
.bind(limit as i64)
.fetch_all(&self.pool)
.await
.map_sql_err()?;
rows.iter().map(map_announcement_row).collect()
}
}
#[async_trait]
@@ -173,9 +207,9 @@ impl AnnouncementWriteRepository for SqliteAnnouncementRepository {
r#"
INSERT INTO announcements (
id, title, content, type, priority, author_id, is_active, is_pinned,
start_time, end_time, created_at, updated_at
requires_ack, start_time, end_time, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(&id)
@@ -185,6 +219,7 @@ VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)
.bind(record.priority)
.bind(record.author_id)
.bind(record.is_pinned)
.bind(record.requires_ack)
.bind(optional_i64_from_u64(
record.start_time_unix_secs,
"announcements.start_time",
@@ -218,6 +253,7 @@ SET title = COALESCE(?, title),
priority = COALESCE(?, priority),
is_active = COALESCE(?, is_active),
is_pinned = COALESCE(?, is_pinned),
requires_ack = COALESCE(?, requires_ack),
start_time = COALESCE(?, start_time),
end_time = COALESCE(?, end_time),
updated_at = ?
@@ -230,6 +266,7 @@ WHERE id = ?
.bind(record.priority)
.bind(record.is_active)
.bind(record.is_pinned)
.bind(record.requires_ack)
.bind(optional_i64_from_u64(
record.start_time_unix_secs,
"announcements.start_time",
@@ -317,6 +354,7 @@ fn map_announcement_row(row: &SqliteRow) -> Result<StoredAnnouncement, DataLayer
row.try_get("priority").map_sql_err()?,
row.try_get("is_active").map_sql_err()?,
row.try_get("is_pinned").map_sql_err()?,
row.try_get("requires_ack").map_sql_err()?,
row.try_get("author_id").map_sql_err()?,
row.try_get("author_username").map_sql_err()?,
row.try_get("start_time_unix_secs").map_sql_err()?,
@@ -355,6 +393,7 @@ mod tests {
kind: "info".to_string(),
priority: 10,
is_pinned: true,
requires_ack: false,
author_id: "user-1".to_string(),
start_time_unix_secs: Some(100),
end_time_unix_secs: Some(300),
@@ -406,6 +445,7 @@ mod tests {
priority: Some(20),
is_active: Some(false),
is_pinned: Some(false),
requires_ack: Some(true),
start_time_unix_secs: None,
end_time_unix_secs: None,
})

View File

@@ -9,6 +9,7 @@ pub struct StoredAnnouncement {
pub priority: i32,
pub is_active: bool,
pub is_pinned: bool,
pub requires_ack: bool,
pub author_id: Option<String>,
pub author_username: Option<String>,
pub start_time_unix_secs: Option<u64>,
@@ -27,6 +28,7 @@ impl StoredAnnouncement {
priority: i32,
is_active: bool,
is_pinned: bool,
requires_ack: bool,
author_id: Option<String>,
author_username: Option<String>,
start_time_unix_secs: Option<i64>,
@@ -63,6 +65,7 @@ impl StoredAnnouncement {
priority,
is_active,
is_pinned,
requires_ack,
author_id,
author_username,
start_time_unix_secs: start_time_unix_secs
@@ -117,6 +120,13 @@ pub trait AnnouncementReadRepository: Send + Sync {
user_id: &str,
now_unix_secs: u64,
) -> Result<u64, crate::DataLayerError>;
async fn list_required_unread_active_announcements(
&self,
user_id: &str,
now_unix_secs: u64,
limit: usize,
) -> Result<Vec<StoredAnnouncement>, crate::DataLayerError>;
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
@@ -126,6 +136,7 @@ pub struct CreateAnnouncementRecord {
pub kind: String,
pub priority: i32,
pub is_pinned: bool,
pub requires_ack: bool,
pub author_id: String,
pub start_time_unix_secs: Option<u64>,
pub end_time_unix_secs: Option<u64>,
@@ -166,6 +177,7 @@ pub struct UpdateAnnouncementRecord {
pub priority: Option<i32>,
pub is_active: Option<bool>,
pub is_pinned: Option<bool>,
pub requires_ack: Option<bool>,
pub start_time_unix_secs: Option<u64>,
pub end_time_unix_secs: Option<u64>,
}