diff --git a/migrations/20251114014715_record_dates.down.sql b/migrations/20251114014715_record_dates.down.sql new file mode 100644 index 00000000..80304090 --- /dev/null +++ b/migrations/20251114014715_record_dates.down.sql @@ -0,0 +1,39 @@ +ALTER TABLE record_modifications DROP COLUMN date; + +CREATE OR REPLACE FUNCTION audit_record_modification() RETURNS trigger AS $record_modification_trigger$ + DECLARE + progress_change SMALLINT; + video_change VARCHAR(200); + status_change RECORD_STATUS; + player_change INT; + demon_change INTEGER; + BEGIN + if (OLD.progress <> NEW.progress) THEN + progress_change = OLD.progress; + END IF; + + IF (OLD.video <> NEW.video) THEN + video_change = OLD.video; + END IF; + + IF (OLD.status_ <> NEW.status_) THEN + status_change = OLD.status_; + END IF; + + IF (OLD.player <> NEW.player) THEN + player_change = OLD.player; + END IF; + + IF (OLD.demon <> NEW.demon) THEN + demon_change = OLD.demon; + END IF; + + INSERT INTO record_modifications (userid, id, progress, video, status_, player, demon) + (SELECT id, NEW.id, progress_change, video_change, status_change, player_change, demon_change + FROM active_user LIMIT 1); + + RETURN NEW; + END; +$record_modification_trigger$ LANGUAGE plpgsql; + +ALTER TABLE records DROP COLUMN date; \ No newline at end of file diff --git a/migrations/20251114014715_record_dates.up.sql b/migrations/20251114014715_record_dates.up.sql new file mode 100644 index 00000000..0bafd48e --- /dev/null +++ b/migrations/20251114014715_record_dates.up.sql @@ -0,0 +1,50 @@ +ALTER TABLE records ADD COLUMN date TIMESTAMP WITHOUT TIME ZONE DEFAULT (NOW() AT TIME ZONE 'utc') NOT NULL; + +UPDATE records +SET date = record_additions.time +FROM record_additions +WHERE records.id = record_additions.id; + +-- audit logs +ALTER TABLE record_modifications ADD COLUMN date TIMESTAMP WITHOUT TIME ZONE; + +CREATE OR REPLACE FUNCTION audit_record_modification() RETURNS trigger AS $record_modification_trigger$ + DECLARE + progress_change SMALLINT; + video_change VARCHAR(200); + status_change RECORD_STATUS; + player_change INT; + demon_change INTEGER; + date_change TIMESTAMP WITHOUT TIME ZONE; + BEGIN + if (OLD.progress <> NEW.progress) THEN + progress_change = OLD.progress; + END IF; + + IF (OLD.video <> NEW.video) THEN + video_change = OLD.video; + END IF; + + IF (OLD.status_ <> NEW.status_) THEN + status_change = OLD.status_; + END IF; + + IF (OLD.player <> NEW.player) THEN + player_change = OLD.player; + END IF; + + IF (OLD.demon <> NEW.demon) THEN + demon_change = OLD.demon; + END IF; + + IF (OLD.date <> NEW.date) THEN + date_change = OLD.date; + END IF; + + INSERT INTO record_modifications (userid, id, progress, video, status_, player, demon, date) + (SELECT id, NEW.id, progress_change, video_change, status_change, player_change, demon_change, date_change + FROM active_user LIMIT 1); + + RETURN NEW; + END; +$record_modification_trigger$ LANGUAGE plpgsql; \ No newline at end of file diff --git a/pointercrate-demonlist-pages/src/account/records.rs b/pointercrate-demonlist-pages/src/account/records.rs index 64b92ee2..1727ad8e 100644 --- a/pointercrate-demonlist-pages/src/account/records.rs +++ b/pointercrate-demonlist-pages/src/account/records.rs @@ -80,6 +80,7 @@ impl AccountPageTab for RecordsPage { (change_video_dialog()) (change_holder_dialog()) (change_demon_dialog(&demons[..])) + (change_date_dialog()) } } } @@ -172,6 +173,15 @@ fn record_manager(demons: &[Demon]) -> Markup { span #record-submitter {} } } + div.stats-container.flex.space { + span { + b { + i.fa.fa-pencil-alt.clickable #record-date-pen aria-hidden = "true" {} " " (tr("record-date")) + } + br; + span #record-date {} + } + } span.button.red.hover #record-delete style = "margin: 15px auto 0px" {(tr("record-viewer.delete"))}; } } @@ -407,3 +417,29 @@ fn change_demon_dialog(demons: &[Demon]) -> Markup { } } } + +fn change_date_dialog() -> Markup { + html! { + div.overlay.closable { + div.dialog #record-date-dialog { + span.plus.cross.hover {} + h2.underlined.pad { + (tr("record-date-dialog")) + } + p style = "max-width: 400px"{ + (tr("record-date-dialog.info")) + } + form.flex.col novalidate = "" { + p.info-red.output {} + p.info-green.output {} + span.form-input #record-date-edit { + label for = "date" {(tr("record-date-dialog.date-field")) } + input name = "date" type = "datetime-local"; + p.error {} + } + input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value = (tr("record-date-dialog.submit")); + } + } + } + } +} diff --git a/pointercrate-demonlist-pages/static/ftl/en-us/record.ftl b/pointercrate-demonlist-pages/static/ftl/en-us/record.ftl index 54ba474c..b7b5b21f 100644 --- a/pointercrate-demonlist-pages/static/ftl/en-us/record.ftl +++ b/pointercrate-demonlist-pages/static/ftl/en-us/record.ftl @@ -11,6 +11,7 @@ record-demon = Demon record-holder = Record Holder record-progress = Progress record-submitter = Submitter ID +record-date = Submission Date ## Records tab (user area) records = Records @@ -94,6 +95,12 @@ record-progress-dialog = Change record progress .progress-validator-stepmismatch = Record progress mustn't be a decimal .progress-validator-valuemissing = Please enter a progress value +record-date-dialog = Change record submission date + .info = Change the submission date of this record. This can be modified to reorder records in demon pages. + .date-field = Date: + + .submit = Edit + # The giant information box below the record manager, split # into different sections here # diff --git a/pointercrate-demonlist-pages/static/js/account/records.js b/pointercrate-demonlist-pages/static/js/account/records.js index 4bb8aca8..72cd33f9 100644 --- a/pointercrate-demonlist-pages/static/js/account/records.js +++ b/pointercrate-demonlist-pages/static/js/account/records.js @@ -50,6 +50,7 @@ class RecordManager extends Paginator { this._progress = document.getElementById("record-progress"); this._submitter = document.getElementById("record-submitter"); this._notes = document.getElementById("record-notes"); + this._date = document.getElementById("record-date"); this.dropdown = new Dropdown( document @@ -84,6 +85,7 @@ class RecordManager extends Paginator { this.output ); this.initDemonDialog(); + this.initDateDialog(); document .getElementById("record-copy-info") @@ -183,6 +185,16 @@ class RecordManager extends Paginator { ); } + initDateDialog() { + setupEditorDialog( + new FormDialog("record-date-dialog"), + "record-date-pen", + new PaginatorEditorBackend(this, true), + this.output, + (date) => ({ date: new Date(date.date).toISOString() }) + ); + } + onReceive(response) { super.onReceive(response); @@ -227,6 +239,9 @@ class RecordManager extends Paginator { this._progress.innerText = this.currentObject.progress + "%"; this._submitter.innerText = this.currentObject.submitter.id; + let date = new Date(this.currentObject.date); + this._date.innerText = date.toLocaleString(); + // this is introducing race conditions. Oh well. return get("/api/v1/records/" + this.currentObject.id + "/notes/").then( (response) => { diff --git a/pointercrate-demonlist/sql/record_by_id.sql b/pointercrate-demonlist/sql/record_by_id.sql index dc13ddd8..8e7768a4 100644 --- a/pointercrate-demonlist/sql/record_by_id.sql +++ b/pointercrate-demonlist/sql/record_by_id.sql @@ -2,6 +2,7 @@ SELECT progress, CASE WHEN players.link_banned THEN NULL ELSE records.video::text END, CASE WHEN players.link_banned THEN NULL ELSE records.raw_footage::text END, status_::text AS "status!: String" , + date AS "date!", players.id AS player_id, players.name AS "player_name: String", players.banned AS player_banned, demons.id AS demon_id, demons.name AS "demon_name: String", demons.position, submitters.submitter_id AS submitter_id, submitters.banned AS submitter_banned diff --git a/pointercrate-demonlist/src/record/get.rs b/pointercrate-demonlist/src/record/get.rs index a5bcb155..323fcf57 100644 --- a/pointercrate-demonlist/src/record/get.rs +++ b/pointercrate-demonlist/src/record/get.rs @@ -6,6 +6,7 @@ use crate::{ record::{FullRecord, MinimalRecordD, MinimalRecordP, RecordStatus}, submitter::Submitter, }; +use chrono::{NaiveDateTime, TimeZone, Utc}; use futures::stream::StreamExt; use sqlx::{Error, PgConnection}; @@ -15,6 +16,7 @@ struct FetchedRecord { video: Option, raw_footage: Option, status: String, + date: NaiveDateTime, player_id: i32, player_name: String, player_banned: bool, @@ -52,6 +54,7 @@ impl FullRecord { id: row.submitter_id, banned: row.submitter_banned, }), + date: Utc.from_utc_datetime(&row.date), }), Err(Error::RowNotFound) => Err(DemonlistError::RecordNotFound { record_id: id }), @@ -106,7 +109,7 @@ pub async fn approved_records_on(demon: &MinimalDemon, connection: &mut PgConnec Fetched, r#"SELECT records.id, progress, CASE WHEN players.link_banned THEN NULL ELSE video::text END, players.id AS player_id, players.name, players.banned, nation::TEXT, iso_country_code::TEXT FROM records INNER JOIN players ON records.player = players.id LEFT OUTER JOIN nationalities ON nationality = iso_country_code WHERE status_ = 'APPROVED' AND - records.demon = $1 ORDER BY progress DESC, id ASC"#, + records.demon = $1 ORDER BY progress DESC, date ASC"#, demon.id ) .fetch(connection); diff --git a/pointercrate-demonlist/src/record/mod.rs b/pointercrate-demonlist/src/record/mod.rs index 441b0c61..4ffcf62e 100644 --- a/pointercrate-demonlist/src/record/mod.rs +++ b/pointercrate-demonlist/src/record/mod.rs @@ -28,6 +28,7 @@ pub use self::{ post::Submission, }; use crate::{demon::MinimalDemon, error::Result, nationality::Nationality, player::DatabasePlayer, submitter::Submitter}; +use chrono::{DateTime, Utc}; use derive_more::Display; use pointercrate_core::etag::Taggable; use serde::{Deserialize, Deserializer, Serialize, Serializer}; @@ -128,6 +129,7 @@ pub struct FullRecord { pub demon: MinimalDemon, pub submitter: Option, pub raw_footage: Option, + pub date: DateTime, } impl Taggable for FullRecord { diff --git a/pointercrate-demonlist/src/record/patch.rs b/pointercrate-demonlist/src/record/patch.rs index 8256a8c0..f4398d1f 100644 --- a/pointercrate-demonlist/src/record/patch.rs +++ b/pointercrate-demonlist/src/record/patch.rs @@ -4,6 +4,7 @@ use crate::{ player::DatabasePlayer, record::{FullRecord, RecordStatus}, }; +use chrono::{DateTime, Utc}; use log::{info, warn}; use pointercrate_core::{ error::CoreError, @@ -31,6 +32,9 @@ pub struct PatchRecord { #[serde(default, deserialize_with = "non_nullable")] demon_id: Option, + + #[serde(default, deserialize_with = "non_nullable")] + date: Option>, } impl FullRecord { @@ -69,6 +73,10 @@ impl FullRecord { _ => (), } + if let Some(date) = data.date { + self.set_date(date, connection).await?; + } + // Not all record update require recomputing scores (for example, changing status from "submitted" to "under consideration") // but the logic for correctly determining this is hard, and updating scores of individual players cheap, so we do not bother. self.player.update_score(connection).await?; @@ -395,4 +403,14 @@ impl FullRecord { Ok(()) } + + pub async fn set_date(&mut self, date: DateTime, connection: &mut PgConnection) -> Result<()> { + sqlx::query!("UPDATE records SET date = $1 WHERE id = $2", date.naive_utc(), self.id) + .execute(&mut *connection) + .await?; + + self.date = date; + + Ok(()) + } } diff --git a/pointercrate-demonlist/src/record/post.rs b/pointercrate-demonlist/src/record/post.rs index 5096ca85..382fcaf3 100644 --- a/pointercrate-demonlist/src/record/post.rs +++ b/pointercrate-demonlist/src/record/post.rs @@ -5,6 +5,7 @@ use crate::{ record::{FullRecord, RecordStatus}, submitter::Submitter, }; +use chrono::{TimeZone, Utc}; use derive_more::Display; use log::debug; use serde::Deserialize; @@ -172,8 +173,8 @@ impl NormalizedSubmission { impl ValidatedSubmission { pub async fn create(self, submitter: Submitter, connection: &mut PgConnection) -> Result { - let id = sqlx::query!( - "INSERT INTO records (progress, video, status_, player, submitter, demon, raw_footage) VALUES ($1, $2::TEXT, 'SUBMITTED', $3, $4, $5, $6) RETURNING id", + let row = sqlx::query!( + "INSERT INTO records (progress, video, status_, player, submitter, demon, raw_footage) VALUES ($1, $2::TEXT, 'SUBMITTED', $3, $4, $5, $6) RETURNING id, date", self.progress, self.video, self.player.id, @@ -182,11 +183,10 @@ impl ValidatedSubmission { self.raw_footage ) .fetch_one(&mut *connection) - .await? - .id; + .await?; let mut record = FullRecord { - id, + id: row.id, progress: self.progress, video: self.video, raw_footage: self.raw_footage, @@ -194,6 +194,7 @@ impl ValidatedSubmission { player: self.player, demon: self.demon, submitter: Some(submitter), + date: Utc.from_utc_datetime(&row.date), }; // Dealing with different status and upholding their invariant is complicated, we should not