update kanban styling, add more detailed error log for gmail
This commit is contained in:
@@ -98,45 +98,69 @@ public sealed class GmailController : ControllerBase
|
|||||||
[HttpPost("import")]
|
[HttpPost("import")]
|
||||||
public async Task<IActionResult> Import([FromBody] ImportGmailMessageRequest request, CancellationToken cancellationToken)
|
public async Task<IActionResult> Import([FromBody] ImportGmailMessageRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required.");
|
try
|
||||||
if (string.IsNullOrWhiteSpace(request.MessageId)) return BadRequest("MessageId is required.");
|
|
||||||
|
|
||||||
var ownerUserId = GetRequiredOwnerUserId();
|
|
||||||
var job = await _db.JobApplications.Include(x => x.Company).FirstOrDefaultAsync(x => x.Id == request.JobApplicationId, cancellationToken);
|
|
||||||
if (job is null) return NotFound("Job application not found.");
|
|
||||||
|
|
||||||
var existing = await _db.Correspondences.FirstOrDefaultAsync(
|
|
||||||
x => x.JobApplicationId == request.JobApplicationId && x.ExternalMessageId == request.MessageId,
|
|
||||||
cancellationToken);
|
|
||||||
if (existing is not null)
|
|
||||||
{
|
{
|
||||||
return Ok(existing);
|
if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required.");
|
||||||
|
if (string.IsNullOrWhiteSpace(request.MessageId)) return BadRequest("MessageId is required.");
|
||||||
|
|
||||||
|
var ownerUserId = GetRequiredOwnerUserId();
|
||||||
|
var job = await _db.JobApplications.Include(x => x.Company).FirstOrDefaultAsync(x => x.Id == request.JobApplicationId, cancellationToken);
|
||||||
|
if (job is null) return NotFound("Job application not found.");
|
||||||
|
|
||||||
|
var existing = await _db.Correspondences.FirstOrDefaultAsync(
|
||||||
|
x => x.JobApplicationId == request.JobApplicationId && x.ExternalMessageId == request.MessageId,
|
||||||
|
cancellationToken);
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
return Ok(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
var detail = await _gmail.GetMessageAsync(ownerUserId, request.MessageId, cancellationToken);
|
||||||
|
var me = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken);
|
||||||
|
var gmailAddress = me?.GmailAddress ?? string.Empty;
|
||||||
|
var isMe = detail.From.Contains(gmailAddress, StringComparison.OrdinalIgnoreCase);
|
||||||
|
var messageDate = detail.Date?.LocalDateTime ?? DateTime.Now;
|
||||||
|
|
||||||
|
var message = new Correspondence
|
||||||
|
{
|
||||||
|
JobApplicationId = request.JobApplicationId,
|
||||||
|
From = isMe ? "Me" : "Company",
|
||||||
|
Subject = string.IsNullOrWhiteSpace(detail.Subject) ? null : detail.Subject.Trim(),
|
||||||
|
Channel = "Email",
|
||||||
|
ExternalMessageId = detail.Id,
|
||||||
|
Content = string.IsNullOrWhiteSpace(detail.BodyText) ? detail.Snippet : detail.BodyText,
|
||||||
|
Date = messageDate,
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.Correspondences.Add(message);
|
||||||
|
if (job.Company is not null)
|
||||||
|
{
|
||||||
|
job.Company.LastContactedAt = messageDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isMe && (!job.ResponseReceived || job.ResponseDate is null || messageDate < job.ResponseDate.Value))
|
||||||
|
{
|
||||||
|
var oldResponse = $"{job.ResponseReceived}:{job.ResponseDate?.ToString("o")}";
|
||||||
|
job.ResponseReceived = true;
|
||||||
|
job.ResponseDate = messageDate;
|
||||||
|
_db.JobEvents.Add(new JobEvent
|
||||||
|
{
|
||||||
|
JobApplicationId = job.Id,
|
||||||
|
Type = "ReplyReceived",
|
||||||
|
OldValue = oldResponse,
|
||||||
|
NewValue = $"{job.ResponseReceived}:{job.ResponseDate?.ToString("o")}",
|
||||||
|
Note = detail.Subject,
|
||||||
|
At = messageDate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
return Ok(message);
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
var detail = await _gmail.GetMessageAsync(ownerUserId, request.MessageId, cancellationToken);
|
|
||||||
var me = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken);
|
|
||||||
var gmailAddress = me?.GmailAddress ?? string.Empty;
|
|
||||||
var isMe = detail.From.Contains(gmailAddress, StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
var message = new Correspondence
|
|
||||||
{
|
{
|
||||||
JobApplicationId = request.JobApplicationId,
|
return BadRequest(ex.Message);
|
||||||
From = isMe ? "Me" : "Company",
|
|
||||||
Subject = string.IsNullOrWhiteSpace(detail.Subject) ? null : detail.Subject.Trim(),
|
|
||||||
Channel = "Email",
|
|
||||||
ExternalMessageId = detail.Id,
|
|
||||||
Content = string.IsNullOrWhiteSpace(detail.BodyText) ? detail.Snippet : detail.BodyText,
|
|
||||||
Date = detail.Date?.LocalDateTime ?? DateTime.Now,
|
|
||||||
};
|
|
||||||
|
|
||||||
_db.Correspondences.Add(message);
|
|
||||||
if (job.Company is not null)
|
|
||||||
{
|
|
||||||
job.Company.LastContactedAt = DateTime.UtcNow;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await _db.SaveChangesAsync(cancellationToken);
|
|
||||||
return Ok(message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetRequiredOwnerUserId()
|
private string GetRequiredOwnerUserId()
|
||||||
|
|||||||
@@ -236,8 +236,12 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
});
|
});
|
||||||
await load();
|
await load();
|
||||||
toast("Email imported from Gmail.", "success");
|
toast("Email imported from Gmail.", "success");
|
||||||
} catch {
|
} catch (error: any) {
|
||||||
toast("Failed to import Gmail message.", "error");
|
const message =
|
||||||
|
error?.response?.data && typeof error.response.data === "string"
|
||||||
|
? error.response.data
|
||||||
|
: "Failed to import Gmail message.";
|
||||||
|
toast(message, "error");
|
||||||
} finally {
|
} finally {
|
||||||
setImportingMessageId(null);
|
setImportingMessageId(null);
|
||||||
}
|
}
|
||||||
@@ -303,8 +307,8 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
sx={{ display: "block", mt: 0.75, color: "text.secondary" }}
|
sx={{ display: "block", mt: 0.75, color: "text.secondary" }}
|
||||||
>
|
>
|
||||||
{isMe ? "Me" : "Company"}
|
{isMe ? "Me" : "Company"}
|
||||||
{m.channel ? ` · ${m.channel}` : ""}
|
{m.channel ? ` · ${m.channel}` : ""}
|
||||||
{m.date ? ` · ${new Date(m.date).toLocaleString()}` : ""}
|
{m.date ? ` · ${new Date(m.date).toLocaleString()}` : ""}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
|||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<JobFlowBar job={job} />
|
<JobFlowBar job={job} history={history} />
|
||||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
|
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
|
||||||
<Tab label="Overview" />
|
<Tab label="Overview" />
|
||||||
<Tab label="Correspondence" />
|
<Tab label="Correspondence" />
|
||||||
|
|||||||
@@ -1,60 +1,141 @@
|
|||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
|
|
||||||
import { Box, Tooltip, Typography, useTheme } from "@mui/material";
|
import { Box, Chip, Typography, useTheme } from "@mui/material";
|
||||||
|
import { alpha } from "@mui/material/styles";
|
||||||
|
|
||||||
import WorkOutlineIcon from "@mui/icons-material/WorkOutline";
|
import WorkOutlineIcon from "@mui/icons-material/WorkOutline";
|
||||||
import ForumIcon from "@mui/icons-material/Forum";
|
|
||||||
import MarkEmailReadIcon from "@mui/icons-material/MarkEmailRead";
|
import MarkEmailReadIcon from "@mui/icons-material/MarkEmailRead";
|
||||||
|
import ForumIcon from "@mui/icons-material/Forum";
|
||||||
import EmojiEventsIcon from "@mui/icons-material/EmojiEvents";
|
import EmojiEventsIcon from "@mui/icons-material/EmojiEvents";
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
|
||||||
import { alpha } from "@mui/material/styles";
|
|
||||||
|
|
||||||
import { JobApplication } from "../types";
|
import { JobApplication } from "../types";
|
||||||
|
|
||||||
|
type HistoryEvent = {
|
||||||
|
id: number;
|
||||||
|
type: string;
|
||||||
|
oldValue?: string;
|
||||||
|
newValue?: string;
|
||||||
|
note?: string;
|
||||||
|
at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FlowItem = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
at: Date;
|
||||||
|
color: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
function normalizeStatus(status?: string): string {
|
function normalizeStatus(status?: string): string {
|
||||||
if (!status) return "Applied";
|
if (!status) return "Applied";
|
||||||
if (status === "Interviewing") return "Interview";
|
if (status === "Interviewing") return "Interview";
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTerminalRejected(status: string) {
|
function parseResponseDate(value?: string): Date | null {
|
||||||
return status === "Rejected" || status === "Ghosted";
|
if (!value) return null;
|
||||||
|
const parts = value.split(":");
|
||||||
|
const raw = parts.length > 1 ? parts.slice(1).join(":") : "";
|
||||||
|
const d = new Date(raw);
|
||||||
|
return Number.isNaN(+d) ? null : d;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function JobFlowBar({ job }: { job: JobApplication | null }) {
|
function firstStatusChange(history: HistoryEvent[], status: string): Date | null {
|
||||||
|
const match = history.find((item) => item.type === "StatusChanged" && normalizeStatus(item.newValue) === status);
|
||||||
|
if (!match) return null;
|
||||||
|
const d = new Date(match.at);
|
||||||
|
return Number.isNaN(+d) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstResponse(history: HistoryEvent[], job: JobApplication): Date | null {
|
||||||
|
if (job.responseDate) {
|
||||||
|
const d = new Date(job.responseDate);
|
||||||
|
if (!Number.isNaN(+d)) return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
const explicit = history.find((item) => item.type === "ReplyReceived");
|
||||||
|
if (explicit) {
|
||||||
|
const d = new Date(explicit.at);
|
||||||
|
if (!Number.isNaN(+d)) return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = history.find((item) => item.type === "ResponseUpdated");
|
||||||
|
if (!updated) return null;
|
||||||
|
return parseResponseDate(updated.newValue) ?? new Date(updated.at);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JobFlowBar({ job, history = [] }: { job: JobApplication | null; history?: HistoryEvent[] }) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const steps = useMemo(
|
|
||||||
() =>
|
|
||||||
[
|
|
||||||
{ key: "applied" as const, label: "Applied", color: theme.palette.info.main, icon: <WorkOutlineIcon fontSize="inherit" /> },
|
|
||||||
{ key: "interview" as const, label: "Interview", color: theme.palette.primary.main, icon: <ForumIcon fontSize="inherit" /> },
|
|
||||||
{ key: "reply" as const, label: "Reply", color: theme.palette.success.main, icon: <MarkEmailReadIcon fontSize="inherit" /> },
|
|
||||||
{ key: "offer" as const, label: "Offer", color: theme.palette.success.dark, icon: <EmojiEventsIcon fontSize="inherit" /> },
|
|
||||||
{ key: "outcome" as const, label: "Outcome", color: theme.palette.text.secondary, icon: <EmojiEventsIcon fontSize="inherit" /> },
|
|
||||||
] as const,
|
|
||||||
[theme.palette.info.main, theme.palette.primary.main, theme.palette.success.main, theme.palette.success.dark, theme.palette.text.secondary],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!job) return null;
|
const items = useMemo(() => {
|
||||||
|
if (!job) return [] as FlowItem[];
|
||||||
|
|
||||||
const status = normalizeStatus(job.status);
|
const appliedAt = new Date(job.dateApplied);
|
||||||
const reply = Boolean(job.responseReceived);
|
const replyAt = firstResponse(history, job);
|
||||||
const rejected = isTerminalRejected(status);
|
const interviewAt = firstStatusChange(history, "Interview") || (normalizeStatus(job.status) === "Interview" ? replyAt ?? appliedAt : null);
|
||||||
|
const offerAt = firstStatusChange(history, "Offer") || (normalizeStatus(job.status) === "Offer" ? replyAt ?? interviewAt ?? appliedAt : null);
|
||||||
|
const rejectedLabel = normalizeStatus(job.status) === "Ghosted" ? "Ghosted" : "Rejected";
|
||||||
|
const rejectedAt = firstStatusChange(history, "Rejected")
|
||||||
|
|| firstStatusChange(history, "Ghosted")
|
||||||
|
|| ((normalizeStatus(job.status) === "Rejected" || normalizeStatus(job.status) === "Ghosted") ? replyAt ?? appliedAt : null);
|
||||||
|
|
||||||
const activeIndex = (() => {
|
const next: FlowItem[] = [
|
||||||
if (rejected) return 4;
|
{
|
||||||
if (status === "Offer") return 3;
|
key: "applied",
|
||||||
if (reply) return 2;
|
label: "Applied",
|
||||||
if (status === "Interview") return 1;
|
at: appliedAt,
|
||||||
return 0;
|
color: theme.palette.info.main,
|
||||||
})();
|
icon: <WorkOutlineIcon fontSize="small" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const breakIndex = rejected ? activeIndex : null;
|
if (replyAt) {
|
||||||
const visibleSteps = breakIndex !== null ? steps.slice(0, breakIndex + 1) : steps;
|
next.push({
|
||||||
|
key: "reply",
|
||||||
|
label: "Reply",
|
||||||
|
at: replyAt,
|
||||||
|
color: theme.palette.success.main,
|
||||||
|
icon: <MarkEmailReadIcon fontSize="small" />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const segCount = visibleSteps.length;
|
if (interviewAt) {
|
||||||
const segWidth = `${100 / segCount}%`;
|
next.push({
|
||||||
|
key: "interview",
|
||||||
|
label: "Interview",
|
||||||
|
at: interviewAt,
|
||||||
|
color: theme.palette.primary.main,
|
||||||
|
icon: <ForumIcon fontSize="small" />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offerAt) {
|
||||||
|
next.push({
|
||||||
|
key: "offer",
|
||||||
|
label: "Offer",
|
||||||
|
at: offerAt,
|
||||||
|
color: theme.palette.success.dark,
|
||||||
|
icon: <EmojiEventsIcon fontSize="small" />,
|
||||||
|
});
|
||||||
|
} else if (rejectedAt) {
|
||||||
|
next.push({
|
||||||
|
key: normalizeStatus(job.status).toLowerCase(),
|
||||||
|
label: rejectedLabel,
|
||||||
|
at: rejectedAt,
|
||||||
|
color: theme.palette.error.main,
|
||||||
|
icon: <CloseIcon fontSize="small" />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return next
|
||||||
|
.filter((item) => !Number.isNaN(+item.at))
|
||||||
|
.sort((a, b) => +a.at - +b.at)
|
||||||
|
.filter((item, index, arr) => index === arr.findIndex((other) => other.key === item.key));
|
||||||
|
}, [history, job, theme.palette.error.main, theme.palette.info.main, theme.palette.primary.main, theme.palette.success.dark, theme.palette.success.main]);
|
||||||
|
|
||||||
|
if (!job || items.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ mb: 2 }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
@@ -65,70 +146,35 @@ export default function JobFlowBar({ job }: { job: JobApplication | null }) {
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
borderRadius: 999,
|
flexWrap: "wrap",
|
||||||
overflow: "hidden",
|
gap: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
p: 1.25,
|
||||||
|
borderRadius: 3,
|
||||||
border: `1px solid ${alpha(theme.palette.text.primary, 0.12)}`,
|
border: `1px solid ${alpha(theme.palette.text.primary, 0.12)}`,
|
||||||
height: 44,
|
background: theme.palette.mode === "dark" ? "rgba(15,23,42,0.42)" : "rgba(248,250,252,0.92)",
|
||||||
background: alpha("#fff", 0.65),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{visibleSteps.map((s, idx) => {
|
{items.map((item, index) => (
|
||||||
const filled = idx <= activeIndex && breakIndex === null;
|
<React.Fragment key={`${item.key}-${item.at.toISOString()}`}>
|
||||||
const terminalFilled = breakIndex !== null && idx < breakIndex;
|
<Chip
|
||||||
const isBreak = breakIndex !== null && idx === breakIndex;
|
icon={item.icon as React.ReactElement}
|
||||||
|
label={`${item.label} · ${item.at.toLocaleDateString()}`}
|
||||||
const bg = filled || terminalFilled ? s.color : alpha(s.color, 0.12);
|
sx={{
|
||||||
const fg = filled || terminalFilled ? "#fff" : alpha(theme.palette.text.primary, 0.6);
|
fontWeight: 800,
|
||||||
|
color: theme.palette.getContrastText(item.color),
|
||||||
const title = (() => {
|
backgroundColor: item.color,
|
||||||
if (isBreak) return `${status} (stopped here)`;
|
borderRadius: 2,
|
||||||
if (idx === 2 && reply) return "Reply received";
|
px: 0.5,
|
||||||
return s.label;
|
}}
|
||||||
})();
|
/>
|
||||||
|
{index < items.length - 1 ? (
|
||||||
return (
|
<Typography sx={{ color: "text.secondary", fontWeight: 900 }}>
|
||||||
<Tooltip key={s.key} title={title}>
|
?
|
||||||
<Box
|
</Typography>
|
||||||
sx={{
|
) : null}
|
||||||
width: segWidth,
|
</React.Fragment>
|
||||||
display: "flex",
|
))}
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
position: "relative",
|
|
||||||
background: isBreak ? theme.palette.error.main : bg,
|
|
||||||
color: "#fff",
|
|
||||||
borderRight: idx === visibleSteps.length - 1 ? "none" : `1px solid ${alpha("#fff", 0.35)}`,
|
|
||||||
userSelect: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isBreak ? (
|
|
||||||
<CloseIcon fontSize="inherit" />
|
|
||||||
) : (
|
|
||||||
<Box sx={{ display: "inline-flex", alignItems: "center", gap: 1, color: fg, fontWeight: 900 }}>
|
|
||||||
<Box sx={{ fontSize: 18, display: "inline-flex" }}>{s.icon}</Box>
|
|
||||||
<span style={{ fontSize: 13 }}>{s.key === "outcome" && rejected ? status : s.label}</span>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isBreak ? (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: "absolute",
|
|
||||||
inset: 0,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
fontWeight: 900,
|
|
||||||
letterSpacing: "0.02em",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{status} <span style={{ marginLeft: 8, display: "inline-flex" }}><CloseIcon fontSize="inherit" /></span>
|
|
||||||
</Box>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ export default function KanbanBoard() {
|
|||||||
border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.22 : 0.14)}`,
|
border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.22 : 0.14)}`,
|
||||||
background: theme.palette.mode === "dark" ? "rgba(15,23,42,0.55)" : "rgba(255,255,255,0.8)",
|
background: theme.palette.mode === "dark" ? "rgba(15,23,42,0.55)" : "rgba(255,255,255,0.8)",
|
||||||
backdropFilter: "blur(8px)",
|
backdropFilter: "blur(8px)",
|
||||||
|
color: theme.palette.text.primary,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ p: 1.25, "&:last-child": { pb: 1.25 } }}>
|
<CardContent sx={{ p: 1.25, "&:last-child": { pb: 1.25 } }}>
|
||||||
@@ -142,6 +143,7 @@ export default function KanbanBoard() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
|
sx={{ color: theme.palette.text.primary }}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setMenuJobId(j.id);
|
setMenuJobId(j.id);
|
||||||
|
|||||||
Reference in New Issue
Block a user