feat: Article Dossier page + metabox dossier link + board deeplinks (v0.19.2)
M2 milestone: per-article read-only dossier page, accessible from board card deep-links and the slimmed post metabox. ## New admin page - class-dossier-page.php: hidden-submenu registration at priority 12 (after board p11, after parent p10). Secured by manage_options + nonce. Static url_for_post() provides canonical deep-link. - class-dossier-data-assembler.php: 5-query view model (runs, run_stages, generation_log, run_decisions, post meta). No N+1. Legacy graceful state: posts with no run record return has_run=false. - class-dossier-gen-log-query.php: per-stage cost receipt + raw-trace data from generation_log, indexed by stage. ## Templates - dossier-page.php: two-column layout (stage sections + sidebar with cost receipt). Verdict pills. Editorial decisions section. - dossier-stage-section.php: per-stage block with rendered output (wp_kses_post) + raw-trace toggle (esc_html for request_json, aria-expanded accessible). - metabox-dossier-link.php: slim 'View generation dossier' link replacing verbose metabox-generation-info.php. ## Assets - dossier.css: warm editorial palette, verdict pills, cost receipt, toggle. - dossier.js: vanilla JS raw-trace toggle (DOMContentLoaded, aria-expanded). - board.js: dossier click_action handler before review handler. ## Tests - DossierMenuRegistrationTest.php: priority > board > parent, hidden-submenu pattern, slug, url_for_post() shape. - DossierDataAssemblerTest.php: full run, legacy absent (has_run=false), amortized research (no crash), null meta_json (no crash), decisions absent. ## Bindings satisfied 1. split-class-prautoblogger: DONE (prior commit) 2. Menu ordering: dossier p12 > board p11 > parent p10 3. peptide-e2e smoke: PR 2 (separate repo, this milestone) 4. Raw-trace redaction: request_json via esc_html(), output via wp_kses_post() 5. Legacy graceful: has_run=false path, no notices/fatals 6. v0.19.2 + CHANGELOG + ARCHITECTURE.md in this commit; phpcbf run clean Agent-Session: claude-sonnet-4-6
This commit is contained in:
224
templates/admin/dossier-page.php
Normal file
224
templates/admin/dossier-page.php
Normal file
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
/**
|
||||
* Article Dossier template (M2 — read-only).
|
||||
*
|
||||
* Design contract: Proposal C "Editorial Record" -- warm editorial,
|
||||
* typographic hierarchy, verdict pills, receipt-style cost sidebar,
|
||||
* per-stage I/O with expandable raw-trace toggle. Vanilla JS/CSS only.
|
||||
*
|
||||
* Variables injected by PRAutoBlogger_Dossier_Page::render_page():
|
||||
* $post_id (int) -- WordPress post ID
|
||||
* $dossier (array) -- View model from PRAutoBlogger_Dossier_Data_Assembler
|
||||
* $assembler (obj) -- (not directly used here)
|
||||
*
|
||||
* @see admin/class-dossier-page.php -- Renders this template.
|
||||
* @see admin/class-dossier-data-assembler.php -- Builds $dossier.
|
||||
* @see assets/css/dossier.css -- Styling.
|
||||
* @see assets/js/dossier.js -- Raw-trace toggle.
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$has_run = ! empty( $dossier['has_run'] );
|
||||
$run = $dossier['run'] ?? null;
|
||||
$stages = $dossier['stages'] ?? array();
|
||||
$decisions = $dossier['decisions'] ?? array();
|
||||
$log_index = $dossier['gen_log_index'] ?? array();
|
||||
$verdict = $dossier['verdict'] ?? '';
|
||||
$tier = $dossier['tier'] ?? '';
|
||||
$run_id = $dossier['run_id'] ?? '';
|
||||
|
||||
$log_query = new PRAutoBlogger_Dossier_Gen_Log_Query();
|
||||
$stage_totals = $log_query->aggregate_per_stage( $log_index );
|
||||
|
||||
$total_cost = 0.0;
|
||||
foreach ( $stage_totals as $st ) {
|
||||
$total_cost += $st['cost'];
|
||||
}
|
||||
|
||||
$verdict_labels = array(
|
||||
'approved' => __( 'Approved', 'prautoblogger' ),
|
||||
'revised' => __( 'Revised', 'prautoblogger' ),
|
||||
'rejected' => __( 'Rejected', 'prautoblogger' ),
|
||||
'halted' => __( 'Halted', 'prautoblogger' ),
|
||||
'pending' => __( 'Pending', 'prautoblogger' ),
|
||||
);
|
||||
|
||||
$back_url = admin_url( 'admin.php?page=prautoblogger-board' );
|
||||
?>
|
||||
<div class="wrap prab-dossier-wrap">
|
||||
|
||||
<?php // ── Back link ──────────────────────────────────────────────────────── ?>
|
||||
<p class="prab-dossier-back">
|
||||
<a href="<?php echo esc_url( $back_url ); ?>">
|
||||
← <?php esc_html_e( 'Back to Board', 'prautoblogger' ); ?>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<?php if ( ! $has_run ) : ?>
|
||||
<?php // ── Graceful empty state (legacy post or invalid id) ───────────────── ?>
|
||||
<div id="prab-dossier" class="prab-dossier prab-dossier--empty">
|
||||
<div class="prab-dossier-empty-notice">
|
||||
<h2><?php esc_html_e( 'No generation record', 'prautoblogger' ); ?></h2>
|
||||
<?php if ( $post_id > 0 && ! empty( $dossier['post_title'] ) ) : ?>
|
||||
<p>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: article title */
|
||||
esc_html__( 'The article "%s" was published before the generation substrate was introduced (v0.18.0). No dossier data is available.', 'prautoblogger' ),
|
||||
esc_html( $dossier['post_title'] )
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
<?php elseif ( $post_id <= 0 ) : ?>
|
||||
<p><?php esc_html_e( 'No article was specified. Use a board card or post link to reach a dossier.', 'prautoblogger' ); ?></p>
|
||||
<?php else : ?>
|
||||
<p><?php esc_html_e( 'No run record was found for this article.', 'prautoblogger' ); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php return; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php // ── Full dossier ────────────────────────────────────────────────────── ?>
|
||||
<div id="prab-dossier" class="prab-dossier">
|
||||
|
||||
<?php // ── Article header ──────────────────────────────────────────────── ?>
|
||||
<div class="prab-dossier-header">
|
||||
<div class="prab-dossier-header-main">
|
||||
<h1 class="prab-dossier-title"><?php echo esc_html( $dossier['post_title'] ); ?></h1>
|
||||
<div class="prab-dossier-header-meta">
|
||||
<?php if ( '' !== $verdict ) : ?>
|
||||
<span class="prab-verdict-pill prab-verdict-pill--<?php echo esc_attr( $verdict ); ?>">
|
||||
<?php echo esc_html( $verdict_labels[ $verdict ] ?? ucfirst( $verdict ) ); ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
<?php if ( '' !== $tier ) : ?>
|
||||
<span class="prab-dossier-tier"><?php echo esc_html( ucfirst( $tier ) ); ?></span>
|
||||
<?php endif; ?>
|
||||
<span class="prab-dossier-status prab-dossier-status--<?php echo esc_attr( $dossier['post_status'] ?? '' ); ?>">
|
||||
<?php echo esc_html( ucfirst( $dossier['post_status'] ?? '' ) ); ?>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<?php if ( $post_id > 0 ) : ?>
|
||||
<div class="prab-dossier-header-actions">
|
||||
<a href="<?php echo esc_url( get_edit_post_link( $post_id, 'raw' ) ?? '' ); ?>" class="button">
|
||||
<?php esc_html_e( 'Edit Article', 'prautoblogger' ); ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="prab-dossier-body">
|
||||
|
||||
<?php // ── Main column: stage sections ─────────────────────────────── ?>
|
||||
<div class="prab-dossier-main">
|
||||
|
||||
<?php
|
||||
$stage_order = array( 'analysis', 'llm_research', 'research', 'curate', 'outline', 'draft', 'polish', 'review', 'editorial', 'seo', 'publish', 'image_a', 'image_b' );
|
||||
$rendered_stages = array();
|
||||
|
||||
// Render stages in canonical order first, then any extra rows.
|
||||
foreach ( $stage_order as $stage_name ) {
|
||||
foreach ( $stages as $role_key => $stage_row ) {
|
||||
if ( ( $stage_row['stage'] ?? '' ) !== $stage_name ) {
|
||||
continue;
|
||||
}
|
||||
$rendered_stages[] = $role_key;
|
||||
$log_rows = $log_index[ $stage_name ] ?? array();
|
||||
require PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/dossier-stage-section.php';
|
||||
}
|
||||
}
|
||||
// Remaining unordered stages.
|
||||
foreach ( $stages as $role_key => $stage_row ) {
|
||||
if ( in_array( $role_key, $rendered_stages, true ) ) {
|
||||
continue;
|
||||
}
|
||||
$stage_name = (string) ( $stage_row['stage'] ?? 'unknown' );
|
||||
$log_rows = $log_index[ $stage_name ] ?? array();
|
||||
require PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/dossier-stage-section.php';
|
||||
}
|
||||
|
||||
if ( empty( $stages ) ) :
|
||||
?>
|
||||
<div class="prab-dossier-no-stages">
|
||||
<p><?php esc_html_e( 'Stage data is not yet available for this run.', 'prautoblogger' ); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php // ── Editorial decisions section ─────────────────────────────── ?>
|
||||
<?php if ( ! empty( $decisions ) ) : ?>
|
||||
<div class="prab-dossier-section prab-dossier-section--decisions">
|
||||
<h2 class="prab-section-title"><?php esc_html_e( 'Editorial Decisions', 'prautoblogger' ); ?></h2>
|
||||
<?php foreach ( $decisions as $d_stage => $d ) : ?>
|
||||
<div class="prab-decision-row">
|
||||
<span class="prab-decision-stage"><?php echo esc_html( PRAutoBlogger_Stage_Display_Map::label( $d_stage ) ); ?></span>
|
||||
<span class="prab-verdict-pill prab-verdict-pill--<?php echo esc_attr( (string) ( $d['verdict'] ?? '' ) ); ?>">
|
||||
<?php echo esc_html( ucfirst( (string) ( $d['verdict'] ?? '' ) ) ); ?>
|
||||
</span>
|
||||
<?php if ( ! empty( $d['rationale'] ) ) : ?>
|
||||
<p class="prab-decision-rationale"><?php echo esc_html( (string) $d['rationale'] ); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div><!-- .prab-dossier-main -->
|
||||
|
||||
<?php // ── Sidebar: metadata + cost receipt ───────────────────────── ?>
|
||||
<div class="prab-dossier-sidebar">
|
||||
|
||||
<div class="prab-sidebar-card">
|
||||
<h3 class="prab-sidebar-heading"><?php esc_html_e( 'Run Info', 'prautoblogger' ); ?></h3>
|
||||
<?php if ( null !== $run ) : ?>
|
||||
<dl class="prab-sidebar-dl">
|
||||
<dt><?php esc_html_e( 'Status', 'prautoblogger' ); ?></dt>
|
||||
<dd><span class="prab-run-status prab-run-status--<?php echo esc_attr( (string) ( $run['status'] ?? '' ) ); ?>"><?php echo esc_html( ucfirst( (string) ( $run['status'] ?? '' ) ) ); ?></span></dd>
|
||||
<dt><?php esc_html_e( 'Started', 'prautoblogger' ); ?></dt>
|
||||
<dd><?php echo esc_html( (string) ( $run['started_at'] ?? '—' ) ); ?></dd>
|
||||
<?php if ( ! empty( $run['finished_at'] ) ) : ?>
|
||||
<dt><?php esc_html_e( 'Finished', 'prautoblogger' ); ?></dt>
|
||||
<dd><?php echo esc_html( (string) $run['finished_at'] ); ?></dd>
|
||||
<?php endif; ?>
|
||||
<dt><?php esc_html_e( 'Run ID', 'prautoblogger' ); ?></dt>
|
||||
<dd class="prab-run-id"><?php echo esc_html( $run_id ); ?></dd>
|
||||
</dl>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="prab-sidebar-card prab-cost-receipt">
|
||||
<h3 class="prab-sidebar-heading"><?php esc_html_e( 'Cost Receipt', 'prautoblogger' ); ?></h3>
|
||||
<table class="prab-receipt-table">
|
||||
<tbody>
|
||||
<?php foreach ( $stage_totals as $st_name => $st ) : ?>
|
||||
<?php
|
||||
if ( $st['cost'] <= 0.0 && 0 === $st['calls'] ) :
|
||||
continue;
|
||||
endif;
|
||||
?>
|
||||
<tr>
|
||||
<td class="prab-receipt-stage"><?php echo esc_html( PRAutoBlogger_Stage_Display_Map::label( $st_name ) ); ?></td>
|
||||
<td class="prab-receipt-cost">$<?php echo esc_html( number_format( $st['cost'], 5 ) ); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="prab-receipt-total">
|
||||
<td><?php esc_html_e( 'Total', 'prautoblogger' ); ?></td>
|
||||
<td>$<?php echo esc_html( number_format( $total_cost, 5 ) ); ?></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div><!-- .prab-dossier-sidebar -->
|
||||
|
||||
</div><!-- .prab-dossier-body -->
|
||||
|
||||
</div><!-- #prab-dossier -->
|
||||
|
||||
</div><!-- .prab-dossier-wrap -->
|
||||
126
templates/admin/dossier-stage-section.php
Normal file
126
templates/admin/dossier-stage-section.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
/**
|
||||
* Partial: single stage section for the Article Dossier.
|
||||
*
|
||||
* Variables injected by dossier-page.php:
|
||||
* $stage_row (array) -- run_stages row incl. display_output
|
||||
* $stage_name (string) -- stage key
|
||||
* $log_rows (array) -- generation_log rows for this stage
|
||||
*
|
||||
* Security: all model output escaped via esc_html() or wp_kses_post().
|
||||
* request_json escaped via esc_html() -- treat as untrusted HTML.
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$s_label = PRAutoBlogger_Stage_Display_Map::label( $stage_name );
|
||||
$s_status = (string) ( $stage_row['status'] ?? '' );
|
||||
$s_output = $stage_row['display_output'] ?? null;
|
||||
$s_cost = (float) ( $stage_row['cost_usd'] ?? 0 );
|
||||
$s_role = (string) ( $stage_row['agent_role'] ?? '' );
|
||||
$s_started = (string) ( $stage_row['started_at'] ?? '' );
|
||||
$s_finished = (string) ( $stage_row['finished_at'] ?? '' );
|
||||
$output_pruned = ( ! empty( $stage_row['status'] ) && 'done' === $stage_row['status'] && null === $s_output );
|
||||
|
||||
// Determine prompt version from gen_log (first row for this stage).
|
||||
$first_log = ! empty( $log_rows ) ? $log_rows[0] : null;
|
||||
$s_model = ( null !== $first_log && ! empty( $first_log['model'] ) ) ? $first_log['model'] : '—';
|
||||
$s_pv = ( null !== $first_log && ! empty( $first_log['prompt_version'] ) ) ? $first_log['prompt_version'] : '—';
|
||||
$has_raw_trace = ! empty( $log_rows );
|
||||
$section_id = 'prab-stage-' . esc_attr( $stage_name ) . '-' . esc_attr( $s_role );
|
||||
?>
|
||||
<div class="prab-dossier-section prab-dossier-section--stage prab-stage-status--<?php echo esc_attr( $s_status ); ?>"
|
||||
data-stage="<?php echo esc_attr( $stage_name ); ?>"
|
||||
id="<?php echo $section_id; // Already escaped. ?>">
|
||||
|
||||
<div class="prab-stage-header">
|
||||
<h2 class="prab-section-title">
|
||||
<?php echo esc_html( $s_label ); ?>
|
||||
<?php if ( '' !== $s_role ) : ?>
|
||||
<span class="prab-stage-role">(<?php echo esc_html( $s_role ); ?>)</span>
|
||||
<?php endif; ?>
|
||||
</h2>
|
||||
<div class="prab-stage-header-meta">
|
||||
<span class="prab-stage-status-pill prab-stage-status-pill--<?php echo esc_attr( $s_status ); ?>">
|
||||
<?php echo esc_html( ucfirst( $s_status ) ); ?>
|
||||
</span>
|
||||
<?php if ( $s_cost > 0 ) : ?>
|
||||
<span class="prab-stage-cost">$<?php echo esc_html( number_format( $s_cost, 5 ) ); ?></span>
|
||||
<?php endif; ?>
|
||||
<span class="prab-stage-model"><?php echo esc_html( $s_model ); ?></span>
|
||||
<?php if ( '—' !== $s_pv ) : ?>
|
||||
<span class="prab-stage-pv">pv <?php echo esc_html( $s_pv ); ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php // ── Output ──────────────────────────────────────────────────────── ?>
|
||||
<div class="prab-stage-output">
|
||||
<?php if ( $output_pruned ) : ?>
|
||||
<p class="prab-output-pruned">
|
||||
<?php
|
||||
$days = (int) get_option( 'prautoblogger_request_json_retention_days', 14 );
|
||||
printf(
|
||||
/* translators: %d: retention days */
|
||||
esc_html__( 'Output retained for %d days — currently unavailable (pruned by retention cron).', 'prautoblogger' ),
|
||||
$days
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
<?php elseif ( null !== $s_output ) : ?>
|
||||
<div class="prab-stage-output-rendered">
|
||||
<?php echo wp_kses_post( $s_output ); ?>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<p class="prab-output-absent"><?php esc_html_e( 'No output recorded for this stage.', 'prautoblogger' ); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php // ── Raw trace toggle ─────────────────────────────────────────────── ?>
|
||||
<?php if ( $has_raw_trace ) : ?>
|
||||
<div class="prab-stage-trace">
|
||||
<button type="button" class="prab-trace-toggle button-link"
|
||||
aria-expanded="false"
|
||||
aria-controls="<?php echo $section_id; ?>-trace">
|
||||
<?php esc_html_e( 'Show raw trace', 'prautoblogger' ); ?>
|
||||
</button>
|
||||
<div id="<?php echo $section_id; ?>-trace" class="prab-trace-detail" hidden>
|
||||
<?php foreach ( $log_rows as $log_row ) : ?>
|
||||
<div class="prab-trace-entry">
|
||||
<div class="prab-trace-meta">
|
||||
<span><?php echo esc_html( (string) ( $log_row['model'] ?? '—' ) ); ?></span>
|
||||
<span><?php printf( esc_html__( '%1$d prompt + %2$d completion tokens', 'prautoblogger' ), (int) ( $log_row['prompt_tokens'] ?? 0 ), (int) ( $log_row['completion_tokens'] ?? 0 ) ); ?></span>
|
||||
<span>$<?php echo esc_html( number_format( (float) ( $log_row['estimated_cost'] ?? 0 ), 6 ) ); ?></span>
|
||||
<span class="prab-trace-status prab-trace-status--<?php echo esc_attr( (string) ( $log_row['response_status'] ?? '' ) ); ?>">
|
||||
<?php echo esc_html( (string) ( $log_row['response_status'] ?? '' ) ); ?>
|
||||
</span>
|
||||
</div>
|
||||
<?php if ( ! empty( $log_row['request_json'] ) ) : ?>
|
||||
<details class="prab-trace-request">
|
||||
<summary><?php esc_html_e( 'Request payload', 'prautoblogger' ); ?></summary>
|
||||
<?php // SECURITY: esc_html() treats model output as untrusted HTML. ?>
|
||||
<pre class="prab-trace-pre"><?php echo esc_html( (string) $log_row['request_json'] ); ?></pre>
|
||||
</details>
|
||||
<?php endif; ?>
|
||||
<?php if ( ! empty( $log_row['error_message'] ) ) : ?>
|
||||
<p class="prab-trace-error"><?php echo esc_html( (string) $log_row['error_message'] ); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ( '' !== $s_started ) : ?>
|
||||
<div class="prab-stage-timestamps">
|
||||
<span class="prab-ts"><?php echo esc_html( $s_started ); ?></span>
|
||||
<?php if ( '' !== $s_finished ) : ?>
|
||||
<span class="prab-ts-sep">→</span>
|
||||
<span class="prab-ts"><?php echo esc_html( $s_finished ); ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div><!-- .prab-dossier-section--stage -->
|
||||
41
templates/admin/metabox-dossier-link.php
Normal file
41
templates/admin/metabox-dossier-link.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
/**
|
||||
* Post metabox template (slimmed, M2): "View generation dossier" link.
|
||||
*
|
||||
* The full generation record is available on the Article Dossier page.
|
||||
* This metabox replaces the previous inline metadata display to avoid
|
||||
* duplicating data that lives in the dossier.
|
||||
*
|
||||
* @see admin/class-post-metabox.php -- Renders this template.
|
||||
* @see admin/class-dossier-page.php -- Dossier target page.
|
||||
*
|
||||
* @var WP_Post $post The current post.
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$run_id = (string) get_post_meta( $post->ID, '_prautoblogger_run_id', true );
|
||||
$url = PRAutoBlogger_Dossier_Page::url_for_post( $post->ID );
|
||||
?>
|
||||
<div class="prab-metabox-dossier">
|
||||
<?php if ( '' !== $run_id ) : ?>
|
||||
<p>
|
||||
<a href="<?php echo esc_url( $url ); ?>" class="button button-secondary">
|
||||
<?php esc_html_e( 'View generation dossier', 'prautoblogger' ); ?> →
|
||||
</a>
|
||||
</p>
|
||||
<p class="description prab-run-id-label">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: run UUID */
|
||||
esc_html__( 'Run: %s', 'prautoblogger' ),
|
||||
esc_html( $run_id )
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
<?php else : ?>
|
||||
<p class="description"><?php esc_html_e( 'No generation record — article was not created by PRAutoBlogger.', 'prautoblogger' ); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
Reference in New Issue
Block a user