ticket: use base32 project record ids
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "project-record"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Shared path-safe project-record identifiers.
|
||||
//!
|
||||
//! Record IDs are fixed-width Crockford base32 encodings of Unix epoch
|
||||
//! milliseconds. The fixed width keeps lexicographic order aligned with
|
||||
//! chronological order.
|
||||
|
||||
use std::fmt;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub const RECORD_ID_WIDTH: usize = 13;
|
||||
pub const RECORD_ID_ALPHABET: &str = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||
pub const MAX_COLLISION_PROBES: u64 = 1000;
|
||||
|
||||
const ALPHABET_BYTES: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RecordIdError {
|
||||
InvalidLength { value: String },
|
||||
InvalidCharacter { value: String, ch: char },
|
||||
TimestampOverflow,
|
||||
TimeBeforeUnixEpoch,
|
||||
ExcessiveCollisions { base_millis: u64, attempts: u64 },
|
||||
}
|
||||
|
||||
impl fmt::Display for RecordIdError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidLength { value } => write!(
|
||||
f,
|
||||
"invalid record id length for {value:?}: expected {RECORD_ID_WIDTH}"
|
||||
),
|
||||
Self::InvalidCharacter { value, ch } => {
|
||||
write!(f, "invalid record id character {ch:?} in {value:?}")
|
||||
}
|
||||
Self::TimestampOverflow => f.write_str("record id timestamp overflow"),
|
||||
Self::TimeBeforeUnixEpoch => f.write_str("system time is before Unix epoch"),
|
||||
Self::ExcessiveCollisions {
|
||||
base_millis,
|
||||
attempts,
|
||||
} => write!(
|
||||
f,
|
||||
"too many record id collisions for timestamp {base_millis} after {attempts} attempts"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RecordIdError {}
|
||||
|
||||
pub fn unix_epoch_millis_now() -> Result<u64, RecordIdError> {
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|_| RecordIdError::TimeBeforeUnixEpoch)?;
|
||||
u64::try_from(duration.as_millis()).map_err(|_| RecordIdError::TimestampOverflow)
|
||||
}
|
||||
|
||||
pub fn encode_unix_epoch_millis(millis: u64) -> String {
|
||||
let mut value = millis;
|
||||
let mut out = [b'0'; RECORD_ID_WIDTH];
|
||||
for slot in out.iter_mut().rev() {
|
||||
*slot = ALPHABET_BYTES[(value & 0b11111) as usize];
|
||||
value >>= 5;
|
||||
}
|
||||
String::from_utf8(out.to_vec()).expect("record id alphabet is ASCII")
|
||||
}
|
||||
|
||||
pub fn decode_unix_epoch_millis(id: &str) -> Result<u64, RecordIdError> {
|
||||
if id.len() != RECORD_ID_WIDTH {
|
||||
return Err(RecordIdError::InvalidLength {
|
||||
value: id.to_string(),
|
||||
});
|
||||
}
|
||||
let mut value = 0_u64;
|
||||
for ch in id.chars() {
|
||||
let digit = decode_digit(ch, id)? as u64;
|
||||
value = value
|
||||
.checked_mul(32)
|
||||
.and_then(|value| value.checked_add(digit))
|
||||
.ok_or(RecordIdError::TimestampOverflow)?;
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub fn validate_record_id(id: &str) -> Result<(), RecordIdError> {
|
||||
decode_unix_epoch_millis(id).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn allocate_record_id<F>(base_millis: u64, mut exists: F) -> Result<String, RecordIdError>
|
||||
where
|
||||
F: FnMut(&str) -> bool,
|
||||
{
|
||||
for offset in 0..MAX_COLLISION_PROBES {
|
||||
let millis = base_millis
|
||||
.checked_add(offset)
|
||||
.ok_or(RecordIdError::TimestampOverflow)?;
|
||||
let id = encode_unix_epoch_millis(millis);
|
||||
if !exists(&id) {
|
||||
return Ok(id);
|
||||
}
|
||||
}
|
||||
Err(RecordIdError::ExcessiveCollisions {
|
||||
base_millis,
|
||||
attempts: MAX_COLLISION_PROBES,
|
||||
})
|
||||
}
|
||||
|
||||
fn decode_digit(ch: char, value: &str) -> Result<u8, RecordIdError> {
|
||||
let digit = match ch {
|
||||
'0'..='9' => ch as u8 - b'0',
|
||||
'A'..='H' => ch as u8 - b'A' + 10,
|
||||
'J'..='K' => ch as u8 - b'J' + 18,
|
||||
'M'..='N' => ch as u8 - b'M' + 20,
|
||||
'P'..='T' => ch as u8 - b'P' + 22,
|
||||
'V'..='Z' => ch as u8 - b'V' + 27,
|
||||
_ => {
|
||||
return Err(RecordIdError::InvalidCharacter {
|
||||
value: value.to_string(),
|
||||
ch,
|
||||
});
|
||||
}
|
||||
};
|
||||
Ok(digit)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn encodes_fixed_width_crockford_base32() {
|
||||
assert_eq!(encode_unix_epoch_millis(0), "0000000000000");
|
||||
assert_eq!(encode_unix_epoch_millis(31), "000000000000Z");
|
||||
assert_eq!(encode_unix_epoch_millis(32), "0000000000010");
|
||||
assert_eq!(decode_unix_epoch_millis("0000000000010").unwrap(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lexicographic_order_matches_numeric_order() {
|
||||
let values = [0, 1, 31, 32, 33, 1024, 1_782_554_447_000, u64::MAX];
|
||||
let encoded = values
|
||||
.iter()
|
||||
.map(|value| encode_unix_epoch_millis(*value))
|
||||
.collect::<Vec<_>>();
|
||||
let mut sorted = encoded.clone();
|
||||
sorted.sort();
|
||||
assert_eq!(encoded, sorted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_ambiguous_or_path_unsafe_characters() {
|
||||
for id in [
|
||||
"000000000000I",
|
||||
"000000000000L",
|
||||
"000000000000O",
|
||||
"00000000000/0",
|
||||
"ZZZZZZZZZZZZZ",
|
||||
] {
|
||||
assert!(validate_record_id(id).is_err(), "{id} should be invalid");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collision_allocation_increments_milliseconds_without_suffixes() {
|
||||
let base = 1_782_554_447_000;
|
||||
let first = encode_unix_epoch_millis(base);
|
||||
let second = encode_unix_epoch_millis(base + 1);
|
||||
let allocated = allocate_record_id(base, |id| id == first).unwrap();
|
||||
assert_eq!(allocated, second);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collision_allocation_is_bounded() {
|
||||
let err = allocate_record_id(42, |_| true).unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
RecordIdError::ExcessiveCollisions {
|
||||
base_millis: 42,
|
||||
attempts: MAX_COLLISION_PROBES,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
project-record = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
fs4 = { workspace = true, features = ["sync"] }
|
||||
|
||||
+32
-30
@@ -12,6 +12,7 @@ use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use chrono::Utc;
|
||||
use fs4::fs_std::FileExt;
|
||||
use project_record::{allocate_record_id, unix_epoch_millis_now, validate_record_id};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
|
||||
use thiserror::Error;
|
||||
@@ -1305,21 +1306,17 @@ impl TicketBackend for LocalTicketBackend {
|
||||
"ticket title must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
let stamp = compact_now_utc();
|
||||
let mut counter = 1_u32;
|
||||
let (id, dir) = loop {
|
||||
let candidate = format!("{stamp}-{counter:03}");
|
||||
let dir = self.ticket_dir(&candidate)?;
|
||||
if !dir.exists() {
|
||||
break (candidate, dir);
|
||||
}
|
||||
counter += 1;
|
||||
if counter > 999 {
|
||||
return Err(TicketError::Conflict(format!(
|
||||
"too many ticket id collisions for timestamp {stamp}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let base_millis = unix_epoch_millis_now().map_err(|err| {
|
||||
TicketError::Conflict(format!("failed to read ticket id timestamp: {err}"))
|
||||
})?;
|
||||
let id = allocate_record_id(base_millis, |candidate| match self.ticket_dir(candidate) {
|
||||
Ok(dir) => dir.exists(),
|
||||
Err(_) => true,
|
||||
})
|
||||
.map_err(|err| {
|
||||
TicketError::Conflict(format!("failed to allocate unique ticket id: {err}"))
|
||||
})?;
|
||||
let dir = self.ticket_dir(&id)?;
|
||||
let created = now_utc();
|
||||
let author = input
|
||||
.author
|
||||
@@ -2180,6 +2177,9 @@ fn ticket_id_from_dir(dir: &Path) -> Result<String> {
|
||||
)));
|
||||
};
|
||||
ensure_safe_component(name)?;
|
||||
validate_record_id(name).map_err(|err| {
|
||||
TicketError::InvalidPathComponent(format!("{name} is not a canonical record id: {err}"))
|
||||
})?;
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
@@ -3536,9 +3536,9 @@ queued_at: 2026-06-05T00:01:00Z
|
||||
## Body
|
||||
"#;
|
||||
let parsed = parse_item(item).unwrap();
|
||||
let meta = ticket_meta(parsed.frontmatter, "20260609-000000-001".to_string());
|
||||
assert_eq!(meta.id, "20260609-000000-001");
|
||||
assert_eq!(meta.slug, "20260609-000000-001");
|
||||
let meta = ticket_meta(parsed.frontmatter, "0000000000001".to_string());
|
||||
assert_eq!(meta.id, "0000000000001");
|
||||
assert_eq!(meta.slug, "0000000000001");
|
||||
assert!(meta.labels.is_empty());
|
||||
assert_eq!(meta.readiness.as_deref(), Some("implementation-ready"));
|
||||
assert_eq!(meta.risk_flags, vec!["low", "local"]);
|
||||
@@ -3558,7 +3558,7 @@ state: planning
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let meta = ticket_meta(frontmatter, "20260609-000000-001".to_string());
|
||||
let meta = ticket_meta(frontmatter, "0000000000001".to_string());
|
||||
assert!(meta.labels.is_empty());
|
||||
assert_eq!(meta.risk_flags, vec!["low", "local"]);
|
||||
assert_eq!(meta.assignee, None);
|
||||
@@ -3600,6 +3600,8 @@ state: planning
|
||||
assert!(dir.join("thread.md").exists());
|
||||
assert!(dir.join("artifacts/.gitkeep").exists());
|
||||
assert!(!ticket.id.contains("example"));
|
||||
assert_eq!(ticket.id.len(), project_record::RECORD_ID_WIDTH);
|
||||
validate_record_id(&ticket.id).unwrap();
|
||||
assert_eq!(ticket.slug, ticket.id);
|
||||
let item = fs::read_to_string(dir.join("item.md")).unwrap();
|
||||
assert!(
|
||||
@@ -3892,14 +3894,14 @@ state: planning
|
||||
let backend = backend(&tmp);
|
||||
let missing_meta = ticket_meta(
|
||||
parse_ticket_frontmatter("title: Missing State").expect("missing state parses"),
|
||||
"20260609-000000-001".to_string(),
|
||||
"0000000000001".to_string(),
|
||||
);
|
||||
assert_eq!(missing_meta.workflow_state, TicketWorkflowState::Planning);
|
||||
assert!(!missing_meta.workflow_state_explicit);
|
||||
|
||||
let closed_meta = ticket_meta(
|
||||
parse_ticket_frontmatter("state: closed").expect("closed state parses"),
|
||||
"20260609-000000-002".to_string(),
|
||||
"0000000000002".to_string(),
|
||||
);
|
||||
assert_eq!(closed_meta.workflow_state, TicketWorkflowState::Closed);
|
||||
assert!(closed_meta.workflow_state_explicit);
|
||||
@@ -4028,13 +4030,13 @@ state: planning
|
||||
fn doctor_reports_invalid_state() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path().join("tickets");
|
||||
fs::create_dir_all(root.join("20260609-000000-001/artifacts")).unwrap();
|
||||
fs::create_dir_all(root.join("0000000000001/artifacts")).unwrap();
|
||||
fs::write(
|
||||
root.join("20260609-000000-001/item.md"),
|
||||
root.join("0000000000001/item.md"),
|
||||
"---\ntitle: Bad\nstate: almost\ncreated_at: x\nupdated_at: x\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(root.join("20260609-000000-001/thread.md"), "").unwrap();
|
||||
fs::write(root.join("0000000000001/thread.md"), "").unwrap();
|
||||
|
||||
let report = LocalTicketBackend::new(&root).doctor().unwrap();
|
||||
let messages = report
|
||||
@@ -4051,14 +4053,14 @@ state: planning
|
||||
fn doctor_validates_typed_thread_event_attributes() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path().join("tickets");
|
||||
fs::create_dir_all(root.join("20260609-000000-001/artifacts")).unwrap();
|
||||
fs::create_dir_all(root.join("0000000000001/artifacts")).unwrap();
|
||||
fs::write(
|
||||
root.join("20260609-000000-001/item.md"),
|
||||
root.join("0000000000001/item.md"),
|
||||
"---\ntitle: Bad\nstate: planning\ncreated_at: x\nupdated_at: x\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join("20260609-000000-001/thread.md"),
|
||||
root.join("0000000000001/thread.md"),
|
||||
"<!-- event: state_changed author: bot at: now from: queued -->\n\n## State changed\n\n---\n\n<!-- event: intake_summary author: bot at: now -->\n\n## Intake summary\n\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
@@ -4086,14 +4088,14 @@ state: planning
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(root.join("open/legacy/thread.md"), "").unwrap();
|
||||
fs::create_dir_all(root.join("20260609-000000-001/artifacts")).unwrap();
|
||||
fs::create_dir_all(root.join("0000000000001/artifacts")).unwrap();
|
||||
fs::write(
|
||||
root.join("20260609-000000-001/item.md"),
|
||||
root.join("0000000000001/item.md"),
|
||||
"---\nid: old\nslug: old\ntitle: Bad\nstatus: pending\nworkflow_state: ready\nkind: task\nlabels: []\naction_required: human\nattention_required: true\ncreated_at: x\nupdated_at: x\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join("20260609-000000-001/thread.md"),
|
||||
root.join("0000000000001/thread.md"),
|
||||
"<!-- event: review author: a at: now -->\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -5,6 +5,7 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
project-record = { workspace = true }
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
client = { workspace = true }
|
||||
memory = { workspace = true }
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::fs;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use chrono::Utc;
|
||||
use project_record::{allocate_record_id, unix_epoch_millis_now, validate_record_id};
|
||||
use serde::Deserialize;
|
||||
use ticket::config::TicketConfig;
|
||||
|
||||
@@ -198,21 +199,13 @@ fn create(
|
||||
|
||||
let root = objective_root(workspace);
|
||||
fs::create_dir_all(&root)?;
|
||||
let stamp = Utc::now().format("%Y%m%d-%H%M%S").to_string();
|
||||
let mut counter = 1_u32;
|
||||
let (id, dir) = loop {
|
||||
let candidate = format!("{stamp}-{counter:03}");
|
||||
let dir = root.join(&candidate);
|
||||
if !dir.exists() {
|
||||
break (candidate, dir);
|
||||
}
|
||||
counter += 1;
|
||||
if counter > 999 {
|
||||
return Err(ObjectiveCliError::new(format!(
|
||||
"too many objective id collisions for timestamp {stamp}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let base_millis = unix_epoch_millis_now().map_err(|error| {
|
||||
ObjectiveCliError::new(format!("failed to read objective id timestamp: {error}"))
|
||||
})?;
|
||||
let id = allocate_record_id(base_millis, |candidate| root.join(candidate).exists()).map_err(
|
||||
|error| ObjectiveCliError::new(format!("failed to allocate unique objective id: {error}")),
|
||||
)?;
|
||||
let dir = root.join(&id);
|
||||
|
||||
fs::create_dir_all(&dir)?;
|
||||
fs::write(
|
||||
@@ -425,7 +418,9 @@ fn validate_record_component(value: &str) -> Result<(), ObjectiveCliError> {
|
||||
)));
|
||||
}
|
||||
match path.components().next() {
|
||||
Some(Component::Normal(_)) => Ok(()),
|
||||
Some(Component::Normal(_)) => validate_record_id(value).map_err(|error| {
|
||||
ObjectiveCliError::new(format!("{value} is not a canonical record id: {error}"))
|
||||
}),
|
||||
_ => Err(ObjectiveCliError::new(format!(
|
||||
"invalid path-derived id component: {value}"
|
||||
))),
|
||||
@@ -631,7 +626,7 @@ mod tests {
|
||||
#[test]
|
||||
fn objective_cli_creates_lists_and_shows_records() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
create_ticket_dir(&temp, "20260608-125430-001");
|
||||
create_ticket_dir(&temp, "00001KTKMS0VG");
|
||||
|
||||
let created = run(
|
||||
&temp,
|
||||
@@ -640,10 +635,12 @@ mod tests {
|
||||
"--title",
|
||||
"Medium-term goal",
|
||||
"--ticket",
|
||||
"20260608-125430-001",
|
||||
"00001KTKMS0VG",
|
||||
],
|
||||
);
|
||||
let objective_id = created_id(&created);
|
||||
validate_record_id(&objective_id).unwrap();
|
||||
assert_eq!(objective_id.len(), project_record::RECORD_ID_WIDTH);
|
||||
assert!(
|
||||
temp.path()
|
||||
.join(".yoi/objectives")
|
||||
@@ -654,7 +651,7 @@ mod tests {
|
||||
|
||||
let listed = run(&temp, &["list", "--state", "active"]);
|
||||
assert!(listed.stdout.contains(&objective_id));
|
||||
assert!(listed.stdout.contains("20260608-125430-001"));
|
||||
assert!(listed.stdout.contains("00001KTKMS0VG"));
|
||||
|
||||
let shown = run(&temp, &["show", &objective_id]);
|
||||
assert!(shown.stdout.contains("# Medium-term goal"));
|
||||
@@ -663,7 +660,7 @@ mod tests {
|
||||
.stdout
|
||||
.contains("## Success criteria / exit conditions")
|
||||
);
|
||||
assert!(shown.stdout.contains("20260608-125430-001"));
|
||||
assert!(shown.stdout.contains("00001KTKMS0VG"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -674,24 +671,24 @@ mod tests {
|
||||
"--title",
|
||||
"Broken link",
|
||||
"--ticket",
|
||||
"missing-ticket",
|
||||
"0000000000ABC",
|
||||
]))
|
||||
.unwrap();
|
||||
let err = run_in_workspace(cli, temp.path()).unwrap_err();
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("linked ticket missing-ticket does not exist")
|
||||
.contains("linked ticket 0000000000ABC does not exist")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objective_doctor_reports_invalid_linked_ticket() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let dir = temp.path().join(".yoi/objectives/20260609-000000-001");
|
||||
let dir = temp.path().join(".yoi/objectives/0000000000001");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(
|
||||
dir.join("item.md"),
|
||||
"---\ntitle: \"Goal\"\nstate: \"active\"\ncreated_at: \"2026-06-09T00:00:00Z\"\nupdated_at: \"2026-06-09T00:00:00Z\"\nlinked_tickets: [\"missing\"]\n---\n\n## Goal\n\nText\n\n## Motivation / background\n\nText\n\n## Strategy / design direction\n\nText\n\n## Success criteria / exit conditions\n\n- Text\n\n## Decision context\n\n- Text\n",
|
||||
"---\ntitle: \"Goal\"\nstate: \"active\"\ncreated_at: \"2026-06-09T00:00:00Z\"\nupdated_at: \"2026-06-09T00:00:00Z\"\nlinked_tickets: [\"0000000000ABD\"]\n---\n\n## Goal\n\nText\n\n## Motivation / background\n\nText\n\n## Strategy / design direction\n\nText\n\n## Success criteria / exit conditions\n\n- Text\n\n## Decision context\n\n- Text\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -700,14 +697,14 @@ mod tests {
|
||||
assert!(
|
||||
output
|
||||
.stdout
|
||||
.contains("linked ticket missing does not exist")
|
||||
.contains("linked ticket 0000000000ABD does not exist")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objective_doctor_accepts_well_formed_records() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
create_ticket_dir(&temp, "20260608-125430-001");
|
||||
create_ticket_dir(&temp, "00001KTKMS0VG");
|
||||
run(
|
||||
&temp,
|
||||
&[
|
||||
@@ -715,7 +712,7 @@ mod tests {
|
||||
"--title",
|
||||
"Good objective",
|
||||
"--ticket",
|
||||
"20260608-125430-001",
|
||||
"00001KTKMS0VG",
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user