feat: add protected admin area with bootstrap and data management
This commit is contained in:
parent
628d73afd6
commit
e44791fd30
6 changed files with 923 additions and 1 deletions
311
src/app.js
311
src/app.js
|
|
@ -8,6 +8,7 @@ const DEVICE_TOKEN_KEY = "staysense.device_token.v1";
|
|||
const SETTINGS_KEY = "staysense.settings.v1";
|
||||
const SCORE_CACHE_KEY = "staysense.score_cache.v1";
|
||||
const SIGNAL_QUEUE_KEY = "staysense.signal_queue.v1";
|
||||
const ADMIN_TOKEN_KEY = "staysense.admin_token.v1";
|
||||
const MAX_CACHE_ITEMS = 50;
|
||||
|
||||
const latEl = document.getElementById("lat");
|
||||
|
|
@ -31,6 +32,32 @@ const queueStatusEl = document.getElementById("queue-status");
|
|||
|
||||
const signalsEnabledEl = document.getElementById("signals-enabled");
|
||||
const legalOutputEl = document.getElementById("legal-output");
|
||||
const adminSetupEl = document.getElementById("admin-setup");
|
||||
const adminLoginEl = document.getElementById("admin-login");
|
||||
const adminContentEl = document.getElementById("admin-content");
|
||||
const adminStatusEl = document.getElementById("admin-status");
|
||||
const adminCountsEl = document.getElementById("admin-counts");
|
||||
const adminEventsEl = document.getElementById("admin-events");
|
||||
const adminSignalsEl = document.getElementById("admin-signals");
|
||||
const adminSourcesEl = document.getElementById("admin-sources");
|
||||
const adminSetupUserEl = document.getElementById("admin-setup-user");
|
||||
const adminSetupPassEl = document.getElementById("admin-setup-pass");
|
||||
const adminSetupSubmitEl = document.getElementById("admin-setup-submit");
|
||||
const adminLoginUserEl = document.getElementById("admin-login-user");
|
||||
const adminLoginPassEl = document.getElementById("admin-login-pass");
|
||||
const adminLoginSubmitEl = document.getElementById("admin-login-submit");
|
||||
const adminLogoutEl = document.getElementById("admin-logout");
|
||||
const adminRefreshEl = document.getElementById("admin-refresh");
|
||||
const adminEventCreateEl = document.getElementById("admin-event-create");
|
||||
const adminEventDeleteEl = document.getElementById("admin-event-delete");
|
||||
const adminEventIdEl = document.getElementById("admin-event-id");
|
||||
const adminEventTypeEl = document.getElementById("admin-event-type");
|
||||
const adminEventRiskEl = document.getElementById("admin-event-risk");
|
||||
const adminEventLatEl = document.getElementById("admin-event-lat");
|
||||
const adminEventLonEl = document.getElementById("admin-event-lon");
|
||||
const adminEventStartEl = document.getElementById("admin-event-start");
|
||||
const adminEventEndEl = document.getElementById("admin-event-end");
|
||||
const adminEventSourceEl = document.getElementById("admin-event-source");
|
||||
|
||||
let currentSpot = null;
|
||||
let scoreCache = loadJSON(SCORE_CACHE_KEY, []);
|
||||
|
|
@ -43,6 +70,7 @@ let map = null;
|
|||
let mapMarker = null;
|
||||
let searchResults = [];
|
||||
let selectedSearchIndex = -1;
|
||||
let adminToken = localStorage.getItem(ADMIN_TOKEN_KEY) || "";
|
||||
|
||||
const deviceToken = ensureDeviceToken();
|
||||
initialize();
|
||||
|
|
@ -90,6 +118,14 @@ function initialize() {
|
|||
legalOutputEl.textContent = "MVP-Hinweis: Impressum im Produktionsbetrieb verpflichtend mit Anbieterkennzeichnung.";
|
||||
});
|
||||
|
||||
adminSetupSubmitEl.addEventListener("click", adminBootstrap);
|
||||
adminLoginSubmitEl.addEventListener("click", adminLogin);
|
||||
adminLogoutEl.addEventListener("click", adminLogout);
|
||||
adminRefreshEl.addEventListener("click", loadAdminOverview);
|
||||
adminEventCreateEl.addEventListener("click", saveAdminEvent);
|
||||
adminEventDeleteEl.addEventListener("click", deleteAdminEvent);
|
||||
adminEventsEl.addEventListener("click", onAdminEventListClick);
|
||||
|
||||
// Pilotwert für Mettmann, falls noch keine Eingabe.
|
||||
if (!latEl.value && !lonEl.value) {
|
||||
latEl.value = "51.2500";
|
||||
|
|
@ -102,6 +138,7 @@ function initialize() {
|
|||
renderQueueStatus();
|
||||
checkApiHealth();
|
||||
setInterval(checkApiHealth, 30000);
|
||||
loadAdminBootstrapStatus();
|
||||
}
|
||||
|
||||
function ensureDeviceToken() {
|
||||
|
|
@ -145,17 +182,30 @@ async function checkApiHealth() {
|
|||
if (!response.ok) {
|
||||
throw new Error("health_failed");
|
||||
}
|
||||
const payload = await response.json();
|
||||
apiOnline = true;
|
||||
lastHealthLatencyMs = Math.round(performance.now() - started);
|
||||
lastHealthCheckAt = new Date().toISOString();
|
||||
renderDataStatusFromHealth(payload && payload.health ? payload.health : null);
|
||||
} catch {
|
||||
apiOnline = false;
|
||||
lastHealthLatencyMs = null;
|
||||
lastHealthCheckAt = new Date().toISOString();
|
||||
dataStatusEl.textContent = "Datenstand: aktuell nicht abrufbar (API offline)";
|
||||
}
|
||||
renderNetworkStatus();
|
||||
}
|
||||
|
||||
function renderDataStatusFromHealth(health) {
|
||||
if (!health || !health.has_data) {
|
||||
dataStatusEl.textContent = "Datenstand: keine Quellenmetadaten";
|
||||
return;
|
||||
}
|
||||
const freshness = `freshest ${health.freshest_age_hours}h, stalest ${health.stalest_age_hours}h`;
|
||||
const stale = health.stale_sources && health.stale_sources.length ? `, stale: ${health.stale_sources.join(", ")}` : "";
|
||||
dataStatusEl.textContent = `Datenstand: ${freshness}${stale}`;
|
||||
}
|
||||
|
||||
function renderQueueStatus() {
|
||||
queueStatusEl.textContent = `Warteschlange: ${signalQueue.length} ausstehend`;
|
||||
}
|
||||
|
|
@ -282,6 +332,267 @@ async function searchLocation() {
|
|||
}
|
||||
}
|
||||
|
||||
async function loadAdminBootstrapStatus() {
|
||||
adminStatusEl.textContent = "Admin-Status wird geladen ...";
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/admin/bootstrap/status`, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error("bootstrap_status_failed");
|
||||
}
|
||||
const payload = await response.json();
|
||||
renderAdminMode(payload.initialized);
|
||||
if (!payload.initialized) {
|
||||
adminStatusEl.textContent = "Erst-Setup erforderlich: Bitte initialen Admin anlegen.";
|
||||
return;
|
||||
}
|
||||
adminStatusEl.textContent = adminToken
|
||||
? "Admin bereit. Session wird geprüft ..."
|
||||
: "Admin bereit. Bitte anmelden.";
|
||||
if (adminToken) {
|
||||
await loadAdminOverview();
|
||||
}
|
||||
} catch {
|
||||
renderAdminMode(false);
|
||||
adminStatusEl.textContent = "Admin-Status konnte nicht geladen werden.";
|
||||
}
|
||||
}
|
||||
|
||||
function renderAdminMode(initialized) {
|
||||
adminSetupEl.classList.toggle("hidden", initialized);
|
||||
adminLoginEl.classList.toggle("hidden", !initialized);
|
||||
if (!initialized) {
|
||||
adminContentEl.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function adminHeaders() {
|
||||
return adminToken ? { Authorization: `Bearer ${adminToken}` } : {};
|
||||
}
|
||||
|
||||
async function adminBootstrap() {
|
||||
const username = adminSetupUserEl.value.trim();
|
||||
const password = adminSetupPassEl.value;
|
||||
if (username.length < 3 || password.length < 10) {
|
||||
adminStatusEl.textContent = "Setup fehlgeschlagen: User mind. 3 Zeichen, Passwort mind. 10 Zeichen.";
|
||||
return;
|
||||
}
|
||||
adminSetupSubmitEl.disabled = true;
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/admin/bootstrap`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || "setup_failed");
|
||||
}
|
||||
adminToken = payload.session.token;
|
||||
localStorage.setItem(ADMIN_TOKEN_KEY, adminToken);
|
||||
adminSetupPassEl.value = "";
|
||||
adminLoginUserEl.value = username;
|
||||
adminStatusEl.textContent = "Admin wurde angelegt und eingeloggt.";
|
||||
renderAdminMode(true);
|
||||
await loadAdminOverview();
|
||||
} catch (error) {
|
||||
adminStatusEl.textContent = `Setup fehlgeschlagen: ${String(error.message || "unbekannter Fehler")}`;
|
||||
} finally {
|
||||
adminSetupSubmitEl.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function adminLogin() {
|
||||
const username = adminLoginUserEl.value.trim();
|
||||
const password = adminLoginPassEl.value;
|
||||
if (!username || !password) {
|
||||
adminStatusEl.textContent = "Bitte User und Passwort eingeben.";
|
||||
return;
|
||||
}
|
||||
adminLoginSubmitEl.disabled = true;
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/admin/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || "login_failed");
|
||||
}
|
||||
adminToken = payload.session.token;
|
||||
localStorage.setItem(ADMIN_TOKEN_KEY, adminToken);
|
||||
adminLoginPassEl.value = "";
|
||||
adminStatusEl.textContent = "Admin Login erfolgreich.";
|
||||
await loadAdminOverview();
|
||||
} catch (error) {
|
||||
adminStatusEl.textContent = `Login fehlgeschlagen: ${String(error.message || "unbekannter Fehler")}`;
|
||||
} finally {
|
||||
adminLoginSubmitEl.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function adminLogout() {
|
||||
try {
|
||||
await fetch(`${API_BASE}/admin/logout`, { method: "POST", headers: adminHeaders() });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
adminToken = "";
|
||||
localStorage.removeItem(ADMIN_TOKEN_KEY);
|
||||
adminContentEl.classList.add("hidden");
|
||||
adminStatusEl.textContent = "Abgemeldet.";
|
||||
}
|
||||
|
||||
function renderAdminList(target, rows, mapper) {
|
||||
target.innerHTML = "";
|
||||
if (!rows || !rows.length) {
|
||||
target.textContent = "Keine Daten.";
|
||||
return;
|
||||
}
|
||||
rows.forEach((row) => {
|
||||
const div = document.createElement("div");
|
||||
div.className = "admin-list-item";
|
||||
if (row && row.id) {
|
||||
div.dataset.eventId = row.id;
|
||||
div.title = "Klick setzt Event-ID";
|
||||
}
|
||||
div.textContent = mapper(row);
|
||||
target.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadAdminOverview() {
|
||||
if (!adminToken) {
|
||||
adminStatusEl.textContent = "Nicht eingeloggt.";
|
||||
adminContentEl.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/admin/overview`, { headers: adminHeaders() });
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (response.status === 401) {
|
||||
adminToken = "";
|
||||
localStorage.removeItem(ADMIN_TOKEN_KEY);
|
||||
adminContentEl.classList.add("hidden");
|
||||
adminStatusEl.textContent = "Session abgelaufen. Bitte erneut anmelden.";
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || "admin_overview_failed");
|
||||
}
|
||||
adminContentEl.classList.remove("hidden");
|
||||
adminStatusEl.textContent = `Eingeloggt als ${payload.admin_user}.`;
|
||||
adminCountsEl.textContent = `Spots: ${payload.counts.spots} | Signale: ${payload.counts.signals} | Events: ${payload.counts.events} | Quellen: ${payload.counts.data_sources}`;
|
||||
renderAdminList(
|
||||
adminEventsEl,
|
||||
payload.latest_events,
|
||||
(row) =>
|
||||
`${row.id} | ${row.event_type} | ${Number(row.lat).toFixed(5)}, ${Number(row.lon).toFixed(5)} | ${row.start_datetime} -> ${row.end_datetime} | risk ${row.risk_modifier} | ${row.source}`
|
||||
);
|
||||
renderAdminList(
|
||||
adminSignalsEl,
|
||||
payload.latest_signals,
|
||||
(row) => `${row.timestamp} | ${row.signal_type} | spot ${row.spot_id}`
|
||||
);
|
||||
renderAdminList(
|
||||
adminSourcesEl,
|
||||
payload.data_sources,
|
||||
(row) => `${row.source_name} | ${row.record_count} records | ${row.imported_at} | ${row.notes}`
|
||||
);
|
||||
} catch (error) {
|
||||
adminStatusEl.textContent = `Admin-Daten konnten nicht geladen werden: ${String(error.message || "unbekannter Fehler")}`;
|
||||
}
|
||||
}
|
||||
|
||||
function buildAdminEventPayload() {
|
||||
return {
|
||||
event_type: adminEventTypeEl.value,
|
||||
risk_modifier: Number(adminEventRiskEl.value),
|
||||
lat: Number(adminEventLatEl.value),
|
||||
lon: Number(adminEventLonEl.value),
|
||||
start_datetime: adminEventStartEl.value.trim(),
|
||||
end_datetime: adminEventEndEl.value.trim(),
|
||||
source: adminEventSourceEl.value.trim() || "admin_manual",
|
||||
};
|
||||
}
|
||||
|
||||
async function saveAdminEvent() {
|
||||
if (!adminToken) {
|
||||
adminStatusEl.textContent = "Bitte zuerst anmelden.";
|
||||
return;
|
||||
}
|
||||
const eventId = adminEventIdEl.value.trim();
|
||||
const payload = buildAdminEventPayload();
|
||||
if (!Number.isFinite(payload.lat) || !Number.isFinite(payload.lon)) {
|
||||
adminStatusEl.textContent = "Ungültige Event-Koordinaten.";
|
||||
return;
|
||||
}
|
||||
const method = eventId ? "PUT" : "POST";
|
||||
const url = eventId ? `${API_BASE}/admin/events/${encodeURIComponent(eventId)}` : `${API_BASE}/admin/events`;
|
||||
adminEventCreateEl.disabled = true;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { ...adminHeaders(), "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(body.error || "event_save_failed");
|
||||
}
|
||||
adminStatusEl.textContent = eventId ? `Event aktualisiert: ${eventId}` : `Event angelegt: ${body.id}`;
|
||||
if (!eventId && body.id) {
|
||||
adminEventIdEl.value = body.id;
|
||||
}
|
||||
await loadAdminOverview();
|
||||
} catch (error) {
|
||||
adminStatusEl.textContent = `Event konnte nicht gespeichert werden: ${String(error.message || "unbekannter Fehler")}`;
|
||||
} finally {
|
||||
adminEventCreateEl.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAdminEvent() {
|
||||
if (!adminToken) {
|
||||
adminStatusEl.textContent = "Bitte zuerst anmelden.";
|
||||
return;
|
||||
}
|
||||
const eventId = adminEventIdEl.value.trim();
|
||||
if (!eventId) {
|
||||
adminStatusEl.textContent = "Bitte Event-ID zum Löschen eingeben.";
|
||||
return;
|
||||
}
|
||||
adminEventDeleteEl.disabled = true;
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/admin/events/${encodeURIComponent(eventId)}`, {
|
||||
method: "DELETE",
|
||||
headers: adminHeaders(),
|
||||
});
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(body.error || "event_delete_failed");
|
||||
}
|
||||
adminStatusEl.textContent = `Event gelöscht: ${eventId}`;
|
||||
adminEventIdEl.value = "";
|
||||
await loadAdminOverview();
|
||||
} catch (error) {
|
||||
adminStatusEl.textContent = `Event konnte nicht gelöscht werden: ${String(error.message || "unbekannter Fehler")}`;
|
||||
} finally {
|
||||
adminEventDeleteEl.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onAdminEventListClick(event) {
|
||||
const row = event.target.closest(".admin-list-item");
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
if (row.dataset.eventId) {
|
||||
adminEventIdEl.value = row.dataset.eventId;
|
||||
adminStatusEl.textContent = `Event-ID übernommen: ${row.dataset.eventId}`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSearchResults() {
|
||||
searchResultsEl.innerHTML = "";
|
||||
searchResults.forEach((result, index) => {
|
||||
|
|
|
|||
|
|
@ -75,6 +75,103 @@
|
|||
<small id="queue-status">Warteschlange: 0 ausstehend</small>
|
||||
</section>
|
||||
|
||||
<section class="panel admin-panel">
|
||||
<h2>Admin</h2>
|
||||
<p class="small">Geschützter Bereich für Datenansicht und Event-Verwaltung.</p>
|
||||
|
||||
<div id="admin-setup" class="admin-block hidden">
|
||||
<h3>Erst-Setup</h3>
|
||||
<label>
|
||||
Admin User
|
||||
<input id="admin-setup-user" type="text" placeholder="admin" />
|
||||
</label>
|
||||
<label>
|
||||
Admin Passwort
|
||||
<input id="admin-setup-pass" type="password" placeholder="mind. 10 Zeichen" />
|
||||
</label>
|
||||
<button id="admin-setup-submit" class="btn">Admin anlegen</button>
|
||||
</div>
|
||||
|
||||
<div id="admin-login" class="admin-block hidden">
|
||||
<h3>Login</h3>
|
||||
<label>
|
||||
User
|
||||
<input id="admin-login-user" type="text" placeholder="admin" />
|
||||
</label>
|
||||
<label>
|
||||
Passwort
|
||||
<input id="admin-login-pass" type="password" />
|
||||
</label>
|
||||
<div class="button-row">
|
||||
<button id="admin-login-submit" class="btn">Anmelden</button>
|
||||
<button id="admin-logout" class="btn secondary">Abmelden</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<small id="admin-status">Admin-Status wird geladen ...</small>
|
||||
|
||||
<div id="admin-content" class="admin-block hidden">
|
||||
<h3>Übersicht</h3>
|
||||
<div id="admin-counts" class="admin-counts">-</div>
|
||||
|
||||
<h3>Event-Verwaltung</h3>
|
||||
<label>
|
||||
Event-ID (leer = neu)
|
||||
<input id="admin-event-id" type="text" placeholder="UUID für Update/Delete" />
|
||||
</label>
|
||||
<div class="field-grid">
|
||||
<label>
|
||||
Typ
|
||||
<select id="admin-event-type">
|
||||
<option value="event">event</option>
|
||||
<option value="market">market</option>
|
||||
<option value="waste">waste</option>
|
||||
<option value="construction">construction</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Risk Modifier
|
||||
<input id="admin-event-risk" type="number" value="-10" min="-50" max="50" />
|
||||
</label>
|
||||
<label>
|
||||
Latitude
|
||||
<input id="admin-event-lat" type="number" step="0.000001" placeholder="51.250000" />
|
||||
</label>
|
||||
<label>
|
||||
Longitude
|
||||
<input id="admin-event-lon" type="number" step="0.000001" placeholder="6.973000" />
|
||||
</label>
|
||||
<label>
|
||||
Start (ISO)
|
||||
<input id="admin-event-start" type="text" placeholder="2026-02-15T20:00:00Z" />
|
||||
</label>
|
||||
<label>
|
||||
Ende (ISO)
|
||||
<input id="admin-event-end" type="text" placeholder="2026-02-16T06:00:00Z" />
|
||||
</label>
|
||||
<label>
|
||||
Source
|
||||
<input id="admin-event-source" type="text" value="admin_manual" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<button id="admin-event-create" class="btn">Event speichern</button>
|
||||
<button id="admin-event-delete" class="btn secondary">Event löschen</button>
|
||||
<button id="admin-refresh" class="btn secondary">Daten neu laden</button>
|
||||
</div>
|
||||
|
||||
<h3>Letzte Events</h3>
|
||||
<div id="admin-events" class="admin-list"></div>
|
||||
|
||||
<h3>Letzte Signale</h3>
|
||||
<div id="admin-signals" class="admin-list"></div>
|
||||
|
||||
<h3>Datenquellen</h3>
|
||||
<div id="admin-sources" class="admin-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel settings-panel">
|
||||
<h2>Settings</h2>
|
||||
<label class="toggle">
|
||||
|
|
|
|||
|
|
@ -108,11 +108,13 @@ label {
|
|||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input {
|
||||
input,
|
||||
select {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 9px;
|
||||
|
|
@ -281,6 +283,45 @@ input {
|
|||
min-height: 42px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-block {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.admin-counts {
|
||||
color: var(--ink);
|
||||
background: #f7fafc;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.admin-list {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
background: #fff;
|
||||
color: var(--muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.admin-list-item {
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.admin-list-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 920px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr 1.2fr 0.9fr;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue