nda improvements
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { ApiError, api, type Staff } from './api.js';
|
||||
import { StaffName } from './staff-color.js';
|
||||
import Login from './views/Login.js';
|
||||
import Today from './views/Today.js';
|
||||
import NdaInbox from './views/NdaInbox.js';
|
||||
@@ -92,7 +93,7 @@ export default function App() {
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-gray-600">{staff.name}</span>
|
||||
<StaffName id={staff.id} name={staff.name} />
|
||||
<button
|
||||
className="rounded border border-gray-300 px-2 py-1 text-xs hover:bg-gray-100"
|
||||
onClick={signOut}
|
||||
|
||||
@@ -283,11 +283,23 @@ export interface InboxRow {
|
||||
|
||||
export type RefreshState = 'idle' | 'running' | string;
|
||||
|
||||
export interface RefreshResult {
|
||||
pages: number;
|
||||
seen: number;
|
||||
stored: number;
|
||||
}
|
||||
|
||||
export interface Inbox {
|
||||
requests: InboxRow[];
|
||||
/** ISO timestamp of the last completed background refresh, null if never. */
|
||||
last_refresh_at: string | null;
|
||||
refresh_state: RefreshState;
|
||||
/** What the last completed walk did, null before the first one. */
|
||||
last_refresh_result: RefreshResult | null;
|
||||
/** The sync runs in the background too, with the same state pattern. */
|
||||
last_sync_at: string | null;
|
||||
sync_state: RefreshState;
|
||||
last_sync_result: SyncResult | null;
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
@@ -314,7 +326,13 @@ export interface SyncResult {
|
||||
signed: number;
|
||||
declined: number;
|
||||
failed: number;
|
||||
/** Old pending mirror rows re-fetched one by one, and how many had moved on. */
|
||||
mirror_candidates: number;
|
||||
mirror_rechecked: number;
|
||||
mirror_changed: number;
|
||||
mirror_failed: number;
|
||||
warnings: string[];
|
||||
warnings_omitted: number;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
@@ -422,8 +440,12 @@ export const api = {
|
||||
|
||||
/** Reads the mirrored requests out of the database — never calls Dropbox. */
|
||||
ndaInbox: (since: string) => request<Inbox>(`/api/nda-inbox?${qs({ since })}`),
|
||||
/** Starts the background walk; resolves as soon as it is queued. */
|
||||
refreshInbox: (since: string) =>
|
||||
/**
|
||||
* Starts the background walk; resolves as soon as it is queued. Passing
|
||||
* `since` makes the server re-read that whole window — leave it out for the
|
||||
* incremental catch-up, which is what a plain Refresh should do.
|
||||
*/
|
||||
refreshInbox: (since?: string) =>
|
||||
post<{ state: RefreshState }>(`/api/nda-inbox/refresh?${qs({ since })}`),
|
||||
importInbox: (signatureRequestId: string, buyerId?: string, businessId?: string) =>
|
||||
post<ImportResult>('/api/nda-inbox/import', {
|
||||
@@ -431,7 +453,7 @@ export const api = {
|
||||
buyer_id: buyerId,
|
||||
business_id: businessId,
|
||||
}),
|
||||
syncInbox: () => post<SyncResult>('/api/nda-inbox/sync'),
|
||||
syncInbox: () => post<{ state: RefreshState }>('/api/nda-inbox/sync'),
|
||||
backfillFields: () => post<BackfillResult>('/api/nda-inbox/backfill-fields'),
|
||||
};
|
||||
|
||||
|
||||
71
web/src/staff-color.tsx
Normal file
71
web/src/staff-color.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* A stable colour per staff member, so "who did this" is readable at a glance
|
||||
* without reading the name. Derived from the id, which means it survives
|
||||
* reloads, sessions and renames, and needs nothing stored anywhere.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Ten hues that stay apart from each other and from the greys the app uses.
|
||||
* Written out in full because Tailwind only ships classes it can see in the
|
||||
* source — a generated `text-${hue}-700` would be purged.
|
||||
*/
|
||||
export interface StaffColor {
|
||||
text: string;
|
||||
dot: string;
|
||||
}
|
||||
|
||||
const PALETTE: StaffColor[] = [
|
||||
{ text: 'text-blue-700', dot: 'bg-blue-500' },
|
||||
{ text: 'text-emerald-700', dot: 'bg-emerald-500' },
|
||||
{ text: 'text-violet-700', dot: 'bg-violet-500' },
|
||||
{ text: 'text-amber-700', dot: 'bg-amber-500' },
|
||||
{ text: 'text-rose-700', dot: 'bg-rose-500' },
|
||||
{ text: 'text-cyan-700', dot: 'bg-cyan-500' },
|
||||
{ text: 'text-lime-700', dot: 'bg-lime-600' },
|
||||
{ text: 'text-fuchsia-700', dot: 'bg-fuchsia-500' },
|
||||
{ text: 'text-orange-700', dot: 'bg-orange-500' },
|
||||
{ text: 'text-teal-700', dot: 'bg-teal-600' },
|
||||
];
|
||||
|
||||
/** FNV-1a — short, stable, and well spread over ten buckets. */
|
||||
function hash(value: string): number {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
h ^= value.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return Math.abs(h);
|
||||
}
|
||||
|
||||
/** Grey stands in for "nobody" — a deleted staff member or an unassigned row. */
|
||||
const UNKNOWN: StaffColor = { text: 'text-gray-500', dot: 'bg-gray-400' };
|
||||
|
||||
export function staffColor(id: string | null | undefined): StaffColor {
|
||||
if (!id) return UNKNOWN;
|
||||
return PALETTE[hash(id) % PALETTE.length] ?? UNKNOWN;
|
||||
}
|
||||
|
||||
/**
|
||||
* A staff member's name in their colour. Deliberately understated — a dot and
|
||||
* tinted text, never a filled pill, so a list of names does not turn into a
|
||||
* traffic light.
|
||||
*/
|
||||
export function StaffName({
|
||||
id,
|
||||
name,
|
||||
dot = true,
|
||||
className = '',
|
||||
}: {
|
||||
id: string | null | undefined;
|
||||
name: string | null | undefined;
|
||||
dot?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const color = staffColor(id);
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 ${color.text} ${className}`}>
|
||||
{dot && <span className={`h-1.5 w-1.5 shrink-0 rounded-full ${color.dot}`} />}
|
||||
{name ?? 'unknown'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api, type Inbox, type InboxRow, type InboxStatus, type SyncResult } from '../api.js';
|
||||
import { api, type Inbox, type InboxRow, type InboxStatus } from '../api.js';
|
||||
import { formatDayTimeParts, formatRelative } from '../components.js';
|
||||
|
||||
const INPUT = 'rounded border border-gray-300 px-2 py-1.5 text-sm';
|
||||
@@ -37,22 +37,34 @@ export default function NdaInbox({
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [since, setSince] = useState(() => isoDaysAgo(90));
|
||||
// Sending ?since= tells the server to re-read the whole window, which is a
|
||||
// minute of paging. A plain Refresh must not do that — only a date the user
|
||||
// actually moved since the last run asks for the wide reload, once.
|
||||
const [sinceChanged, setSinceChanged] = useState(false);
|
||||
const [inbox, setInbox] = useState<Inbox | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncResult, setSyncResult] = useState<SyncResult | null>(null);
|
||||
// Business chosen per row; '' means "import the NDA without a deal".
|
||||
const [picked, setPicked] = useState<Record<string, string>>({});
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
// Which row is mid-import, if any.
|
||||
const [importing, setImporting] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const poll = useRef<number | null>(null);
|
||||
// Set when a task started *here* finishes, so its result is reported once
|
||||
// rather than on every mount.
|
||||
const [refreshNotice, setRefreshNotice] = useState<string | null>(null);
|
||||
const [syncNotice, setSyncNotice] = useState<string | null>(null);
|
||||
const watchingRefresh = useRef(false);
|
||||
const watchingSync = useRef(false);
|
||||
|
||||
const rows: InboxRow[] | null = inbox?.requests ?? null;
|
||||
const refreshing = inbox?.refresh_state === 'running';
|
||||
const refreshError = inbox?.refresh_state?.startsWith('error:')
|
||||
? inbox.refresh_state.slice('error:'.length)
|
||||
: null;
|
||||
const syncing = inbox?.sync_state === 'running';
|
||||
// While either task runs the table keeps polling; both report when done.
|
||||
const busy = refreshing || syncing;
|
||||
const taskError = [inbox?.refresh_state, inbox?.sync_state]
|
||||
.find((state) => state?.startsWith('error:'))
|
||||
?.slice('error:'.length);
|
||||
|
||||
/** Reads the mirror out of the database — this never talks to Dropbox. */
|
||||
const load = useCallback(async () => {
|
||||
@@ -61,16 +73,9 @@ export default function NdaInbox({
|
||||
const res = await api.ndaInbox(since);
|
||||
setInbox(res);
|
||||
setError(null);
|
||||
// Preselect the best suggestion, but never overwrite a manual choice.
|
||||
setPicked((current) => {
|
||||
const next = { ...current };
|
||||
for (const row of res.requests) {
|
||||
if (next[row.signature_request_id] === undefined) {
|
||||
next[row.signature_request_id] = row.business_suggestions[0]?.id ?? '';
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
// Deliberately no preselection: a suggestion is a guess, and an import
|
||||
// must never attach a deal the user did not pick. Anything already
|
||||
// chosen by hand is kept across reloads.
|
||||
return res;
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
@@ -87,7 +92,31 @@ export default function NdaInbox({
|
||||
// Whenever a walk is in flight — started here or elsewhere — watch it until
|
||||
// it finishes, then show whatever it brought in.
|
||||
useEffect(() => {
|
||||
if (!refreshing) {
|
||||
if (!refreshing && watchingRefresh.current && inbox?.last_refresh_result) {
|
||||
watchingRefresh.current = false;
|
||||
const { pages, seen, stored } = inbox.last_refresh_result;
|
||||
setRefreshNotice(
|
||||
`Refreshed: ${stored} request${stored === 1 ? '' : 's'} stored ` +
|
||||
`from ${seen} seen over ${pages} page${pages === 1 ? '' : 's'}.`,
|
||||
);
|
||||
}
|
||||
if (!syncing && watchingSync.current && inbox?.last_sync_result) {
|
||||
watchingSync.current = false;
|
||||
const r = inbox.last_sync_result;
|
||||
setSyncNotice(
|
||||
`Synced: ${r.checked} NDA(s) checked, ${r.signed} newly signed, ` +
|
||||
`${r.declined} declined` +
|
||||
(r.failed > 0 ? `, ${r.failed} failed` : '') +
|
||||
` · ${r.mirror_rechecked}/${r.mirror_candidates} older request(s) re-read, ` +
|
||||
`${r.mirror_changed} had moved on` +
|
||||
(r.mirror_failed > 0 ? `, ${r.mirror_failed} unreadable` : '') +
|
||||
(r.warnings.length > 0
|
||||
? ` · ${r.warnings.join('; ')}${r.warnings_omitted > 0 ? ` (+${r.warnings_omitted} more)` : ''}`
|
||||
: ''),
|
||||
);
|
||||
}
|
||||
|
||||
if (!busy) {
|
||||
if (poll.current !== null) {
|
||||
window.clearInterval(poll.current);
|
||||
poll.current = null;
|
||||
@@ -102,12 +131,16 @@ export default function NdaInbox({
|
||||
poll.current = null;
|
||||
}
|
||||
};
|
||||
}, [refreshing, load]);
|
||||
}, [busy, refreshing, syncing, load, inbox?.last_refresh_result, inbox?.last_sync_result]);
|
||||
|
||||
async function refresh() {
|
||||
setError(null);
|
||||
setRefreshNotice(null);
|
||||
try {
|
||||
await api.refreshInbox(since);
|
||||
// Only a moved date asks for the full window; then back to incremental.
|
||||
await api.refreshInbox(sinceChanged ? since : undefined);
|
||||
setSinceChanged(false);
|
||||
watchingRefresh.current = true;
|
||||
// Flip to "running" at once so the poller starts without a round trip.
|
||||
setInbox((current) => (current ? { ...current, refresh_state: 'running' } : current));
|
||||
} catch (err) {
|
||||
@@ -116,22 +149,20 @@ export default function NdaInbox({
|
||||
}
|
||||
|
||||
async function sync() {
|
||||
setSyncing(true);
|
||||
setSyncResult(null);
|
||||
setError(null);
|
||||
setSyncNotice(null);
|
||||
try {
|
||||
setSyncResult(await api.syncInbox());
|
||||
await load();
|
||||
onChanged();
|
||||
await api.syncInbox();
|
||||
watchingSync.current = true;
|
||||
// Flip to "running" at once so the poller starts without a round trip.
|
||||
setInbox((current) => (current ? { ...current, sync_state: 'running' } : current));
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function importRow(row: InboxRow) {
|
||||
setBusy(row.signature_request_id);
|
||||
setImporting(row.signature_request_id);
|
||||
setNotice(null);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -151,7 +182,7 @@ export default function NdaInbox({
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
setImporting(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +195,11 @@ export default function NdaInbox({
|
||||
<input
|
||||
type="date"
|
||||
value={since}
|
||||
onChange={(e) => setSince(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setSince(e.target.value);
|
||||
setSinceChanged(true);
|
||||
}}
|
||||
title="Changing the date makes the next Refresh re-read that whole window"
|
||||
className={INPUT}
|
||||
/>
|
||||
</label>
|
||||
@@ -177,10 +212,14 @@ export default function NdaInbox({
|
||||
<button
|
||||
onClick={refresh}
|
||||
disabled={refreshing}
|
||||
title="Fetches the signature requests from Dropbox Sign in the background"
|
||||
title={
|
||||
sinceChanged
|
||||
? 'Reload the whole window from the changed date'
|
||||
: 'Refresh (new since last check)'
|
||||
}
|
||||
className="rounded border border-gray-300 bg-white px-3 py-1.5 text-sm hover:bg-gray-100 disabled:opacity-50"
|
||||
>
|
||||
{refreshing ? 'Refreshing…' : 'Refresh'}
|
||||
{refreshing ? 'Refreshing…' : sinceChanged ? 'Reload window' : 'Refresh'}
|
||||
</button>
|
||||
<button
|
||||
onClick={sync}
|
||||
@@ -192,24 +231,16 @@ export default function NdaInbox({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{refreshing && (
|
||||
{busy && (
|
||||
<p className="mb-3 text-sm text-gray-600">
|
||||
Fetching from Dropbox Sign in the background — the table below stays usable.
|
||||
{refreshing ? 'Fetching from' : 'Re-checking with'} Dropbox Sign in the background — the
|
||||
table below stays usable.
|
||||
</p>
|
||||
)}
|
||||
{refreshError && (
|
||||
<p className="mb-3 text-sm text-red-600">Last refresh failed: {refreshError}</p>
|
||||
)}
|
||||
{taskError && <p className="mb-3 text-sm text-red-600">Last run failed: {taskError}</p>}
|
||||
|
||||
{syncResult && (
|
||||
<p className="mb-3 text-sm text-gray-600">
|
||||
Checked {syncResult.checked} · newly signed {syncResult.signed} · declined{' '}
|
||||
{syncResult.declined} · failed {syncResult.failed}
|
||||
{syncResult.warnings.length > 0 && (
|
||||
<span className="text-amber-700"> · {syncResult.warnings.join('; ')}</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{refreshNotice && <p className="mb-3 text-sm text-gray-600">{refreshNotice}</p>}
|
||||
{syncNotice && <p className="mb-3 text-sm text-gray-600">{syncNotice}</p>}
|
||||
{notice && <p className="mb-3 text-sm text-gray-600">{notice}</p>}
|
||||
{error && <p className="mb-3 text-sm text-red-600">{error}</p>}
|
||||
|
||||
@@ -289,10 +320,10 @@ export default function NdaInbox({
|
||||
) : (
|
||||
<button
|
||||
onClick={() => importRow(row)}
|
||||
disabled={busy === row.signature_request_id}
|
||||
disabled={importing === row.signature_request_id}
|
||||
className="rounded bg-gray-900 px-2 py-1 text-xs text-white disabled:opacity-50"
|
||||
>
|
||||
{busy === row.signature_request_id ? 'Importing…' : 'Import'}
|
||||
{importing === row.signature_request_id ? 'Importing…' : 'Import'}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
|
||||
@@ -9,17 +9,30 @@ import {
|
||||
} from '../api.js';
|
||||
import { DEAL_LABELS, Dialog, Panel, StatusBadge, formatDay } from '../components.js';
|
||||
import { TodoRow } from '../workflow.js';
|
||||
import { StaffName } from '../staff-color.js';
|
||||
|
||||
const INPUT = 'w-full rounded border border-gray-300 px-2 py-1 text-sm';
|
||||
|
||||
/** Groups anything with a person attached, for the team tab. */
|
||||
function groupBy<T>(rows: T[], name: (row: T) => string): [string, T[]][] {
|
||||
const groups = new Map<string, T[]>();
|
||||
interface Group<T> {
|
||||
id: string | null;
|
||||
name: string;
|
||||
rows: T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups anything with a person attached, for the team tab. Keyed by staff id
|
||||
* rather than name, so the group heading can carry that person's colour.
|
||||
*/
|
||||
function groupByStaff<T>(rows: T[], who: (row: T) => { id: string | null; name: string }): Group<T>[] {
|
||||
const groups = new Map<string, Group<T>>();
|
||||
for (const row of rows) {
|
||||
const key = name(row);
|
||||
groups.set(key, [...(groups.get(key) ?? []), row]);
|
||||
const { id, name } = who(row);
|
||||
const key = id ?? name;
|
||||
const group = groups.get(key) ?? { id, name, rows: [] };
|
||||
group.rows.push(row);
|
||||
groups.set(key, group);
|
||||
}
|
||||
return [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
||||
return [...groups.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export default function Today({
|
||||
@@ -110,10 +123,16 @@ export default function Today({
|
||||
{data && data.todos.length > 0 && (
|
||||
<Panel title="Todos">
|
||||
{team ? (
|
||||
groupBy(data.todos, (todo) => todo.assigned_to.name).map(([name, todos]) => (
|
||||
<div key={name} className="mb-2 last:mb-0">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">{name}</p>
|
||||
<TodoList todos={todos} onChanged={refresh} open={{ onOpenBuyer, onOpenBusiness }} />
|
||||
groupByStaff(data.todos, (todo) => todo.assigned_to).map((group) => (
|
||||
<div key={group.id ?? group.name} className="mb-2 last:mb-0">
|
||||
<p className="text-xs font-medium uppercase tracking-wide">
|
||||
<StaffName id={group.id} name={group.name} />
|
||||
</p>
|
||||
<TodoList
|
||||
todos={group.rows}
|
||||
onChanged={refresh}
|
||||
open={{ onOpenBuyer, onOpenBusiness }}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
@@ -125,11 +144,16 @@ export default function Today({
|
||||
{data && data.follow_ups.length > 0 && (
|
||||
<Panel title="Follow-ups due">
|
||||
{team ? (
|
||||
groupBy(data.follow_ups, (row) => row.created_by_name ?? 'unassigned').map(
|
||||
([name, rows]) => (
|
||||
<div key={name} className="mb-2 last:mb-0">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">{name}</p>
|
||||
{rows.map((row) => (
|
||||
groupByStaff(data.follow_ups, (row) => ({
|
||||
id: row.created_by_id,
|
||||
name: row.created_by_name ?? 'unassigned',
|
||||
})).map(
|
||||
(group) => (
|
||||
<div key={group.id ?? group.name} className="mb-2 last:mb-0">
|
||||
<p className="text-xs font-medium uppercase tracking-wide">
|
||||
<StaffName id={group.id} name={group.name} />
|
||||
</p>
|
||||
{group.rows.map((row) => (
|
||||
<FollowUpRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
@@ -278,7 +302,11 @@ function PendingNdaRow({
|
||||
</button>
|
||||
, sent <span className="text-gray-500">{formatDay(row.sent_at)}</span>
|
||||
</span>
|
||||
{team && <span className="text-xs text-gray-500">{row.created_by_name ?? '—'}</span>}
|
||||
{team && (
|
||||
<span className="text-xs">
|
||||
<StaffName id={row.created_by_id} name={row.created_by_name ?? '—'} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type TodoKind,
|
||||
} from './api.js';
|
||||
import { Dialog, formatDay, formatStamp } from './components.js';
|
||||
import { StaffName } from './staff-color.js';
|
||||
|
||||
const INPUT = 'rounded border border-gray-300 px-2 py-1 text-sm';
|
||||
|
||||
@@ -80,8 +81,9 @@ function NoteRow({
|
||||
{note.context.label}
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
{note.author?.name ?? 'unknown'} · {formatStamp(note.created_at)}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<StaffName id={note.author?.id} name={note.author?.name} />·{' '}
|
||||
{formatStamp(note.created_at)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => run(() => api.updateNote(note.id, { highlight: !note.highlight }))}
|
||||
@@ -281,7 +283,7 @@ export function TodoRow({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-400">
|
||||
<span>{todo.assigned_to.name}</span>
|
||||
<StaffName id={todo.assigned_to.id} name={todo.assigned_to.name} />
|
||||
{todo.due_at && (
|
||||
<span className={todo.overdue && !done ? 'font-medium text-red-600' : ''}>
|
||||
due {formatDay(todo.due_at)}
|
||||
|
||||
Reference in New Issue
Block a user